From 03ce9808e647b7f0890d9f87af875a199be01562 Mon Sep 17 00:00:00 2001 From: Joao Goncalves Date: Tue, 11 Nov 2025 00:14:13 -0100 Subject: [PATCH] feat: add devmode flag; introduce live reload support; update examples --- .gitignore | 3 + configuration.go | 7 + context.go | 16 +- go.mod | 2 + go.sum | 4 + internal/examples/counter/main.go | 2 +- internal/examples/countercomp/main.go | 2 +- internal/examples/greeter/main.go | 2 +- internal/examples/livereload/livereload.go | 54 ------- internal/examples/livereload/main.go | 7 +- internal/examples/picocss/main.go | 2 +- internal/examples/plugins/main.go | 2 +- internal/examples/realtimechart/main.go | 2 +- via.go | 165 ++++++++++++++++----- 14 files changed, 161 insertions(+), 109 deletions(-) delete mode 100644 internal/examples/livereload/livereload.go diff --git a/.gitignore b/.gitignore index aaadf73..0347222 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Via related +.via diff --git a/configuration.go b/configuration.go index 24b48cd..20e884d 100644 --- a/configuration.go +++ b/configuration.go @@ -9,10 +9,17 @@ const ( LogLevelDebug ) +// 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 type Options struct { + // The development mode flag. If true, enables server and browser auto-reload on `.go` file changes. + DevMode bool + + // The http server address. e.g. ':3000' + ServerAddress string + // Level of the logs to write to stdout. // Options: Error, Warn, Info, Debug. LogLvl LogLevel diff --git a/context.go b/context.go index 1ec493f..1da9661 100644 --- a/context.go +++ b/context.go @@ -6,7 +6,6 @@ import ( "log" "reflect" "sync" - "time" "github.com/go-via/via/h" "github.com/starfederation/datastar-go/datastar" @@ -25,7 +24,6 @@ type Context struct { actionRegistry map[string]func() signals map[string]*signal signalsMux sync.Mutex - createdAt time.Time } // View defines the UI rendered by this context. @@ -116,14 +114,6 @@ func (c *Context) getActionFn(id string) (func(), error) { return nil, fmt.Errorf("action '%s' not found", id) } -func (c *Context) Signals() map[string]*signal { - if c.signals == nil { - c.app.logErr(c, "failed to get signal: nil signals in ctx") - return make(map[string]*signal) - } - return c.signals -} - // Signal creates a reactive signal and initializes it with the given value. // Use Bind() to link the value of input elements to the signal and Text() to // display the signal value and watch the UI update live as the input changes. @@ -179,6 +169,11 @@ func (c *Context) injectSignals(sigs map[string]any) { } for k, v := range sigs { if _, ok := c.signals[k]; !ok { + c.signals[k] = &signal{ + id: k, + t: reflect.TypeOf(v), + v: reflect.ValueOf(v), + } continue } c.signals[k].v = reflect.ValueOf(v) @@ -313,6 +308,5 @@ func newContext(id string, a *V) *Context { componentRegistry: make(map[string]*Context), actionRegistry: make(map[string]func()), signals: make(map[string]*signal), - createdAt: time.Now(), } } diff --git a/go.mod b/go.mod index 13613bf..bba3cf1 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require github.com/starfederation/datastar-go v1.0.3 require ( github.com/CAFxX/httpcompression v0.0.9 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + golang.org/x/sys v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index dd39832..0991bca 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -35,6 +37,8 @@ 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/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/counter/main.go b/internal/examples/counter/main.go index b088371..ed12ab9 100644 --- a/internal/examples/counter/main.go +++ b/internal/examples/counter/main.go @@ -33,5 +33,5 @@ func main() { }) }) - v.Start(":3000") + v.Start() } diff --git a/internal/examples/countercomp/main.go b/internal/examples/countercomp/main.go index 273c5c5..d47a3f9 100644 --- a/internal/examples/countercomp/main.go +++ b/internal/examples/countercomp/main.go @@ -22,7 +22,7 @@ func main() { }) }) - v.Start(":3000") + v.Start() } func counterCompFn(c *via.Context) { diff --git a/internal/examples/greeter/main.go b/internal/examples/greeter/main.go index 41d0456..610ed99 100644 --- a/internal/examples/greeter/main.go +++ b/internal/examples/greeter/main.go @@ -30,5 +30,5 @@ func main() { }) }) - v.Start(":3000") + v.Start() } diff --git a/internal/examples/livereload/livereload.go b/internal/examples/livereload/livereload.go deleted file mode 100644 index 885f982..0000000 --- a/internal/examples/livereload/livereload.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/go-via/via" - "github.com/go-via/via/h" -) - -func LiveReloadPlugin(v *via.V) { - v.HandleFunc("GET /dev/reload", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - <-r.Context().Done() - }) - v.AppendToFoot(h.Script(h.Raw(` - if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { - const evtSource = new EventSource('/dev/reload'); - let overlay = null; - let showTimer = null; - - evtSource.onerror = () => { - evtSource.close(); - - showTimer = setTimeout(() => { - if (!overlay) { - overlay = document.createElement('div'); - overlay.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(200, 200, 200, 0.95); padding: 20px 40px; border-radius: 8px; color: #333; font-size: 24px; z-index: 999999; font-family: -apple-system, sans-serif;'; - overlay.textContent = '🔌 Reconnecting...'; - document.body.appendChild(overlay); - } - }, 1000); - - (async function poll() { - for (let i = 0; i < 100; i++) { - try { - const res = await fetch('/', { method: 'HEAD', signal: AbortSignal.timeout(1000) }); - if (res.ok) { - clearTimeout(showTimer); - if (overlay) overlay.remove(); - location.reload(); - return; - } - } catch (e) {} - await new Promise(r => setTimeout(r, 50)); - } - })(); - }; - } -`))) -} diff --git a/internal/examples/livereload/main.go b/internal/examples/livereload/main.go index 9624091..797274d 100644 --- a/internal/examples/livereload/main.go +++ b/internal/examples/livereload/main.go @@ -12,7 +12,8 @@ func main() { v.Config(via.Options{ DocumentTitle: "Live Reload", - Plugins: []via.Plugin{LiveReloadPlugin}, + DevMode: true, + LogLvl: via.LogLevelDebug, }) v.Page("/", func(c *via.Context) { @@ -26,7 +27,7 @@ func main() { c.View(func() h.H { return h.Div( - h.H1(h.Text("Live Reload")), + h.H1(h.Text("Live Reload with Via DevMode !!!")), h.P(h.Textf("Count: %d", data.Count)), h.P(h.Span(h.Text("Step: ")), h.Span(step.Text())), h.Label( @@ -38,5 +39,5 @@ func main() { }) }) - v.Start(":3000") + v.Start() } diff --git a/internal/examples/picocss/main.go b/internal/examples/picocss/main.go index 28341cb..f0ad2b9 100644 --- a/internal/examples/picocss/main.go +++ b/internal/examples/picocss/main.go @@ -26,5 +26,5 @@ func main() { ) }) }) - v.Start(":3000") + v.Start() } diff --git a/internal/examples/plugins/main.go b/internal/examples/plugins/main.go index 05149ec..5831116 100644 --- a/internal/examples/plugins/main.go +++ b/internal/examples/plugins/main.go @@ -33,7 +33,7 @@ func main() { ) }) }) - v.Start(":3000") + v.Start() } func PicoCSSPlugin(v *via.V) { diff --git a/internal/examples/realtimechart/main.go b/internal/examples/realtimechart/main.go index 8ef31d8..f73f3ee 100644 --- a/internal/examples/realtimechart/main.go +++ b/internal/examples/realtimechart/main.go @@ -28,7 +28,7 @@ func main() { }) }) - v.Start(":3000") + v.Start() } func chartCompFn(c *via.Context) { diff --git a/via.go b/via.go index 20b2f6c..3894d6c 100644 --- a/via.go +++ b/via.go @@ -9,9 +9,13 @@ import ( "crypto/rand" _ "embed" "encoding/hex" + "encoding/json" "fmt" "log" "net/http" + "os" + "path" + "path/filepath" "strings" "sync" @@ -25,12 +29,14 @@ 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 + 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 } func (v *V) logErr(c *Context, format string, a ...any) { @@ -46,7 +52,7 @@ func (v *V) logWarn(c *Context, format string, a ...any) { if c != nil && c.id != "" { cRef = fmt.Sprintf("via-ctx=%q ", c.id) } - if v.cfg.LogLvl <= LogLevelWarn { + if v.cfg.LogLvl >= LogLevelWarn { log.Printf("[warn] %smsg=%q", cRef, fmt.Sprintf(format, a...)) } } @@ -56,7 +62,7 @@ func (v *V) logInfo(c *Context, format string, a ...any) { if c != nil && c.id != "" { cRef = fmt.Sprintf("via-ctx=%q ", c.id) } - if v.cfg.LogLvl <= LogLevelInfo { + if v.cfg.LogLvl >= LogLevelInfo { log.Printf("[info] %smsg=%q", cRef, fmt.Sprintf(format, a...)) } } @@ -86,6 +92,12 @@ func (v *V) Config(cfg Options) { } } } + if cfg.DevMode != v.cfg.DevMode { + v.cfg.DevMode = cfg.DevMode + } + if cfg.ServerAddress != "" { + v.cfg.ServerAddress = cfg.ServerAddress + } } // AppendToHead appends the given h.H nodes to the head of the base HTML document. @@ -106,7 +118,6 @@ func (v *V) AppendToFoot(elements ...h.H) { v.documentFootIncludes = append(v.documentFootIncludes, el) } } - } // Page registers a route and its associated page handler. @@ -119,7 +130,12 @@ func (v *V) AppendToFoot(elements ...h.H) { // return h.H1(h.Text("Hello, Via!")) // }) // }) -func (v *V) Page(route string, composeContext func(c *Context)) { +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) { if strings.Contains(r.URL.Path, "favicon") { return @@ -127,7 +143,7 @@ func (v *V) Page(route string, composeContext func(c *Context)) { id := fmt.Sprintf("%s_/%s", route, genRandID()) c := newContext(id, v) v.logDebug(c, "GET %s", route) - composeContext(c) + initContextFn(c) v.registerCtx(c.id, c) headElements := v.documentHeadIncludes headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id)))) @@ -146,6 +162,10 @@ func (v *V) Page(route string, composeContext func(c *Context)) { func (v *V) registerCtx(id string, 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.logDebug(c, "new context added to registry") } @@ -174,83 +194,158 @@ func (v *V) HandleFunc(pattern string, f http.HandlerFunc) { } // Start starts the Via HTTP server on the given address. -func (v *V) Start(addr string) { - v.logInfo(nil, "via started") - log.Fatalf("via failed: %v", http.ListenAndServe(addr, v.mux)) +func (v *V) Start() { + v.logInfo(nil, "via started on address: %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") + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + return fmt.Errorf("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) + } + defer file.Close() + + encoder := json.NewEncoder(file) + if err := encoder.Encode(ctxmap); err != nil { + return fmt.Errorf("failed to encode ctx: %s", err) + } + return nil +} + +func (v *V) restoreCtx() *Context { + p := path.Join(".via", "devmode", "ctx.json") + file, err := os.Open(p) + if err != nil { + fmt.Println("Error opening file:", err) + return nil + } + 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 + } + + c := newContext(ctxId, v) + pageInitFn(c) + return c } // New creates a new Via application with default configuration. func New() *V { mux := http.NewServeMux() - app := &V{ - mux: mux, - contextRegistry: make(map[string]*Context), + v := &V{ + mux: mux, + contextRegistry: make(map[string]*Context), + devModePageInitFnMap: make(map[string]func(*Context)), cfg: Options{ - LogLvl: LogLevelDebug, - DocumentTitle: "Via Application", + DevMode: false, + ServerAddress: ":3000", + LogLvl: LogLevelInfo, + DocumentTitle: "⚡ Via", }, } - app.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) { + v.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript") _, _ = w.Write(datastarJS) }) - app.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) { + 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) - c, err := app.getCtx(cID) + c, err := v.getCtx(cID) if err != nil { - app.logErr(nil, "failed to render page: %v", err) + v.logErr(nil, "failed to render page: %v", err) return } c.sse = datastar.NewSSE(w, r) - app.logDebug(c, "SSE connection established") - c.SyncSignals() + v.logDebug(c, "SSE connection established") + if v.cfg.DevMode { + c.Sync() + v.persistCtx(c) + } else { + c.SyncSignals() + } <-c.sse.Context().Done() c.sse = nil - app.logDebug(c, "SSE connection closed") + v.logDebug(c, "SSE connection closed") }) - app.mux.HandleFunc("GET /_action/{id}", func(w http.ResponseWriter, r *http.Request) { + v.mux.HandleFunc("GET /_action/{id}", func(w http.ResponseWriter, r *http.Request) { actionID := r.PathValue("id") 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 app.contextRegistry { + for _, c := range v.contextRegistry { if c.sse != nil { active_ctx_count++ continue } inactive_ctx_count++ } - app.logDebug(nil, "active_ctx_count=%d inactive_ctx_count=%d", active_ctx_count, inactive_ctx_count) - c, err := app.getCtx(cID) + 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 { - app.logErr(nil, "action '%s' failed: %v", actionID, err) + v.logErr(nil, "action '%s' failed: %v", actionID, err) return } actionFn, err := c.getActionFn(actionID) if err != nil { - app.logDebug(c, "action '%s' failed: %v", actionID, err) + v.logDebug(c, "action '%s' failed: %v", actionID, err) return } // log err if actionFn panics defer func() { if r := recover(); r != nil { - app.logErr(c, "action '%s' failed: %v", actionID, r) + v.logErr(c, "action '%s' failed: %v", actionID, r) } }() c.signalsMux.Lock() defer c.signalsMux.Unlock() - app.logDebug(c, "signals=%v", sigs) + v.logDebug(c, "signals=%v", sigs) c.injectSignals(sigs) actionFn() }) - return app + return v } func genRandID() string {