Files
via/context.go
Ryan Hamamura 0762ddbbc2 feat: add token-bucket rate limiting for action endpoints
Add per-context and per-action rate limiting using golang.org/x/time/rate.
Configure globally via Options.ActionRateLimit or per-action with
WithRateLimit(). Defaults to 10 req/s with burst of 20.
2026-02-06 11:52:07 -10:00

500 lines
13 KiB
Go

package via
import (
"bytes"
"context"
"encoding/json"
"fmt"
"maps"
"reflect"
"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
componentRegistry map[string]*Context
parentPageCtx *Context
patchChan chan patch
actionLimiter *rate.Limiter
actionRegistry map[string]actionEntry
signals *sync.Map
mu sync.RWMutex
ctxDisposedChan chan struct{}
reqCtx context.Context
subscriptions []Subscription
subsMu sync.Mutex
disposeOnce sync.Once
createdAt time.Time
sseConnected atomic.Bool
}
// 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")
}
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)
c.componentRegistry[id] = 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 go routine that sets a time.Ticker with the given duration and executes
// the given handler func() on every tick. Use *Routine.UpdateInterval to update the interval.
func (c *Context) OnInterval(duration time.Duration, handler func()) *OnIntervalRoutine {
var cn chan struct{}
if c.isComponent() { // components use the chan on the parent page ctx
cn = c.parentPageCtx.ctxDisposedChan
} else {
cn = c.ctxDisposedChan
}
r := newOnIntervalRoutine(cn, duration, handler)
return r
}
// 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
}
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 {
if _, ok := c.signals.Load(sigID); !ok {
c.signals.Store(sigID, &signal{
id: sigID,
val: val,
})
continue
}
item, _ := c.signals.Load(sigID)
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 {
if sig, ok := value.(*signal); ok {
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)
}
}
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() {
elemsPatch := bytes.NewBuffer(make([]byte, 0))
if err := c.view().Render(elemsPatch); err != nil {
c.app.logErr(c, "sync view failed: %v", err)
return
}
c.sendPatch(patch{patchTypeElements, 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})
}
// 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...))
}
// 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 (OnIntervalRoutine, 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
}
m := make(map[string]string)
c.mu.Lock()
defer c.mu.Unlock()
maps.Copy(m, params)
c.routeParams = m
}
// 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()
}
}
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,
componentRegistry: make(map[string]*Context),
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
actionRegistry: make(map[string]actionEntry),
signals: new(sync.Map),
patchChan: make(chan patch, 1),
ctxDisposedChan: make(chan struct{}, 1),
createdAt: time.Now(),
}
}