diff --git a/contribs/gnodev/go.mod b/contribs/gnodev/go.mod index 30069312ae4..ec6fe77e4b1 100644 --- a/contribs/gnodev/go.mod +++ b/contribs/gnodev/go.mod @@ -7,6 +7,7 @@ replace github.com/gnolang/gno => ../.. require ( github.com/fsnotify/fsnotify v1.7.0 github.com/gnolang/gno v0.0.0-00010101000000-000000000000 + github.com/gorilla/websocket v1.5.1 go.uber.org/zap v1.26.0 golang.org/x/term v0.16.0 ) @@ -35,7 +36,6 @@ require ( github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect - github.com/gorilla/websocket v1.5.1 // indirect github.com/gotuna/gotuna v0.6.0 // indirect github.com/jaekwon/testify v1.6.1 // indirect github.com/jmhodges/levigo v1.0.0 // indirect diff --git a/contribs/gnodev/main.go b/contribs/gnodev/main.go index 2f551104c13..b6d0f34dd16 100644 --- a/contribs/gnodev/main.go +++ b/contribs/gnodev/main.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "io" - "net" "net/http" "os" "path/filepath" @@ -14,27 +13,31 @@ import ( "github.com/fsnotify/fsnotify" "github.com/gnolang/gno/contribs/gnodev/pkg/dev" gnodev "github.com/gnolang/gno/contribs/gnodev/pkg/dev" + "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" "github.com/gnolang/gno/contribs/gnodev/pkg/rawterm" + "github.com/gnolang/gno/contribs/gnodev/pkg/watcher" "github.com/gnolang/gno/gno.land/pkg/gnoweb" "github.com/gnolang/gno/gno.land/pkg/log" "github.com/gnolang/gno/gnovm/pkg/gnoenv" "github.com/gnolang/gno/gnovm/pkg/gnomod" "github.com/gnolang/gno/tm2/pkg/commands" osm "github.com/gnolang/gno/tm2/pkg/os" + "go.uber.org/zap" "go.uber.org/zap/zapcore" ) const ( - NodeLogName = "Node" - WebLogName = "GnoWeb" - KeyPressLogName = "KeyPress" - HotReloadLogName = "HotReload" + NodeLogName = "Node" + WebLogName = "GnoWeb" + KeyPressLogName = "KeyPress" + EventServerLogName = "Events" ) type devCfg struct { webListenerAddr string minimal bool verbose bool + hotreload bool noWatch bool } @@ -97,7 +100,7 @@ func execDev(cfg *devCfg, args []string, io commands.IO) error { ctx, cancel := context.WithCancelCause(context.Background()) defer cancel(nil) - // guess root dir + // Guess root dir gnoroot := gnoenv.RootDir() // Check and Parse packages @@ -124,9 +127,13 @@ func execDev(cfg *devCfg, args []string, io commands.IO) error { cancel(nil) }) + zapLoggerEvents := NewZapLogger(rt.NamespacedWriter(EventServerLogName), zapcore.DebugLevel) + loggerEvents := log.ZapLoggerToSlog(zapLoggerEvents) + emitterServer := emitter.NewServer(loggerEvents) + // Setup Dev Node // XXX: find a good way to export or display node logs - devNode, err := setupDevNode(ctx, rt, pkgpaths) + devNode, err := setupDevNode(ctx, emitterServer, rt, pkgpaths) if err != nil { return err } @@ -136,51 +143,66 @@ func execDev(cfg *devCfg, args []string, io commands.IO) error { rt.Taskf(NodeLogName, "Default Address: %s\n", gnodev.DefaultCreator.String()) rt.Taskf(NodeLogName, "Chain ID: %s\n", devNode.Config().ChainID()) - // Setup packages watcher - pathChangeCh := make(chan []string, 1) - go func() { - defer close(pathChangeCh) + // Create server + mux := http.NewServeMux() + server := http.Server{ + Handler: mux, + Addr: cfg.webListenerAddr, + } + defer server.Close() + + // Setup gnoweb + webhandler := setupGnoWebServer(cfg, devNode, rt) + + // Setup HotReload if needed + if !cfg.noWatch { + evtstarget := fmt.Sprintf("%s/_events", server.Addr) + mux.Handle("/_events", emitterServer) + mux.Handle("/", emitter.NewMiddleware(evtstarget, webhandler)) + } else { + mux.Handle("/", webhandler) + } - cancel(runPkgsWatcher(ctx, cfg, devNode.ListPkgs(), pathChangeCh)) + go func() { + err := server.ListenAndServe() + cancel(err) }() - // Setup GnoWeb listener - l, err := net.Listen("tcp", cfg.webListenerAddr) + rt.Taskf(WebLogName, "Listener: http://%s\n", server.Addr) + + watcher, err := watcher.NewPackageWatcher(loggerEvents, emitterServer) if err != nil { - return fmt.Errorf("unable to listen to %q: %w", cfg.webListenerAddr, err) + return fmt.Errorf("unable to setup packages watcher: %w", err) } - defer l.Close() + defer watcher.Stop() - // Run GnoWeb server - go func() { - cancel(serveGnoWebServer(l, devNode, rt)) - }() - - rt.Taskf(WebLogName, "Listener: http://%s\n", l.Addr()) + // Add node pkgs to watcher + watcher.AddPackages(devNode.ListPkgs()...) // GnoDev should be ready, run event loop rt.Taskf("[Ready]", "for commands and help, press `h`") // Run the main event loop - return runEventLoop(ctx, cfg, rt, devNode, pathChangeCh) + return runEventLoop(ctx, cfg, rt, devNode, watcher) } // XXX: Automatize this the same way command does func printHelper(rt *rawterm.RawTerm) { rt.Taskf("Helper", ` Gno Dev Helper: - h, H Help - display this message - r, R Reload - Reload all packages to take change into account. + H Help - display this message + R Reload - Reload all packages to take change into account. Ctrl+R Reset - Reset application state. Ctrl+C Exit - Exit the application `) } -func runEventLoop(ctx context.Context, +func runEventLoop( + ctx context.Context, cfg *devCfg, rt *rawterm.RawTerm, dnode *dev.Node, - pathsCh <-chan []string, + watch *watcher.PackageWatcher, ) error { nodeOut := rt.NamespacedWriter(NodeLogName) keyOut := rt.NamespacedWriter(KeyPressLogName) @@ -192,26 +214,21 @@ func runEventLoop(ctx context.Context, select { case <-ctx.Done(): return context.Cause(ctx) - case paths, ok := <-pathsCh: + case pkgs, ok := <-watch.PackagesUpdate: if !ok { return nil } - if cfg.verbose { - for _, path := range paths { - rt.Taskf(HotReloadLogName, "path %q has been modified", path) - } - } - fmt.Fprintln(nodeOut, "Loading package updates...") - if err = dnode.UpdatePackages(paths...); err != nil { - checkForError(rt, err) - continue + if err = dnode.UpdatePackages(pkgs.PackagesPath()...); err != nil { + return fmt.Errorf("unable to update packages: %w", err) } fmt.Fprintln(nodeOut, "Reloading...") err = dnode.Reload(ctx) + checkForError(rt, err) + case key, ok := <-keyPressCh: if !ok { return nil @@ -248,7 +265,7 @@ func runPkgsWatcher(ctx context.Context, cfg *devCfg, pkgs []gnomod.Pkg, changed } if cfg.noWatch { - // noop watcher, wait until context has been cancel + // Noop watcher, wait until context has been cancel <-ctx.Done() return ctx.Err() } @@ -294,42 +311,28 @@ func setupRawTerm(io commands.IO) (rt *rawterm.RawTerm, restore func() error, er return nil, nil, err } - // correctly format output for terminal + // Correctly format output for terminal io.SetOut(commands.WriteNopCloser(rt)) - return rt, restore, nil } // setupDevNode initializes and returns a new DevNode. -func setupDevNode(ctx context.Context, rt *rawterm.RawTerm, pkgspath []string) (*gnodev.Node, error) { +func setupDevNode(ctx context.Context, emitter emitter.Emitter, rt *rawterm.RawTerm, pkgspath []string) (*gnodev.Node, error) { nodeOut := rt.NamespacedWriter("Node") - - zapLogger := log.NewZapConsoleLogger(nodeOut, zapcore.ErrorLevel) - - return gnodev.NewDevNode(ctx, log.ZapLoggerToSlog(zapLogger), pkgspath) + zapLogger := NewZapLogger(nodeOut, zapcore.ErrorLevel) + return gnodev.NewDevNode(ctx, log.ZapLoggerToSlog(zapLogger), emitter, pkgspath) } // setupGnowebServer initializes and starts the Gnoweb server. -func serveGnoWebServer(l net.Listener, dnode *gnodev.Node, rt *rawterm.RawTerm) error { - var server http.Server - +func setupGnoWebServer(cfg *devCfg, dnode *gnodev.Node, rt *rawterm.RawTerm) http.Handler { webConfig := gnoweb.NewDefaultConfig() webConfig.RemoteAddr = dnode.GetRemoteAddress() webConfig.HelpChainID = dnode.Config().ChainID() webConfig.HelpRemote = dnode.GetRemoteAddress() - zapLogger := log.NewZapConsoleLogger(rt.NamespacedWriter("GnoWeb"), zapcore.DebugLevel) - + zapLogger := NewZapLogger(rt.NamespacedWriter("GnoWeb"), zapcore.DebugLevel) app := gnoweb.MakeApp(log.ZapLoggerToSlog(zapLogger), webConfig) - - server.ReadHeaderTimeout = 60 * time.Second - server.Handler = app.Router - - if err := server.Serve(l); err != nil { - return fmt.Errorf("unable to serve GnoWeb: %w", err) - } - - return nil + return app.Router } func parseArgsPackages(args []string) (paths []string, err error) { @@ -375,3 +378,16 @@ func checkForError(w io.Writer, err error) { fmt.Fprintln(w, "[DONE]") } + +// NewZapLogger creates a zap logger with a console encoder for development use. +func NewZapLogger(w io.Writer, level zapcore.Level) *zap.Logger { + // Build encoder config + consoleConfig := zap.NewDevelopmentEncoderConfig() + consoleConfig.TimeKey = "" + consoleConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + consoleConfig.EncodeName = zapcore.FullNameEncoder + + // Build encoder + enc := zapcore.NewConsoleEncoder(consoleConfig) + return log.NewZapLogger(enc, w, level) +} diff --git a/contribs/gnodev/pkg/dev/node.go b/contribs/gnodev/pkg/dev/node.go index 0b271c14636..d70cf0f1c85 100644 --- a/contribs/gnodev/pkg/dev/node.go +++ b/contribs/gnodev/pkg/dev/node.go @@ -5,6 +5,8 @@ import ( "fmt" "log/slog" + "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" + "github.com/gnolang/gno/contribs/gnodev/pkg/events" "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/gno.land/pkg/integration" vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" @@ -16,6 +18,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" bft "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/crypto" + tm2events "github.com/gnolang/gno/tm2/pkg/events" "github.com/gnolang/gno/tm2/pkg/std" // backup "github.com/gnolang/tx-archive/backup/client" // restore "github.com/gnolang/tx-archive/restore/client" @@ -27,9 +30,10 @@ const gnoDevChainID = "tendermint_test" // XXX: this is hardcoded and cannot be type Node struct { *node.Node - client client.Client - logger *slog.Logger - pkgs PkgsMap // path -> pkg + emitter emitter.Emitter + client client.Client + logger *slog.Logger + pkgs PkgsMap // path -> pkg // keep track of number of loaded package to be able to skip them on restore loadedPackages int } @@ -45,7 +49,7 @@ var ( } ) -func NewDevNode(ctx context.Context, logger *slog.Logger, pkgslist []string) (*Node, error) { +func NewDevNode(ctx context.Context, logger *slog.Logger, emitter emitter.Emitter, pkgslist []string) (*Node, error) { mpkgs, err := newPkgsMap(pkgslist) if err != nil { return nil, fmt.Errorf("unable map pkgs list: %w", err) @@ -62,26 +66,16 @@ func NewDevNode(ctx context.Context, logger *slog.Logger, pkgslist []string) (*N Txs: pkgsTxs, } - node, err := newNode(logger, genesis) + node, err := initializeNode(ctx, logger, emitter, genesis) if err != nil { - return nil, fmt.Errorf("unable to create the node: %w", err) + return nil, fmt.Errorf("unable to initialize the node: %w", err) } client := client.NewLocal(node) - if err := node.Start(); err != nil { - return nil, fmt.Errorf("unable to start node: %w", err) - } - - // Wait for readiness - select { - case <-gnoland.GetNodeReadiness(node): // ok - case <-ctx.Done(): - return nil, ctx.Err() - } - return &Node{ Node: node, + emitter: emitter, client: client, pkgs: mpkgs, logger: logger, @@ -150,7 +144,14 @@ func (d *Node) Reset(ctx context.Context) error { } // Reset the node with the new genesis state. - return d.reset(ctx, genesis) + node, err := initializeNode(ctx, d.logger, d.emitter, genesis) + if err != nil { + return fmt.Errorf("unable to initialize a new node: %w", err) + } + + d.Node = node + d.emitter.Emit(&events.Reset{}) + return nil } // ReloadAll updates all currently known packages and then reloads the node. @@ -199,65 +200,21 @@ func (d *Node) Reload(ctx context.Context) error { } // Reset the node with the new genesis state. - if err := d.reset(ctx, genesis); err != nil { - return fmt.Errorf("unable to reset the node: %w", err) + node, err := initializeNode(ctx, d.logger, d.emitter, genesis) + if err != nil { + return fmt.Errorf("unable to initialize a new node: %w", err) } d.logger.Info("reload done", "pkgs", len(pkgsTxs), "state applied", len(state)) + + // Update node infos + d.Node = node d.loadedPackages = len(pkgsTxs) + d.emitter.Emit(&events.Reload{}) return nil } -func (d *Node) reset(ctx context.Context, genesis gnoland.GnoGenesisState) error { - var err error - - // recoverError handles panics and converts them to errors. - recoverError := func() { - if r := recover(); r != nil { - panicErr, ok := r.(error) - if !ok { - panic(r) // Re-panic if not an error. - } - - err = panicErr - } - } - - createNode := func() { - defer recoverError() - - node, nodeErr := newNode(d.logger, genesis) - if nodeErr != nil { - err = fmt.Errorf("unable to create node: %w", nodeErr) - return - } - - if startErr := node.Start(); startErr != nil { - err = fmt.Errorf("unable to start the node: %w", startErr) - return - } - - d.Node = node - d.client = client.NewLocal(d.Node) - } - - // Execute node creation and handle any errors. - createNode() - if err != nil { - return err - } - - // Wait for the node to be ready - select { - case <-d.GetNodeReadiness(): // Ok - case <-ctx.Done(): - return ctx.Err() - } - - return err -} - // GetBlockTransactions returns the transactions contained // within the specified block, if any func (d *Node) GetBlockTransactions(blockNum uint64) ([]std.Tx, error) { @@ -411,13 +368,74 @@ func (pm PkgsMap) Load(creator bft.Address, fee std.Fee, deposit std.Coins) ([]s return txs, nil } -func newNode(logger *slog.Logger, genesis gnoland.GnoGenesisState) (*node.Node, error) { +func initializeNode(ctx context.Context, logger *slog.Logger, emitter emitter.Emitter, genesis gnoland.GnoGenesisState) (*node.Node, error) { rootdir := gnoenv.RootDir() + // Setup node config nodeConfig := gnoland.NewDefaultInMemoryNodeConfig(rootdir) nodeConfig.SkipFailingGenesisTxs = true nodeConfig.TMConfig.Consensus.SkipTimeoutCommit = false // avoid time drifting, see issue #1507 - nodeConfig.Genesis.AppState = genesis - return gnoland.NewInMemoryNode(logger, nodeConfig) + + var recoverErr error + + // recoverFromError handles panics and converts them to errors. + recoverFromError := func() { + if r := recover(); r != nil { + var ok bool + recoverErr, ok = r.(error) + if !ok { + panic(r) // Re-panic if not an error. + } + } + } + + // Execute node creation and handle any errors. + defer recoverFromError() + node, nodeErr := buildNode(logger, emitter, nodeConfig) + if recoverErr != nil { // First check for recover error in case of panic + return nil, fmt.Errorf("recovered from a node panic: %w", recoverErr) + } + if nodeErr != nil { // Then for any node error + return nil, fmt.Errorf("unable to build the node: %w", nodeErr) + } + + // Wait for the node to be ready + select { + case <-gnoland.GetNodeReadiness(node): // Ok + return node, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func buildNode(logger *slog.Logger, emitter emitter.Emitter, cfg *gnoland.InMemoryNodeConfig) (*node.Node, error) { + node, err := gnoland.NewInMemoryNode(logger, cfg) + if err != nil { + return nil, fmt.Errorf("unable to create a new node: %w", err) + } + + node.EventSwitch().AddListener("dev-emitter", func(evt tm2events.Event) { + switch data := evt.(type) { + case bft.EventTx: + resEvt := events.TxResult{ + Height: data.Result.Height, + Index: data.Result.Index, + Response: data.Result.Response, + } + + if err := amino.Unmarshal(data.Result.Tx, &resEvt.Tx); err != nil { + logger.Error("unable to unwarp tx result", + "error", err) + } + + emitter.Emit(resEvt) + } + }) + + if startErr := node.Start(); startErr != nil { + return nil, fmt.Errorf("unable to start the node: %w", startErr) + } + + return node, nil } diff --git a/contribs/gnodev/pkg/emitter/middleware.go b/contribs/gnodev/pkg/emitter/middleware.go new file mode 100644 index 00000000000..80c07ec93aa --- /dev/null +++ b/contribs/gnodev/pkg/emitter/middleware.go @@ -0,0 +1,119 @@ +package emitter + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "text/template" + + "github.com/gnolang/gno/contribs/gnodev/pkg/events" +) + +//go:embed static/hotreload.js +var reloadscript string + +type middleware struct { + remote string + muRemote sync.RWMutex + + next http.Handler + tmpl *template.Template + onceExec sync.Once + script []byte +} + +// NewMiddleware creates an HTTP handler that acts as middleware. Its primary +// purpose is to intercept HTTP responses and inject a WebSocket client script +// into the body of HTML pages. This injection allows for dynamic content +// updates on the client side without requiring a page refresh. +func NewMiddleware(remote string, next http.Handler) http.Handler { + tmpl := template.Must(template.New("reloadscript"). + Funcs(tmplFuncs). + Parse(reloadscript)) + + return &middleware{ + tmpl: tmpl, + remote: remote, + next: next, + onceExec: sync.Once{}, + } +} + +type middlewareResponseWriter struct { + http.ResponseWriter + buffer *bytes.Buffer +} + +func (m *middlewareResponseWriter) Write(b []byte) (int, error) { + return m.buffer.Write(b) +} + +type data struct { + Remote string + ReloadEvents []events.Type +} + +func (m *middleware) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + m.muRemote.RLock() + defer m.muRemote.RUnlock() + + // Create a buffer to hold the modified response + buffer := bytes.NewBuffer(nil) + + // Create a ResponseWriter that writes to our buffer + mw := &middlewareResponseWriter{ + ResponseWriter: rw, + buffer: buffer, + } + + // Call the next handler, which writes to our buffer + m.next.ServeHTTP(mw, req) + + // Check for any "text/html" answer + content := mw.ResponseWriter.Header().Get("Content-Type") + if !strings.Contains(content, "text/html") { + rw.Write(buffer.Bytes()) + return + } + + m.onceExec.Do(func() { + script := &bytes.Buffer{} + script.WriteString(`") + script.WriteString("") + m.script = script.Bytes() + }) + + // Inject the script before + updated := bytes.Replace( + buffer.Bytes(), + []byte(""), + m.script, 1) + + rw.Write(updated) +} + +var tmplFuncs = template.FuncMap{ + "json": func(obj any) string { + raw, err := json.Marshal(obj) + if err != nil { + panic(fmt.Errorf("marshal error: %w", err)) + } + + return string(raw) + }, +} diff --git a/contribs/gnodev/pkg/emitter/server.go b/contribs/gnodev/pkg/emitter/server.go new file mode 100644 index 00000000000..68a304d38fe --- /dev/null +++ b/contribs/gnodev/pkg/emitter/server.go @@ -0,0 +1,85 @@ +package emitter + +import ( + "log/slog" + "net/http" + "sync" + + "github.com/gnolang/gno/contribs/gnodev/pkg/events" + "github.com/gorilla/websocket" +) + +type Emitter interface { + Emit(evt events.Event) +} + +type Server struct { + logger *slog.Logger + upgrader websocket.Upgrader + clients map[*websocket.Conn]struct{} + muClients sync.RWMutex +} + +func NewServer(logger *slog.Logger) *Server { + return &Server{ + logger: logger, + clients: make(map[*websocket.Conn]struct{}), + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // XXX: adjust this + }, + }, + } +} + +// ws handler +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + s.logger.Error("unable to upgrade connection", "remote", r.RemoteAddr, "error", err) + return + } + defer conn.Close() + + s.muClients.Lock() + s.clients[conn] = struct{}{} + s.muClients.Unlock() + + for { + _, _, err := conn.ReadMessage() + if err != nil { + s.muClients.Lock() + delete(s.clients, conn) + s.muClients.Unlock() + break + } + } +} + +func (s *Server) Emit(evt events.Event) { + go s.emit(evt) +} + +func (s *Server) emit(evt events.Event) { + s.muClients.RLock() + defer s.muClients.RUnlock() + + jsonEvt := struct { + Type events.Type `json:"type"` + Data any `json:"data"` + }{evt.Type(), evt} + + s.logger.Info("sending event to clients", + "clients", len(s.clients), + "type", evt.Type(), + "event", evt) + + for conn := range s.clients { + err := conn.WriteJSON(jsonEvt) + if err != nil { + s.logger.Error("write json event", "error", err) + conn.Close() + delete(s.clients, conn) + } + } +} diff --git a/contribs/gnodev/pkg/emitter/static/hotreload.js b/contribs/gnodev/pkg/emitter/static/hotreload.js new file mode 100644 index 00000000000..aabad4f341c --- /dev/null +++ b/contribs/gnodev/pkg/emitter/static/hotreload.js @@ -0,0 +1,51 @@ +(function() { + // Define the events that will trigger a page reload + const eventsReload = {{ .ReloadEvents | json }}; + + // Establish the WebSocket connection to the event server + const ws = new WebSocket('ws://{{- .Remote -}}'); + + // `gracePeriod` mitigates reload loops due to excessive events. This period + // occurs post-loading and lasts for the `graceTimeout` duration. + const graceTimeout = 1000; // ms + let gracePeriod = true; + let debounceTimeout = setTimeout(function() { + gracePeriod = false; + }, graceTimeout); + + // Handle incoming WebSocket messages + ws.onmessage = function(event) { + try { + const message = JSON.parse(event.data); + console.log('Receiving message:', message); + + // Ignore events not in the reload-triggering list + if (!eventsReload.includes(message.type)) { + return; + } + + // Reload the page immediately if we're not in the grace period + if (!gracePeriod) { + window.location.reload(); + return; + } + + // If still in the grace period, debounce the reload + clearTimeout(debounceTimeout); + debounceTimeout = setTimeout(function() { + window.location.reload(); + }, graceTimeout); + + } catch (e) { + console.error('Error handling message:', e); + } + }; + + // Handle ws errors and closure + ws.onerror = function(error) { + console.error('WebSocket Error:', error); + }; + ws.onclose = function() { + console.log('WebSocket connection closed'); + }; +})(); diff --git a/contribs/gnodev/pkg/events/events.go b/contribs/gnodev/pkg/events/events.go new file mode 100644 index 00000000000..c387c331eed --- /dev/null +++ b/contribs/gnodev/pkg/events/events.go @@ -0,0 +1,61 @@ +package events + +import ( + abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type Type string + +const ( + EvtReload Type = "NODE_RELOAD" + EvtReset Type = "NODE_RESET" + EvtPackagesUpdate Type = "PACKAGES_UPDATE" + EvtTxResult Type = "TX_RESULT" +) + +type Event interface { + Type() Type + + assertEvent() +} + +// Reload Event + +type Reload struct{} + +func (Reload) Type() Type { return EvtReload } +func (Reload) assertEvent() {} + +// Reset Event + +type Reset struct{} + +func (Reset) Type() Type { return EvtReset } +func (Reset) assertEvent() {} + +// PackagesUpdate Event + +type PackagesUpdate struct { + Pkgs []PackageUpdate `json:"packages"` +} + +type PackageUpdate struct { + Package string `json:"package"` + Files []string `json:"files"` +} + +func (PackagesUpdate) Type() Type { return EvtPackagesUpdate } +func (PackagesUpdate) assertEvent() {} + +// TxResult Event + +type TxResult struct { + Height int64 `json:"height"` + Index uint32 `json:"index"` + Tx std.Tx `json:"tx"` + Response abci.ResponseDeliverTx `json:"response"` +} + +func (TxResult) Type() Type { return EvtTxResult } +func (TxResult) assertEvent() {} diff --git a/contribs/gnodev/pkg/watcher/watch.go b/contribs/gnodev/pkg/watcher/watch.go new file mode 100644 index 00000000000..a9ab189947f --- /dev/null +++ b/contribs/gnodev/pkg/watcher/watch.go @@ -0,0 +1,202 @@ +package watcher + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "sort" + "strings" + "time" + + emitter "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" + events "github.com/gnolang/gno/contribs/gnodev/pkg/events" + + "github.com/fsnotify/fsnotify" + "github.com/gnolang/gno/gnovm/pkg/gnomod" +) + +type PackageWatcher struct { + PackagesUpdate <-chan PackageUpdateList + Errors <-chan error + + ctx context.Context + stop context.CancelFunc + + logger *slog.Logger + watcher *fsnotify.Watcher + pkgsDir []string + emitter emitter.Emitter +} + +func NewPackageWatcher(logger *slog.Logger, emitter emitter.Emitter) (*PackageWatcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("unable to watch files: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + p := &PackageWatcher{ + ctx: ctx, + stop: cancel, + pkgsDir: []string{}, + logger: logger, + watcher: watcher, + emitter: emitter, + } + + p.startWatching() + + return p, nil +} + +func (p *PackageWatcher) startWatching() { + const timeout = time.Millisecond * 500 // Debounce interval + + errorsChan := make(chan error, 1) + pkgsUpdateChan := make(chan PackageUpdateList) + + go func() { + defer close(errorsChan) + defer close(pkgsUpdateChan) + + var debounceTimer <-chan time.Time + var pathList = []string{} + var err error + + for err == nil { + select { + case <-p.ctx.Done(): + err = p.ctx.Err() + case watchErr := <-p.watcher.Errors: + err = fmt.Errorf("watch error: %w", watchErr) + case <-debounceTimer: + // Process and emit package updates after the debounce interval + updates := p.generatePackagesUpdateList(pathList) + for _, update := range updates { + p.logger.Info("packages update", + "pkg", update.Package, + "files", update.Files, + ) + } + + // Send updates + pkgsUpdateChan <- updates + p.emitter.Emit(&events.PackagesUpdate{ + Pkgs: updates, + }) + + // Reset the path list and debounce timer + pathList = []string{} + debounceTimer = nil + case evt := <-p.watcher.Events: + // Only handle write operations + if evt.Op != fsnotify.Write { + continue + } + + pathList = append(pathList, evt.Name) + + // Set up the debounce timer + debounceTimer = time.After(timeout) + } + } + + errorsChan <- err // Send any final error to the channel + }() + + // Set update channels + p.PackagesUpdate = pkgsUpdateChan + p.Errors = errorsChan +} + +func (p *PackageWatcher) Stop() { + p.stop() +} + +// AddPackages adds new packages to the watcher. +// Packages are sorted by their length in descending order to facilitate easier +// and more efficient matching with corresponding paths. The longest paths are +// compared first. +func (p *PackageWatcher) AddPackages(pkgs ...gnomod.Pkg) error { + for _, pkg := range pkgs { + dir := pkg.Dir + + abs, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("unable to get absolute path of %q: %w", dir, err) + } + + // Use binary search to find the correct insertion point + index := sort.Search(len(p.pkgsDir), func(i int) bool { + return len(p.pkgsDir[i]) <= len(dir) // Longest paths first + }) + + // Check for duplicates + if index < len(p.pkgsDir) && p.pkgsDir[index] == dir { + continue // Skip + } + + // Insert the package + p.pkgsDir = append(p.pkgsDir[:index], append([]string{abs}, p.pkgsDir[index:]...)...) + + // Add the package to the watcher and handle any errors + if err := p.watcher.Add(abs); err != nil { + return fmt.Errorf("unable to watch %q: %w", pkg.Dir, err) + } + } + + return nil +} + +func (p *PackageWatcher) generatePackagesUpdateList(paths []string) PackageUpdateList { + pkgsUpdate := []events.PackageUpdate{} + + mpkgs := map[string]*events.PackageUpdate{} // Pkg -> Update + for _, path := range paths { + for _, pkg := range p.pkgsDir { + dirPath := filepath.Dir(path) + + // Check if a package directory contain our path directory + if !strings.HasPrefix(pkg, dirPath) { + continue + } + + if len(pkg) == len(path) { + continue // Skip if pkg == path + } + + // Accumulate file updates for each package + pkgu, ok := mpkgs[pkg] + if !ok { + pkgsUpdate = append(pkgsUpdate, events.PackageUpdate{ + Package: pkg, + Files: []string{}, + }) + pkgu = &pkgsUpdate[len(pkgsUpdate)-1] + } + + pkgu.Files = append(pkgu.Files, path) + } + } + + return pkgsUpdate +} + +type PackageUpdateList []events.PackageUpdate + +func (pkgsu PackageUpdateList) PackagesPath() []string { + pkgs := make([]string, len(pkgsu)) + for i, pkg := range pkgsu { + pkgs[i] = pkg.Package + } + return pkgs +} + +func (pkgsu PackageUpdateList) FilesPath() []string { + files := make([]string, 0) + for _, pkg := range pkgsu { + files = append(files, pkg.Files...) + } + return files +}