From b9df99889e55cecd0534a0438e81835412d5cdbc Mon Sep 17 00:00:00 2001 From: Joao Goncalves Date: Thu, 13 Nov 2025 12:11:26 -0100 Subject: [PATCH] feat: introduce ctx close mechanism using beforeunload event; small fixes and improvements; improve live reload example --- configuration.go | 5 +- go.mod | 3 +- go.sum | 2 + internal/examples/livereload/.air.toml | 2 +- internal/examples/livereload/main.go | 22 +++-- via.go | 113 ++++++++++++++++--------- 6 files changed, 92 insertions(+), 55 deletions(-) diff --git a/configuration.go b/configuration.go index 20e884d..10a53bc 100644 --- a/configuration.go +++ b/configuration.go @@ -3,7 +3,8 @@ package via type LogLevel int const ( - LogLevelError LogLevel = iota + undefined LogLevel = iota + LogLevelError LogLevelWarn LogLevelInfo LogLevelDebug @@ -12,7 +13,7 @@ const ( // Plugin is a func that can mutate the given *via.V app runtime. It is useful to integrate popular JS/CSS UI libraries or tools. type Plugin func(v *V) -// Config defines configuration options for the via application +// Config defines configuration options for the via application. type Options struct { // The development mode flag. If true, enables server and browser auto-reload on `.go` file changes. DevMode bool diff --git a/go.mod b/go.mod index ef8c5ca..a35921e 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,12 @@ module github.com/go-via/via -go 1.25.3 +go 1.25.4 require maragu.dev/gomponents v1.2.0 require ( github.com/fsnotify/fsnotify v1.9.0 + github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8 github.com/starfederation/datastar-go v1.0.3 github.com/stretchr/testify v1.10.0 ) diff --git a/go.sum b/go.sum index cc65f9c..69e7967 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ 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/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8 h1:FRjqaCz+MbG53a8EJOJglSK/dcUx7LwR8+UuJBs5bGU= +github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8/go.mod h1:5LEnLE7q8YfYY7jtH/TLPvfquB7Qt9WZ7TbKrskUW+0= 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= diff --git a/internal/examples/livereload/.air.toml b/internal/examples/livereload/.air.toml index 49468ed..2945cea 100644 --- a/internal/examples/livereload/.air.toml +++ b/internal/examples/livereload/.air.toml @@ -5,7 +5,7 @@ tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" - cmd = "go build -o ./tmp/main ." + cmd = "go build -race -o ./tmp/main ." delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] diff --git a/internal/examples/livereload/main.go b/internal/examples/livereload/main.go index c89d54d..87bbd4b 100644 --- a/internal/examples/livereload/main.go +++ b/internal/examples/livereload/main.go @@ -2,6 +2,7 @@ package main import ( "github.com/go-via/via" + "github.com/go-via/via-plugin-picocss/picocss" "github.com/go-via/via/h" ) @@ -11,9 +12,10 @@ func main() { v := via.New() v.Config(via.Options{ - DocumentTitle: "Live Reload", + DocumentTitle: "Live Reload Demo", DevMode: true, LogLvl: via.LogLevelDebug, + Plugins: []via.Plugin{picocss.Default}, }) v.Page("/", func(c *via.Context) { @@ -26,15 +28,17 @@ func main() { }) c.View(func() h.H { - return h.Div( - 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( - h.Text("Update Step: "), - h.Input(h.Type("number"), step.Bind()), + return h.Main(h.Class("container"), h.Br(), + h.H1(h.Text("⚡Via Live Reload Demo")), + h.Hr(), + h.Div( + h.H2(h.Strong(h.Text("Count - ")), h.Textf("%d", data.Count)), + h.H5(h.Strong(h.Text("Step - ")), h.Span(step.Text())), + h.Div(h.Role("group"), + h.Input(h.Type("number"), step.Bind()), + h.Button(h.Text("Increment"), increment.OnClick()), + ), ), - h.Button(h.Text("Increment"), increment.OnClick()), ) }) }) diff --git a/via.go b/via.go index d30b87d..9cc88c6 100644 --- a/via.go +++ b/via.go @@ -14,7 +14,6 @@ import ( "log" "net/http" "os" - "path" "path/filepath" "strings" "sync" @@ -32,7 +31,7 @@ type V struct { cfg Options mux *http.ServeMux contextRegistry map[string]*Context - contextRegistryMutex sync.RWMutex + contextRegistryMutex sync.Mutex documentHeadIncludes []h.H documentFootIncludes []h.H devModePageInitFnMap map[string]func(*Context) @@ -78,7 +77,7 @@ func (v *V) logDebug(c *Context, format string, a ...any) { // Config overrides the default configuration with the given options. func (v *V) Config(cfg Options) { - if cfg.LogLvl != v.cfg.LogLvl { + if cfg.LogLvl != undefined { v.cfg.LogLvl = cfg.LogLvl } if cfg.DocumentTitle != "" { @@ -141,46 +140,51 @@ func (v *V) Page(route string, initContextFn func(c *Context)) { id := fmt.Sprintf("%s_/%s", route, genRandID()) c := newContext(id, route, v) initContextFn(c) - v.registerCtx(c.id, c) + v.registerCtx(c) if v.cfg.DevMode { - v.Persist() + v.devModePersist(c) } 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", `window.addEventListener('beforeunload', (evt) => { + evt.preventDefault(); evt.returnValue = ''; @post('/_session/close'); return ''; })`))) headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')"))) bottomBodyElements := []h.H{c.view()} bottomBodyElements = append(bottomBodyElements, v.documentFootIncludes...) view := h.HTML5(h.HTML5Props{ - Title: v.cfg.DocumentTitle, - Head: headElements, - Body: bottomBodyElements, + Title: v.cfg.DocumentTitle, + Head: headElements, + Body: bottomBodyElements, + HTMLAttrs: []h.H{}, }) _ = view.Render(w) })) } -func (v *V) registerCtx(id string, c *Context) { +func (v *V) registerCtx(c *Context) { v.contextRegistryMutex.Lock() defer v.contextRegistryMutex.Unlock() if c == nil { v.logErr(c, "failed to add nil context to registry") return } - v.contextRegistry[id] = c + v.contextRegistry[c.id] = c v.logDebug(c, "new context added to registry") } -// func (a *App) unregisterCtx(id string) { -// if _, ok := a.contextRegistry[id]; ok { -// a.contextRegistryMutex.Lock() -// defer a.contextRegistryMutex.Unlock() -// delete(a.contextRegistry, id) -// } -// } +func (v *V) unregisterCtx(id string) { + v.contextRegistryMutex.Lock() + defer v.contextRegistryMutex.Unlock() + if id == "" { + return + } + v.logDebug(nil, "ctx '%s' removed from registry", id) + delete(v.contextRegistry, id) +} func (v *V) getCtx(id string) (*Context, error) { - v.contextRegistryMutex.RLock() - defer v.contextRegistryMutex.RUnlock() + v.contextRegistryMutex.Lock() + defer v.contextRegistryMutex.Unlock() if c, ok := v.contextRegistry[id]; ok { return c, nil } @@ -195,59 +199,75 @@ func (v *V) HandleFunc(pattern string, f http.HandlerFunc) { // Start starts the Via HTTP server on the given address. func (v *V) Start() { + if v.cfg.DevMode { + v.devModeRestore() + } v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress) log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux)) } -func (v *V) Persist() { +func (v *V) devModePersist(c *Context) { p := filepath.Join(".via", "devmode", "ctx.json") if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { log.Fatalf("failed to create directory for devmode files: %v", err) } - file, err := os.Create(p) + + // load persisted list from file, or empty list if file not found + file, err := os.Open(p) + ctxRegMap := make(map[string]string) + if err == nil { + json.NewDecoder(file).Decode(&ctxRegMap) + } + file.Close() + + // add ctx to persisted list + if _, ok := ctxRegMap[c.id]; !ok { + ctxRegMap[c.id] = c.route + } + + // write persisted list to file + file, err = os.Create(p) if err != nil { - log.Printf("devmode: failed to persist ctx: %v", err) + v.logErr(c, "devmode failed to percist ctx: %v", err) + } defer file.Close() - m := make(map[string]string) - for ctxID, ctx := range v.contextRegistry { - m[ctxID] = ctx.route - } + encoder := json.NewEncoder(file) - if err := encoder.Encode(m); err != nil { - log.Printf("devmode: failed to persist ctx: %s", err) + if err := encoder.Encode(ctxRegMap); err != nil { + v.logErr(c, "devmode failed to persist ctx") } - log.Printf("devmode persisted ctx registryv") + v.logDebug(c, "devmode persisted ctx to file") } -func (v *V) Restore() { - p := path.Join(".via", "devmode", "ctx.json") +func (v *V) devModeRestore() { + p := filepath.Join(".via", "devmode", "ctx.json") file, err := os.Open(p) if err != nil { - v.logErr(nil, "devmode failed to restore ctx: %v", err) + v.logWarn(nil, "devmode could not restore ctx from file: %v", err) return } defer file.Close() var ctxRegMap map[string]string if err := json.NewDecoder(file).Decode(&ctxRegMap); err != nil { - v.logErr(nil, "devmode failed to restore ctx: %v", err) + v.logWarn(nil, "devmode could not restore ctx from file: %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 + v.logWarn(nil, "devmode could not restore ctx from file: page init fn for route '%s' not found", pageRoute) + continue } c := newContext(ctxID, pageRoute, v) pageInitFn(c) - v.registerCtx(ctxID, c) - v.logDebug(nil, "devmode restored ctx reg=%v", v.contextRegistry) + v.registerCtx(c) } + v.logDebug(nil, "devmode restored ctx registry") } -// New creates a new Via application with default configuration. +// New creates a new *V application with default configuration. func New() *V { mux := http.NewServeMux() v := &V{ @@ -271,9 +291,6 @@ func New() *V { var sigs map[string]any _ = datastar.ReadSignals(r, &sigs) 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) @@ -313,10 +330,22 @@ func New() *V { }() c.signalsMux.Lock() defer c.signalsMux.Unlock() - v.logDebug(c, "signals=%v", sigs) c.injectSignals(sigs) actionFn() }) + v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) { + var sigs map[string]any + _ = datastar.ReadSignals(r, &sigs) + cID, _ := sigs["via-ctx"].(string) + c, err := v.getCtx(cID) + if err != nil { + v.logErr(c, "failed to handle session close: %v", err) + return + } + v.logDebug(c, "session close event triggered") + v.unregisterCtx(c.id) + + }) return v }