9 Commits

Author SHA1 Message Date
Ryan Hamamura
58ad9a2699 feat: add SSE keepalive and liveness tracking for resilient connections
Some checks failed
CI / Build and Test (push) Has been cancelled
Add 30s keepalive pings to prevent proxy/CDN idle timeouts from killing
SSE connections silently. Track lastSeenAt on each SSE connect attempt
so Datastar's retry signals keep contexts alive through the reaper.
2026-02-19 12:07:25 -10:00
Ryan Hamamura
f3a9c8036f refactor: use computed signals in pubsub-crud and chatroom examples
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 09:03:13 -10:00
Ryan Hamamura
6763e1a420 feat: add computed signals for derived reactive values
All checks were successful
CI / Build and Test (push) Successful in 33s
Read-only signals whose value is a function of other signals,
recomputed automatically at sync time. Supports String, Int, Bool,
and Text methods. Components store computed signals on the parent
page context like regular signals.
2026-02-18 09:22:40 -10:00
Ryan Hamamura
5d61149fa3 fix: make embedded NATS opt-in so tests don't hang
All checks were successful
CI / Build and Test (push) Successful in 31s
Move NATS startup from New() to Start(), so tests that don't use
pubsub never block on server initialization. Add a 10s timeout to
WaitForServer() and skip NATS tests gracefully when unavailable.
2026-02-18 08:45:03 -10:00
Ryan Hamamura
08b7dbd17f feat: add WithDebounce and WithThrottle action trigger options
Unify event attribute construction through buildAttrKey() so debounce,
throttle, and window modifiers compose cleanly. OnChange no longer
hardcodes a 200ms debounce — callers opt in explicitly.
2026-02-18 08:44:58 -10:00
Ryan Hamamura
cd2bfb6978 feat: add /release claude command for automated releases
All checks were successful
CI / Build and Test (push) Successful in 29s
2026-02-13 18:05:24 -10:00
Ryan Hamamura
539a2ad504 feat: three-tier context lifecycle (grace → suspended → reaped)
All checks were successful
CI / Build and Test (push) Successful in 1m22s
Contexts that lose their SSE connection now pass through a suspended
state before being fully reaped. Suspended contexts keep their shell
(ID, route, CSRF token) but free page resources. On reconnect, the
page init function is re-run for a seamless resume. Contexts past
the TTL trigger a client-side reload instead of a silent dead page.

Configurable via ContextSuspendAfter (default 15m) and ContextTTL
(default 1h).
2026-02-13 15:22:08 -10:00
Ryan Hamamura
11c6354da0 docs: add guide covering routing, state, HTML DSL, pubsub, and project structure 2026-02-13 10:55:07 -10:00
Ryan Hamamura
719b389be6 refactor: split nats-chatroom into modules with profile, layout, and auth
Extract chat, profile, types, and user data into separate files.
Embed CSS via go:embed. Add a layout with nav, session-based profile
persistence, and a middleware guard that redirects to /profile when
no identity is set.
2026-02-13 10:55:04 -10:00
26 changed files with 2502 additions and 299 deletions

View File

@@ -0,0 +1,14 @@
Create a new release for this project. Steps:
1. Fetch tags from all remotes so the version list is current.
2. Check for uncommitted changes. If any exist, commit them with a clean semantic commit message. No Claude attribution lines.
3. Review the commits since the last tag. Based on their content, recommend a semver bump:
- **major**: breaking/incompatible API changes
- **minor**: new features, meaningful new behavior
- **patch**: bug fixes, docs, refactoring with no new features
Present the proposed version, the bump rationale, and the commit list. Wait for user approval before continuing.
4. Tag the new version and push the tag + commits to all remotes (origin, gitea, etc.).
5. Generate release notes from the commits since the last tag, grouped by type (features, fixes, docs/refactoring).
6. Create a GitHub release using `gh release create`.
7. Create a Gitea release using `tea releases create` with the same notes.
8. Report both release URLs and confirm all remotes are up to date.

1
.gitignore vendored
View File

@@ -48,6 +48,7 @@ internal/examples/plugins/plugins
internal/examples/realtimechart/realtimechart
internal/examples/shakespeare/shakespeare
internal/examples/nats-chatroom/nats-chatroom
/nats-chatroom
# NATS data directory
data/

View File

