feat: introduce ctx close mechanism using beforeunload event; small fixes and improvements; improve live reload example

This commit is contained in:
Joao Goncalves
2025-11-13 12:11:26 -01:00
parent d282773379
commit b9df99889e
6 changed files with 92 additions and 55 deletions

View File

@@ -3,7 +3,8 @@ package via
type LogLevel int type LogLevel int
const ( const (
LogLevelError LogLevel = iota undefined LogLevel = iota
LogLevelError
LogLevelWarn LogLevelWarn
LogLevelInfo LogLevelInfo
LogLevelDebug 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. // 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) type Plugin func(v *V)
// Config defines configuration options for the via application // Config defines configuration options for the via application.
type Options struct { type Options struct {
// The development mode flag. If true, enables server and browser auto-reload on `.go` file changes. // The development mode flag. If true, enables server and browser auto-reload on `.go` file changes.
DevMode bool DevMode bool

3
go.mod
View File

@@ -1,11 +1,12 @@
module github.com/go-via/via module github.com/go-via/via
go 1.25.3 go 1.25.4
require maragu.dev/gomponents v1.2.0 require maragu.dev/gomponents v1.2.0
require ( require (
github.com/fsnotify/fsnotify v1.9.0 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/starfederation/datastar-go v1.0.3
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
) )

2
go.sum
View File

@@ -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/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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 h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= 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= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=

View File

@@ -5,7 +5,7 @@ tmp_dir = "tmp"
[build] [build]
args_bin = [] args_bin = []
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ." cmd = "go build -race -o ./tmp/main ."
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = [] exclude_file = []

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"github.com/go-via/via" "github.com/go-via/via"
"github.com/go-via/via-plugin-picocss/picocss"
"github.com/go-via/via/h" "github.com/go-via/via/h"
) )
@@ -11,9 +12,10 @@ func main() {
v := via.New() v := via.New()
v.Config(via.Options{ v.Config(via.Options{
DocumentTitle: "Live Reload", DocumentTitle: "Live Reload Demo",
DevMode: true, DevMode: true,
LogLvl: via.LogLevelDebug, LogLvl: via.LogLevelDebug,
Plugins: []via.Plugin{picocss.Default},
}) })
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {
@@ -26,15 +28,17 @@ func main() {
}) })
c.View(func() h.H { c.View(func() h.H {
return h.Div( return h.Main(h.Class("container"), h.Br(),
h.H1(h.Text("⚡Via Live Reload!")), h.H1(h.Text("⚡Via Live Reload Demo")),
h.P(h.Textf("Count: %d", data.Count)), h.Hr(),
h.P(h.Span(h.Text("Step: ")), h.Span(step.Text())), h.Div(
h.Label( h.H2(h.Strong(h.Text("Count - ")), h.Textf("%d", data.Count)),
h.Text("Update Step: "), h.H5(h.Strong(h.Text("Step - ")), h.Span(step.Text())),
h.Input(h.Type("number"), step.Bind()), 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()),
) )
}) })
}) })

113
via.go
View File

