From 3b658ed7d070bd6f2442e9f6dc77cf6adea12a82 Mon Sep 17 00:00:00 2001 From: Jeff Winkler Date: Sat, 8 Nov 2025 13:05:23 -0500 Subject: [PATCH] LiveReload --- internal/examples/livereload/.air.toml | 46 +++++++++++++++++ internal/examples/livereload/.gitignore | 2 + internal/examples/livereload/README.md | 28 +++++++++++ internal/examples/livereload/livereload.go | 57 ++++++++++++++++++++++ internal/examples/livereload/main.go | 46 +++++++++++++++++ signal.go | 2 +- via.go | 5 ++ 7 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 internal/examples/livereload/.air.toml create mode 100644 internal/examples/livereload/.gitignore create mode 100644 internal/examples/livereload/README.md create mode 100644 internal/examples/livereload/livereload.go create mode 100644 internal/examples/livereload/main.go diff --git a/internal/examples/livereload/.air.toml b/internal/examples/livereload/.air.toml new file mode 100644 index 0000000..49468ed --- /dev/null +++ b/internal/examples/livereload/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/internal/examples/livereload/.gitignore b/internal/examples/livereload/.gitignore new file mode 100644 index 0000000..8b2f15d --- /dev/null +++ b/internal/examples/livereload/.gitignore @@ -0,0 +1,2 @@ +tmp/ +build-errors.log diff --git a/internal/examples/livereload/README.md b/internal/examples/livereload/README.md new file mode 100644 index 0000000..bc4f348 --- /dev/null +++ b/internal/examples/livereload/README.md @@ -0,0 +1,28 @@ +# Live Reload with Air + +Hot-reloads your Go code and web page. + +## Setup + +If you don't have Air yet: +```bash +go install github.com/air-verse/air@latest +``` + +## Run +```bash +air +``` + +Then open `http://localhost:3000` in your browser. + +## How It Works + +Air watches your Go files and rebuilds when you make changes. + +LiveReloadPlugin handles browser refresh through a SSE connection at `/dev/reload`. When Air restarts the server, the connection drops, triggering an automatic page reload after 100ms. This only runs on localhost. + +## Files + +- `.air.toml` - Air config +- `livereload.go` - Via plugin for browser auto-reload diff --git a/internal/examples/livereload/livereload.go b/internal/examples/livereload/livereload.go new file mode 100644 index 0000000..7d79803 --- /dev/null +++ b/internal/examples/livereload/livereload.go @@ -0,0 +1,57 @@ +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() + }) +} + +func liveReloadScript() h.H { + return 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 new file mode 100644 index 0000000..91f5365 --- /dev/null +++ b/internal/examples/livereload/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "github.com/go-via/via" + "github.com/go-via/via/h" +) + +type Counter struct{ Count int } + +func main() { + v := via.New() + + LiveReloadPlugin(v) + + v.Config(via.Options{ + DocumentTitle: "Live Reload", + DocumentHeadIncludes: []h.H{ + liveReloadScript(), + }, + }) + + v.Page("/", func(c *via.Context) { + data := Counter{Count: 0} + step := c.Signal(1) + + increment := c.Action(func() { + data.Count += step.Int() + c.Sync() + }) + + c.View(func() h.H { + return h.Div(h.Class("container"), + h.H1(h.Text("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()), + ), + h.Button(h.Text("Increment"), increment.OnClick()), + ) + }) + }) + + v.Start(":3000") +} diff --git a/signal.go b/signal.go index edb38c8..ef83e31 100644 --- a/signal.go +++ b/signal.go @@ -35,7 +35,7 @@ func (s *signal) Err() error { return s.err } -// Bind binds this signal to an imput element. When the imput changes +// Bind binds this signal to an input element. When the input changes // its value the signal updates in real-time in the browser. // // Example: diff --git a/via.go b/via.go index 79f77a7..2ea53c6 100644 --- a/via.go +++ b/via.go @@ -75,6 +75,9 @@ func (v *V) Config(cfg Options) { if cfg.LogLvl != v.cfg.LogLvl { v.cfg.LogLvl = cfg.LogLvl } + if cfg.DocumentTitle != "" { + v.cfg.DocumentTitle = cfg.DocumentTitle + } if cfg.DocumentHeadIncludes != nil { v.cfg.DocumentHeadIncludes = cfg.DocumentHeadIncludes } @@ -142,6 +145,8 @@ func (v *V) registerCtx(id string, c *Context) { // } func (v *V) getCtx(id string) (*Context, error) { + v.contextRegistryMutex.RLock() + defer v.contextRegistryMutex.RUnlock() if c, ok := v.contextRegistry[id]; ok { return c, nil }