@@ -3,6 +3,7 @@ package via
import (
"fmt"
"strconv"
"time"
"github.com/ryanhamamura/via/h"
)
@@ -23,6 +24,8 @@ type triggerOpts struct {
value string
window bool
preventDefault bool
debounce time.Duration
throttle time.Duration
}
type withSignalOpt struct {
@@ -58,6 +61,41 @@ func WithPreventDefault() ActionTriggerOption {
return withPreventDefaultOpt{}
}
type withDebounceOpt struct{ d time.Duration }
func (o withDebounceOpt) apply(opts *triggerOpts) { opts.debounce = o.d }
// WithDebounce adds a debounce modifier to the event trigger.
func WithDebounce(d time.Duration) ActionTriggerOption { return withDebounceOpt{d} }
type withThrottleOpt struct{ d time.Duration }
func (o withThrottleOpt) apply(opts *triggerOpts) { opts.throttle = o.d }
// WithThrottle adds a throttle modifier to the event trigger.
func WithThrottle(d time.Duration) ActionTriggerOption { return withThrottleOpt{d} }
// formatDuration renders a duration as e.g. "200ms" for Datastar modifiers.
func formatDuration(d time.Duration) string {
return fmt.Sprintf("%dms", d.Milliseconds())
}
// buildAttrKey constructs a Datastar attribute key with modifiers.
// Order: event → debounce/throttle → window.
func buildAttrKey(event string, opts *triggerOpts) string {
key := "on:" + event
if opts.debounce > 0 {
key += "__debounce." + formatDuration(opts.debounce)
}
if opts.throttle > 0 {
key += "__throttle." + formatDuration(opts.throttle)
}
if opts.window {
key += "__window"
}
return key
}
// WithSignal sets a signal value before triggering the action.
func WithSignal(sig *signal, value string) ActionTriggerOption {
return withSignalOpt{
@@ -97,62 +135,62 @@ func actionURL(id string) string {
// to element nodes in a view.
func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:click", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("click", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnChange returns a via.h DOM attribute that triggers on input change. It can be added
// to element nodes in a view.
func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("change", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnSubmit returns a via.h DOM attribute that triggers on form submit.
func (a *actionTrigger) OnSubmit(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:submit", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("submit", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnInput returns a via.h DOM attribute that triggers on input (without debounce).
func (a *actionTrigger) OnInput(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:input", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("input", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnFocus returns a via.h DOM attribute that triggers when the element gains focus.
func (a *actionTrigger) OnFocus(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:focus", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("focus", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnBlur returns a via.h DOM attribute that triggers when the element loses focus.
func (a *actionTrigger) OnBlur(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:blur", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("blur", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnMouseEnter returns a via.h DOM attribute that triggers when the mouse enters the element.
func (a *actionTrigger) OnMouseEnter(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:mouseenter", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("mouseenter", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnMouseLeave returns a via.h DOM attribute that triggers when the mouse leaves the element.
func (a *actionTrigger) OnMouseLeave(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:mouseleave", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("mouseleave", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnScroll returns a via.h DOM attribute that triggers on scroll.
func (a *actionTrigger) OnScroll(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:scroll", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("scroll", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnDblClick returns a via.h DOM attribute that triggers on double click.
func (a *actionTrigger) OnDblClick(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:dblclick", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("dblclick", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed.
@@ -164,11 +202,7 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
if key != "" {
condition = fmt.Sprintf("evt.key==='%s' &&", key)
}
attrName := "on:keydown"
if opts.window {
attrName = "on:keydown__window"
}
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
return h.Data(buildAttrKey("keydown", &opts), fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
}
// KeyBinding pairs a key with an action and per-binding options.

55
computed.go Normal file
View File

@@ -0,0 +1,55 @@
package via
import (
"fmt"
"strconv"
"strings"
"github.com/ryanhamamura/via/h"
)
// computedSignal is a read-only signal whose value is derived from other signals.
// It recomputes on every read and is included in patches only when the value changes.
type computedSignal struct {
id string
compute func() string
lastVal string
changed bool
}
func (s *computedSignal) ID() string {
return s.id
}
func (s *computedSignal) String() string {
return s.compute()
}
func (s *computedSignal) Int() int {
if n, err := strconv.Atoi(s.String()); err == nil {
return n
}
return 0
}
func (s *computedSignal) Bool() bool {
val := strings.ToLower(s.String())
return val == "true" || val == "1" || val == "yes" || val == "on"
}
func (s *computedSignal) Text() h.H {
return h.Span(h.Data("text", "$"+s.id))
}
// recompute calls the compute function and sets changed if the value differs from lastVal.
func (s *computedSignal) recompute() {
val := s.compute()
if val != s.lastVal {
s.lastVal = val
s.changed = true
}
}
func (s *computedSignal) patchValue() string {
return fmt.Sprintf("%v", s.lastVal)
}

190
computed_test.go Normal file
View File

@@ -0,0 +1,190 @@
package via
import (
"bytes"
"fmt"
"testing"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
)
func TestComputedBasic(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig1 := c.Signal("hello")
sig2 := c.Signal("world")
cs = c.Computed(func() string {
return sig1.String() + " " + sig2.String()
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "hello world", cs.String())
}
func TestComputedReactivity(t *testing.T) {
v := New()
var cs *computedSignal
var sig1 *signal
v.Page("/", func(c *Context) {
sig1 = c.Signal("a")
sig2 := c.Signal("b")
cs = c.Computed(func() string {
return sig1.String() + sig2.String()
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "ab", cs.String())
sig1.SetValue("x")
assert.Equal(t, "xb", cs.String())
}
func TestComputedInt(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig := c.Signal(21)
cs = c.Computed(func() string {
return fmt.Sprintf("%d", sig.Int()*2)
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, 42, cs.Int())
}
func TestComputedBool(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig := c.Signal("true")
cs = c.Computed(func() string {
return sig.String()
})
c.View(func() h.H { return h.Div() })
})
assert.True(t, cs.Bool())
}
func TestComputedText(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
cs = c.Computed(func() string { return "hi" })
c.View(func() h.H { return h.Div() })
})
var buf bytes.Buffer
err := cs.Text().Render(&buf)
assert.NoError(t, err)
assert.Contains(t, buf.String(), `data-text="$`+cs.ID()+`"`)
}
func TestComputedChangeDetection(t *testing.T) {
v := New()
var ctx *Context
var sig *signal
v.Page("/", func(c *Context) {
ctx = c
sig = c.Signal("a")
c.Computed(func() string {
return sig.String() + "!"
})
c.View(func() h.H { return h.Div() })
})
// First patch includes computed (changed=true from init)
patch1 := ctx.prepareSignalsForPatch()
assert.NotEmpty(t, patch1)
// Second patch: nothing changed, computed should not be included
patch2 := ctx.prepareSignalsForPatch()
// Regular signal still has changed=true (not reset in prepareSignalsForPatch),
// but computed should not appear since its value didn't change.
hasComputed := false
ctx.signals.Range(func(_, value any) bool {
if cs, ok := value.(*computedSignal); ok {
_, inPatch := patch2[cs.ID()]
hasComputed = inPatch
}
return true
})
assert.False(t, hasComputed)
// After changing dependency, computed should reappear
sig.SetValue("b")
patch3 := ctx.prepareSignalsForPatch()
found := false
ctx.signals.Range(func(_, value any) bool {
if cs, ok := value.(*computedSignal); ok {
if v, ok := patch3[cs.ID()]; ok {
assert.Equal(t, "b!", v)
found = true
}
}
return true
})
assert.True(t, found)
}
func TestComputedInComponent(t *testing.T) {
v := New()
var cs *computedSignal
var parentCtx *Context
v.Page("/", func(c *Context) {
parentCtx = c
c.Component(func(comp *Context) {
sig := comp.Signal("via")
cs = comp.Computed(func() string {
return "hello " + sig.String()
})
comp.View(func() h.H { return h.Div() })
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "hello via", cs.String())
// Verify it's stored on the parent page context
found := false
parentCtx.signals.Range(func(_, value any) bool {
if stored, ok := value.(*computedSignal); ok && stored.ID() == cs.ID() {
found = true
}
return true
})
assert.True(t, found)
}
func TestComputedIsReadOnly(t *testing.T) {
// Compile-time guarantee: *computedSignal has no Bind() or SetValue() methods.
// This test exists as documentation — if someone adds those methods, the
// interface assertion below will need updating and serve as a reminder.
var cs interface{} = &computedSignal{}
type writable interface {
SetValue(any)
}
type bindable interface {
Bind() h.H
}
_, isWritable := cs.(writable)
_, isBindable := cs.(bindable)
assert.False(t, isWritable, "computedSignal must not have SetValue")
assert.False(t, isBindable, "computedSignal must not have Bind")
}
func TestComputedInjectSignalsSkips(t *testing.T) {
v := New()
var ctx *Context
var cs *computedSignal
v.Page("/", func(c *Context) {
ctx = c
cs = c.Computed(func() string { return "fixed" })
c.View(func() h.H { return h.Div() })
})
// Simulate browser sending back a value for the computed signal — should be ignored
ctx.injectSignals(map[string]any{
cs.ID(): "injected",
})
assert.Equal(t, "fixed", cs.String())
}

View File

@@ -57,9 +57,14 @@ type Options struct {
// embedded NATS backend, or supply any PubSub implementation.
PubSub PubSub
// ContextSuspendAfter is the time a context may be disconnected before
// the reaper suspends it (frees page resources but keeps the context
// shell for seamless re-init on reconnect). Default: 15m.
ContextSuspendAfter time.Duration
// ContextTTL is the maximum time a context may exist without an SSE
// connection before the background reaper disposes it.
// Default: 30s. Negative value disables the reaper.
// connection before the background reaper fully disposes it.
// Default: 1h. Negative value disables the reaper.
ContextTTL time.Duration
// ActionRateLimit configures the default token-bucket rate limiter for

View File

@@ -42,6 +42,8 @@ type Context struct {
createdAt time.Time
sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
lastSeenAt atomic.Pointer[time.Time]
suspended atomic.Bool
}
// View defines the UI rendered by this context.
@@ -206,6 +208,40 @@ func (c *Context) Signal(v any) *signal {
}
// Computed creates a read-only signal whose value is derived from the given function.
// The function is called on every read (String/Int/Bool) for fresh values,
// and during sync to detect changes for browser patches.
//
// Computed signals cannot be bound to inputs or set manually.
//
// Example:
//
// full := c.Computed(func() string {
// return first.String() + " " + last.String()
// })
// c.View(func() h.H {
// return h.Span(full.Text())
// })
func (c *Context) Computed(fn func() string) *computedSignal {
sigID := genRandID()
initial := fn()
cs := &computedSignal{
id: sigID,
compute: fn,
lastVal: initial,
changed: true,
}
c.mu.Lock()
defer c.mu.Unlock()
if c.isComponent() {
c.parentPageCtx.signals.Store(sigID, cs)
} else {
c.signals.Store(sigID, cs)
}
return cs
}
func (c *Context) injectSignals(sigs map[string]any) {
if sigs == nil {
c.app.logErr(c, "signal injection failed: nil signals")
@@ -247,7 +283,8 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
defer c.mu.RUnlock()
updatedSigs := make(map[string]any)
c.signals.Range(func(sigID, value any) bool {
if sig, ok := value.(*signal); ok {
switch sig := value.(type) {
case *signal:
if sig.err != nil {
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
return true
@@ -255,6 +292,12 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
if sig.changed {
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
}
case *computedSignal:
sig.recompute()
if sig.changed {
updatedSigs[sigID.(string)] = sig.patchValue()
sig.changed = false
}
}
return true
})
@@ -400,6 +443,13 @@ func (c *Context) resetPageState() {
c.mu.Unlock()
}
// suspend frees page-scoped resources while keeping the context shell alive
// in the registry for seamless re-init on reconnect.
func (c *Context) suspend() {
c.resetPageState()
c.suspended.Store(true)
}
// Navigate performs an SPA navigation to the given path. It resets page state,
// runs the target page's init function (with middleware), and pushes the new
// view over the existing SSE connection with a view transition animation.

179
docs/getting-started.md Normal file
View File

@@ -0,0 +1,179 @@
# Getting Started
Via is a server-side reactive web framework for Go. The browser connects over SSE (Server-Sent Events), and all state lives on the server — signals, actions, and view rendering happen in Go. The browser is a thin display layer that Datastar keeps in sync via DOM morphing.
## Core Loop
Every Via app follows the same pattern:
```go
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "My App",
})
v.Page("/", func(c *via.Context) {
count := 0
step := c.Signal(1)
increment := c.Action(func() {
count += step.Int()
c.Sync()
})
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Count: %d", count)),
h.Label(
h.Text("Step: "),
h.Input(h.Type("number"), step.Bind()),
),
h.Button(h.Text("+"), increment.OnClick()),
)
})
})
v.Start()
}
```
What happens:
1. `via.New()` creates the app, starts an embedded NATS server, and registers internal routes (`/_sse`, `/_action/{id}`, `/_navigate`, `/_session/close`).
2. `v.Config()` applies settings.
3. `v.Page()` registers a route. The init function receives a `*Context` where you define signals, actions, and the view.
4. `v.Start()` starts the HTTP server and blocks until SIGINT/SIGTERM.
When a browser hits the page, Via creates a new `Context`, runs the init function, renders the full HTML document, and opens an SSE connection. From that point, every `c.Sync()` re-renders the view and pushes a DOM patch to the browser.
## Configuration
```go
v.Config(via.Options{
DevMode: true,
ServerAddress: ":8080",
LogLevel: via.LogLevelDebug,
DocumentTitle: "My App",
Plugins: []via.Plugin{MyPlugin},
SessionManager: sm,
PubSub: customBackend,
ContextTTL: 60 * time.Second,
ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40},
})
```
| Field | Default | Description |
|-------|---------|-------------|
| `DevMode` | `false` | Enables context persistence across restarts, console logger, and Datastar inspector widget |
| `ServerAddress` | `":3000"` | HTTP listen address |
| `LogLevel` | `InfoLevel` | Minimum log level. Use `via.LogLevelDebug`, `LogLevelInfo`, `LogLevelWarn`, `LogLevelError` |
| `Logger` | (auto) | Replace the default logger entirely. When set, `LogLevel` and `DevMode` have no effect on logging |
| `DocumentTitle` | `"⚡ Via"` | The `<title>` of the HTML document |
| `Plugins` | `nil` | Slice of plugin functions executed during `Config()` |
| `SessionManager` | in-memory | Cookie-based session manager. See [PubSub and Sessions](pubsub-and-sessions.md) |
| `DatastarContent` | (embedded) | Custom Datastar JS bytes |
| `DatastarPath` | `"/_datastar.js"` | URL path for the Datastar script |
| `PubSub` | embedded NATS | Custom PubSub backend. Replaces the default NATS. See [PubSub and Sessions](pubsub-and-sessions.md) |
| `ContextTTL` | `30s` | Max time a context survives without an SSE connection before cleanup. Negative value disables the reaper |
| `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting |
## Static Files
Serve files from a directory:
```go
v.Static("/assets/", "./static")
```
Or from an embedded filesystem:
```go
//go:embed static
var staticFS embed.FS
v.StaticFS("/assets/", staticFS)
```
Both disable directory listing and return 404 for directory paths.
## Head and Foot Injection
Add elements to every page's `<head>` or end of `<body>`:
```go
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("/assets/style.css")),
h.Meta(h.Attr("name", "viewport"), h.Attr("content", "width=device-width, initial-scale=1")),
)
v.AppendToFoot(
h.Script(h.Src("/assets/app.js")),
)
```
These are additive and affect all pages globally.
## Plugins
A plugin is a `func(v *via.V)` that mutates the app during configuration — registering routes, injecting assets, or applying middleware.
```go
func PicoCSSPlugin(v *via.V) {
v.HTTPServeMux().HandleFunc("GET /css/pico.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
w.Write(picoCSSBytes)
})
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")))
}
// Usage:
v.Config(via.Options{
Plugins: []via.Plugin{PicoCSSPlugin},
})
```
Plugins have full access to the `*V` public API: `HTTPServeMux()`, `AppendToHead()`, `AppendToFoot()`, `Config()`, etc.
## DevMode
Enable during development for a better feedback loop:
```go
v.Config(via.Options{DevMode: true})
```
What it does:
- **Console logger** — Human-readable log output with timestamps.
- **Context persistence** — Saves context-to-route mappings to `.via/devmode/ctx.json`. On server restart, reconnecting browsers restore their state instead of getting a blank page. Pair with [Air](https://github.com/air-verse/air) for hot-reloading.
- **Datastar inspector** — Injects a widget showing live signal values and SSE activity.
## Custom HTTP Handlers
Access the underlying `*http.ServeMux` for custom routes:
```go
mux := v.HTTPServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
```
Register custom handlers before calling `v.Start()`.
## Next Steps
- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation
- [Routing and Navigation](routing-and-navigation.md) — Multi-page apps, middleware, SPA navigation
- [PubSub and Sessions](pubsub-and-sessions.md) — Real-time messaging, persistent sessions
- [HTML DSL](html-dsl.md) — The `h` package reference
- [Project Structure](project-structure.md) — Organizing files as your app grows

164
docs/html-dsl.md Normal file
View File

@@ -0,0 +1,164 @@
# HTML DSL
Reference for the `h` package — Via's HTML builder.
## Overview
The `h` package wraps [gomponents](https://github.com/maragudk/gomponents) with a single interface:
```go
type H interface {
Render(w io.Writer) error
}
```
Every element, attribute, and text node implements `H`. Build HTML by nesting function calls:
```go
import "github.com/ryanhamamura/via/h"
h.Div(h.Class("card"),
h.H2(h.Text("Title")),
h.P(h.Textf("Count: %d", count)),
h.Button(h.Text("Click"), action.OnClick()),
)
```
For cleaner templates, use a dot import:
```go
import . "github.com/ryanhamamura/via/h"
Div(Class("card"),
H2(Text("Title")),
P(Textf("Count: %d", count)),
Button(Text("Click"), action.OnClick()),
)
```
## Text Nodes
| Function | Description |
|----------|-------------|
| `Text(s)` | Escaped text node |
| `Textf(fmt, args...)` | Escaped text with `fmt.Sprintf` |
| `Raw(s)` | Unescaped raw HTML — use for trusted content like SVG |
| `Rawf(fmt, args...)` | Unescaped raw HTML with `fmt.Sprintf` |
## Elements
Every element function takes `...H` children (elements, attributes, and text nodes mixed together) except `Style(v string)` and `Title(v string)` which take a single string.
### Document structure
`HTML`, `Head`, `Body`, `Main`, `Header`, `Footer`, `Section`, `Article`, `Aside`, `Nav`, `Div`, `Span`
### Headings
`H1`, `H2`, `H3`, `H4`, `H5`, `H6`
### Text
`P`, `A`, `Strong`, `Em`, `B`, `I`, `U`, `S`, `Small`, `Mark`, `Del`, `Ins`, `Sub`, `Sup`, `Abbr`, `Cite`, `Code`, `Pre`, `Samp`, `Kbd`, `Var`, `Q`, `BlockQuote`, `Dfn`, `Wbr`, `Br`, `Hr`
### Forms
`Form`, `Input`, `Textarea`, `Select`, `Option`, `OptGroup`, `Button`, `Label`, `FieldSet`, `Legend`, `DataList`, `Meter`, `Progress`
### Tables
`Table`, `THead`, `TBody`, `TFoot`, `Tr`, `Th`, `Td`, `Caption`, `Col`, `ColGroup`
### Lists
`Ul`, `Ol`, `Li`, `Dl`, `Dt`, `Dd`
### Media
`Img`, `Audio`, `Video`, `Source`, `Picture`, `Canvas`, `IFrame`, `Embed`, `Object`
### Other
`Details`, `Summary`, `Dialog`, `Template`, `NoScript`, `Figure`, `FigCaption`, `Address`, `Time`, `Base`, `Link`, `Meta`, `Script`, `Area`
### Special signatures
| Function | Signature | Notes |
|----------|-----------|-------|
| `Style(v)` | `func Style(v string) H` | Inline `style` attribute, not a container element |
| `StyleEl(children...)` | `func StyleEl(children ...H) H` | The `<style>` element as a container |
| `Title(v)` | `func Title(v string) H` | Sets `<title>` text |
## Attributes
### Generic
```go
Attr("name", "value") // name="value"
Attr("disabled") // boolean attribute (no value)
```
`Attr` with no value produces a boolean attribute. With one value, it produces a name-value pair. More than one value panics.
### Named helpers
| Function | HTML output |
|----------|-------------|
| `ID(v)` | `id="v"` |
| `Class(v)` | `class="v"` |
| `Href(v)` | `href="v"` |
| `Src(v)` | `src="v"` |
| `Type(v)` | `type="v"` |
| `Value(v)` | `value="v"` |
| `Placeholder(v)` | `placeholder="v"` |
| `Rel(v)` | `rel="v"` |
| `Role(v)` | `role="v"` |
| `Data(name, v)` | `data-name="v"` (auto-prefixes `data-`) |
## Conditional Rendering
```go
h.If(showError, h.P(h.Class("error"), h.Text("Something went wrong")))
```
Returns the node when `true`, `nil` (renders nothing) when `false`.
## Datastar Helpers
These produce attributes used by Datastar for client-side reactivity.
| Function | Output | Description |
|----------|--------|-------------|
| `DataInit(expr)` | `data-init="expr"` | Initialize client-side state |
| `DataEffect(expr)` | `data-effect="expr"` | Reactive side effect expression |
| `DataIgnoreMorph()` | `data-ignore-morph` | Skip this element during DOM morph. See [SPA Navigation](routing-and-navigation.md#dataignoremorph) |
| `DataViewTransition(name)` | `style="view-transition-name: name"` | Animate element across SPA navigations. See [View Transitions](routing-and-navigation.md#view-transitions) |
> `DataViewTransition` sets the entire `style` attribute. If you also need other inline styles, include `view-transition-name` directly in a `Style()` call.
## Utilities
### HTML5
Full HTML5 document template:
```go
h.HTML5(h.HTML5Props{
Title: "My Page",
Description: "Page description",
Language: "en",
Head: []h.H{h.Link(h.Rel("stylesheet"), h.Href("/style.css"))},
Body: []h.H{h.Div(h.Text("Hello"))},
})
```
Via uses this internally to render the initial page document. You typically don't need it directly.
### JoinAttrs
Joins attribute values from child nodes by spaces:
```go
h.JoinAttrs("class", h.Class("card"), h.Class("active"))
// → class="card active"
```

164
docs/project-structure.md Normal file
View File

@@ -0,0 +1,164 @@
# Project Structure
Via's closure-based page model pulls signals, actions, and views into a single scope — similar to Svelte's single-file components. This works well at every scale, but the way you organize files should evolve as your app grows.
## Stage 1: Everything in main.go
For small apps and prototypes, keep everything in `main.go`. This is the right choice when your app is under ~150 lines or has a single page.
Within the file, follow this ordering convention inside each page:
```go
v.Page("/", func(c *via.Context) {
// State — plain Go variables and signals
count := 0
step := c.Signal(1)
// Actions — event handlers that mutate state
increment := c.Action(func() {
count += step.Int()
c.Sync()
})
// View — returns the HTML tree
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Count: %d", count)),
h.Button(h.Text("+"), increment.OnClick()),
)
})
})
```
State → signals → actions → view. This reads top-to-bottom and matches the data flow: state is declared, actions mutate it, the view renders it.
The [counter](../internal/examples/counter/main.go) and [greeter](../internal/examples/greeter/main.go) examples use this layout.
## Stage 2: Page per file
When `main.go` has multiple pages or exceeds ~150 lines, extract each page into its own file as a package-level function.
`main.go` becomes the app skeleton — setup, configuration, routes, and start:
```go
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "My App",
})
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")),
)
v.Page("/", HomePage)
v.Page("/chat", ChatPage)
v.Start()
}
```
Each page lives in its own file with a descriptive name:
```go
// home.go
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func HomePage(c *via.Context) {
greeting := c.Signal("Hello")
c.View(func() h.H {
return h.Div(h.P(h.Text(greeting.String())))
})
}
```
Components follow the same pattern — keep them in the page file if single-use, or extract to their own file if reused across pages. Middleware goes in the same file as the route group it protects, or in `middleware.go` if shared.
```
myapp/
├── main.go # skeleton + routes
├── home.go # func HomePage(c *via.Context)
├── chat.go # func ChatPage(c *via.Context)
└── middleware.go # shared middleware
```
## Stage 3: Co-located CSS and shared types
As pages accumulate custom styling, CSS strings in Go become hard to maintain — no syntax highlighting, no linting. Extract them to `.css` files alongside the pages they belong to and use `//go:embed` to load them.
```go
// main.go
package main
import "embed"
//go:embed chat.css
var chatCSS string
func main() {
v := via.New()
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.StyleEl(h.Raw(chatCSS)),
)
// ...
}
```
When multiple pages share the same structs, extract them to `types.go`. Framework-agnostic domain logic (helpers, dummy data, business rules) gets its own file too.
```
myapp/
├── main.go # skeleton + routes + global styles
├── home.go
├── chat.go
├── chat.css # //go:embed in main.go
├── types.go # shared types
└── userdata.go # helpers, dummy data
```
The [nats-chatroom](../internal/examples/nats-chatroom/) example demonstrates this layout.
## CSS Approaches
Via doesn't prescribe a CSS strategy. Two approaches work well:
**CSS framework classes in Go code** — Use Pico, Tailwind, or similar. Classes go directly in the view via `h.Class()`. Good for rapid prototyping since there's nothing to extract.
```go
h.Div(h.Class("container"),
h.Button(h.Class("primary"), h.Text("Save")),
)
```
**Co-located `.css` files with `//go:embed`** — Write plain CSS in a separate file, embed it, and inject via `AppendToHead`. You get syntax highlighting, linting, and clean separation.
```go
//go:embed chat.css
var chatCSS string
// in main():
v.AppendToHead(h.StyleEl(h.Raw(chatCSS)))
```
Use a framework for quick prototypes and dashboards. Switch to co-located CSS files when you have significant custom styling or want tooling support.
## Next Steps
- [Getting Started](getting-started.md) — The core loop and configuration
- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation

251
docs/pubsub-and-sessions.md Normal file
View File

@@ -0,0 +1,251 @@
# PubSub and Sessions
Infrastructure for multi-user real-time communication and persistent state.
## PubSub
Via includes an embedded NATS server that starts automatically with `via.New()`. No external services required — pub/sub works out of the box.
### Interface
```go
type PubSub interface {
Publish(subject string, data []byte) error
Subscribe(subject string, handler func(data []byte)) (Subscription, error)
Close() error
}
type Subscription interface {
Unsubscribe() error
}
```
You can replace the default NATS with any backend implementing this interface via `Options.PubSub`.
### Basic pub/sub
```go
// Subscribe to messages
via.Subscribe(c, "chat.room.general", func(msg ChatMessage) {
messages = append(messages, msg)
c.Sync()
})
// Publish a message
via.Publish(c, "chat.room.general", ChatMessage{
User: username,
Message: text,
Time: time.Now().UnixMilli(),
})
```
The generic helpers `via.Publish[T]` and `via.Subscribe[T]` handle JSON marshaling/unmarshaling automatically. They are package-level functions (not methods) because Go doesn't support generic methods.
Raw byte-level access is also available on the context:
```go
c.Publish("subject", []byte("raw data"))
c.Subscribe("subject", func(data []byte) { /* ... */ })
```
### Auto-cleanup
Subscriptions created via `c.Subscribe()` or `via.Subscribe()` are tracked on the context and automatically unsubscribed when:
- The context is disposed (browser disconnects, tab closes)
- SPA navigation moves to a different page
You don't need to manually unsubscribe in normal usage.
### Custom backend
Replace the embedded NATS with your own PubSub implementation:
```go
v.Config(via.Options{
PubSub: myRedisBackend,
})
```
This disables the embedded NATS server. The `NATSConn()` and `JetStream()` accessors will return nil.
## JetStream
NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages.
### Ensure a stream exists
```go
err := via.EnsureStream(v, via.StreamConfig{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
})
```
| Field | Description |
|-------|-------------|
| `Name` | Stream name |
| `Subjects` | NATS subjects to capture (supports wildcards: `>` matches all sub-levels) |
| `MaxMsgs` | Maximum number of messages to retain |
| `MaxAge` | Maximum age before messages are discarded |
Call `EnsureStream` during app initialization, before `v.Start()`.
### Replay history
Retrieve recent messages from a stream:
```go
messages, err := via.ReplayHistory[ChatMessage](v, "chat.room.general", 50)
```
Returns up to the last `limit` messages on the subject, deserialized as `T`. Use this when a new user joins and needs to see recent history.
### Direct NATS access
For advanced use cases, access the NATS connection and JetStream context directly:
```go
nc := v.NATSConn() // *nats.Conn, nil if custom PubSub
js := v.JetStream() // nats.JetStreamContext, nil if custom PubSub
```
### PubSub accessor
Access the configured PubSub backend from the `V` instance:
```go
ps := v.PubSub() // via.PubSub interface, nil if none configured
```
## Sessions
Via uses [SCS](https://github.com/alexedwards/scs) for cookie-based session management.
### Setup with SQLite
```go
db, _ := sql.Open("sqlite3", "app.db")
sm, _ := via.NewSQLiteSessionManager(db)
sm.Lifetime = 24 * time.Hour
sm.Cookie.SameSite = http.SameSiteLaxMode
v.Config(via.Options{SessionManager: sm})
```
`NewSQLiteSessionManager` creates the `sessions` table and index if they don't exist. The returned `*scs.SessionManager` can be configured further (lifetime, cookie settings) before passing to `Config`.
A default in-memory session manager is always available, even without explicit configuration. Use `NewSQLiteSessionManager` when you need sessions to survive server restarts.
### Session API
Access the session from any context:
```go
s := c.Session()
```
**Getters:**
| Method | Return type |
|--------|-------------|
| `s.Get(key)` | `any` |
| `s.GetString(key)` | `string` |
| `s.GetInt(key)` | `int` |
| `s.GetBool(key)` | `bool` |
| `s.GetFloat64(key)` | `float64` |
| `s.GetTime(key)` | `time.Time` |
| `s.GetBytes(key)` | `[]byte` |
**Pop** (get and delete — useful for flash messages):
| Method | Return type |
|--------|-------------|
| `s.Pop(key)` | `any` |
| `s.PopString(key)` | `string` |
| `s.PopInt(key)` | `int` |
| `s.PopBool(key)` | `bool` |
| `s.PopFloat64(key)` | `float64` |
| `s.PopTime(key)` | `time.Time` |
| `s.PopBytes(key)` | `[]byte` |
**Mutators:**
| Method | Description |
|--------|-------------|
| `s.Set(key, val)` | Store a value |
| `s.Delete(key)` | Remove a single key |
| `s.Clear()` | Remove all session data |
| `s.Destroy()` | Destroy the entire session (for logout) |
| `s.RenewToken()` | Regenerate session ID (prevents session fixation — call after login) |
**Introspection:**
| Method | Description |
|--------|-------------|
| `s.Exists(key)` | True if key exists |
| `s.Keys()` | All keys in the session |
| `s.ID()` | Session token (cookie value) |
All getters return zero values if the key doesn't exist or the session manager is nil.
### Auth pattern
A common login/logout flow using sessions and middleware:
```go
// Middleware
func authRequired(c *via.Context, next func()) {
if c.Session().GetString("username") == "" {
c.Session().Set("flash", "Please log in first")
c.RedirectView("/login")
return
}
next()
}
// Login page
v.Page("/login", func(c *via.Context) {
user := c.Signal("")
pass := c.Signal("")
flash := c.Session().PopString("flash")
login := c.Action(func() {
if authenticate(user.String(), pass.String()) {
c.Session().RenewToken()
c.Session().Set("username", user.String())
c.Redirect("/dashboard")
} else {
flash = "Invalid credentials"
c.Sync()
}
})
c.View(func() h.H {
return h.Form(login.OnSubmit(),
h.If(flash != "", h.P(h.Text(flash))),
h.Input(h.Type("text"), user.Bind(), h.Placeholder("Username")),
h.Input(h.Type("password"), pass.Bind(), h.Placeholder("Password")),
h.Button(h.Type("submit"), h.Text("Log In")),
)
})
})
// Protected pages
protected := v.Group("", authRequired)
protected.Page("/dashboard", dashboardHandler)
// Logout action (inside a protected page)
logout := c.Action(func() {
c.Session().Destroy()
c.Redirect("/login")
})
```
Key points:
- Call `RenewToken()` after login to prevent session fixation.
- Use `PopString` for flash messages — they're read once then removed.
- Use `RedirectView` in middleware, `Redirect` in actions. See the [gotcha in routing](routing-and-navigation.md#middleware).

View File

@@ -0,0 +1,222 @@
# Routing and Navigation
Multi-page app structure, middleware, and Via's SPA navigation system.
## Pages
Register a page with a route pattern and an init function:
```go
v.Page("/", func(c *via.Context) {
c.View(func() h.H {
return h.H1(h.Text("Home"))
})
})
```
Routes use Go's standard `net/http.ServeMux` patterns. Via registers each page as a `GET` handler.
> **Gotcha:** Via runs every page init function at registration time (in a `defer/recover` block) to catch panics early. If your init function panics — e.g. by forgetting `c.View()` — the app crashes at startup, not at request time.
## Path Parameters
Use `{param}` syntax in route patterns:
```go
v.Page("/users/{id}/posts/{post_id}", func(c *via.Context) {
userID := c.GetPathParam("id")
postID := c.GetPathParam("post_id")
c.View(func() h.H {
return h.P(h.Textf("User %s, Post %s", userID, postID))
})
})
```
`GetPathParam` returns an empty string if the parameter doesn't exist.
## Route Groups
Group pages under a shared prefix with shared middleware:
```go
admin := v.Group("/admin", authRequired)
admin.Page("/dashboard", dashboardHandler) // route: /admin/dashboard
admin.Page("/settings", settingsHandler) // route: /admin/settings
```
### Nesting
Groups nest — the child inherits the parent's prefix and middleware:
```go
admin := v.Group("/admin", authRequired)
admin.Use(auditLog) // add middleware after creation
superAdmin := admin.Group("/super", superAdminOnly)
superAdmin.Page("/nuke", nukeHandler) // route: /admin/super/nuke
// middleware order: global → authRequired → auditLog → superAdminOnly → handler
```
### Empty prefix
Use an empty prefix when you need shared middleware without a path prefix:
```go
protected := v.Group("", authRequired)
protected.Page("/dashboard", dashboardHandler) // route: /dashboard
protected.Page("/profile", profileHandler) // route: /profile
```
## Middleware
```go
type Middleware func(c *Context, next func())
```
Call `next()` to continue the chain. Return without calling `next()` to abort — but set a view first.
```go
func authRequired(c *via.Context, next func()) {
if c.Session().GetString("username") == "" {
c.Session().Set("flash", "Please log in")
c.RedirectView("/login")
return // don't call next — chain is aborted
}
next()
}
```
> **Gotcha:** Use `c.RedirectView()` in middleware, not `c.Redirect()`. The SSE connection isn't open yet during the initial page load, so `Redirect()` (which sends a patch over SSE) won't work. `RedirectView()` sets the view to one that triggers a redirect once SSE connects.
### Three levels
| Level | Registration | Scope |
|-------|-------------|-------|
| Global | `v.Use(mw...)` | Every page |
| Group | `v.Group(prefix, mw...)` or `g.Use(mw...)` | Pages in the group |
| Action | `c.Action(fn, via.WithMiddleware(mw...))` | A single action endpoint |
### Execution order
Middleware runs in registration order: global first, then group, then the handler.
```go
v.Use(logger) // 1st
admin := v.Group("/admin", auth) // 2nd
admin.Use(audit) // 3rd
admin.Page("/x", handler) // 4th
// execution: logger → auth → audit → handler
```
Action-level middleware runs after CSRF validation and rate limiting, when the action endpoint is invoked.
## SPA Navigation
Via intercepts same-origin link clicks and navigates without a full page reload. The SSE connection persists, and the new page's view is morphed into the DOM with a view transition.
### How it works
1. `navigate.js` (embedded in every page) intercepts clicks on `<a>` elements.
2. For same-origin links, it POSTs to `/_navigate` with the context ID, CSRF token, and target URL.
3. The server calls `c.Navigate()`, which:
- Resets page state (stops intervals, unsubscribes PubSub, clears signals/actions/fields)
- Runs the target page's init function (with middleware) on the **same context**
- Pushes the new view via SSE with a view transition
- Updates the browser URL via `history.pushState()`
### What gets cleaned up on navigate
- Intervals stop (via `pageStopChan`)
- PubSub subscriptions are unsubscribed
- Signals, actions, and fields are cleared
- The new page starts completely fresh
The SSE connection and the context itself survive. This is what makes it an SPA — the existing stream is reused.
### Layouts
Define a layout to provide persistent chrome (nav bars, sidebars) that wraps every page:
```go
v.Layout(func(content func() h.H) h.H {
return h.Div(
h.Nav(
h.A(h.Href("/"), h.Text("Home")),
h.A(h.Href("/counter"), h.Text("Counter")),
h.A(h.Href("/clock"), h.Text("Clock")),
),
h.Main(content()),
)
})
```
The `content` parameter is the page's view function. During SPA navigation, the entire layout + content is re-rendered and morphed — Datastar's morph algorithm (idiomorph) efficiently updates only the changed parts, so the nav bar stays visually stable while the main content transitions.
> **Gotcha:** Layout state does not persist across navigations in the way page state doesn't — the layout is re-rendered from scratch each time. If you need state that survives navigation (like a selected nav item), derive it from the current route rather than storing it in a variable.
### View transitions
Animate elements across page navigations using the browser View Transitions API:
```go
// On the home page:
h.H1(h.Text("Home"), h.DataViewTransition("page-title"))
// On the counter page:
h.H1(h.Text("Counter"), h.DataViewTransition("page-title"))
```
Elements with matching `view-transition-name` values animate smoothly during SPA navigation. `DataViewTransition` sets the CSS `view-transition-name` as an inline `style` attribute. If the element also needs other inline styles, set `view-transition-name` directly in a `Style()` call instead.
Via automatically includes the `<meta name="view-transition" content="same-origin">` tag to enable the API.
### Opting out
Add `data-via-no-boost` to links that should trigger a full page reload:
```go
h.A(h.Href("/"), h.Text("Full Reload"), h.Attr("data-via-no-boost"))
```
Links are also auto-ignored when:
- They have a `target` attribute (e.g. `target="_blank"`)
- Modifier keys are held (Ctrl, Meta, Shift, Alt)
- The `href` starts with `#` or is cross-origin
- The `href` is missing
### Programmatic navigation
Trigger SPA navigation from an action handler:
```go
goCounter := c.Action(func() {
c.Navigate("/counter", false)
})
```
The second parameter controls history behavior: `false` for `pushState` (normal navigation), `true` for `replaceState` (back/forward).
If the path doesn't match any registered route, `Navigate` falls back to `c.Redirect()` (full page navigation).
### DataIgnoreMorph
Prevent Datastar from overwriting an element during morph:
```go
h.Div(h.ID("toast-container"), h.DataIgnoreMorph())
```
The element and its subtree are skipped during DOM patches. Useful for elements with client-side state: a focused input, an animation, a third-party widget, or a toast notification container.
## Custom HTTP Handlers
Access the underlying mux for non-Via routes (APIs, webhooks, health checks):
```go
mux := v.HTTPServeMux()
mux.HandleFunc("GET /api/health", healthHandler)
mux.HandleFunc("POST /api/webhook", webhookHandler)
```
Register before `v.Start()`. These routes bypass Via's context/SSE system entirely.

View File

@@ -0,0 +1,313 @@
# State and Interactivity
This is the core reactive model — signals, actions, views, components, and validation.
## Context Lifecycle
A `*Context` is created per browser visit. It holds all page state: signals, actions, fields, subscriptions, and the view function.
```
Browser hits page → new Context created → init function runs → HTML rendered
SSE connection opens ← browser loads page
action fires → signals injected from browser → handler runs → Sync() → DOM patched
```
The context is disposed when the SSE connection closes (tab close, navigation away, network loss). A background reaper also cleans up contexts that never establish an SSE connection within `ContextTTL` (default 30s).
During [SPA navigation](routing-and-navigation.md#spa-navigation), the context itself survives — only page-level state (signals, actions, fields, intervals, subscriptions) is reset. The SSE connection persists.
## Signals
Signals are reactive values synchronized between server and browser. Create one with an initial value:
```go
name := c.Signal("world")
count := c.Signal(0)
items := c.Signal([]string{"a", "b"})
```
### Reading values
```go
name.String() // "world"
count.Int() // 0
count.Bool() // false (parses "true", "1", "yes", "on")
```
Signal values come from the browser. Before every action call, the browser sends all current signal values to the server. You always read the latest browser state inside action handlers.
### Writing values
```go
name.SetValue("Via")
c.SyncSignals() // push only changed signals to browser
// or
c.Sync() // re-render view AND push changed signals
```
`SetValue` marks the signal as changed. The change is not sent to the browser until you call `Sync()` or `SyncSignals()`.
### Rendering in the view
```go
// Two-way binding on an input — browser edits update the signal
h.Input(h.Type("text"), name.Bind())
// Reactive text display — updates when the signal changes
h.Span(name.Text())
// Read value at render time — static until next Sync()
h.P(h.Textf("Count: %d", count.Int()))
```
`Bind()` outputs a `data-bind` attribute for two-way binding. `Text()` outputs a `<span data-text="$signalID">` for reactive display.
## Actions
Actions are server-side event handlers. They run on the server when triggered by a browser event.
```go
submit := c.Action(func() {
// signals are already injected — read them here
fmt.Println(name.String())
count.SetValue(count.Int() + 1)
c.Sync()
})
```
### Trigger methods
Attach an action to a DOM event by calling a trigger method in the view:
```go
h.Button(h.Text("Submit"), submit.OnClick())
h.Input(name.Bind(), submit.OnKeyDown("Enter"))
h.Select(category.Bind(), filter.OnChange())
h.Form(submit.OnSubmit())
```
Available triggers:
| Method | Event | Notes |
|--------|-------|-------|
| `OnClick()` | `click` | |
| `OnDblClick()` | `dblclick` | |
| `OnChange()` | `change` | 200ms debounce |
| `OnInput()` | `input` | No debounce |
| `OnSubmit()` | `submit` | |
| `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) |
| `OnFocus()` | `focus` | |
| `OnBlur()` | `blur` | |
| `OnMouseEnter()` | `mouseenter` | |
| `OnMouseLeave()` | `mouseleave` | |
| `OnScroll()` | `scroll` | |
### Trigger options
Every trigger method accepts `ActionTriggerOption` values:
```go
// Set a signal value before the action fires
submit.OnClick(via.WithSignal(mode, "delete"))
submit.OnClick(via.WithSignalInt(page, 3))
// Listen on window instead of the element
submit.OnKeyDown("Escape", via.WithWindow())
// Prevent browser default behavior
submit.OnKeyDown("ArrowDown", via.WithPreventDefault())
```
### Multi-key dispatch
`OnKeyDownMap` binds multiple keys to different actions in a single attribute:
```go
via.OnKeyDownMap(
via.KeyBind("w", move, via.WithSignal(dir, "up")),
via.KeyBind("s", move, via.WithSignal(dir, "down")),
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
)
```
This produces a single `data-on:keydown__window` attribute. Place it on any element in the view.
### Action options
```go
// Per-action rate limiting (overrides the context-level default)
c.Action(handler, via.WithRateLimit(5, 10))
// Per-action middleware (runs after CSRF and rate-limit checks)
c.Action(handler, via.WithMiddleware(requireAdmin))
```
## Views and Sync
Every page handler must call `c.View()` to define the UI:
```go
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Hello, %s!", name.String())),
)
})
```
> **Gotcha:** If you forget `c.View()`, the app panics at startup during route registration — not at request time.
The view function is re-evaluated on every `c.Sync()`. The resulting HTML is pushed to the browser via SSE, where Datastar morphs the DOM.
### Sync variants
| Method | What it sends |
|--------|---------------|
| `c.Sync()` | Re-renders the view HTML **and** pushes changed signals |
| `c.SyncSignals()` | Pushes only changed signals, no view re-render |
| `c.SyncElements(elem...)` | Pushes specific HTML elements to merge into the DOM. Each element **must have an ID** matching an existing DOM element |
| `c.ExecScript(js)` | Sends JavaScript for the browser to execute (auto-removed after execution) |
Use `SyncSignals()` when only signal values changed and the view structure is the same. Use `SyncElements()` for targeted updates without re-rendering the entire view. Use `ExecScript()` to interact with client-side libraries (e.g. pushing data to a chart).
## Components
Extract reusable UI with `c.Component()`:
```go
func counterFn(c *via.Context) {
count := 0
step := c.Signal(1)
increment := c.Action(func() {
count += step.Int()
c.Sync()
})
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Count: %d", count)),
h.Input(h.Type("number"), step.Bind()),
h.Button(h.Text("+"), increment.OnClick()),
)
})
}
// In a page:
v.Page("/", func(c *via.Context) {
counter1 := c.Component(counterFn)
counter2 := c.Component(counterFn)
c.View(func() h.H {
return h.Div(
h.H2(h.Text("Counter 1")), counter1(),
h.H2(h.Text("Counter 2")), counter2(),
)
})
})
```
Each component instance gets its own closure state, but signals, actions, and fields are registered on the parent page context. Components share the parent's SSE stream — `c.Sync()` from a component re-renders the entire page view.
## Fields and Validation
Fields are signals with validation rules. Use them for form inputs:
```go
username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20))
email := c.Field("", via.Required(), via.Email())
age := c.Field("", via.Required(), via.Min(13), via.Max(120))
website := c.Field("", via.Pattern(`^https?://`, "Must start with http:// or https://"))
```
### Built-in rules
| Rule | Description |
|------|-------------|
| `Required(msg...)` | Rejects empty/whitespace-only values |
| `MinLen(n, msg...)` | Minimum character count (Unicode-aware) |
| `MaxLen(n, msg...)` | Maximum character count (Unicode-aware) |
| `Min(n, msg...)` | Minimum numeric value (parsed as int) |
| `Max(n, msg...)` | Maximum numeric value (parsed as int) |
| `Email(msg...)` | Email format regex |
| `Pattern(re, msg...)` | Custom regex |
| `Custom(fn)` | `func(string) error` — return non-nil to fail |
All rules accept an optional custom error message as the last argument.
### Using fields in views and actions
```go
submit := c.Action(func() {
if !c.ValidateAll() {
c.Sync()
return
}
// Server-side validation
if userExists(username.String()) {
username.AddError("Username taken")
c.Sync()
return
}
createUser(username.String(), email.String())
c.ResetFields()
c.Sync()
})
c.View(func() h.H {
return h.Form(submit.OnSubmit(),
h.Input(h.Type("text"), username.Bind(), h.Placeholder("Username")),
h.If(username.HasError(), h.Small(h.Text(username.FirstError()))),
h.Input(h.Type("email"), email.Bind(), h.Placeholder("Email")),
h.If(email.HasError(), h.Small(h.Text(email.FirstError()))),
h.Button(h.Type("submit"), h.Text("Sign Up")),
)
})
```
| Method | Description |
|--------|-------------|
| `field.Validate()` | Run rules, return true if all pass |
| `field.HasError()` | True if any validation errors exist |
| `field.FirstError()` | First error message, or `""` |
| `field.Errors()` | All error messages |
| `field.AddError(msg)` | Add a custom server-side error |
| `field.ClearErrors()` | Remove all errors |
| `field.Reset()` | Restore initial value and clear errors |
| `c.ValidateAll(fields...)` | Validate given fields (or all if none specified). Does not short-circuit — all fields get validated so all errors are populated |
| `c.ResetFields(fields...)` | Reset given fields (or all if none specified) |
Fields embed `*signal`, so `Bind()`, `Text()`, `String()`, `Int()`, `Bool()`, `SetValue()`, and `ID()` all work.
## OnInterval
Run a function at regular intervals, tied to the page lifecycle:
```go
stop := c.OnInterval(time.Second, func() {
now = time.Now()
c.Sync()
})
```
- Starts immediately — no separate start call needed.
- Returns a `func()` that stops the interval (idempotent).
- Automatically stops on context disposal (tab close) or SPA navigation away.
- Call `c.Sync()` inside the handler to push updates to the browser.
## Navigation Helpers
| Method | Effect |
|--------|--------|
| `c.Redirect(url)` | Full page navigation. Disposes the context, browser loads a new page |
| `c.Redirectf(fmt, args...)` | `Redirect` with `fmt.Sprintf` |
| `c.RedirectView(url)` | Sets the view to trigger a redirect on SSE connect. Use in [middleware](routing-and-navigation.md#middleware) to abort the chain and redirect |
| `c.ReplaceURL(url)` | Updates the browser URL bar without navigation. Useful for reflecting state in query params |
| `c.ReplaceURLf(fmt, args...)` | `ReplaceURL` with `fmt.Sprintf` |
| `c.Navigate(path, popstate)` | [SPA navigation](routing-and-navigation.md#spa-navigation). Resets page state, runs the target page handler on the same context, pushes the new view with a view transition |
> **Gotcha:** In middleware, use `c.RedirectView()`, not `c.Redirect()`. `Redirect` sends a patch over SSE, but the SSE connection isn't established yet during the initial page load.

View File

@@ -0,0 +1,153 @@
body { margin: 0; }
/* Layout navbar */
.app-nav {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
background: var(--pico-card-background-color);
border-bottom: 1px solid var(--pico-muted-border-color);
}
.app-nav .brand {
font-weight: 700;
text-decoration: none;
margin-right: auto;
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-links a {
text-decoration: none;
font-size: 0.875rem;
}
/* Chat page */
.chat-page {
display: flex;
flex-direction: column;
height: calc(100vh - 53px);
}
nav[role="tab-control"] ul li a[aria-current="page"] {
background-color: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border-bottom: 2px solid var(--pico-primary);
}
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; }
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--pico-muted-border-color);
display: grid;
place-items: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.avatar-lg {
width: 3rem;
height: 3rem;
font-size: 2rem;
}
.bubble { flex: 1; }
.bubble p { margin: 0; }
.chat-history {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
}
.chat-input {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: var(--pico-background-color);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--pico-muted-border-color);
}
.chat-input fieldset {
flex: 1;
margin: 0;
}
/* NATS badge with status dot */
.nats-badge {
background: #27AAE1;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
margin-left: auto;
display: flex;
align-items: center;
gap: 0.375rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4ade80;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Profile page */
.profile-page {
max-width: 480px;
margin: 0 auto;
padding: 2rem 1rem;
}
.profile-preview {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--pico-card-background-color);
border-radius: 8px;
}
.preview-name {
font-size: 1.125rem;
font-weight: 600;
}
.profile-form label {
display: block;
margin-bottom: 0.5rem;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 0.25rem;
margin-bottom: 1.5rem;
}
.emoji-option {
padding: 0.375rem;
font-size: 1.25rem;
border: 2px solid transparent;
border-radius: 8px;
background: none;
cursor: pointer;
text-align: center;
}
.emoji-option:hover {
background: var(--pico-muted-border-color);
}
.emoji-selected {
border-color: var(--pico-primary);
background: var(--pico-primary-focus);
}
.profile-actions {
display: flex;
gap: 0.75rem;
}
.field-error {
color: var(--pico-del-color);
}

View File

@@ -0,0 +1,148 @@
package main
import (
"sync"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var (
WithSignal = via.WithSignal
)
func ChatPage(c *via.Context) {
currentUser := UserInfo{
Name: c.Session().GetString(SessionKeyUsername),
Emoji: c.Session().GetString(SessionKeyEmoji),
}
roomSignal := c.Signal("Go")
statement := c.Signal("")
var messages []ChatMessage
var messagesMu sync.Mutex
currentRoom := "Go"
var currentSub via.Subscription
subscribeToRoom := func(room string) {
if currentSub != nil {
currentSub.Unsubscribe()
}
subject := "chat.room." + room
if hist, err := via.ReplayHistory[ChatMessage](v, subject, 50); err == nil {
messages = hist
}
sub, _ := via.Subscribe(c, subject, func(msg ChatMessage) {
messagesMu.Lock()
messages = append(messages, msg)
if len(messages) > 50 {
messages = messages[len(messages)-50:]
}
messagesMu.Unlock()
c.Sync()
})
currentSub = sub
currentRoom = room
}
subscribeToRoom("Go")
// Heartbeat — keeps connected indicator alive
connected := true
c.OnInterval(30*time.Second, func() {
connected = true
c.Sync()
})
switchRoom := c.Action(func() {
newRoom := roomSignal.String()
if newRoom != currentRoom {
messagesMu.Lock()
messages = nil
messagesMu.Unlock()
subscribeToRoom(newRoom)
c.Sync()
}
})
say := c.Action(func() {
msg := statement.String()
if msg == "" {
msg = randomDevQuote()
}
statement.SetValue("")
via.Publish(c, "chat.room."+currentRoom, ChatMessage{
User: currentUser,
Message: msg,
Time: time.Now().UnixMilli(),
})
})
c.View(func() h.H {
var tabs []h.H
for _, name := range roomNames {
isCurrent := name == currentRoom
tabs = append(tabs, h.Li(
h.A(
h.If(isCurrent, h.Attr("aria-current", "page")),
h.Text(name),
switchRoom.OnClick(WithSignal(roomSignal, name)),
),
))
}
messagesMu.Lock()
chatHistoryChildren := []h.H{
h.Class("chat-history"),
h.Script(h.Raw(`new MutationObserver(()=>scrollChatToBottom()).observe(document.querySelector('.chat-history'), {childList:true})`)),
}
for _, msg := range messages {
chatHistoryChildren = append(chatHistoryChildren,
h.Div(h.Class("chat-message"),
h.Div(h.Class("avatar"), h.Attr("title", msg.User.Name), h.Text(msg.User.Emoji)),
h.Div(h.Class("bubble"),
h.P(h.Text(msg.Message)),
),
),
)
}
messagesMu.Unlock()
_ = connected
return h.Div(h.Class("chat-page"),
h.Nav(
h.Attr("role", "tab-control"),
h.Ul(tabs...),
h.Span(h.Class("nats-badge"),
h.Span(h.Class("status-dot")),
h.Text("NATS"),
),
),
h.Div(chatHistoryChildren...),
h.Div(
h.Class("chat-input"),
h.DataIgnoreMorph(),
currentUser.Avatar(),
h.FieldSet(
h.Attr("role", "group"),
h.Input(
h.Type("text"),
h.Placeholder(currentUser.Name+" says..."),
statement.Bind(),
h.Attr("autofocus"),
say.OnKeyDown("Enter"),
),
h.Button(h.Text("Send"), say.OnClick()),
),
),
)
})
}

View File

@@ -1,40 +1,21 @@
package main
import (
_ "embed"
"log"
"math/rand"
"sync"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var (
WithSignal = via.WithSignal
)
//go:embed chat.css
var chatCSS string
// ChatMessage represents a message in a chat room
type ChatMessage struct {
User UserInfo `json:"user"`
Message string `json:"message"`
Time int64 `json:"time"`
}
// UserInfo identifies a chat participant
type UserInfo struct {
Name string `json:"name"`
Emoji string `json:"emoji"`
}
func (u *UserInfo) Avatar() h.H {
return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.Emoji))
}
var roomNames = []string{"Go", "Rust", "Python", "JavaScript", "Clojure"}
var v *via.V
func main() {
v := via.New()
v = via.New()
v.Config(via.Options{
DevMode: true,
DocumentTitle: "NATS Chat",
@@ -54,62 +35,7 @@ func main() {
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.StyleEl(h.Raw(`
body { margin: 0; }
main {
display: flex;
flex-direction: column;
height: 100vh;
}
nav[role="tab-control"] ul li a[aria-current="page"] {
background-color: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border-bottom: 2px solid var(--pico-primary);
}
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; }
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--pico-muted-border-color);
display: grid;
place-items: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.bubble { flex: 1; }
.bubble p { margin: 0; }
.chat-history {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
}
.chat-input {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: var(--pico-background-color);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--pico-muted-border-color);
}
.chat-input fieldset {
flex: 1;
margin: 0;
}
.nats-badge {
background: #27AAE1;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
margin-left: auto;
}
`)),
h.StyleEl(h.Raw(chatCSS)),
h.Script(h.Raw(`
function scrollChatToBottom() {
const chatHistory = document.querySelector('.chat-history');
@@ -118,156 +44,38 @@ func main() {
`)),
)
v.Page("/", func(c *via.Context) {
currentUser := randUser()
roomSignal := c.Signal("Go")
statement := c.Signal("")
var messages []ChatMessage
var messagesMu sync.Mutex
currentRoom := "Go"
var currentSub via.Subscription
subscribeToRoom := func(room string) {
if currentSub != nil {
currentSub.Unsubscribe()
}
subject := "chat.room." + room
// Replay history from JetStream
if hist, err := via.ReplayHistory[ChatMessage](v, subject, 50); err == nil {
messages = hist
}
sub, _ := via.Subscribe(c, subject, func(msg ChatMessage) {
messagesMu.Lock()
messages = append(messages, msg)
if len(messages) > 50 {
messages = messages[len(messages)-50:]
}
messagesMu.Unlock()
c.Sync()
})
currentSub = sub
currentRoom = room
}
subscribeToRoom("Go")
switchRoom := c.Action(func() {
newRoom := roomSignal.String()
if newRoom != currentRoom {
messagesMu.Lock()
messages = nil
messagesMu.Unlock()
subscribeToRoom(newRoom)
c.Sync()
}
})
say := c.Action(func() {
msg := statement.String()
if msg == "" {
msg = randomDevQuote()
}
statement.SetValue("")
via.Publish(c, "chat.room."+currentRoom, ChatMessage{
User: currentUser,
Message: msg,
Time: time.Now().UnixMilli(),
})
})
c.View(func() h.H {
var tabs []h.H
for _, name := range roomNames {
isCurrent := name == currentRoom
tabs = append(tabs, h.Li(
h.A(
h.If(isCurrent, h.Attr("aria-current", "page")),
h.Text(name),
switchRoom.OnClick(WithSignal(roomSignal, name)),
),
))
}
messagesMu.Lock()
chatHistoryChildren := []h.H{
h.Class("chat-history"),
h.Script(h.Raw(`new MutationObserver(()=>scrollChatToBottom()).observe(document.querySelector('.chat-history'), {childList:true})`)),
}
for _, msg := range messages {
chatHistoryChildren = append(chatHistoryChildren,
h.Div(h.Class("chat-message"),
h.Div(h.Class("avatar"), h.Attr("title", msg.User.Name), h.Text(msg.User.Emoji)),
h.Div(h.Class("bubble"),
h.P(h.Text(msg.Message)),
v.Layout(func(content func() h.H) h.H {
return h.Div(
h.Nav(h.Class("app-nav"),
h.A(h.Href("/"), h.Class("brand"), h.Text("NATS Chat")),
h.Div(h.Class("nav-links"),
h.A(h.Href("/"), h.Text("Chat")),
h.A(h.Href("/profile"), h.Text("Profile")),
),
),
)
}
messagesMu.Unlock()
return h.Main(h.Class("container"),
h.Nav(
h.Attr("role", "tab-control"),
h.Ul(tabs...),
h.Span(h.Class("nats-badge"), h.Text("NATS")),
),
h.Div(chatHistoryChildren...),
h.Div(
h.Class("chat-input"),
currentUser.Avatar(),
h.FieldSet(
h.Attr("role", "group"),
h.Input(
h.Type("text"),
h.Placeholder(currentUser.Name+" says..."),
statement.Bind(),
h.Attr("autofocus"),
say.OnKeyDown("Enter"),
),
h.Button(h.Text("Send"), say.OnClick()),
),
h.Main(h.Class("container"),
h.DataViewTransition("page-content"),
content(),
),
)
})
})
// Profile page — public, no auth required
v.Page("/profile", ProfilePage)
// Auth middleware — redirects to profile if no identity set
requireProfile := func(c *via.Context, next func()) {
if c.Session().GetString(SessionKeyUsername) == "" {
c.RedirectView("/profile")
return
}
next()
}
// Chat page — protected by profile middleware
protected := v.Group("", requireProfile)
protected.Page("/", ChatPage)
log.Println("Starting NATS chatroom on :7331 (embedded NATS server)")
v.Start()
}
func randUser() UserInfo {
adjectives := []string{"Happy", "Clever", "Brave", "Swift", "Gentle", "Wise", "Bold", "Calm", "Eager", "Fierce"}
animals := []string{"Panda", "Tiger", "Eagle", "Dolphin", "Fox", "Wolf", "Bear", "Hawk", "Otter", "Lion"}
emojis := []string{"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦅", "🦦", "🦁"}
idx := rand.Intn(len(animals))
return UserInfo{
Name: adjectives[rand.Intn(len(adjectives))] + " " + animals[idx],
Emoji: emojis[idx],
}
}
var quoteIdx = rand.Intn(len(devQuotes))
var devQuotes = []string{
"Just use NATS.",
"Pub/sub all the things!",
"Messages are the new API.",
"JetStream for durability.",
"No more polling.",
"Event-driven architecture FTW.",
"Decouple everything.",
"NATS is fast.",
"Subjects are like topics.",
"Request-reply is cool.",
}
func randomDevQuote() string {
quoteIdx = (quoteIdx + 1) % len(devQuotes)
return devQuotes[quoteIdx]
}

View File

@@ -0,0 +1,111 @@
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func ProfilePage(c *via.Context) {
existingName := c.Session().GetString(SessionKeyUsername)
existingEmoji := c.Session().GetString(SessionKeyEmoji)
if existingEmoji == "" {
existingEmoji = emojiChoices[0]
}
nameField := c.Field(existingName,
via.Required("Display name is required"),
via.MinLen(2, "Must be at least 2 characters"),
via.MaxLen(20, "Must be at most 20 characters"),
)
selectedEmoji := c.Signal(existingEmoji)
previewName := c.Computed(func() string {
if name := nameField.String(); name != "" {
return name
}
return "Your Name"
})
saveToSession := func() bool {
if !c.ValidateAll() {
c.Sync()
return false
}
c.Session().Set(SessionKeyUsername, nameField.String())
c.Session().Set(SessionKeyEmoji, selectedEmoji.String())
return true
}
save := c.Action(func() {
saveToSession()
})
saveAndChat := c.Action(func() {
if saveToSession() {
c.Navigate("/", false)
}
})
c.View(func() h.H {
// Emoji grid
emojiGrid := []h.H{h.Class("emoji-grid")}
for _, emoji := range emojiChoices {
cls := "emoji-option"
if emoji == selectedEmoji.String() {
cls += " emoji-selected"
}
emojiGrid = append(emojiGrid,
h.Button(
h.Class(cls),
h.Type("button"),
h.Text(emoji),
save.OnClick(WithSignal(selectedEmoji, emoji)),
),
)
}
// Action buttons — "Start Chatting" only if editing is meaningful
actionButtons := []h.H{h.Class("profile-actions")}
if existingName != "" {
actionButtons = append(actionButtons,
h.Button(h.Text("Save"), save.OnClick(), h.Class("secondary")),
)
}
actionButtons = append(actionButtons,
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
)
return h.Div(h.Class("profile-page"),
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
// Live preview
h.Div(h.Class("profile-preview"),
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
h.Span(h.Class("preview-name"), previewName.Text()),
),
h.Div(h.Class("profile-form"),
// Name field
h.Label(h.Text("Display Name"),
h.Input(
h.Type("text"),
h.Placeholder("Enter a display name"),
nameField.Bind(),
h.Attr("autofocus"),
saveAndChat.OnKeyDown("Enter"),
h.If(nameField.HasError(), h.Attr("aria-invalid", "true")),
),
h.If(nameField.HasError(),
h.Small(h.Class("field-error"), h.Text(nameField.FirstError())),
),
),
// Emoji picker
h.Label(h.Text("Choose an Avatar")),
h.Div(emojiGrid...),
// Actions
h.Div(actionButtons...),
),
)
})
}

View File

@@ -0,0 +1,30 @@
package main
import "github.com/ryanhamamura/via/h"
const (
SessionKeyUsername = "username"
SessionKeyEmoji = "emoji"
)
type ChatMessage struct {
User UserInfo `json:"user"`
Message string `json:"message"`
Time int64 `json:"time"`
}
type UserInfo struct {
Name string `json:"name"`
Emoji string `json:"emoji"`
}
func (u *UserInfo) Avatar() h.H {
return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.Emoji))
}
var roomNames = []string{"Go", "Rust", "Python", "JavaScript", "Clojure"}
var emojiChoices = []string{
"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦦", "🦁", "🐸",
"🦄", "🐙", "🦀", "🐝", "🦋", "🐢", "🦉", "🐳", "🦈", "🐧",
}

View File

@@ -0,0 +1,22 @@
package main
import "math/rand"
var quoteIdx = rand.Intn(len(devQuotes))
var devQuotes = []string{
"Just use NATS.",
"Pub/sub all the things!",
"Messages are the new API.",
"JetStream for durability.",
"No more polling.",
"Event-driven architecture FTW.",
"Decouple everything.",
"NATS is fast.",
"Subjects are like topics.",
"Request-reply is cool.",
}
func randomDevQuote() string {
quoteIdx = (quoteIdx + 1) % len(devQuotes)
return devQuotes[quoteIdx]
}

View File

@@ -76,6 +76,12 @@ func main() {
titleSignal := c.Signal("")
urlSignal := c.Signal("")
targetIDSignal := c.Signal("")
saveLabel := c.Computed(func() string {
if targetIDSignal.String() != "" {
return "Update Bookmark"
}
return "Add Bookmark"
})
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
if evt.UserID == userID {
@@ -205,11 +211,6 @@ func main() {
}
bookmarksMu.RUnlock()
saveLabel := "Add Bookmark"
if isEditing {
saveLabel = "Update Bookmark"
}
return h.Div(h.Class("min-h-screen bg-base-200"),
// Navbar
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
@@ -225,7 +226,7 @@ func main() {
// Form card
h.Div(h.Class("card bg-base-100 shadow"),
h.Div(h.Class("card-body"),
h.H2(h.Class("card-title"), h.Text(saveLabel)),
h.H2(h.Class("card-title"), saveLabel.Text()),
h.Div(h.Class("flex flex-col gap-2"),
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
@@ -233,7 +234,7 @@ func main() {
h.If(isEditing,
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
),
h.Button(h.Class("btn btn-primary"), h.Text(saveLabel), save.OnClick()),
h.Button(h.Class("btn btn-primary"), saveLabel.Text(), save.OnClick()),
),
),
),

12
nats.go
View File

@@ -56,7 +56,19 @@ func startDefaultNATS() (dn *defaultNATS, err error) {
os.RemoveAll(dataDir)
return nil, fmt.Errorf("start embedded nats: %w", err)
}
ready := make(chan struct{})
go func() {
ns.WaitForServer()
close(ready)
}()
select {
case <-ready:
case <-time.After(10 * time.Second):
ns.Close()
cancel()
os.RemoveAll(dataDir)
return nil, fmt.Errorf("embedded nats server did not start within 10s")
}
nc, err := ns.Client()
if err != nil {

View File

@@ -10,9 +10,24 @@ import (
"github.com/stretchr/testify/require"
)
func TestPubSub_RoundTrip(t *testing.T) {
// setupNATSTest creates a *V with an embedded NATS server.
// Skips the test if NATS fails to start (e.g. port conflict in CI).
func setupNATSTest(t *testing.T) *V {
t.Helper()
v := New()
defer v.Shutdown()
dn, err := getSharedNATS()
if err != nil {
v.Shutdown()
t.Skipf("embedded NATS unavailable: %v", err)
}
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
t.Cleanup(v.Shutdown)
return v
}
func TestPubSub_RoundTrip(t *testing.T) {
v := setupNATSTest(t)
var received []byte
done := make(chan struct{})
@@ -38,8 +53,7 @@ func TestPubSub_RoundTrip(t *testing.T) {
}
func TestPubSub_MultipleSubscribers(t *testing.T) {
v := New()
defer v.Shutdown()
v := setupNATSTest(t)
var mu sync.Mutex
var results []string
@@ -84,8 +98,7 @@ func TestPubSub_MultipleSubscribers(t *testing.T) {
}
func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
v := New()
defer v.Shutdown()
v := setupNATSTest(t)
c := newContext("cleanup-ctx", "/", v)
c.View(func() h.H { return h.Div() })
@@ -100,8 +113,7 @@ func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
}
func TestPubSub_ManualUnsubscribe(t *testing.T) {
v := New()
defer v.Shutdown()
v := setupNATSTest(t)
c := newContext("unsub-ctx", "/", v)
c.View(func() h.H { return h.Div() })
@@ -120,8 +132,7 @@ func TestPubSub_ManualUnsubscribe(t *testing.T) {
}
func TestPubSub_NoOpDuringPanicCheck(t *testing.T) {
v := New()
defer v.Shutdown()
v := setupNATSTest(t)
// Panic-check context has id=""
c := newContext("", "/", v)

View File

@@ -1,8 +1,9 @@
package via
// PubSub is an interface for publish/subscribe messaging backends.
// By default, New() starts an embedded NATS server. Supply a custom
// implementation via Config(Options{PubSub: yourBackend}) to override.
// By default, Start() launches an embedded NATS server if no backend
// has been configured. Supply a custom implementation via
// Config(Options{PubSub: yourBackend}) to override.
type PubSub interface {
Publish(subject string, data []byte) error
Subscribe(subject string, handler func(data []byte)) (Subscription, error)

View File

@@ -10,8 +10,7 @@ import (
)
func TestPublishSubscribe_RoundTrip(t *testing.T) {
v := New()
defer v.Shutdown()
v := setupNATSTest(t)
type event struct {
Name string `json:"name"`
@@ -43,8 +42,7 @@ func TestPublishSubscribe_RoundTrip(t *testing.T) {
}
func TestSubscribe_SkipsBadJSON(t *testing.T) {
v := New()
defer v.Shutdown()
v := setupNATSTest(t)
type msg struct {
Text string `json:"text"`

88
via.go
View File

@@ -139,6 +139,9 @@ func (v *V) Config(cfg Options) {
v.defaultNATS = nil
v.pubsub = cfg.PubSub
}
if cfg.ContextSuspendAfter != 0 {
v.cfg.ContextSuspendAfter = cfg.ContextSuspendAfter
}
if cfg.ContextTTL != 0 {
v.cfg.ContextTTL = cfg.ContextTTL
}
@@ -292,9 +295,16 @@ func (v *V) startReaper() {
return
}
if ttl == 0 {
ttl = 30 * time.Second
ttl = time.Hour
}
interval := ttl / 3
suspendAfter := v.cfg.ContextSuspendAfter
if suspendAfter == 0 {
suspendAfter = 15 * time.Minute
}
if suspendAfter > ttl {
suspendAfter = ttl
}
interval := suspendAfter / 3
if interval < 5*time.Second {
interval = 5 * time.Second
}
@@ -307,35 +317,42 @@ func (v *V) startReaper() {
case <-v.reaperStop:
return
case <-ticker.C:
v.reapOrphanedContexts(ttl)
v.reapOrphanedContexts(suspendAfter, ttl)
}
}
}()
}
func (v *V) reapOrphanedContexts(ttl time.Duration) {
func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
now := time.Now()
v.contextRegistryMutex.RLock()
var orphans []*Context
var toSuspend, toReap []*Context
for _, c := range v.contextRegistry {
if c.sseConnected.Load() {
continue
}
if dc := c.sseDisconnectedAt.Load(); dc != nil {
// SSE was connected then dropped — reap if gone too long
if now.Sub(*dc) > ttl {
orphans = append(orphans, c)
// Use the most recent liveness signal
lastAlive := c.createdAt
if dc := c.sseDisconnectedAt.Load(); dc != nil && dc.After(lastAlive) {
lastAlive = *dc
}
} else {
// SSE never connected — reap based on creation time
if now.Sub(c.createdAt) > ttl {
orphans = append(orphans, c)
if seen := c.lastSeenAt.Load(); seen != nil && seen.After(lastAlive) {
lastAlive = *seen
}
silentFor := now.Sub(lastAlive)
if silentFor > ttl {
toReap = append(toReap, c)
} else if silentFor > suspendAfter && !c.suspended.Load() {
toSuspend = append(toSuspend, c)
}
}
v.contextRegistryMutex.RUnlock()
for _, c := range orphans {
for _, c := range toSuspend {
v.logInfo(c, "suspending context (no SSE connection after %s)", suspendAfter)
c.suspend()
}
for _, c := range toReap {
v.logInfo(c, "reaping orphaned context (no SSE connection after %s)", ttl)
v.cleanupCtx(c)
}
@@ -344,6 +361,16 @@ func (v *V) reapOrphanedContexts(ttl time.Duration) {
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
// signal is received, then performs a graceful shutdown.
func (v *V) Start() {
if v.pubsub == nil {
dn, err := getSharedNATS()
if err != nil {
v.logWarn(nil, "embedded NATS unavailable: %v", err)
} else {
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
}
}
handler := http.Handler(v.mux)
if v.sessionManager != nil {
handler = v.sessionManager.LoadAndSave(v.mux)
@@ -625,10 +652,14 @@ func New() *V {
}
c, err := v.getCtx(cID)
if err != nil {
v.logErr(nil, "sse stream failed to start: %v", err)
v.logInfo(nil, "context expired, reloading client: %s", cID)
sse := datastar.NewSSE(w, r)
sse.ExecuteScript("window.location.reload()")
return
}
c.reqCtx = r.Context()
now := time.Now()
c.lastSeenAt.Store(&now)
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
@@ -650,19 +681,34 @@ func New() *V {
c.sseDisconnectedAt.Store(nil)
v.logDebug(c, "SSE connection established")
if c.suspended.Load() {
c.navMu.Lock()
c.suspended.Store(false)
if initFn := v.pageRegistry[c.route]; initFn != nil {
v.logInfo(c, "resuming suspended context")
initFn(c)
}
c.navMu.Unlock()
}
go c.Sync()
keepalive := time.NewTicker(30 * time.Second)
defer keepalive.Stop()
for {
select {
case <-sse.Context().Done():
v.logDebug(c, "SSE connection ended")
c.sseConnected.Store(false)
now := time.Now()
c.sseDisconnectedAt.Store(&now)
dcNow := time.Now()
c.sseDisconnectedAt.Store(&dcNow)
return
case <-c.ctxDisposedChan:
v.logDebug(c, "context disposed, closing SSE")
return
case <-keepalive.C:
sse.PatchSignals([]byte("{}"))
case patch := <-c.patchChan:
switch patch.typ {
case patchTypeElements:
@@ -807,14 +853,6 @@ func New() *V {
v.cleanupCtx(c)
})
dn, err := getSharedNATS()
if err != nil {
v.logWarn(nil, "embedded NATS unavailable: %v", err)
} else {
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
}
return v
}

View File

@@ -127,7 +127,7 @@ func TestAction(t *testing.T) {
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:click")
assert.Contains(t, body, "data-on:change__debounce.200ms")
assert.Contains(t, body, "data-on:change")
assert.Contains(t, body, "data-on:keydown")
assert.Contains(t, body, "/_action/")
}
@@ -281,6 +281,112 @@ func TestOnKeyDownMap(t *testing.T) {
})
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{200 * time.Millisecond, "200ms"},
{1 * time.Second, "1000ms"},
{50 * time.Millisecond, "50ms"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, formatDuration(tt.d))
}
}
func TestBuildAttrKey(t *testing.T) {
tests := []struct {
name string
event string
opts triggerOpts
want string
}{
{"bare event", "click", triggerOpts{}, "on:click"},
{"debounce only", "change", triggerOpts{debounce: 200 * time.Millisecond}, "on:change__debounce.200ms"},
{"throttle only", "scroll", triggerOpts{throttle: 100 * time.Millisecond}, "on:scroll__throttle.100ms"},
{"window only", "keydown", triggerOpts{window: true}, "on:keydown__window"},
{"debounce + window", "input", triggerOpts{debounce: 300 * time.Millisecond, window: true}, "on:input__debounce.300ms__window"},
{"throttle + window", "scroll", triggerOpts{throttle: 500 * time.Millisecond, window: true}, "on:scroll__throttle.500ms__window"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, buildAttrKey(tt.event, &tt.opts))
})
}
}
func TestWithDebounce(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Button(trigger.OnClick(WithDebounce(300 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:click__debounce.300ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestWithThrottle(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Div(trigger.OnScroll(WithThrottle(100 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:scroll__throttle.100ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestWithDebounceOnChange(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Input(trigger.OnChange(WithDebounce(200 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:change__debounce.200ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestDebounceWithWindow(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Div(trigger.OnKeyDown("Enter", WithDebounce(150*time.Millisecond), WithWindow()))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:keydown__debounce.150ms__window")
}
func TestConfig(t *testing.T) {
v := New()
v.Config(Options{DocumentTitle: "Test"})
@@ -303,12 +409,66 @@ func TestReaperCleansOrphanedContexts(t *testing.T) {
_, err := v.getCtx("orphan-1")
assert.NoError(t, err)
v.reapOrphanedContexts(10 * time.Second)
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
_, err = v.getCtx("orphan-1")
assert.Error(t, err, "orphaned context should have been reaped")
}
func TestReaperSuspendsContext(t *testing.T) {
v := New()
c := newContext("suspend-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("suspend-1")
assert.NoError(t, err, "suspended context should still be in registry")
assert.True(t, got.suspended.Load(), "context should be marked suspended")
}
func TestReaperReapsAfterTTL(t *testing.T) {
v := New()
c := newContext("reap-1", "/", v)
c.createdAt = time.Now().Add(-3 * time.Hour)
dc := time.Now().Add(-2 * time.Hour)
c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("reap-1")
assert.Error(t, err, "context past TTL should have been reaped")
}
func TestReaperIgnoresAlreadySuspended(t *testing.T) {
v := New()
c := newContext("already-sus-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true)
// give it a fresh pageStopChan so we can verify it's not re-closed
c.pageStopChan = make(chan struct{})
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("already-sus-1")
assert.NoError(t, err, "already-suspended context within TTL should survive")
assert.True(t, got.suspended.Load())
// pageStopChan should still be open (not re-suspended)
select {
case <-got.pageStopChan:
t.Fatal("pageStopChan was closed — context was re-suspended")
default:
}
}
func TestReaperIgnoresConnectedContexts(t *testing.T) {
v := New()
c := newContext("connected-1", "/", v)
@@ -316,7 +476,7 @@ func TestReaperIgnoresConnectedContexts(t *testing.T) {
c.sseConnected.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(10 * time.Second)
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
_, err := v.getCtx("connected-1")
assert.NoError(t, err, "connected context should survive reaping")
@@ -343,6 +503,74 @@ func TestCleanupCtxIdempotent(t *testing.T) {
assert.Error(t, err, "context should be removed after cleanup")
}
func TestReaperRespectsLastSeenAt(t *testing.T) {
v := New()
c := newContext("seen-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
// Disconnected 20 min ago, but client retried (lastSeenAt) 2 min ago
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
seen := time.Now().Add(-2 * time.Minute)
c.lastSeenAt.Store(&seen)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("seen-1")
assert.NoError(t, err, "context with recent lastSeenAt should survive suspend threshold")
assert.False(t, c.suspended.Load(), "context should not be suspended")
}
func TestReaperFallsBackWithoutLastSeenAt(t *testing.T) {
v := New()
c := newContext("noseen-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
// no lastSeenAt set — should fall back to sseDisconnectedAt
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("noseen-1")
assert.NoError(t, err, "context should still be in registry (suspended, not reaped)")
assert.True(t, got.suspended.Load(), "context should be suspended using sseDisconnectedAt fallback")
}
func TestReaperReapsWithStaleLastSeenAt(t *testing.T) {
v := New()
c := newContext("stale-seen-1", "/", v)
c.createdAt = time.Now().Add(-3 * time.Hour)
dc := time.Now().Add(-2 * time.Hour)
c.sseDisconnectedAt.Store(&dc)
// lastSeenAt is also old — beyond TTL
seen := time.Now().Add(-90 * time.Minute)
c.lastSeenAt.Store(&seen)
c.suspended.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("stale-seen-1")
assert.Error(t, err, "context with stale lastSeenAt beyond TTL should be reaped")
}
func TestLastSeenAtUpdatedOnSSEConnect(t *testing.T) {
v := New()
c := newContext("seen-sse-1", "/", v)
v.registerCtx(c)
assert.Nil(t, c.lastSeenAt.Load(), "lastSeenAt should be nil before SSE connect")
// Simulate what the SSE handler does after getCtx
now := time.Now()
c.lastSeenAt.Store(&now)
got := c.lastSeenAt.Load()
assert.NotNil(t, got, "lastSeenAt should be set after SSE connect")
assert.WithinDuration(t, now, *got, time.Second)
}
func TestDevModeRemovePersistedFix(t *testing.T) {
v := New()
v.cfg.DevMode = true