diff --git a/actiontrigger.go b/actiontrigger.go index c198c1a..126ba21 100644 --- a/actiontrigger.go +++ b/actiontrigger.go @@ -83,7 +83,7 @@ func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H { return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts)) } -// OnEnterKey returns a via.h DOM attribute that triggers when a key is pressed. +// OneyDown returns a via.h DOM attribute that triggers when a key is pressed. // key: optional, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key // Example: OnKeyDown("Enter") func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.H { diff --git a/context.go b/context.go index 1da9661..a15cfc4 100644 --- a/context.go +++ b/context.go @@ -13,9 +13,10 @@ import ( // Context is the living bridge between Go and the browser. // -// It binds user state and actions, manages reactive signals, and defines UI through View. +// It holds runtime state, defines actions, manages reactive signals, and defines UI through View. type Context struct { id string + route string app *V view func() h.H componentRegistry map[string]*Context @@ -60,7 +61,7 @@ func (c *Context) View(f func() h.H) { // }) func (c *Context) Component(f func(c *Context)) func() h.H { id := c.id + "/_component/" + genRandID() - compCtx := newContext(id, c.app) + compCtx := newContext(id, c.route, c.app) if c.isComponent() { compCtx.parentPageCtx = c.parentPageCtx } else { @@ -297,14 +298,15 @@ func (c *Context) ExecScript(s string) { _ = sse.ExecuteScript(s) } -func newContext(id string, a *V) *Context { - if a == nil { +func newContext(id string, route string, app *V) *Context { + if app == nil { log.Fatalf("create context failed: app pointer is nil") } return &Context{ id: id, - app: a, + route: route, + app: app, componentRegistry: make(map[string]*Context), actionRegistry: make(map[string]func()), signals: make(map[string]*signal), diff --git a/go.mod b/go.mod index ccef8f7..ef8c5ca 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.3 require maragu.dev/gomponents v1.2.0 require ( + github.com/fsnotify/fsnotify v1.9.0 github.com/starfederation/datastar-go v1.0.3 github.com/stretchr/testify v1.10.0 ) @@ -16,5 +17,6 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + golang.org/x/sys v0.13.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f0abff8..cc65f9c 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUS github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -33,6 +35,9 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/examples/chatroom/main.go b/internal/examples/chatroom/main.go index f23e11c..700220f 100644 --- a/internal/examples/chatroom/main.go +++ b/internal/examples/chatroom/main.go @@ -24,7 +24,6 @@ func main() { DevMode: true, DocumentTitle: "ViaChat", LogLvl: via.LogLevelInfo, - Plugins: []via.Plugin{via.SigQuitPlugin}, }) v.AppendToHead( diff --git a/internal/examples/livereload/main.go b/internal/examples/livereload/main.go index 797274d..c89d54d 100644 --- a/internal/examples/livereload/main.go +++ b/internal/examples/livereload/main.go @@ -27,7 +27,7 @@ func main() { c.View(func() h.H { return h.Div( - h.H1(h.Text("Live Reload with Via DevMode !!!")), + h.H1(h.Text("⚡Via Live Reload!")), h.P(h.Textf("Count: %d", data.Count)), h.P(h.Span(h.Text("Step: ")), h.Span(step.Text())), h.Label( diff --git a/via.go b/via.go index 3894d6c..d30b87d 100644 --- a/via.go +++ b/via.go @@ -29,14 +29,13 @@ var datastarJS []byte // V is the root application. // It manages page routing, user sessions, and SSE connections for live updates. type V struct { - cfg Options - mux *http.ServeMux - contextRegistry map[string]*Context - contextRegistryMutex sync.RWMutex - documentHeadIncludes []h.H - documentFootIncludes []h.H - devModePageInitFnMap map[string]func(*Context) - devModePageInitFnMapMutex sync.Mutex + cfg Options + mux *http.ServeMux + contextRegistry map[string]*Context + contextRegistryMutex sync.RWMutex + documentHeadIncludes []h.H + documentFootIncludes []h.H + devModePageInitFnMap map[string]func(*Context) } func (v *V) logErr(c *Context, format string, a ...any) { @@ -77,7 +76,7 @@ func (v *V) logDebug(c *Context, format string, a ...any) { } } -// Config overrides the default configuration with the given configuration options. +// Config overrides the default configuration with the given options. func (v *V) Config(cfg Options) { if cfg.LogLvl != v.cfg.LogLvl { v.cfg.LogLvl = cfg.LogLvl @@ -132,19 +131,20 @@ func (v *V) AppendToFoot(elements ...h.H) { // }) func (v *V) Page(route string, initContextFn func(c *Context)) { if v.cfg.DevMode { - v.devModePageInitFnMapMutex.Lock() - defer v.devModePageInitFnMapMutex.Unlock() v.devModePageInitFnMap[route] = initContextFn } v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v.logDebug(nil, "GET %s", route) if strings.Contains(r.URL.Path, "favicon") { return } id := fmt.Sprintf("%s_/%s", route, genRandID()) - c := newContext(id, v) - v.logDebug(c, "GET %s", route) + c := newContext(id, route, v) initContextFn(c) v.registerCtx(c.id, c) + if v.cfg.DevMode { + v.Persist() + } headElements := v.documentHeadIncludes headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id)))) headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')"))) @@ -195,68 +195,56 @@ func (v *V) HandleFunc(pattern string, f http.HandlerFunc) { // Start starts the Via HTTP server on the given address. func (v *V) Start() { - v.logInfo(nil, "via started on address: %s", v.cfg.ServerAddress) + v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress) log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux)) } -func (v *V) persistCtx(c *Context) error { - idsplit := strings.Split(c.id, "_") - if len(idsplit) < 2 { - return fmt.Errorf("failed to identify ctx page route") - } - route := idsplit[0] - ctxmap := map[string]any{"id": c.id, "route": route} - - p := path.Join(".via", "devmode", "ctx.json") +func (v *V) Persist() { + p := filepath.Join(".via", "devmode", "ctx.json") if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { - return fmt.Errorf("failed to create directory for devmode files: %v", err) + log.Fatalf("failed to create directory for devmode files: %v", err) } - file, err := os.Create(p) if err != nil { - return fmt.Errorf("failed to create file in devmode directory: %v", err) + log.Printf("devmode: failed to persist ctx: %v", err) } defer file.Close() - - encoder := json.NewEncoder(file) - if err := encoder.Encode(ctxmap); err != nil { - return fmt.Errorf("failed to encode ctx: %s", err) + m := make(map[string]string) + for ctxID, ctx := range v.contextRegistry { + m[ctxID] = ctx.route } - return nil + encoder := json.NewEncoder(file) + if err := encoder.Encode(m); err != nil { + log.Printf("devmode: failed to persist ctx: %s", err) + } + log.Printf("devmode persisted ctx registryv") } -func (v *V) restoreCtx() *Context { +func (v *V) Restore() { p := path.Join(".via", "devmode", "ctx.json") file, err := os.Open(p) if err != nil { - fmt.Println("Error opening file:", err) - return nil + v.logErr(nil, "devmode failed to restore ctx: %v", err) + return } defer file.Close() - var ctxmap map[string]any - if err := json.NewDecoder(file).Decode(&ctxmap); err != nil { - fmt.Println("Error restoring ctx:", err) - return nil - } - ctxId, ok := ctxmap["id"].(string) - if !ok { - fmt.Println("Error restoring ctx") - return nil - } - pageRoute, ok := ctxmap["route"].(string) - if !ok { - fmt.Println("Error restoring ctx") - return nil - } - pageInitFn, ok := v.devModePageInitFnMap[pageRoute] - if !ok { - fmt.Println("devmode failed to restore ctx: ") - return nil + var ctxRegMap map[string]string + if err := json.NewDecoder(file).Decode(&ctxRegMap); err != nil { + v.logErr(nil, "devmode failed to restore ctx: %v", err) + return } + for ctxID, pageRoute := range ctxRegMap { + pageInitFn, ok := v.devModePageInitFnMap[pageRoute] + if !ok { + fmt.Println("devmode failed to restore ctx: page init func of ctx not found") + return + } - c := newContext(ctxId, v) - pageInitFn(c) - return c + c := newContext(ctxID, pageRoute, v) + pageInitFn(c) + v.registerCtx(ctxID, c) + v.logDebug(nil, "devmode restored ctx reg=%v", v.contextRegistry) + } } // New creates a new Via application with default configuration. @@ -282,14 +270,10 @@ func New() *V { v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) { var sigs map[string]any _ = datastar.ReadSignals(r, &sigs) - if v.cfg.DevMode && len(v.contextRegistry) == 0 { - restoredC := v.restoreCtx() - if restoredC != nil { - restoredC.injectSignals(sigs) - v.registerCtx(restoredC.id, restoredC) - } - } cID, _ := sigs["via-ctx"].(string) + if v.cfg.DevMode && len(v.contextRegistry) == 0 { + v.Restore() + } c, err := v.getCtx(cID) if err != nil { v.logErr(nil, "failed to render page: %v", err) @@ -299,7 +283,6 @@ func New() *V { v.logDebug(c, "SSE connection established") if v.cfg.DevMode { c.Sync() - v.persistCtx(c) } else { c.SyncSignals() } @@ -312,16 +295,6 @@ func New() *V { var sigs map[string]any _ = datastar.ReadSignals(r, &sigs) cID, _ := sigs["via-ctx"].(string) - active_ctx_count := 0 - inactive_ctx_count := 0 - for _, c := range v.contextRegistry { - if c.sse != nil { - active_ctx_count++ - continue - } - inactive_ctx_count++ - } - v.logDebug(nil, "active_ctx_count=%d inactive_ctx_count=%d", active_ctx_count, inactive_ctx_count) c, err := v.getCtx(cID) if err != nil { v.logErr(nil, "action '%s' failed: %v", actionID, err) @@ -343,7 +316,6 @@ func New() *V { v.logDebug(c, "signals=%v", sigs) c.injectSignals(sigs) actionFn() - }) return v }