Files
via/context.go
Ryan Hamamura b3c2d3ae32
Some checks failed
CI / Build and Test (push) Failing after 35s
CI / Build and Test (pull_request) Failing after 35s
fix: remove context reaper to prevent background tabs from going stale
Background windows stopped updating because the reaper suspended contexts
after ContextSuspendAfter and fully reaped them after ContextTTL. Suspended
contexts had to re-run the page init function from scratch on reconnect,
losing the live-updating experience.

Contexts now live until the browser tab closes (beforeunload beacon) or
the server shuts down. The context map grows indefinitely — no background
reaper.

Removes: startReaper, reapOrphanedContexts, suspend/resume logic,
ContextSuspendAfter/ContextTTL config fields, lastSeenAt/suspended
context fields, and all associated tests.
2026-02-20 09:09:05 -10:00

647 lines
17 KiB
Go

package via
import (
"bytes"
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/ryanhamamura/via/h"
"golang.org/x/time/rate"
)
// Context is the living bridge between Go and the browser.
//
// It holds runtime state, defines actions, manages reactive signals, and defines UI through View.
type Context struct {
id string
route string
csrfToken string
app *V
view func() h.H
routeParams map[string]string
parentPageCtx *Context
patchChan chan patch
actionLimiter *rate.Limiter
actionRegistry map[string]actionEntry
signals *sync.Map
mu sync.RWMutex
navMu sync.Mutex
ctxDisposedChan chan struct{}
pageStopChan chan struct{}
reqCtx context.Context
fields []*Field
subscriptions []Subscription
subsMu sync.Mutex
disposeOnce sync.Once
createdAt time.Time
sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
}
// View defines the UI rendered by this context.
// The function should return an h.H element (from via/h).
//
// Changes to signals or state can be pushed live with Sync().
func (c *Context) View(f func() h.H) {
if f == nil {
panic("nil viewfn")
}
if c.app.layout != nil {
c.view = func() h.H { return h.Div(h.ID(c.id), c.app.layout(f)) }
} else {
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
}
}
// Component registers a subcontext that has self contained data, actions and signals.
// It returns the component's view as a DOM node fn that can be placed in the view
// of the parent. Components can be added to components.
//
// Example:
//
// counterCompFn := func(c *via.Context) {
// (...)
// }
//
// v.Page("/", func(c *via.Context) {
// counterComp := c.Component(counterCompFn)
//
// c.View(func() h.H {
// return h.Div(
// h.H1(h.Text("Counter")),
// counterComp(),
// )
// })
// })
func (c *Context) Component(initCtx func(c *Context)) func() h.H {
id := c.id + "/_component/" + genRandID()
compCtx := newContext(id, c.route, c.app)
if c.isComponent() {
compCtx.parentPageCtx = c.parentPageCtx
} else {
compCtx.parentPageCtx = c
}
initCtx(compCtx)
return compCtx.view
}
func (c *Context) isComponent() bool {
return c.parentPageCtx != nil
}
// Action registers an event handler and returns a trigger to that event that
// that can be added to the view fn as any other via.h element.
//
// Example:
//
// n := 0
// increment := c.Action(func(){
// n++
// c.Sync()
// })
//
// c.View(func() h.H {
// return h.Div(
// h.P(h.Textf("Value of n: %d", n)),
// h.Button(h.Text("Increment n"), increment.OnClick()),
// )
// })
func (c *Context) Action(f func(), opts ...ActionOption) *actionTrigger {
id := genRandID()
if f == nil {
c.app.logErr(c, "failed to bind action '%s' to context: nil func", id)
return nil
}
entry := actionEntry{fn: f}
for _, opt := range opts {
opt(&entry)
}
if c.isComponent() {
c.parentPageCtx.actionRegistry[id] = entry
} else {
c.actionRegistry[id] = entry
}
return &actionTrigger{id}
}
func (c *Context) getAction(id string) (actionEntry, error) {
if e, ok := c.actionRegistry[id]; ok {
return e, nil
}
return actionEntry{}, fmt.Errorf("action '%s' not found", id)
}
// OnInterval starts a goroutine that executes handler on every tick of the given duration.
// The goroutine is tied to the context lifecycle and will stop when the context is disposed.
// Returns a func() that stops the interval when called.
func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
var disposeCh, pageCh chan struct{}
if c.isComponent() {
disposeCh = c.parentPageCtx.ctxDisposedChan
pageCh = c.parentPageCtx.pageStopChan
} else {
disposeCh = c.ctxDisposedChan
pageCh = c.pageStopChan
}
return newOnInterval(disposeCh, pageCh, duration, handler)
}
// Signal creates a reactive signal and initializes it with the given value.
// Use Bind() to link the value of input elements to the signal and Text() to
// display the signal value and watch the UI update live as the input changes.
//
// Example:
//
// mysignal := c.Signal("world")
//
// c.View(func() h.H {
// return h.Div(
// h.P(h.Span(h.Text("Hello, ")), h.Span(mysignal.Text())),
// h.Input(mysignal.Bind()),
// )
// })
//
// Signals are 'alive' only in the browser, but Via always injects their values into
// the Context before each action call.
// If any signal value is updated by the server, the update is automatically sent to the
// browser when using Sync() or SyncSignsls().
func (c *Context) Signal(v any) *Signal {
sigID := genRandID()
if v == nil {
c.app.logErr(c, "failed to bind signal: nil signal value")
return &Signal{
id: sigID,
val: "error",
err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID),
}
}
switch reflect.TypeOf(v).Kind() {
case reflect.Slice, reflect.Struct:
if j, err := json.Marshal(v); err == nil {
v = string(j)
}
}
sig := &Signal{
id: sigID,
val: v,
changed: true,
}
c.mu.Lock()
defer c.mu.Unlock()
if c.isComponent() { // components register signals on parent page
c.parentPageCtx.signals.Store(sigID, sig)
} else {
c.signals.Store(sigID, sig)
}
return sig
}
// 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")
return
}
c.mu.Lock()
defer c.mu.Unlock()
for sigID, val := range sigs {
item, ok := c.signals.Load(sigID)
if !ok {
c.signals.Store(sigID, &Signal{
id: sigID,
val: val,
})
continue
}
if sig, ok := item.(*Signal); ok {
sig.val = val
sig.changed = false
}
}
}
func (c *Context) getPatchChan() chan patch {
// components use parent page sse stream
var patchChan chan patch
if c.isComponent() {
patchChan = c.parentPageCtx.patchChan
} else {
patchChan = c.patchChan
}
return patchChan
}
func (c *Context) prepareSignalsForPatch() map[string]any {
c.mu.RLock()
defer c.mu.RUnlock()
updatedSigs := make(map[string]any)
c.signals.Range(func(sigID, value any) bool {
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
}
if sig.changed {
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
sig.changed = false
}
case *computedSignal:
sig.recompute()
if sig.changed {
updatedSigs[sigID.(string)] = sig.patchValue()
sig.changed = false
}
}
return true
})
return updatedSigs
}
// sendPatch queues a patch on this *Context sse stream. If the sse is closed or queue is full, the patch
// is dropped to prevent runtime blocks.
func (c *Context) sendPatch(p patch) {
patchChan := c.getPatchChan()
select {
case patchChan <- p:
default: // closed or buffer full - drop patch without blocking
}
}
// Sync pushes the current view state and signal changes to the browser immediately
// over the live SSE event stream.
func (c *Context) Sync() {
c.syncView(false)
}
func (c *Context) syncView(viewTransition bool) {
elemsPatch := new(bytes.Buffer)
if err := c.view().Render(elemsPatch); err != nil {
c.app.logErr(c, "sync view failed: %v", err)
return
}
typ := patchType(patchTypeElements)
if viewTransition {
typ = patchTypeElementsWithVT
}
c.sendPatch(patch{typ, elemsPatch.String()})
updatedSigs := c.prepareSignalsForPatch()
if len(updatedSigs) != 0 {
outgoingSigs, _ := json.Marshal(updatedSigs)
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
}
}
// SyncElements pushes an immediate html patch over the live SSE stream to the
// browser that merges with the DOM
//
// For the merge to occur, each top lever element in the patch needs to have
// an ID that matches the ID of an element that already sits in the view.
//
// Example:
//
// If the view already contains the element:
//
// h.Div(
// h.ID("my-element"),
// h.P(h.Text("Hello from Via!"))
// )
//
// Then, the merge will only occur if the ID of one of the top level elements in the patch
// matches 'my-element'.
func (c *Context) SyncElements(elem ...h.H) {
b := bytes.NewBuffer(nil)
for idx, el := range elem {
if el == nil {
c.app.logWarn(c, "sync elements failed: element at idx=%d is nil", idx)
continue
}
if err := el.Render(b); err != nil {
c.app.logWarn(c, "sync elements failed: element at idx=%d has invalid html", idx)
continue
}
}
c.sendPatch(patch{patchTypeElements, b.String()})
}
// SyncSignals pushes the current signal changes to the browser immediately
// over the live SSE event stream.
func (c *Context) SyncSignals() {
updatedSigs := c.prepareSignalsForPatch()
if len(updatedSigs) != 0 {
outgoingSignals, _ := json.Marshal(updatedSigs)
c.sendPatch(patch{patchTypeSignals, string(outgoingSignals)})
}
}
func (c *Context) ExecScript(s string) {
if s == "" {
c.app.logWarn(c, "exec script failed: empty script")
return
}
c.sendPatch(patch{patchTypeScript, s})
}
// RedirectView sets a view that redirects the browser to the given URL.
// Use this in middleware to abort the chain and redirect in one step.
func (c *Context) RedirectView(url string) {
c.View(func() h.H {
c.Redirect(url)
return h.Div()
})
}
// Redirect navigates the browser to the given URL.
// This triggers a full page navigation - the current context will be disposed
// and a new context created at the destination URL.
func (c *Context) Redirect(url string) {
if url == "" {
c.app.logWarn(c, "redirect failed: empty url")
return
}
c.sendPatch(patch{patchTypeRedirect, url})
}
// Redirectf navigates the browser to a URL constructed from the format string and arguments.
func (c *Context) Redirectf(format string, a ...any) {
c.Redirect(fmt.Sprintf(format, a...))
}
// ReplaceURL updates the browser's URL and history without triggering navigation.
// Useful for updating query params or path to reflect UI state changes.
func (c *Context) ReplaceURL(url string) {
if url == "" {
c.app.logWarn(c, "replace url failed: empty url")
return
}
c.sendPatch(patch{patchTypeReplaceURL, url})
}
// ReplaceURLf updates the browser's URL using a format string.
func (c *Context) ReplaceURLf(format string, a ...any) {
c.ReplaceURL(fmt.Sprintf(format, a...))
}
// resetPageState tears down page-specific state (intervals, subscriptions,
// actions, signals, fields) without disposing the context itself. The SSE
// connection and context lifetime are unaffected.
func (c *Context) resetPageState() {
close(c.pageStopChan)
c.unsubscribeAll()
c.mu.Lock()
c.actionRegistry = make(map[string]actionEntry)
c.signals = new(sync.Map)
c.fields = nil
c.pageStopChan = make(chan struct{})
c.mu.Unlock()
}
// 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.
// If popstate is true, replaceState is used instead of pushState.
func (c *Context) Navigate(path string, popstate bool) {
c.navMu.Lock()
defer c.navMu.Unlock()
route, initFn, params := c.app.matchRoute(path)
if initFn == nil {
c.Redirect(path)
return
}
c.resetPageState()
c.route = route
c.injectRouteParams(params)
initFn(c)
c.syncView(true)
safe := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(path)
if popstate {
c.ExecScript(fmt.Sprintf("history.replaceState({},'','%s')", safe))
} else {
c.ExecScript(fmt.Sprintf("history.pushState({},'','%s')", safe))
}
}
// dispose idempotently tears down this context: unsubscribes all pubsub
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
func (c *Context) dispose() {
c.disposeOnce.Do(func() {
c.unsubscribeAll()
c.stopAllRoutines()
})
}
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
// goroutines (OnInterval, SSE loop) that this context is done.
func (c *Context) stopAllRoutines() {
select {
case <-c.ctxDisposedChan:
// already closed
default:
close(c.ctxDisposedChan)
}
}
func (c *Context) injectRouteParams(params map[string]string) {
if params == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.routeParams = params
}
// GetPathParam retrieves the value from the page request URL for the given parameter name
// or an empty string if not found.
//
// Example:
//
// v.Page("/users/{user_id}", func(c *via.Context) {
//
// userID := GetPathParam("user_id")
//
// c.View(func() h.H {
// return h.Div(
// h.H1(h.Textf("User ID: %s", userID)),
// )
// })
// })
func (c *Context) GetPathParam(param string) string {
c.mu.RLock()
defer c.mu.RUnlock()
if p, ok := c.routeParams[param]; ok {
return p
}
return ""
}
// Session returns the session for this context.
// Session data persists across page views for the same browser.
// Returns a no-op session if no SessionManager is configured.
func (c *Context) Session() *Session {
return &Session{
ctx: c.reqCtx,
manager: c.app.sessionManager,
}
}
// Publish sends data to the given subject via the configured PubSub backend.
// Returns an error if no PubSub is configured. No-ops during panic-check init.
func (c *Context) Publish(subject string, data []byte) error {
if c.id == "" {
return nil
}
if c.app.pubsub == nil {
return fmt.Errorf("pubsub not configured")
}
return c.app.pubsub.Publish(subject, data)
}
// Subscribe creates a subscription on the configured PubSub backend.
// The subscription is tracked for automatic cleanup when the context is disposed.
// Returns an error if no PubSub is configured. No-ops during panic-check init.
func (c *Context) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
if c.id == "" {
return nil, nil
}
if c.app.pubsub == nil {
return nil, fmt.Errorf("pubsub not configured")
}
sub, err := c.app.pubsub.Subscribe(subject, handler)
if err != nil {
return nil, err
}
// Track on page context for cleanup (components use parent, like signals/actions)
target := c
if c.isComponent() {
target = c.parentPageCtx
}
target.subsMu.Lock()
target.subscriptions = append(target.subscriptions, sub)
target.subsMu.Unlock()
return sub, nil
}
// unsubscribeAll cleans up all tracked subscriptions for this context and its components.
func (c *Context) unsubscribeAll() {
c.subsMu.Lock()
subs := c.subscriptions
c.subscriptions = nil
c.subsMu.Unlock()
for _, sub := range subs {
sub.Unsubscribe()
}
}
// Field creates a signal with validation rules attached.
// The initial value seeds both the signal and the reset target.
// The field is tracked on the context so ValidateAll/ResetFields
// can operate on all fields by default.
func (c *Context) Field(initial any, rules ...Rule) *Field {
f := &Field{
Signal: c.Signal(initial),
rules: rules,
initialVal: initial,
}
target := c
if c.isComponent() {
target = c.parentPageCtx
}
target.fields = append(target.fields, f)
return f
}
// ValidateAll runs Validate on each field, returning true only if all pass.
// With no arguments it validates every field tracked on this context.
func (c *Context) ValidateAll(fields ...*Field) bool {
if len(fields) == 0 {
fields = c.fields
}
ok := true
for _, f := range fields {
if !f.Validate() {
ok = false
}
}
return ok
}
// ResetFields resets each field to its initial value and clears errors.
// With no arguments it resets every field tracked on this context.
func (c *Context) ResetFields(fields ...*Field) {
if len(fields) == 0 {
fields = c.fields
}
for _, f := range fields {
f.Reset()
}
}
func newContext(id string, route string, v *V) *Context {
if v == nil {
panic("create context failed: app pointer is nil")
}
return &Context{
id: id,
route: route,
csrfToken: genCSRFToken(),
routeParams: make(map[string]string),
app: v,
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
actionRegistry: make(map[string]actionEntry),
signals: new(sync.Map),
patchChan: make(chan patch, 8),
ctxDisposedChan: make(chan struct{}, 1),
pageStopChan: make(chan struct{}),
createdAt: time.Now(),
}
}