@@ -14,7 +14,6 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -32,7 +31,7 @@ type V struct {
cfg Options cfg Options
mux *http.ServeMux mux *http.ServeMux
contextRegistry map[string]*Context contextRegistry map[string]*Context
contextRegistryMutex sync.RWMutex contextRegistryMutex sync.Mutex
documentHeadIncludes []h.H documentHeadIncludes []h.H
documentFootIncludes []h.H documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context) 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. // Config overrides the default configuration with the given options.
func (v *V) Config(cfg Options) { func (v *V) Config(cfg Options) {
if cfg.LogLvl != v.cfg.LogLvl { if cfg.LogLvl != undefined {
v.cfg.LogLvl = cfg.LogLvl v.cfg.LogLvl = cfg.LogLvl
} }
if cfg.DocumentTitle != "" { if cfg.DocumentTitle != "" {
@@ -141,46 +140,51 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
id := fmt.Sprintf("%s_/%s", route, genRandID()) id := fmt.Sprintf("%s_/%s", route, genRandID())
c := newContext(id, route, v) c := newContext(id, route, v)
initContextFn(c) initContextFn(c)
v.registerCtx(c.id, c) v.registerCtx(c)
if v.cfg.DevMode { if v.cfg.DevMode {
v.Persist() v.devModePersist(c)
} }
headElements := v.documentHeadIncludes headElements := v.documentHeadIncludes
headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id)))) 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')"))) headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')")))
bottomBodyElements := []h.H{c.view()} bottomBodyElements := []h.H{c.view()}
bottomBodyElements = append(bottomBodyElements, v.documentFootIncludes...) bottomBodyElements = append(bottomBodyElements, v.documentFootIncludes...)
view := h.HTML5(h.HTML5Props{ view := h.HTML5(h.HTML5Props{
Title: v.cfg.DocumentTitle, Title: v.cfg.DocumentTitle,
Head: headElements, Head: headElements,
Body: bottomBodyElements, Body: bottomBodyElements,
HTMLAttrs: []h.H{},
}) })
_ = view.Render(w) _ = view.Render(w)
})) }))
} }
func (v *V) registerCtx(id string, c *Context) { func (v *V) registerCtx(c *Context) {
v.contextRegistryMutex.Lock() v.contextRegistryMutex.Lock()
defer v.contextRegistryMutex.Unlock() defer v.contextRegistryMutex.Unlock()
if c == nil { if c == nil {
v.logErr(c, "failed to add nil context to registry") v.logErr(c, "failed to add nil context to registry")
return return
} }
v.contextRegistry[id] = c v.contextRegistry[c.id] = c
v.logDebug(c, "new context added to registry") v.logDebug(c, "new context added to registry")
} }
// func (a *App) unregisterCtx(id string) { func (v *V) unregisterCtx(id string) {
// if _, ok := a.contextRegistry[id]; ok { v.contextRegistryMutex.Lock()
// a.contextRegistryMutex.Lock() defer v.contextRegistryMutex.Unlock()
// defer a.contextRegistryMutex.Unlock() if id == "" {
// delete(a.contextRegistry, id) return
// } }
// } v.logDebug(nil, "ctx '%s' removed from registry", id)
delete(v.contextRegistry, id)
}
func (v *V) getCtx(id string) (*Context, error) { func (v *V) getCtx(id string) (*Context, error) {
v.contextRegistryMutex.RLock() v.contextRegistryMutex.Lock()
defer v.contextRegistryMutex.RUnlock() defer v.contextRegistryMutex.Unlock()
if c, ok := v.contextRegistry[id]; ok { if c, ok := v.contextRegistry[id]; ok {
return c, nil 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. // Start starts the Via HTTP server on the given address.
func (v *V) Start() { func (v *V) Start() {
if v.cfg.DevMode {
v.devModeRestore()
}
v.logInfo(nil, "via started at [%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)) 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") p := filepath.Join(".via", "devmode", "ctx.json")
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
log.Fatalf("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)
// 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 { 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() defer file.Close()
m := make(map[string]string)
for ctxID, ctx := range v.contextRegistry {
m[ctxID] = ctx.route
}
encoder := json.NewEncoder(file) encoder := json.NewEncoder(file)
if err := encoder.Encode(m); err != nil { if err := encoder.Encode(ctxRegMap); err != nil {
log.Printf("devmode: failed to persist ctx: %s", err) 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() { func (v *V) devModeRestore() {
p := path.Join(".via", "devmode", "ctx.json") p := filepath.Join(".via", "devmode", "ctx.json")
file, err := os.Open(p) file, err := os.Open(p)
if err != nil { 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 return
} }
defer file.Close() defer file.Close()
var ctxRegMap map[string]string var ctxRegMap map[string]string
if err := json.NewDecoder(file).Decode(&ctxRegMap); err != nil { 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 return
} }
for ctxID, pageRoute := range ctxRegMap { for ctxID, pageRoute := range ctxRegMap {
pageInitFn, ok := v.devModePageInitFnMap[pageRoute] pageInitFn, ok := v.devModePageInitFnMap[pageRoute]
if !ok { if !ok {
fmt.Println("devmode failed to restore ctx: page init func of ctx not found") v.logWarn(nil, "devmode could not restore ctx from file: page init fn for route '%s' not found", pageRoute)
return continue
} }
c := newContext(ctxID, pageRoute, v) c := newContext(ctxID, pageRoute, v)
pageInitFn(c) pageInitFn(c)
v.registerCtx(ctxID, c) v.registerCtx(c)
v.logDebug(nil, "devmode restored ctx reg=%v", v.contextRegistry)
} }
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 { func New() *V {
mux := http.NewServeMux() mux := http.NewServeMux()
v := &V{ v := &V{
@@ -271,9 +291,6 @@ func New() *V {
var sigs map[string]any var sigs map[string]any
_ = datastar.ReadSignals(r, &sigs) _ = datastar.ReadSignals(r, &sigs)
cID, _ := sigs["via-ctx"].(string) cID, _ := sigs["via-ctx"].(string)
if v.cfg.DevMode && len(v.contextRegistry) == 0 {
v.Restore()
}
c, err := v.getCtx(cID) c, err := v.getCtx(cID)
if err != nil { if err != nil {
v.logErr(nil, "failed to render page: %v", err) v.logErr(nil, "failed to render page: %v", err)
@@ -313,10 +330,22 @@ func New() *V {
}() }()
c.signalsMux.Lock() c.signalsMux.Lock()
defer c.signalsMux.Unlock() defer c.signalsMux.Unlock()
v.logDebug(c, "signals=%v", sigs)
c.injectSignals(sigs) c.injectSignals(sigs)
actionFn() 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 return v
} }