tether

package module
v0.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 13, 2026 License: MIT Imports: 49 Imported by: 0

README

Tether

Reactive server-driven UI for Fluent. Write Go, get live updates.

[!WARNING] API Stability: Tether is currently in active development and the API is not yet stable. We will break APIs freely to arrive at the best possible design before the v1.0.0 release.

Requires Go 1.25 or later.

Tether connects Fluent's node trees to the browser via WebSocket (with SSE fallback). When state changes, only the parts that actually changed are sent as targeted patches. The client morphs the DOM in place, preserving input focus, scroll position, and form state.

Three update modes give you the right tool for every situation:

  • Server rendering - the default. Handle returns new state, the framework diffs and sends patches or morphs. Works for everything.
  • Signals - push individual values from the server and bound elements update instantly with no render cycle. Ideal for counters, progress bars, and status indicators.
  • Client directives - toggle classes, attributes, and signals entirely in the browser. Perfect for menus, modals, and optimistic updates.

Quick example

mux.Handle("/counter", tether.Stateful(tether.App{}, tether.StatefulConfig[CounterState]{
    InitialState: func(r *http.Request) CounterState {
        return CounterState{Count: 0}
    },
    Render: func(state CounterState) node.Node {
        return div.New(
            span.Textf("Count: %d", state.Count).Dynamic("count"),
            bind.Apply(button.Text("+1"), bind.OnClick("increment")),
        )
    },
    Handle: func(_ tether.Session, state CounterState, event tether.Event) CounterState {
        if event.Action == "increment" {
            state.Count++
        }
        return state
    },
}))

// Serve the client JS runtime.
mux.Handle("/_tether/", http.StripPrefix("/_tether/", tether.ServeClient()))

When the handler is not at root, mount the client JS runtime separately:

mux.Handle("/_tether/", http.StripPrefix("/_tether/", tether.ServeClient()))

No JavaScript to write. No diff algorithm to understand.

Stateful vs Stateless

Tether offers two handler modes:

Stateful (tether.Stateful) - maintains a persistent connection (WebSocket or SSE) between browser and server. State lives in memory across interactions. The server can push updates at any time. Use this for dashboards, chat, real-time collaboration - anything where the server needs to push updates or maintain session state.

Stateless (tether.Stateless) - traditional HTTP request/response. State is reconstructed from each request. No persistent connection, no session pool. Use this for forms, navigation, and pages where each interaction is independent. See stateless pages for details.

Both modes share the same rendering engine, event system, and bind helpers. The difference is how state is managed - persistent or per-request.

Standalone server

When your application is a single handler, ListenAndServe handles signal trapping, graceful shutdown, and sensible defaults:

h := tether.Stateful(tether.App{}, tether.StatefulConfig[State]{
    InitialState: func(r *http.Request) State { return State{} },
    Render:       render,
    Handle:       handle,
})

h.ListenAndServe("") // checks PORT env var, then defaults to :8080

No mux, no signal handling, no shutdown boilerplate. The handler serves the client JS runtime, your pages, and manages the full session lifecycle. On SIGINT or SIGTERM, sessions are drained gracefully before the process exits.

Embedded assets

Serve CSS, JS, and images from an embed.FS with automatic content-hashed URLs. Add assets to App.Assets and they're auto-served - no extra mux setup needed:

//go:embed static
var staticFS embed.FS

var assets = &tether.Asset{FS: staticFS, Prefix: "/static/"}

app := tether.App{
    Assets: []*tether.Asset{assets},
}

tether.Stateful(app, tether.StatefulConfig[State]{
    Layout: func(state State, content node.Node) node.Node {
        return html.New(
            head.New(assets.Stylesheet("styles.css")),
            body.New(content),
        )
    },
    // ...
})
// GET /static/styles.css?v=a1b2c3d4e5f6 → served automatically with immutable cache headers

Persistence

Two independent, opt-in stores handle different concerns:

Session store - persists application state S for crash recovery and node migration. Set StatefulConfig.SessionStore to enable. On disconnect and graceful shutdown, the framework serialises S (CBOR by default) and saves it. When a reconnecting client hits a server with no in-memory session, the framework restores from the store. See session-store for details.

Diff store - offloads differ snapshots to external storage during the reconnect window, freeing Go memory. Set StatefulConfig.DiffStore to enable. This is a memory optimisation, not a recovery mechanism. See store for details.

Cluster

Bus and Value are in-process by default. For multi-node deployments, set a Cluster on App and add topic names to the primitives you want distributed:

app := tether.App{
    Cluster: tetheredis.New(rdb),
}
var messages = tether.NewBus[Message](tether.BusConfig{Topic: "messages"})
var online   = tether.NewValue(0, "online-count")

Events and values are serialised with CBOR and routed through the broker. Groups remain local - cross-node broadcasting flows through Bus events. See cluster for details.

Diagnostics

Handler.Diagnostics is a typed event bus for framework-level signals - transport errors, panics, buffer overflows, and more. Subscribe for metrics, alerting, or custom logging. See operations for details and examples.

Documentation

Guide Description
Architecture Core concepts, session lifecycle, command loop, transport
Reactivity Observer pattern, event-driven design, how Bus/Value/Group compose
API reference App, StatefulConfig, Session, Event, Component, Middleware, tethertest, bind helpers
Getting started Setup, how updates reach the browser
Stateless pages tether.Stateless for request/response pages without persistent connections
Events Event binding, timing, loading states, forms
Signals Reactive signals, client directives, optimistic updates
Server updates Side effects, Dynamic keys, diff observability
Background goroutines Session.Go, transport vs session context, session methods
Components tether.Component, declarative mounting, RouteTyped, Mounter
URL routing OnNavigate, bind.Link, multi-page apps with the router package
Client-side Directives, transitions, JS hooks
Broadcasting Groups, broadcast, presence
Cluster Cross-node Bus and Value via Redis or other brokers
Extensions File uploads, service worker, push notifications
SessionStore Session state persistence for crash recovery and node migration
Frozen mode Zero-memory disconnected sessions via Freeze
DiffStore External snapshot persistence for disconnected sessions
Transport WebSocket, SSE, resilience
Push notifications Web Push with VAPID
Operations Health check, drain, dev mode, diagnostics, error reporting
Scaling Per-session overhead, horizontal scaling, capacity planning
Security TLS, session identity, origin checking, CSRF, rate limiting
Best practices Common patterns, performance tips, pitfalls to avoid
Engine Differ, Memoiser, Patch, coalescing, and how they compose
Performance Benchmarks, windowing, PGO
Windowing Virtual scrolling for large lists

Third-party libraries

Library Licence Purpose
idiomorph 0.3.0 0BSD DOM morphing (bundled JS)
lxzan/gws Apache-2.0 WebSocket transport
fxamacker/cbor MIT CBOR encoding for session state persistence
golang.org/x/crypto BSD-3-Clause VAPID push notification encryption

Documentation

Overview

Package tether is a reactive UI layer for Go. The server owns application state and renders HTML; the client owns ephemeral UI state - toggling drawers, binding text to signals, showing and hiding elements - without a round-trip.

Tether provides two handler modes:

Stateful handlers maintain a persistent connection (WebSocket or SSE) between browser and server. State survives across interactions - when the user clicks a button, the server updates state and pushes the change without a page reload. Use Stateful for interactive applications: dashboards, forms, chat, real-time collaboration.

Stateless handlers reconstruct state from each HTTP request. No persistent connection - every interaction is a standard request/response cycle. Use Stateless for content-focused pages that don't need real-time updates.

Both modes share the same rendering engine (Fluent), the same event system, and the same component model. The difference is whether state persists between interactions.

Stateful mode

The lifecycle of a stateful page visit is:

  1. The browser GETs the page. The handler renders the initial HTML from StatefulConfig.InitialState and StatefulConfig.Render, pre-warms a session with the diff state, and embeds the session ID in the root element.
  2. The client JS opens a persistent transport and reclaims the pre-warmed session. Pre-warming avoids a second render on connect and ensures the diff baseline matches the HTML the browser already has.
  3. When the user interacts with the page, the client sends an Event. The server calls StatefulConfig.Handle to produce new state, diffs the old and new render trees, and sends only the changed fragments back as targeted patches or structural morphs.
  4. For lightweight updates that don't need a full render cycle, the server pushes signals via [Session.Signal]. Bound elements ([bind.BindText], [bind.BindShow], [bind.BindClass], [bind.BindAttr]) update instantly on the client - no diff, no HTML.

Stateless mode

GET requests render the full HTML page. POST requests handle a client event, render the new state, and return a JSON update with the new HTML and any side effects. The client applies the update without a full page reload.

Event binding helpers ([bind.Click], [bind.Submit], [bind.Input], etc.) attach data-tether-* attributes to Fluent elements so the client JS knows which DOM events to forward. Signal bindings and client-side directives handle interactions that stay in the browser. See the bind sub-package for the full set.

Wire format

Server-to-client updates are encoded as JSON by default. Set App.WireFormat or StatefulConfig.WireFormat to wire.CBOR for smaller, faster binary payloads. The client detects the format automatically - no additional configuration is needed on the browser side.

Client runtime

The default client runtime is tether.js, a JavaScript implementation that handles transport, morphing, signals, and event binding. For applications that benefit from shared Go types on both sides of the wire, set App.Client.Runtime to [Runtime.WASM] to use a Go WASM client instead. See the tether-wasm module for details.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrPushNotConfigured is returned by [Session.Push] when the
	// handler was created without push support (no PushConfig).
	ErrPushNotConfigured = errors.New("tether: push not configured")

	// ErrPushNoSubscription is returned by [Session.Push] when the
	// browser has not yet registered a push subscription.
	ErrPushNoSubscription = errors.New("tether: no push subscription for session")

	// ErrPushPreWarm is returned by [CaptureSession.Push] during
	// pre-warming because no browser subscription exists yet.
	ErrPushPreWarm = errors.New("tether: push not available during pre-warming")
)

Sentinel errors returned by push-related methods.

View Source
var Runtime runtimeFactory

Runtime provides constructors for the built-in client runtimes. Use Runtime.Default() for the standard JS client (idiomorph and tether.js) or Runtime.WASM() for a Go WASM client.

Functions

func Catch

func Catch(render func() node.Node, fallback node.Node) (result node.Node)

Catch calls render and returns its result. If render panics, the panic is recovered, logged, and the fallback node is returned instead. This allows individual components to fail gracefully without taking down the entire page.

func render(s State) node.Node {
    return div.New(
        header(s),
        tether.Catch(func() node.Node {
            return riskyWidget(s)
        }, span.Text("Widget unavailable")),
        footer(s),
    )
}

func InitMounts

func InitMounts[S any](mounts []ComponentMount[S], sess Session, state S) S

InitMounts calls Mounter.Mount on each mounted component that implements the Mounter interface. Called once per session after the command loop starts. Components that do not implement Mounter are left unchanged.

func ListenAndServe

func ListenAndServe(addr string, handler http.Handler, drainers ...Drainable) error

ListenAndServe starts an HTTP server at addr with the provided http.Handler and shuts down gracefully on SIGINT or SIGTERM. All drainers are drained concurrently, then the HTTP server is stopped, then remaining sessions are force-closed.

Use this when multiple tether handlers share a single mux:

mux := http.NewServeMux()
mux.Handle("/ws/", wsHandler)
mux.Handle("/sse/", sseHandler)
tether.ListenAndServe("", mux, wsHandler, sseHandler)

The addr parameter follows net.Listen conventions. When empty, the PORT environment variable is checked; if that is also empty, ":8080" is used.

Returns nil on clean shutdown. Returns an error only for startup failures such as a port already in use. A second signal during shutdown forces an immediate exit.

For single-handler apps, Handler.ListenAndServe is simpler - it uses the handler's configured ShutdownGrace timeout and serves itself without a separate mux.

func ListenAndServeTLS

func ListenAndServeTLS(addr, certFile, keyFile string, handler http.Handler, drainers ...Drainable) error

ListenAndServeTLS starts an HTTPS server with graceful shutdown. It behaves identically to ListenAndServe but accepts TLS certificate and key file paths. If addr is empty and the PORT environment variable is not set, ":443" is used.

func Observe

func Observe[V any, S any](s *StatefulSession[S], val *Value[V], fn func(V, S) S)

Observe subscribes a session to a shared Value. The callback receives the shared value and the session's current state, and returns the new state - same shape as On.

The current value is delivered immediately so the session's state is up to date from the moment of subscription. Future changes via Value.Store or Value.Update are delivered automatically.

Updates are coalesced: if a Value changes faster than the session can render, intermediate values are skipped and only the latest value is applied. This prevents render storms when a shared Value updates at high frequency (e.g. a global counter or ticker). The callback always receives the latest value via Value.Load, not the value that was current when the change was published.

The subscription, initial read, and initial state application all happen within a single session command. A concurrent Value.Store that arrives after the subscription is registered will always be ordered after the initial value - the session never sees a stale overwrite.

The subscription is cleaned up when the session is destroyed.

tether.Observe(s, onlineCount, func(count int, state State) State {
    state.OnlineUsers = count
    return state
})

func On

func On[E any, S any](s *StatefulSession[S], bus *Bus[E], fn func(E, S) S)

On subscribes a session to a typed event bus. When the bus publishes an event, fn is called inside the session's command loop (via StatefulSession.Update) with the event and the current state. The callback returns the new state - same pattern as Update.

Sender filtering is automatic: if the event was emitted by this session (via Bus.Emit), the callback is skipped. This prevents double-apply - Handle updates the sender's state directly, the bus updates everyone else.

The subscription is cleaned up automatically when the session is destroyed (context cancelled). No manual unsubscribe needed.

On is a top-level function rather than a Bus method because it needs two type parameters (E for the event, S for the state). Go methods cannot introduce additional type parameters.

tether.On(s, messages, func(ev MessageSent, state ChatState) ChatState {
    state.Messages = append(state.Messages, ev.Text)
    return state
})

func RouteMount

func RouteMount[S any](mounts []ComponentMount[S], sess Session, state S, ev Event) (S, bool)

RouteMount tries each mount in order. If one matches, it returns the updated state and true. Otherwise it returns the original state and false. Called during Handle composition in Stateful and Stateless constructors so component events flow through the middleware chain.

func RouteTyped

func RouteTyped[C Component](comp C, prefix string, sess Session, ev Event) C

RouteTyped dispatches an event to a component by prefix, preserving the concrete type through the Component interface dispatch. This allows the parent to store the concrete component type in its state struct - full compile-time safety, direct field access, no type assertions needed.

The type assertion comp.Handle(...).(C) is safe because a Component's Handle method returns the same concrete type as its receiver. This is a contract of the pattern - a Widget.Handle always returns a Widget. If a Handle implementation returns a different concrete type, RouteTyped panics. In debug/dev mode, the panic message identifies the mismatch.

type State struct {
    Chat chatwidget.Widget // concrete type, not tether.Component
}

func handle(sess tether.Session, s State, ev tether.Event) State {
    s.Chat = tether.RouteTyped(s.Chat, "chat", sess, ev)
    return s
}

func ServeClient

func ServeClient() http.Handler

ServeClient returns an http.Handler that serves the embedded client JS runtime (tether.js, idiomorph, service worker). Mount it at /_tether/ when the tether handler is not at the root path:

mux.Handle("/_tether/", http.StripPrefix("/_tether/", tether.ServeClient()))

When the tether handler IS mounted at "/" the client runtime is served automatically and this function is not needed.

func SetCluster added in v0.3.0

func SetCluster(c Cluster)

SetCluster configures the package-level cluster instance. Called internally by Stateful and Stateless when App.Cluster is set. Safe to call multiple times with the same value (idempotent for the common case of multiple handlers sharing one App).

func Stateless

func Stateless[S any](app App, cfg StatelessConfig[S]) http.Handler

Page creates an http.Handler for a stateless page. State is reconstructed from each HTTP request - nothing persists between interactions. GET requests render the full HTML page. POST requests handle a client event and return a JSON update with the new HTML and any side effects.

For stateful pages with persistent connections and session state, use Stateful instead.

Types

type App

type App struct {
	// DevMode enables development conveniences: debug logging by
	// default, Cache-Control: no-store on all responses, service
	// worker unregistration, and the Tether.disconnect() test hook
	// in the client JS. Enable via this field or set the TETHER_DEV
	// environment variable to any non-empty value.
	DevMode bool

	// Logger used for framework log output. When nil, the framework
	// creates a text handler at INFO level (DEBUG in DevMode) and
	// configures the dev package's scoped logger. The process-wide
	// slog default is NEVER modified - tether's logger is scoped
	// to the framework. When provided, the framework uses it
	// without touching the global default.
	Logger *slog.Logger

	// Client groups browser-side settings passed to the client JS
	// as data attributes on the tether root element.
	Client Client

	// Security groups origin-checking and CSRF protection settings.
	Security Security

	// Assets lists embedded asset collections to auto-serve. Each
	// [Asset] provides content-hashed URLs for cache-busting.
	// Assets are served at their configured prefix (default
	// "/assets/") with appropriate cache headers.
	Assets []*Asset

	// Upgrade is the primary transport upgrade function for stateful
	// handlers. Defaults to ws.Upgrade() (WebSocket) when nil.
	// Per-handler overrides on [StatefulConfig] take precedence.
	Upgrade func(w http.ResponseWriter, r *http.Request) (Transport, error)

	// Fallback is the secondary transport upgrade function for
	// stateful handlers. Defaults to sse.Upgrade() (Server-Sent
	// Events) when nil. Per-handler overrides on [StatefulConfig]
	// take precedence.
	Fallback func(w http.ResponseWriter, r *http.Request) (Transport, error)

	// ShutdownGrace is how long [ListenAndServe] and
	// [Handler.ListenAndServe] wait for sessions to drain during
	// graceful shutdown. After this period, remaining sessions are
	// force-closed. Also used as the TTL when persisting session
	// state to the [SessionStore] during shutdown. Zero defaults to
	// 10 seconds.
	ShutdownGrace time.Duration

	// MaxSessions limits the total number of concurrent sessions
	// (pending + active + disconnected) across all handlers. Zero
	// means unlimited. In production, set a limit to prevent
	// resource exhaustion.
	MaxSessions int

	// MaxPending limits the number of pre-warmed sessions waiting
	// for a browser to open a transport connection. Each GET request
	// creates a pending session (state + differ), so this cap
	// protects against GET-flooding attacks where an attacker scripts
	// thousands of requests without ever connecting. Pending sessions
	// are cheap but unauthenticated - capping them separately
	// prevents an attacker from crowding out legitimate active
	// sessions under the global MaxSessions limit. Zero defaults to
	// 128.
	MaxPending int

	// WireFormat selects the default encoding for server-to-client
	// updates across all handlers. Individual handlers can override
	// this via [StatefulConfig].WireFormat. The zero value is [wire.JSON].
	WireFormat wire.Format

	// Cluster enables cross-node communication for [Bus] and [Value].
	// When set, any Bus or Value created with a topic name publishes
	// state changes to the cluster and subscribes to changes from
	// other nodes. See [Cluster] for the interface contract.
	Cluster Cluster
}

App holds configuration shared across all handlers in an application: logging, client-side behaviour, security, assets, and transports. Create one App and pass it to Stateful and Stateless - each handler gets its own copy, so shared settings are defined once.

The zero value provides sensible defaults: WebSocket as the primary transport and SSE as the fallback. Override Upgrade and/or Fallback to customise transport options.

app := tether.App{
    DevMode: true,
    Assets:  []*tether.Asset{assets},
}

live := tether.Stateful(app, tether.StatefulConfig[State]{...})
page := tether.Stateless(app, tether.StatelessConfig[State]{...})

Value semantics

App is passed by value. Each handler receives an independent copy so settings cannot be mutated from outside after construction. Internally the handler configures the dev package's process-wide logger and dev-mode flag. This global state is intentional: dev mode is a property of the development session, not of individual handlers. See the dev package documentation for the full rationale.

Observability

App.Logger configures the dev package's scoped logger, which is a development debugging aid (verbose, human-readable, gated behind DevMode). For production observability, subscribe to Handler.Diagnostics which provides typed, per-handler events for metrics, alerting, and structured logging. See Diagnostic and Bus for details.

type Asset

type Asset struct {
	// FS is the filesystem containing application assets (CSS, images,
	// JS). Use embed.FS for single-binary deployments or os.DirFS for
	// external assets with live updates. Required.
	FS fs.FS

	// Prefix is the URL path prefix where assets are served. Must end
	// with "/". Defaults to "/assets/" when empty.
	Prefix string

	// Precache lists asset paths (relative to FS) that the service
	// worker should cache on install. These are served with
	// content-hashed query strings for cache-busting. Optional.
	Precache []string

	// WatchDir is the filesystem path to watch for changes. When set,
	// the asset manager uses fsnotify to detect file modifications and
	// recomputes hashes only for changed files. Leave empty for
	// embedded assets (which are immutable). The path should correspond
	// to the directory used in os.DirFS.
	WatchDir string
	// contains filtered or unexported fields
}

Asset manages an asset filesystem with content-hashed URLs. Construct one as a struct literal and pass it to StatefulConfig.Assets. The first call to Asset.URL, Asset.Stylesheet, or Asset.Script (or handler startup) walks the filesystem and hashes every file.

For embedded assets (single-binary deployments):

//go:embed static
var staticFS embed.FS

var assets = &tether.Asset{
    FS:     staticFS,
    Prefix: "/static/",
}

For filesystem assets (external, mutable):

var assets = &tether.Asset{
    FS:       os.DirFS("./static"),
    Prefix:   "/static/",
    WatchDir: "./static",
}

When WatchDir is set, the asset manager watches the directory for changes and recomputes hashes only for files that change. This allows deploying new assets without restarting the server.

func (*Asset) Close added in v0.2.0

func (a *Asset) Close() error

Close stops the filesystem watcher if one is running. Call this on shutdown to clean up the watcher goroutine.

func (*Asset) Script

func (a *Asset) Script(path string) node.Node

Script returns a <script> node for the given asset path with a content-hashed URL.

func (*Asset) Stylesheet

func (a *Asset) Stylesheet(path string) node.Node

Stylesheet returns a <link rel="stylesheet"> node for the given asset path with a content-hashed URL.

func (*Asset) URL

func (a *Asset) URL(path string) string

URL returns the hashed URL for the given asset path. The path is relative to the asset filesystem root.

assets.URL("styles.css") // "/static/styles.css?v=a1b2c3d4e5f6"

If the path is not found in the filesystem (typo, read failure), the unhashed URL is returned and an error is logged.

type AsyncOverflow

type AsyncOverflow int

AsyncOverflow controls what happens when all async worker slots are occupied during publication.

const (
	// Block waits for a semaphore slot before spawning the
	// goroutine. No data loss, but the publisher's goroutine
	// stalls until a worker finishes. This is the default.
	Block AsyncOverflow = iota + 1

	// Drop discards the event for this subscriber and logs a
	// warning. The publisher never stalls.
	Drop

	// Inline runs the callback synchronously in the publisher's
	// goroutine. No data loss, but the publisher blocks for the
	// full duration of the callback.
	Inline
)

type Bus

type Bus[E any] struct {
	// contains filtered or unexported fields
}

Bus routes typed domain events to subscribers. Create one per event type at program startup and share it across handlers:

var messages = tether.NewBus[MessageSent](tether.BusConfig{Topic: "messages"})

Bus enables cross-handler communication that Group cannot provide. Group requires all sessions to share the same state type. Bus is parameterised on the event type, so any session can subscribe regardless of its state.

When created with a BusConfig containing a Topic, the Bus publishes events to the cluster (if configured on App) and receives events from other nodes. Without a Topic, the Bus is local-only.

Internally the subscriber map is stored in an atomic.Value so publish is completely lock-free. Subscribe and unsubscribe use a write mutex and copy-on-write semantics - they are rare relative to publish so the copy cost is negligible.

Async subscribers are bounded by a semaphore (default 64 workers). When all slots are occupied, the overflow strategy controls behaviour: Block (default), Drop, or Inline. Configure via BusConfig in NewBus.

func NewBus

func NewBus[E any](cfg ...BusConfig) *Bus[E]

NewBus creates an empty bus ready to accept subscribers. The topic An optional BusConfig can be provided to customise async worker limits, overflow behaviour, and cluster topic. Without config, the bus operates locally with 64 concurrent async workers and blocks when the semaphore is full.

var messages = tether.NewBus[MessageSent](tether.BusConfig{Topic: "messages"})
var local    = tether.NewBus[InternalEvent]()

func (*Bus[E]) Emit

func (b *Bus[E]) Emit(s Session, event E)

Emit publishes a domain event with sender filtering. Subscriptions registered via On whose session ID matches the emitting session are skipped - the sender's Handle already updated its own state.

Behaviour varies by context:

  • Stateful session (*StatefulSession): the publication is enqueued on the session's command loop. This preserves ordering - the sender's diff reaches the client before other subscribers react.
  • Pre-warm or test (*CaptureSession): synchronous publish. CaptureSession executes enqueue inline, so publish runs immediately in the caller's goroutine - deterministic in tests, harmless during pre-warm (no subscribers).

func (*Bus[E]) Len

func (b *Bus[E]) Len() int

Len returns the number of active subscribers. Lock-free.

func (*Bus[E]) Publish

func (b *Bus[E]) Publish(event E)

Publish sends an event to all subscribers with no sender filter. Use this for external event sources (database change listeners, message queue consumers, cron jobs) that have no session identity.

Subscriber callbacks run synchronously in the caller's goroutine. Session-bound subscribers (registered via On) are non-blocking because they route through the session's command channel, but raw [Subscribe] callbacks that block will stall the caller.

If the bus has a cluster topic and a cluster is configured, the event is also published to the cluster after local delivery.

func (*Bus[E]) Subscribe

func (b *Bus[E]) Subscribe(ctx context.Context, fn func(E)) func()

Subscribe registers a callback that receives every event (no sender filter). The subscription lives until ctx is cancelled. Returns an unsubscribe function for early removal.

The callback runs synchronously in the publisher's goroutine - it must not block. If the publisher is a session (via Bus.Emit), a blocking callback stalls that session's command loop. For expensive work, use Bus.SubscribeAsync or On which routes through the subscriber's own command loop.

func (*Bus[E]) SubscribeAsync

func (b *Bus[E]) SubscribeAsync(ctx context.Context, fn func(E)) func()

SubscribeAsync registers a callback that receives every event in its own goroutine. Concurrency is bounded by a semaphore (default 64 workers, configurable via BusConfig.AsyncWorkers). When all worker slots are occupied, the BusConfig.AsyncOverflow strategy applies: Block (default) waits for a slot, Drop discards the event, and Inline runs the callback in the publisher's goroutine.

Use this for external consumers that perform I/O (database writes, HTTP calls, logging) in response to events. For session-bound subscriptions, prefer On which routes through the session's command loop. The subscription lives until ctx is cancelled. Returns an unsubscribe function for early removal.

type BusConfig

type BusConfig struct {
	// Topic enables cluster distribution for this bus. When set and
	// a [Cluster] is configured on [App], events are published to
	// the cluster topic and remote events are delivered to local
	// subscribers. Leave empty for local-only operation.
	Topic string

	// AsyncWorkers limits the number of concurrent goroutines for
	// async subscribers. Each publication acquires a semaphore slot
	// before spawning a goroutine. Default 64.
	AsyncWorkers int

	// AsyncOverflow controls what happens when all worker slots
	// are occupied. Default [Block].
	AsyncOverflow AsyncOverflow
}

BusConfig customises the behaviour of a Bus. Pass to NewBus to override defaults.

type CaptureSession

type CaptureSession struct {
	// SessionID is returned by ID().
	SessionID string
	// Ctx is the context returned by Context() and passed to Go().
	// When nil, context.Background() is used. Set this to the HTTP
	// request context during pre-warming and stateless handling so
	// goroutines spawned via Go() are cancelled when the client
	// disconnects.
	Ctx context.Context
	// PushErr is returned by Push(). Nil by default (appropriate for
	// tests); set to [ErrPushPreWarm] for pre-warming contexts.
	PushErr error
	// Effects holds the buffered side effects from the most recent
	// event cycle. Callers read these fields after Handle returns.
	Effects Effects
	// MorphKeys holds the Dynamic keys declared via Morph. When
	// non-empty, the stateless handler returns targeted keyed morphs
	// instead of a full root morph.
	MorphKeys []string
}

func (*CaptureSession) Announce

func (cs *CaptureSession) Announce(text string)

Announce buffers an accessibility announcement (ARIA live region) for replay after connection.

func (*CaptureSession) Close

func (cs *CaptureSession) Close()

Close is a no-op. There is no transport to shut down and no command loop to stop.

func (*CaptureSession) Context

func (cs *CaptureSession) Context() context.Context

Context returns the context set via the Ctx field. When Ctx is nil (the default in tests), context.Background() is returned. In production, Ctx is the HTTP request context so goroutines are cancelled when the client disconnects.

func (*CaptureSession) Download added in v0.2.0

func (cs *CaptureSession) Download(url string)

Download buffers a file download URL for replay after connection.

func (*CaptureSession) Flash

func (cs *CaptureSession) Flash(selector, text string)

Flash buffers a targeted flash message keyed by CSS selector.

func (*CaptureSession) Go

func (cs *CaptureSession) Go(fn func(context.Context))

Go spawns a goroutine bound to the session's context. When Ctx is set to the HTTP request context, the goroutine is cancelled if the client disconnects before it finishes.

func (*CaptureSession) ID

func (cs *CaptureSession) ID() string

ID returns the session identifier.

func (*CaptureSession) Morph added in v0.2.2

func (cs *CaptureSession) Morph(keys ...string)

Morph declares which Dynamic keys should be returned as targeted morphs. Multiple calls accumulate keys.

func (*CaptureSession) Navigate

func (cs *CaptureSession) Navigate(rawURL string)

Navigate buffers a client-side navigation. The redirect is applied in the first update after connection, before the client renders.

func (*CaptureSession) Push

Push returns PushErr. Nil by default; set to ErrPushPreWarm for pre-warming contexts where no browser subscription exists yet.

func (*CaptureSession) ReplaceURL

func (cs *CaptureSession) ReplaceURL(rawURL string)

ReplaceURL buffers a history replacement. Unlike Navigate, this replaces the current URL without adding a history entry.

func (*CaptureSession) ScrollTo added in v0.2.0

func (cs *CaptureSession) ScrollTo(selector string)

ScrollTo buffers a scroll-into-view command for replay after connection.

func (*CaptureSession) SetTitle

func (cs *CaptureSession) SetTitle(title string)

SetTitle buffers a document title change for replay after connection.

func (*CaptureSession) Signal

func (cs *CaptureSession) Signal(key string, value any)

Signal buffers a single reactive value for the client.

func (*CaptureSession) Signals

func (cs *CaptureSession) Signals(signals map[string]any)

Signals buffers multiple reactive values for the client.

func (*CaptureSession) Toast

func (cs *CaptureSession) Toast(text string)

Toast buffers a toast message into the effects struct. The message is delivered to the client in the first update after connection.

type Client

type Client struct {
	// DefaultDebounce is the debounce interval applied to input events
	// when the element does not specify data-tether-debounce. Zero
	// defaults to 300 milliseconds.
	DefaultDebounce time.Duration

	// TransitionTimeout is how long the client waits for a CSS
	// transitionend event before forcibly removing a leaving element.
	// This prevents nodes from getting stuck in the DOM when no CSS
	// transition is defined. Zero defaults to 5 seconds.
	TransitionTimeout time.Duration

	// FlashDuration is how long flash messages remain visible before
	// auto-clearing. Zero defaults to 5 seconds.
	FlashDuration time.Duration

	// ToastDuration is how long toast notifications remain visible
	// before animating out. Zero defaults to 5 seconds.
	ToastDuration time.Duration

	// BackgroundSync enables IndexedDB event queuing and background
	// sync for SSE mode. When true, failed POST events are stored in
	// IndexedDB and replayed on reconnect (or via the service worker's
	// Background Sync API). When false (default), failed events are
	// reported as errors and not retried.
	BackgroundSync bool

	// SyncRetention controls how long queued events are retained in
	// IndexedDB before being discarded as stale. Events older than
	// this are deleted during replay rather than sent to the server.
	// Zero defaults to 1 hour. Only relevant when BackgroundSync is
	// enabled.
	SyncRetention time.Duration

	// Runtime selects the browser-side client implementation injected
	// after the tether root element. When nil, the framework uses the
	// default JS runtime (idiomorph and tether.js). Set to
	// Runtime.WASM() to use a Go WASM client instead.
	//
	//     tether.Client{
	//         Runtime: tether.Runtime.WASM("/static/client.go.wasm"),
	//     }
	Runtime ClientRuntime
}

Client groups settings that control the browser-side JS runtime. These are passed to the browser as data attributes on the tether root element.

type ClientRuntime added in v0.3.2

type ClientRuntime interface {
	// contains filtered or unexported methods
}

ClientRuntime controls which browser-side scripts the framework injects after the tether root element. The default JS runtime injects idiomorph and tether.js. A WASM runtime injects the Go WASM loader and support scripts instead.

type Cluster added in v0.3.0

type Cluster interface {
	// Publish sends data to all subscribers of the given topic.
	// Implementations should be fire-and-forget from the caller's
	// perspective - errors are logged internally, not returned.
	Publish(ctx context.Context, topic string, data []byte) error

	// Subscribe registers a callback for messages on the given topic.
	// Returns an unsubscribe function. Subscribe is called at startup
	// and must not fail silently - panic on subscription errors so
	// they surface immediately, like duplicate HTTP routes.
	Subscribe(topic string, fn func(data []byte)) func()
}

Cluster enables cross-node communication for Bus and Value. When a Cluster is configured on App, any Bus or Value created with a topic name publishes state changes to the cluster and subscribes to changes from other nodes. Local-only buses and values (empty topic) are unaffected.

Implement this interface to integrate with your messaging infrastructure (Redis Pub/Sub, NATS, etc.). The framework handles serialisation, self-filtering, and topic naming - implementations only need to move bytes between nodes.

type Component

type Component interface {
	// Render builds the component's node tree. The root node should
	// have a stable Dynamic key so the diff engine can track it across
	// renders. Without a Dynamic key, changes to the component produce
	// no patches and the client never updates.
	Render() node.Node

	// Handle processes an event and returns the updated component. If
	// the event is not relevant, return the receiver unchanged - the
	// parent's Equal check will detect no change and skip the diff.
	//
	// Handle runs inside the session's command loop. Keep it fast - no
	// blocking I/O, no sleeps, no channel waits. Use [Session.Go] for
	// slow work.
	Handle(Session, Event) Component
}

Component is a self-contained rendering unit with its own state. A component knows how to render itself and handle its own events, without any knowledge of the parent's state type S.

Components are value types. Handle returns a new Component value after each event - the receiver is not mutated. This matches the HandleFunc pattern (returns new S) and is critical for the diff engine: the parent stores the returned Component in S, the next render calls Render on the new value, and the diff detects changes.

A component struct implements this interface by embedding its own state as fields. Library authors publish components as concrete structs that satisfy Component - callers store the concrete type in their state for full compile-time safety via RouteTyped.

Components only receive Session (not *StatefulSession), so they work during SSR pre-warming (CaptureSession satisfies Session) and in tests without special cases.

func Route

func Route(comp Component, prefix string, sess Session, ev Event) Component

Route dispatches an event to a component by prefix. If the event's action starts with "prefix.", the prefix is stripped and the event is forwarded to comp.Handle. Otherwise the component is returned unchanged.

Route returns the Component interface type. For typed field access in the parent's state, use RouteTyped instead.

func handle(sess tether.Session, s State, ev tether.Event) State {
    s.Chat = tether.Route(s.Chat, "chat", sess, ev)
    return s
}

type ComponentMount

type ComponentMount[S any] interface {
	// contains filtered or unexported methods
}

ComponentMount wires a Component into the session's event dispatch. Create mounts with Mount and list them in [StatefulConfig.Components].

ComponentMount has unexported methods so the framework controls dispatch - callers cannot implement this interface directly.

func Mount

func Mount[S any, C Component](prefix string, getter func(S) C, setter func(S, C) S) ComponentMount[S]

Mount creates a ComponentMount that wires a Component-implementing type into the session's event dispatch. The prefix identifies the component's event namespace - events with actions starting with "prefix." are routed to the component. The getter extracts the component from S; the setter writes the updated component back.

This follows the same pattern as WatchValue and WatchBus: a generic constructor that returns a non-generic interface, allowing StatefulConfig.Components to hold mounts for different component types.

tether.StatefulConfig[State]{
    Components: []tether.ComponentMount[State]{
        tether.Mount("chat",
            func(s State) chatwidget.Widget { return s.Chat },
            func(s State, c chatwidget.Widget) State { s.Chat = c; return s },
        ),
    },
}

type Diagnostic

type Diagnostic struct {
	// Kind identifies the category of this diagnostic event.
	Kind DiagnosticKind

	// SessionID is the session that produced this event. Empty for
	// events that occur outside a session context (e.g. asset errors).
	SessionID string

	// Err is the underlying error, if any. Nil for informational
	// events like BufferOverflow where the count matters more than
	// the error.
	Err error

	// Detail provides human-readable context when the Kind and Err
	// alone are insufficient. For example, the event action that
	// triggered a panic, or the endpoint path.
	Detail string
}

Diagnostic carries a framework-level event from the session lifecycle, transport layer, or command loop. Subscribe to [Handler.Diagnostics] to observe these events for metrics, alerting, or custom logging.

Each diagnostic has a DiagnosticKind that identifies the category, an optional [Diagnostic.Err] with the underlying failure, and a [Diagnostic.SessionID] linking it to the affected session. Subscribers run synchronously by default - use Bus.SubscribeAsync for callbacks that perform I/O.

h.Diagnostics.Subscribe(ctx, func(d tether.Diagnostic) {
    switch d.Kind {
    case tether.HandlerPanic:
        alerting.Critical(d.SessionID, d.Err)
    case tether.TransportError:
        log.Warn("transport", "session", d.SessionID, "err", d.Err)
    case tether.BufferOverflow:
        metrics.Inc("tether.overflow")
    }
})

type DiagnosticKind

type DiagnosticKind string

DiagnosticKind identifies the category of a framework diagnostic event.

const (
	// TransportError signals a failure reading from or writing to the
	// session's transport (WebSocket or SSE). Normal disconnects
	// (io.EOF) are not emitted - only genuine failures.
	TransportError DiagnosticKind = "transport_error"

	// EncodeError signals a failure encoding a wire update (JSON
	// serialisation). This usually indicates a bug in the state or
	// render output (e.g. an unencodable type).
	EncodeError DiagnosticKind = "encode_error"

	// BufferOverflow signals that a session's command channel was full
	// and a goroutine was spawned to deliver the command. Sustained
	// overflow indicates a slow handler or broadcast storm.
	BufferOverflow DiagnosticKind = "buffer_overflow"

	// HandlerPanic signals a recovered panic inside Handle, Update,
	// or a command callback. The Err field contains the panic value.
	// This is also logged via slog as a critical safety net.
	HandlerPanic DiagnosticKind = "handler_panic"

	// UploadError signals a failure in an upload handler callback.
	UploadError DiagnosticKind = "upload_error"

	// UploadRejected signals that an upload was rejected because its
	// MIME type did not match the UploadConfig.Accept list. The Detail
	// field contains the rejected content type.
	UploadRejected DiagnosticKind = "upload_rejected"

	// CommandDropped signals that a command was discarded because
	// both the session's command buffer and its overflow goroutine
	// cap were exhausted. This means data was lost - the command
	// will not be delivered. Unlike [BufferOverflow] (which copes
	// by spawning a goroutine), a drop indicates the session is
	// critically overwhelmed.
	CommandDropped DiagnosticKind = "command_dropped"

	// SessionBindingFailed signals that a client attempted to claim
	// or reconnect a session with a User-Agent that does not match
	// the one captured when the session was created. This may
	// indicate a stolen session ID. The connection is rejected.
	SessionBindingFailed DiagnosticKind = "session_binding_failed"

	// StoreError signals a failure saving, loading, or deleting
	// differ snapshots from the configured [DiffStore]. The Detail
	// field indicates the operation ("save", "load", or "delete").
	// Store failures are non-fatal - the framework falls back to
	// in-memory behaviour.
	StoreError DiagnosticKind = "store_error"

	// SessionStoreError signals a failure saving, loading, or
	// deleting session state from the configured [SessionStore].
	// The Detail field indicates the operation. Session store
	// failures are non-fatal - the framework continues with
	// in-memory state.
	SessionStoreError DiagnosticKind = "session_store_error"

	// CommandDiscarded signals that a command or effect was silently
	// discarded because the session is frozen or destroyed. This
	// happens when code calls Update, Signal, Toast, or other
	// session methods after the client has disconnected (frozen) or
	// the session has been permanently destroyed. The Detail field
	// contains the session status at the time of discard.
	CommandDiscarded DiagnosticKind = "command_discarded"

	// StateSizeExceeded signals that the serialised session state
	// exceeded [Limits].MaxStateBytes. The save proceeds regardless
	// - this is a warning, not a hard limit. Large state increases
	// write amplification on disconnect and reconnect, especially
	// on mobile networks. The Detail field contains the size in
	// bytes.
	StateSizeExceeded DiagnosticKind = "state_size_exceeded"

	// SlowRender signals that a render+diff cycle exceeded the
	// [Timeouts].SlowRender threshold. The Detail field contains the
	// duration. Use this to detect expensive render functions in
	// production and identify candidates for memoisation.
	SlowRender DiagnosticKind = "slow_render"

	// NavigateRedirectLoop signals that an OnNavigate handler
	// triggered more than [Limits].MaxNavigateRedirects consecutive
	// redirects via [Session.Navigate]. The framework resolves
	// redirects inline (no client round-trip), but caps the depth
	// to prevent infinite loops. The final redirect URL is sent to
	// the client regardless.
	NavigateRedirectLoop DiagnosticKind = "navigate_redirect_loop"

	// RenderCoalesced signals that multiple commands were batched
	// into a single render-diff-send cycle. The Detail field contains
	// the number of commands in the batch. This fires only when the
	// batch size exceeds one, confirming that coalescing is active
	// under load.
	RenderCoalesced DiagnosticKind = "render_coalesced"

	// HighMemoiseMissRate signals that the memoisation cache miss rate for a
	// render cycle exceeded the [Timeouts].MemoiseMissThreshold. A high
	// miss rate usually indicates broken or overly granular cache
	// keys. The Detail field contains the miss ratio. Configure the
	// threshold via [Timeouts].MemoiseMissThreshold; zero disables.
	HighMemoiseMissRate DiagnosticKind = "high_memoise_miss_rate"
)

type DiffStore

type DiffStore interface {
	// Save persists snapshot data for a disconnected session. The id
	// is the session ID. The data is an opaque blob from the differ.
	Save(ctx context.Context, id string, data []byte) error

	// Load retrieves previously saved snapshot data. Returns the data
	// and nil on success. If the session ID is not found, returns
	// (nil, nil) - a missing entry is not an error. When data is nil
	// or Load fails, the framework falls back to a full root morph.
	Load(ctx context.Context, id string) ([]byte, error)

	// Delete removes snapshot data for a session. Called after a
	// successful reconnect (data is back in memory) or when a
	// session expires. If the session ID is not found, Delete
	// should be a no-op and return nil.
	Delete(ctx context.Context, id string) error
}

DiffStore persists differ snapshots for disconnected sessions, allowing snapshot data to live outside process memory during the reconnect window. This is a memory optimisation - not a recovery mechanism. The framework calls Save when a session disconnects and Delete when it reconnects or is destroyed.

This interface is distinct from SessionStore, which persists the developer's application state S for crash recovery and node migration. The two stores handle different data with different lifecycles and can be configured independently.

The data passed to Save is an opaque blob produced by the differ's Export method. Implementations must not interpret or modify the bytes - the encoding is an internal detail that may change between framework versions.

On reconnect, the framework calls Load to restore the differ's snapshots. If the restore succeeds, the client receives targeted patches (only what changed during the disconnect) instead of a full root morph. If Load returns nil or an error, the framework falls back to a full re-render.

The framework does not ship any DiffStore implementations. Developers provide their own, backed by whatever storage suits their deployment (SQLite, Redis, filesystem, etc.). The default (nil on StatefulConfig) keeps everything in memory.

Implementations must be safe for concurrent use.

type Drainable

type Drainable interface {
	Drain(ctx context.Context) error
	Shutdown(ctx context.Context) error
}

Drainable is satisfied by *Handler. It provides graceful shutdown in two phases: Drain stops accepting new sessions and waits for existing ones to finish; Shutdown force-closes any remaining sessions. Use with ListenAndServe when multiple handlers share a single HTTP server.

type Effects

type Effects struct {
	Announce string
	Flash    map[string]string
	Signals  map[string]any
	Toast    string
	Title    string
	URL      string
	Replace  bool // true for replaceState, false for pushState
	ScrollTo string
	Download string
}

Effects accumulates side effects during an exec cycle. Session methods (Toast, Navigate, Signal, etc.) populate these fields when called inside Handle. After Handle returns, the effects are flushed into the same Update message as the state diff so the client receives everything atomically in one frame.

In stateful mode the framework manages Effects internally. In testing and pre-warming, CaptureSession exposes Effects directly so callers can read the buffered side effects after Handle returns.

func (*Effects) Any

func (fx *Effects) Any() bool

Any reports whether any side effects have been buffered.

type EqualComponent

type EqualComponent interface {
	Component
	EqualComponent(Component) bool
}

EqualComponent is an optional interface that components can implement to provide fast equality checking. The parent's Equal function (or the framework's default comparison) checks for this interface before falling back to reflect.DeepEqual or byte-level diffing.

Implement this when your component contains slices, maps, or other fields that make reflect.DeepEqual expensive. Simple components with only scalar fields can rely on the default comparison.

When a Component is stored as an interface field (Approach B), Go's == operator cannot compare interface values containing slices or maps - it panics. EqualComponent is load-bearing in that scenario, not optional.

type Event

type Event = xport.Event

Event is the message the client sends to the server when the user interacts with the page. The client JS intercepts DOM events on elements annotated with data-tether-* attributes and serialises them into this structure.

Event and Params are the two data extraction types in the framework. Event represents DOM interactions (clicks, inputs, submits); Params represents URL navigation context (path and query string). Both share a similar helper API (Get, Int, Float64, Bool) so developers learn one extraction pattern. Params additionally has soft getters (IntDefault, BoolDefault, Float64Default) because URL parameters are user-supplied and routinely absent, whereas event data is developer- controlled wire protocol where absence signals a bug. Params lives in params.go.

Type is the DOM event.Type (e.g. event.Click, event.Input, event.Submit, event.Navigate). Action is the value of the data-tether-* attribute - it is the application-defined name that the Handle function switches on. Data carries event-specific key-value pairs: for input events this is {"value": "..."}, for submit events it includes all named form fields, and for keydown events it includes {"key": "Enter"}.

Target identifies which component handled this event. It is empty for events routed to the application's Handle function, and set by the framework when [StatefulConfig.Components] dispatches an event to a mounted component. Middleware and logging can inspect Target to see which component an event was routed to without parsing the Action string.

EventID is a monotonically increasing counter generated by the client JS and echoed back in the server's response. The client uses it to correlate responses with specific events, which allows loading state indicators (e.g. disabled buttons) to be restored on exactly the element that triggered the action rather than globally.

type FreezeMode

type FreezeMode int

FreezeMode controls how a session behaves when its transport disconnects with freeze enabled. Zero disables freeze.

const (
	// FreezeWithRestore requires [StatefulConfig.OnRestore] to be
	// set. On thaw, the framework calls OnRestore so the developer
	// can re-fetch authoritative state from the database. The
	// framework panics at startup if OnRestore is nil.
	FreezeWithRestore FreezeMode = iota + 1

	// FreezeWithConnect falls back to [StatefulConfig.OnConnect]
	// on thaw. Use this when OnConnect already performs full
	// initialisation and a dedicated OnRestore is not needed. The
	// developer accepts that the restored snapshot may be stale.
	FreezeWithConnect
)

type Group

type Group[S any] struct {

	// OnJoin is called after a session is added to the group.
	// Runs outside the write lock so it is safe to call Broadcast
	// or other Group methods from within. Optional.
	OnJoin func(session *StatefulSession[S])

	// OnLeave is called after a session is removed from the group.
	// Runs outside the write lock so it is safe to call Broadcast
	// or other Group methods from within. Optional.
	OnLeave func(session *StatefulSession[S])
	// contains filtered or unexported fields
}

Group tracks a set of sessions for broadcasting state updates. Add sessions in OnConnect and remove them in OnDisconnect, or use StatefulConfig.Groups for automatic membership:

group := tether.NewGroup[State]()

OnConnect: func(s *tether.StatefulSession[State]) {
    group.Add(s)
},
OnDisconnect: func(s *tether.StatefulSession[State]) {
    group.Remove(s)
},

// Later, push an update to every session in the group:
group.Broadcast(func(target *tether.StatefulSession[State], state State) State {
    state.Message = "Hello everyone"
    return state
})

The session map is stored in an atomic.Value so Broadcast (the hot path) is completely lock-free. Add and Remove use a write mutex with copy-on-write semantics.

func NewGroup

func NewGroup[S any]() *Group[S]

NewGroup creates an empty group ready to accept sessions. Typically called once at program startup and shared across the OnConnect/OnDisconnect callbacks and any code that broadcasts.

func (*Group[S]) Add

func (g *Group[S]) Add(s *StatefulSession[S])

Add registers a session with the group. If the session is new and OnJoin is set, the callback fires after the session is added. Safe to call from any goroutine. Adding a session that is already in the group is a no-op (OnJoin does not fire).

func (*Group[S]) All

func (g *Group[S]) All() iter.Seq[*StatefulSession[S]]

All returns an iterator over sessions in the group. The map is read via a single atomic load - no lock is held during iteration, so it is safe to call Add, Remove, or Broadcast from within the loop body.

func (*Group[S]) Broadcast

func (g *Group[S]) Broadcast(fn func(target *StatefulSession[S], state S) S)

Broadcast applies fn to every session in the group. The callback receives the target session so side-effect methods (Toast, Navigate, etc.) are called on the correct session. Each session's Update is non-blocking - it queues a command on the session's channel - so Broadcast does not spawn goroutines per session.

Safe to call from any goroutine, including from within Handle.

func (*Group[S]) BroadcastOthers

func (g *Group[S]) BroadcastOthers(exclude Session, fn func(target *StatefulSession[S], state S) S)

BroadcastOthers applies fn to every session in the group except the excluded one. The exclude parameter accepts Session (the non-generic interface) so it can be called directly from HandleFunc without a type assertion:

group.BroadcastOthers(sess, func(target *tether.StatefulSession[State], s State) State {
    s.Message = "someone else did something"
    return s
})

This is the typical pattern when broadcasting from inside Handle: Handle updates the sender's state directly (via the return value) and uses BroadcastOthers to push the change to everyone else, avoiding a double-apply on the sender.

Safe to call from any goroutine, including from within Handle.

func (*Group[S]) Count added in v0.2.0

func (g *Group[S]) Count() *Value[int]

Count returns a reactive Value tracking the number of sessions in the group. Updated automatically by Add and Remove - after the membership change, not before. Wire it with WatchValue for an always-accurate online count:

tether.WatchValue(group.Count(), func(n int, s State) State {
    s.OnlineCount = n
    return s
})

func (*Group[S]) Each added in v0.2.2

func (g *Group[S]) Each(fn func(target *StatefulSession[S]))

Each calls fn for every session in the group. Unlike [Broadcast], it does not trigger a state update or render cycle. Use this for signal-only pushes where the DOM structure is unchanged and only bound values need updating:

group.Each(func(sess *tether.StatefulSession[State]) {
    sess.Signal("status", "updated")
})

The callback runs on the caller's goroutine. Signal, Signals, Toast, and other side-effect methods are safe to call - they enqueue effects that are sent as standalone updates without triggering a render.

Safe to call from any goroutine, including from within Handle.

func (*Group[S]) Len

func (g *Group[S]) Len() int

Len returns the number of sessions currently in the group. Useful for displaying an "N users online" indicator via StatefulSession.Update. Lock-free.

func (*Group[S]) Remove

func (g *Group[S]) Remove(s *StatefulSession[S])

Remove unregisters a session from the group. If the session was present and OnLeave is set, the callback fires after removal. Safe to call from any goroutine. Removing a session that is not in the group is a no-op (OnLeave does not fire).

type HandleFunc

type HandleFunc[S any] func(session Session, state S, event Event) S

HandleFunc processes a client event and returns the new state. Side effects (toast, navigate, announce, flash, title, URL) are expressed as imperative calls on the session - there is no wrapper type. The session buffers effects during Handle and flushes them atomically with the state diff, so the client receives everything in one frame.

Handle runs inside the session's command loop. While it is executing, no other commands, events, or effects are processed for this session. Keep Handle fast - do not perform blocking I/O, sleep, or wait on channels. For slow operations, use [Session.Go] to run them in a background goroutine and feed results back via [Session.Update].

The session parameter is a Session so that the same handler can be used in stateful mode, stateless mode, and tethertest without changing its signature. In stateful mode the underlying value is a *StatefulSession which provides additional methods (Update, Go, Context, Close) via type assertion when needed.

Returning the original state unchanged is valid and will produce no diff (especially when an Equal function is configured).

func Chain

func Chain[S any](h HandleFunc[S], mw []Middleware[S]) HandleFunc[S]

Chain applies a slice of middleware to a handler in outermost-first order. Given [A, B, C] and handler H, the resulting call order is: A -> B -> C -> H.

type Handler

type Handler[S any] struct {

	// Diagnostics emits framework-level events so application code
	// can observe them for metrics, alerting, or custom logging.
	// The framework is quiet by default - slog is only used for
	// panics. All other operational signals (transport errors,
	// encode failures, buffer overflows, upload errors) flow
	// exclusively through this bus.
	//
	// Subscribe with [Bus.Subscribe] (synchronous) or
	// [Bus.SubscribeAsync] (own goroutine per event, safe for I/O):
	//
	//	h.Diagnostics.Subscribe(ctx, func(d tether.Diagnostic) {
	//	    metrics.Inc("tether." + string(d.Kind))
	//	})
	//
	//	h.Diagnostics.SubscribeAsync(ctx, func(d tether.Diagnostic) {
	//	    if d.Kind == tether.HandlerPanic {
	//	        alerting.Critical(d.SessionID, d.Err)
	//	    }
	//	})
	Diagnostics *Bus[Diagnostic]
	// contains filtered or unexported fields
}

Handler manages the lifecycle of tether sessions. Sessions move through three pools - pending, active, and disconnected - so the server can pre-warm state on the initial GET and preserve it across brief network interruptions. Use Shutdown for graceful termination.

The handler also serves the embedded client runtime at /_tether/ - there is no need to mount a separate file server for the JS assets.

func Stateful

func Stateful[S any](app App, cfg StatefulConfig[S]) *Handler[S]

Stateful creates a Handler that maintains a persistent connection (WebSocket or SSE) between browser and server. State survives across interactions - when the user triggers an event, the server updates state and pushes the change without a page reload.

Use Stateful for interactive applications: dashboards, forms, chat, real-time collaboration - anything where the server needs to push updates or maintain session state between interactions.

For traditional request/response pages that reconstruct state from each HTTP request, use Stateless instead.

Session lifecycle is managed by per-session timers (idle, lifetime, disconnect) - there is no centralised reaper goroutine. A lightweight pending-cleanup goroutine removes pre-warmed sessions that are never claimed.

Call Handler.Shutdown to cancel all sessions before the process exits.

func (*Handler[S]) Drain

func (h *Handler[S]) Drain(ctx context.Context) error

Drain stops accepting new sessions but lets existing ones finish naturally. It blocks until all sessions have disconnected or ctx is cancelled. Per-session lifecycle timers continue enforcing idle and lifetime limits during the drain period. Reconnecting clients can still reattach to their existing sessions.

After Drain returns, call Handler.Shutdown to stop the pending cleanup goroutine and release resources. Safe to call from any goroutine.

func (*Handler[S]) Health

func (h *Handler[S]) Health() HealthStatus

Health returns a snapshot of the session pool counts. Safe to call from any goroutine. Useful for load balancer health checks, readiness probes, or metrics collection.

func (*Handler[S]) ListenAndServe

func (h *Handler[S]) ListenAndServe(addr string, handler ...http.Handler) error

Handler.ListenAndServe starts an HTTP server with graceful shutdown. It handles SIGINT and SIGTERM, drains sessions, shuts down the HTTP listener, then force-closes any remaining sessions.

The addr parameter follows net.Listen conventions (e.g. ":8080", "127.0.0.1:3000"). When empty, the PORT environment variable is checked (standard for cloud platforms such as Cloud Run, Fly.io, and Railway); if that is also empty, ":8080" is used.

The optional handler parameter sets which http.Handler the HTTP server routes requests through. When omitted, the tether handler serves all requests directly. Pass a custom mux to add routes or HTTP-level middleware alongside tether:

mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthCheck)
mux.Handle("/{path...}", h)
h.ListenAndServe("", mux)

ListenAndServe returns nil on clean shutdown. It only returns an error for startup failures such as a port already in use. A second signal during shutdown forces an immediate exit.

For multi-handler apps, use the package-level ListenAndServe which drains and shuts down all handlers.

func (*Handler[S]) ListenAndServeTLS

func (h *Handler[S]) ListenAndServeTLS(addr, certFile, keyFile string, handler ...http.Handler) error

ListenAndServeTLS starts an HTTPS server with graceful shutdown. It behaves identically to Handler.ListenAndServe but accepts TLS certificate and key file paths. If addr is empty and the PORT environment variable is not set, ":443" is used.

func (*Handler[S]) ServeHTTP

func (h *Handler[S]) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler. A single endpoint serves the initial HTML page (GET without upgrade headers), the transport connection (WebSocket upgrade or SSE stream), POST events (SSE mode only), and the embedded client JS runtime at /_tether/. The Mode field in StatefulConfig determines which transport paths are active. Requests that don't match any transport path fall through to the initial page render.

func (*Handler[S]) Shutdown

func (h *Handler[S]) Shutdown(ctx context.Context) error

Shutdown closes all active sessions and stops the pending cleanup goroutine. It blocks until every session's loop has exited or ctx is cancelled. Safe to call more than once.

Shutdown waits for command loops, not for goroutines started via [Session.Go]. Those goroutines receive context cancellation and must exit promptly. A goroutine that ignores its context will leak and may race with the final state save. See [Session.Go].

type HealthStatus

type HealthStatus struct {
	Pending      int `json:"pending"`
	Active       int `json:"active"`
	Disconnected int `json:"disconnected"`
}

HealthStatus reports the number of sessions in each pool. Use Handler.Health to retrieve it.

type Heartbeater

type Heartbeater = xport.Heartbeater

Heartbeater is an optional interface for transports that need periodic keep-alive activity. Both built-in transports implement it: SSE sends comment lines to prevent proxy timeouts; WebSocket sends ping frames and sets read deadlines to detect silently dropped connections.

When the handler detects that a transport implements Heartbeater, it calls StartHeartbeat with the configured interval after the session is established.

type Limits

type Limits struct {
	// CmdBufferSize sets the capacity of each session's internal
	// command channel. Commands include state updates, broadcasts,
	// and side effects. When the buffer is full, a short-lived
	// goroutine delivers the command to prevent cross-session
	// deadlocks during broadcasts. Each overflow emits a
	// [BufferOverflow] diagnostic. Sustained overflow usually
	// indicates a blocking [HandleFunc] or a broadcast rate that
	// exceeds the session's processing speed - increase the buffer
	// or move slow work into [StatefulSession.Go]. Zero defaults to 64.
	CmdBufferSize int

	// MaxEventBytes limits the size of a POST event body. Events carry
	// a type, action, and a map of string values (typically form
	// fields). Zero defaults to 64 KB. Increase this if your forms
	// contain large text fields (e.g. a rich-text editor).
	MaxEventBytes int64

	// MaxPushSubscriptionBytes limits the size of a push subscription
	// POST body. Push subscriptions contain base64-encoded P-256 keys
	// and vendor-specific endpoint URLs that are typically larger than
	// UI events. Zero defaults to 4 KB.
	MaxPushSubscriptionBytes int64

	// MaxStateBytes warns when the serialised session state exceeds
	// this size. The save still proceeds - this is a diagnostic
	// guardrail, not a hard limit. Large state increases write
	// amplification on disconnect and reconnect. Zero disables the
	// check.
	MaxStateBytes int64

	// MaxNavigateRedirects caps how many consecutive server-side
	// redirects the framework resolves within a single navigate event.
	// When OnNavigate calls Navigate(), the framework re-processes the
	// target URL inline rather than round-tripping to the client. This
	// limit prevents infinite loops when OnNavigate unconditionally
	// redirects. Zero defaults to 5.
	MaxNavigateRedirects int
}

Limits groups capacity constraints for sessions and requests.

type Middleware

type Middleware[S any] func(HandleFunc[S]) HandleFunc[S]

Middleware wraps a HandleFunc to add cross-cutting behaviour. Each middleware receives the next handler in the chain and returns a new handler that may inspect or modify the event, state, or session before and after calling next:

func withLogging[S any](next tether.HandleFunc[S]) tether.HandleFunc[S] {
    return func(sess tether.Session, s S, ev tether.Event) S {
        slog.Info("event", "action", ev.Action)
        return next(sess, s, ev)
    }
}

Middleware is applied outermost-first: the first middleware in the slice wraps the outermost layer of the chain. Use the Middleware field on StatefulConfig to register middleware for all events.

type Mounter

type Mounter interface {
	Component
	Mount(Session) Component
}

Mounter is an optional interface that components can implement to perform one-time setup when they are first mounted into a session. The framework calls Mount once per component - after the session's command loop starts but before any client events are processed - when the component is registered via [StatefulConfig.Components].

Mount receives the Session so the component can fire side effects (Toast, Signal, etc.) or start background work via [Session.Go]. It returns the updated component value, which the framework writes back to the session state.

Components that do not need initial setup simply implement Component without Mounter - the framework skips the Mount call.

type Dashboard struct {
    loaded bool
    Items  []Item
}

func (d Dashboard) Mount(sess tether.Session) tether.Component {
    sess.Toast("Dashboard ready")
    return d
}

type NoPatch

type NoPatch struct {
	Source string // "update", "navigate", or "event"
	Action string // event action; empty for "update" source
}

NoPatch describes a render cycle that produced no DOM changes. Passed to [StatefulConfig.OnNoPatch] so the developer can log, count, or ignore it as appropriate.

type Params

type Params struct {
	// Path is the URL path component (e.g. "/settings"). The router
	// uses this to determine which page to render. Always present.
	Path string

	// Query holds the parsed URL query parameters. It is nil when the
	// URL has no query string. All extraction methods are nil-safe  -
	// calling Get, IntDefault, etc. on a nil Query returns zero values or
	// defaults without panicking, because url.Values is a map type and
	// nil map reads return zero values in Go.
	Query url.Values
}

Params carries URL information from a navigation event. The handler passes this to StatefulConfig.OnNavigate on the initial page load (so the application can derive state from the URL) and whenever the browser navigates via a tether link click or the back/forward buttons.

Params lives in its own file (separate from Event) because it represents a different data source - URL navigation context rather than DOM interaction - even though both types share a similar extraction API. Keeping them apart makes the conceptual boundary clear: Event is wire protocol from the client JS, Params is parsed from the browser's address bar.

The extraction helpers are organised into three tiers:

Single-value with error - Params.Get, Params.Int, Params.Bool, Params.Float64. These mirror Event's API so developers learn one extraction pattern for the whole framework. Use these when a missing or malformed value is a hard error (e.g. a required resource ID).

Soft getters with default - Params.IntDefault, Params.BoolDefault, Params.Float64Default. These exist because URL parameters are fundamentally different from event data: event data is developer- controlled wire protocol where a missing field signals a bug, but URL parameters are user-supplied and routinely absent. Soft getters eliminate the if-err-else boilerplate that would otherwise dominate every OnNavigate handler.

Multi-value - Params.Strings, Params.Ints, Params.Float64s. These exist because url.Values is map[string][]string - query keys can repeat (e.g. ?tag=go&tag=web). Without these, developers would have to bypass the helper API and access p.Query directly, defeating the purpose of the abstraction.

func (Params) Bool

func (p Params) Bool(key string) bool

Bool returns true when the first query value for key is the string "true". All other values - including "false", "0", empty, and missing keys - return false. This matches [Event.Bool]'s semantics so the same truthiness rules apply everywhere in the framework.

func (Params) BoolDefault

func (p Params) BoolDefault(key string, def bool) bool

BoolDefault returns the first query value for key parsed as a boolean. If the key is missing (empty string from url.Values.Get), it returns the provided default - this distinguishes "user didn't specify" from "user explicitly set to false". When the key is present, any value other than the literal string "true" evaluates to false, matching Params.Bool and [Event.Bool] semantics.

s.ShowDrafts = p.BoolDefault("drafts", false)

func (Params) Float64

func (p Params) Float64(key string) (float64, error)

Float64 returns the first query value for key parsed as a float. If the key is missing or the value is not a valid number, it returns 0 and an error. For optional parameters prefer Params.Float64Default.

func (Params) Float64Default

func (p Params) Float64Default(key string, def float64) float64

Float64Default returns the first query value for key parsed as a float. If the key is missing or the value is not a valid number, it returns the provided default instead of an error.

s.MinPrice = p.Float64Default("min", 0.0)

func (Params) Float64s

func (p Params) Float64s(key string) ([]float64, error)

Float64s returns all query values for key parsed as floats. If any value cannot be parsed, it returns the values successfully parsed before the error and the error itself. If the key is not present, it returns nil and no error.

func (Params) Get

func (p Params) Get(key string) string

Get returns the first query value for key. If the key is not present, it returns an empty string. url.Values.Get always returns the first value when a key appears multiple times; for all values use Params.Strings.

func (Params) Int

func (p Params) Int(key string) (int, error)

Int returns the first query value for key parsed as an integer. If the key is missing or the value is not a valid integer, it returns 0 and an error. Most navigation handlers should prefer Params.IntDefault because URL parameters are typically optional - Int is here for the rare case where absence genuinely means something is wrong.

func (Params) IntDefault

func (p Params) IntDefault(key string, def int) int

IntDefault returns the first query value for key parsed as an integer. If the key is missing or the value is not a valid integer, it returns the provided default instead of an error. This is the idiomatic way to read optional numeric URL parameters:

s.PageNum = p.IntDefault("page", 1)
s.Limit   = p.IntDefault("limit", 20)

func (Params) Ints

func (p Params) Ints(key string) ([]int, error)

Ints returns all query values for key parsed as integers. If any value cannot be parsed, it returns the values successfully parsed before the error and the error itself - callers can choose to use the partial result or treat it as a failure. If the key is not present, it returns nil and no error.

func (Params) Strings

func (p Params) Strings(key string) []string

Strings returns all query values for key as a string slice. If the key is not present, it returns nil. Use this for query parameters that may appear more than once (e.g. ?tag=go&tag=web).

type Presence added in v0.2.0

type Presence[T any] struct {
	// contains filtered or unexported fields
}

Presence tracks per-session metadata and makes it available to all sessions. It handles the common pattern of knowing who is here and what they are doing.

Use Presence for collaborative features: who is viewing a card, who is typing, which page each user is on, or any per-session state that other sessions need to see.

var viewers = tether.NewPresence[ViewInfo]()

// In Handle - set when state changes:
viewers.Set(sess.ID(), ViewInfo{Card: id, Name: s.Name})

// In Handle - clear when leaving:
viewers.Clear(sess.ID())

// In OnDisconnect - clean up:
viewers.Clear(sess.ID())

// In Render - read all:
viewers.Each(s.SessionID, func(sid string, v ViewInfo) {
    // show presence for other sessions
})

func NewPresence added in v0.2.0

func NewPresence[T any]() *Presence[T]

NewPresence creates an empty presence tracker.

func (*Presence[T]) All added in v0.2.0

func (p *Presence[T]) All() map[string]T

All returns a snapshot of all session metadata. The returned map is a copy - mutations do not affect the presence state.

func (*Presence[T]) Clear added in v0.2.0

func (p *Presence[T]) Clear(sessionID string)

Clear removes a session's metadata. Call in Handle when the user navigates away, and in OnDisconnect for cleanup.

func (*Presence[T]) Each added in v0.2.0

func (p *Presence[T]) Each(exclude string, fn func(sessionID string, val T))

Each calls fn for every tracked session, excluding the given session ID (pass empty to include everyone). Use this in Render to show what other users are doing without including yourself.

func (*Presence[T]) Get added in v0.2.0

func (p *Presence[T]) Get(sessionID string) (T, bool)

Get returns the metadata for a session, if present.

func (*Presence[T]) Len added in v0.2.0

func (p *Presence[T]) Len() int

Len returns the number of tracked sessions.

func (*Presence[T]) Set added in v0.2.0

func (p *Presence[T]) Set(sessionID string, val T)

Set stores metadata for a session. Call this in Handle when the session's state changes (e.g. they open a card, start typing).

type PushConfig

type PushConfig[S any] struct {
	// Sender handles push notification delivery. Create with
	// [push.NewSender]. The sender's public key is automatically
	// used for client-side push subscription.
	Sender *push.Sender

	// OnSubscribe is called when a client sends its push subscription
	// to the server. Store the subscription to send notifications later
	// via [push.Sender.Send]. The callback runs in its own goroutine
	// so it is safe to perform I/O (e.g. database writes).
	//
	// The context is derived from the session and cancels when the
	// session is destroyed - use it for database calls and external
	// requests to avoid leaking goroutines. The subscription is passed
	// as a parameter; do not read it from the session object as the
	// store may not have completed yet. Optional.
	OnSubscribe func(ctx context.Context, session *StatefulSession[S], sub push.Subscription)
}

PushConfig enables Web Push notifications for the page. The VAPID public key is passed to the client so it can subscribe when the user clicks a [bind.PushSubscribe] element. Subscription is never automatic - it always requires a user gesture.

type RenderFunc

type RenderFunc[S any] func(state S) node.Node

RenderFunc builds a Fluent node tree from the current state. It is called on initial page render, after each client event, and after each call to [Session.Update]. The function must be pure - given the same state it must always produce the same tree, because the diff engine compares consecutive renders to compute patches.

type Security

type Security struct {
	// TrustedOrigins lists origins that are allowed to make
	// state-changing requests. POST requests (events, uploads,
	// push subscriptions) are checked by Go 1.25's
	// [http.CrossOriginProtection]. WebSocket upgrades use a
	// dedicated check since they are GET requests that the stdlib
	// exempts as safe methods. Both use Sec-Fetch-Site as the
	// primary signal with Origin header comparison as a fallback.
	//
	// Safe read-only methods (initial page GET, SSE streams) are
	// always allowed regardless of origin.
	//
	// Example: []string{"https://example.com", "https://staging.example.com"}
	//
	// When empty, the handler falls back to same-host checking
	// (the Origin header's host:port must match the request's
	// Host header exactly). This is suitable for development but
	// should be replaced with an explicit list in production.
	TrustedOrigins []string

	// DisableSessionBinding turns off User-Agent verification on
	// session reconnect entirely. When true, [SessionMatch] is
	// ignored and any client can reconnect to any session. Use
	// only in trusted environments where session theft is not a
	// concern.
	DisableSessionBinding bool

	// SessionMatch customises how the framework compares
	// User-Agent strings on reconnect. The function receives the
	// original UA (captured at session creation) and the
	// reconnecting client's UA. Return true to allow the
	// reconnect, false to reject it.
	//
	// When nil (the default), the framework performs an exact
	// string match. This is the strictest and safest option.
	//
	// Set this when exact matching is too strict for your
	// deployment - for example, when browser auto-updates change
	// the UA version during long-lived frozen sessions:
	//
	//	Security: tether.Security{
	//	    SessionMatch: func(original, reconnect string) bool {
	//	        return extractBrowser(original) == extractBrowser(reconnect)
	//	    },
	//	}
	//
	// Ignored when [DisableSessionBinding] is true.
	SessionMatch func(original, reconnect string) bool
}

Security groups CSRF protection and session binding settings.

type Session

type Session interface {
	// ID returns the session identifier. In stateful mode this is the
	// unique, cryptographically random session ID. In stateless
	// mode (StatelessConfig) this returns an empty string because there is
	// no persistent session. In tethertest it returns "tethertest".
	ID() string
	Context() context.Context
	Go(fn func(context.Context))
	Toast(text string)
	Navigate(rawURL string)
	ReplaceURL(rawURL string)
	SetTitle(title string)
	Announce(text string)
	Flash(selector, text string)
	ScrollTo(selector string)
	Download(url string)
	Signal(key string, value any)
	Signals(signals map[string]any)
	Push(n push.Notification) error
	// Morph declares which Dynamic keys should be returned as targeted
	// morphs instead of a full root morph. Only meaningful in stateless
	// mode - the handler extracts the named subtrees from the rendered
	// tree and returns them as individual keyed morphs. In stateful
	// mode the differ handles targeting automatically, so Morph is a
	// no-op with a dev warning.
	Morph(keys ...string)
	// Close terminates the session by closing its transport. In
	// stateless mode ([CaptureSession]) and tethertest this is
	// a no-op - there is no persistent connection to close.
	Close()
}

Session is the interface every handler receives. It provides side-effect methods (Toast, Navigate, Signal, etc.) that work identically in stateful mode, stateless mode, and tests.

In stateful mode the underlying value is a *StatefulSession which provides additional methods (Update, State, Close) via type assertion when needed. During pre-warming (initial GET) a capture implementation buffers side effects. In tethertest a test double captures them for assertions.

Session is deliberately non-generic - component handlers can accept it without inheriting the application's state type parameter, making them reusable across different page states.

type SessionCodec

type SessionCodec[S any] interface {
	Marshal(state S) ([]byte, error)
	Unmarshal(data []byte) (S, error)
}

SessionCodec controls how session state S is serialised and deserialised for external storage. The framework uses this to convert S to bytes before wrapping it in a session envelope and passing it to SessionStore.

When nil on StatefulConfig, the framework uses CBOR encoding (RFC 8949), which handles any struct with exported fields - no configuration, no struct tags, no boilerplate.

Implement this when you need encryption, a company-standard wire format, or custom handling of complex types. The codec only handles S - it does not need to know about session IDs, URLs, or binding metadata (the framework wraps those separately in the envelope).

type SessionStore

type SessionStore interface {
	// Save persists session data with a time-to-live hint. The
	// framework passes an appropriate TTL for each save context:
	// the reconnect window on disconnect, or a recovery window on
	// graceful shutdown. Implementations may use TTL for automatic
	// expiry (e.g. Redis SETEX), store it for periodic cleanup, or
	// ignore it entirely - the framework calls Delete when it can.
	// TTL is a safety net for orphaned data, not the primary
	// cleanup mechanism.
	Save(ctx context.Context, id string, data []byte, ttl time.Duration) error

	// Load retrieves previously saved session data. Returns the
	// data and nil on success. If the session ID is not found,
	// returns (nil, nil) - a missing entry is not an error. The
	// framework treats a miss as "no session to restore" and gives
	// the client a fresh session.
	Load(ctx context.Context, id string) ([]byte, error)

	// Delete removes session data. Called after a successful
	// reconnect (session is back in memory) or when a session is
	// destroyed. If the session ID is not found, Delete should be
	// a no-op and return nil.
	Delete(ctx context.Context, id string) error
}

SessionStore persists the developer's application state S (plus session metadata) for crash recovery and node migration. When a reconnecting client reaches a server that has no in-memory session, the framework checks the SessionStore before rejecting the reconnect - allowing sessions to survive server restarts.

This is an opt-in capability. By default (nil on StatefulConfig), sessions live entirely in memory and a server restart loses all state. Set StatefulConfig.SessionStore to enable persistence.

This interface is distinct from DiffStore, which persists opaque differ snapshots as a memory optimisation. The two stores handle different data with different lifecycles and can be configured independently - a developer may use one without the other, both, or neither.

The data passed to Save is an opaque envelope produced by the framework, containing the serialised state S and session metadata. Implementations must not interpret or modify the bytes.

The framework does not ship any SessionStore implementations. Developers provide their own, backed by whatever storage suits their deployment (Redis, SQLite, filesystem, etc.).

Implementations must be safe for concurrent use.

type StatefulConfig

type StatefulConfig[S any] struct {
	// Upgrade converts an HTTP request into a Transport connection.
	// Defaults to ws.Upgrade() (WebSocket). When nil, inherits from
	// [App].Upgrade, then falls back to the built-in default. Set
	// this to customise WebSocket options for a specific handler.
	Upgrade func(w http.ResponseWriter, r *http.Request) (Transport, error)

	// Fallback converts an HTTP request into a Transport connection
	// using SSE+POST. Defaults to sse.Upgrade(). When nil, inherits
	// from [App].Fallback, then falls back to the built-in default.
	// Set this to customise SSE options for a specific handler.
	Fallback func(w http.ResponseWriter, r *http.Request) (Transport, error)

	// Mode selects which transports the handler accepts. Defaults to
	// [mode.Both] when not set. See [mode] package for options.
	Mode mode.Transport

	// Protocol sets the HTTP protocol the server uses. When set to
	// [protocol.Auto] (the default), the framework detects the
	// protocol from each request. Set explicitly when you know your
	// environment - e.g. [protocol.HTTP2] when serving HTTPS
	// directly, or [protocol.HTTP1] behind a downgrading proxy.
	// Mismatches between the configured and detected protocol emit
	// a warning on every affected request.
	//
	// Can also be set via the TETHER_PROTO environment variable
	// (HTTP1, HTTP2, HTTP3, AUTO). Explicit config takes precedence.
	//
	// Protocol awareness applies to stateful sessions only - [Stateless] is
	// stateless and does not benefit from protocol-specific behaviour.
	Protocol protocol.Protocol

	// InitialState returns the starting state for a new session.
	// Called once per connection to create the initial state.
	InitialState func(r *http.Request) S

	// Render builds a node tree from the current state.
	Render RenderFunc[S]

	// Handle processes a client event and returns the new state. Side
	// effects (toast, navigate, title, etc.) are expressed as imperative
	// calls on the session parameter. In stateful mode the session is a
	// [*StatefulSession] which can be type-asserted for Update, Go, and Close.
	// See [HandleFunc] for concurrency constraints - Handle runs inside
	// the session's command loop and must not block.
	Handle HandleFunc[S]

	// Middleware wraps the Handle function with cross-cutting behaviour
	// such as logging, authentication, or metrics. Middleware fires for
	// all client events including navigation. Middleware is applied
	// outermost-first: the first entry in the slice is the outermost
	// layer of the chain. Optional.
	Middleware []Middleware[S]

	// OnNavigate processes a URL change and returns the new state.
	// Called on initial page load (after InitialState) and when the
	// browser navigates via link click or back/forward. If nil,
	// navigation events fall through to Handle.
	//
	// The session parameter is a [Session] because this function
	// runs both during pre-warming (initial GET, before a real session
	// exists) and during live navigation. Side-effect methods (SetTitle,
	// Toast, etc.) are always safe to call. During pre-warming, effects
	// are captured; during navigation, they are sent to the client.
	//
	// Redirects are resolved inline: if OnNavigate calls
	// [Session.Navigate], the framework immediately re-processes the
	// redirect target within the same event cycle rather than
	// round-tripping to the client. This makes redirects instant and
	// eliminates the risk of navigation loops. The redirect chain is
	// capped at 5 steps; exceeding the cap emits a
	// [NavigateRedirectLoop] diagnostic. The final URL is sent to the
	// client as a history replacement.
	OnNavigate func(session Session, state S, params Params) S

	// OnConnect is called after a new session is created, its transport
	// is ready, and any [StatefulConfig.Watchers] have been subscribed. Use
	// this for imperative setup: incrementing counters, publishing
	// events, starting background goroutines, or logging. For reactive
	// subscriptions, prefer [StatefulConfig.Watchers] which are declarative
	// and visible on StatefulConfig. Optional.
	//
	// OnConnect runs on the HTTP handler goroutine after the session's
	// command loop has started but before the transport begins reading
	// client events. This means State, Update, On, Observe, and all
	// side-effect methods are safe to call. However, any blocking work
	// (slow database queries, HTTP calls) delays the session becoming
	// fully interactive - move heavy initialisation into [StatefulSession.Go].
	OnConnect func(session *StatefulSession[S])

	// OnDisconnect is called after a session's transport closes (either
	// because the client disconnected or the session was reaped). Use
	// this to remove the session from a [Group] and clean up any
	// resources started in OnConnect. Optional.
	OnDisconnect func(session *StatefulSession[S])

	// Equal compares two states. When provided and the old and new state
	// are equal, the render and diff are skipped entirely - no work is
	// done and nothing is sent to the client. This is an optimisation
	// for handlers where many events leave state unchanged (e.g.
	// keystrokes that don't affect the model). Optional.
	Equal func(a, b S) bool

	// OnStructuralChange is called whenever the diff engine detects that
	// the render tree's structure has changed (Dynamic keys added,
	// removed, or reordered). Structural changes force a full root morph
	// instead of targeted patches, which is heavier for the client.
	//
	// Use this callback to track these occurrences in production via
	// telemetry or metrics. The change parameter describes exactly what
	// shifted so you can pinpoint which state transitions need keyed
	// containers. When nil and DevMode is active, the framework logs a
	// debug message for each occurrence.
	//
	// The callback runs inside the session's command loop - keep it
	// fast and offload any expensive work to a goroutine. Optional.
	OnStructuralChange func(session *StatefulSession[S], change StructuralChange)

	// OnNoPatch is called when a render cycle produces no patches and
	// no structural change. This usually indicates a missing .Dynamic()
	// key, or an intentional signal-only update where the render tree
	// is unaffected.
	//
	// Use this to detect missing Dynamic keys during development, or
	// wire it into telemetry for production monitoring. When nil and
	// DevMode is active, the framework logs a debug message for each
	// occurrence.
	//
	// The callback runs inside the session's command loop - keep it
	// fast and offload any expensive work to a goroutine. Optional.
	OnNoPatch func(session *StatefulSession[S], info NoPatch)

	// Layout wraps the tether content in a full HTML document. The state
	// parameter is the session's initial state, which can be used to set
	// the page title or other document-level elements. The content
	// parameter is a node that renders the tether root div and client
	// scripts. Return a complete document tree (e.g.
	// html.New(head.New(...), body.New(content))).
	//
	// Layout runs once on the initial GET request. After that, only the
	// tether root div is morphed - the outer shell is not re-rendered.
	// To update shell elements during navigation or event handling, use
	// [Session.SetTitle] for the page title, and signal bindings
	// ([bind.BindText], [bind.BindClass], [bind.BindShow], etc.) for
	// everything else. Signal bindings work document-wide, so elements
	// in the Layout shell react to [Session.Signal] calls just like
	// elements inside the tether root.
	//
	// When nil, the handler outputs a bare HTML fragment (the tether root
	// div and scripts only), which puts the browser in quirks mode.
	Layout func(state S, content node.Node) node.Node

	// Name identifies this handler in log output. When multiple handlers
	// share the same transport, a name distinguishes their "tether: ready"
	// lines and any other structured log output that includes it. Optional.
	Name string

	// Worker enables the full service worker for asset caching, offline
	// page shells, and background sync. When true, the client JS
	// registers /_tether/tether-worker.js as a service worker with
	// scope "/". When false and Push is configured, a lightweight
	// push-only service worker is registered instead - it handles push
	// events without intercepting fetch requests or caching. Default
	// false.
	Worker bool

	// Upload enables file upload support. When set, the handler accepts
	// multipart POST requests from the upload extension JS and delivers
	// each file to the Handle callback. Optional.
	Upload *UploadConfig[S]

	// Push enables Web Push notification support. A lightweight
	// push-only service worker is registered automatically; set Worker
	// to true for the full service worker with caching and sync.
	// Subscription requires a user gesture via [bind.PushSubscribe].
	// Optional.
	Push *PushConfig[S]

	// Groups are collections that the session will automatically join
	// when its transport is ready and leave when the session is
	// permanently destroyed. Using Groups on StatefulConfig avoids repetitive
	// Add/Remove boilerplate in OnConnect/OnDisconnect. Optional.
	Groups []*Group[S]

	// Watchers are reactive sources that sessions automatically
	// subscribe to when connected. Each watcher maps external changes
	// into the session's state. Watchers are subscribed before
	// [StatefulConfig.OnConnect] runs, so the session receives updates from
	// the moment it connects. Create watchers with [WatchValue] and
	// [WatchBus]. Optional.
	Watchers []Watcher[S]

	// Components declares component mounts for automatic event routing.
	// Before the session's [HandleFunc] runs, each event is checked
	// against the mounted components. If a mount's prefix matches the
	// event action, the component handles the event and the user's
	// Handle function never sees it. Create mounts with [Mount].
	//
	// This follows the same declarative pattern as [StatefulConfig.Watchers]
	// and [StatefulConfig.Groups]: wired once at StatefulConfig time, automatically
	// managed by the framework.
	Components []ComponentMount[S]

	// Timeouts groups all duration-based settings that control session
	// lifecycle, reconnection, and transport keep-alive timing.
	Timeouts Timeouts

	// Limits groups capacity constraints: session counts, channel
	// buffer sizes, and request body limits.
	Limits Limits

	// WireFormat selects the encoding for server-to-client updates.
	// Defaults to [wire.JSON]. Set to [wire.CBOR] for compact binary
	// encoding that reduces payload size on the wire.
	WireFormat wire.Format

	// DiffStore provides external persistence for disconnected session
	// snapshots. When set, differ data is saved to the store on
	// disconnect and deleted on reconnect (Render re-seeds the
	// differ), freeing Go memory during the reconnect window. When
	// nil (default), snapshots remain in process memory.
	DiffStore DiffStore

	// SessionStore provides external persistence for session state
	// S, enabling crash recovery and node migration. When set, the
	// framework saves state on disconnect and graceful shutdown, and
	// restores it when a reconnecting client reaches a server with
	// no in-memory session. When nil (default), sessions live
	// entirely in memory. See [SessionStore] for the interface
	// contract.
	SessionStore SessionStore

	// Codec controls how session state S is serialised for external
	// storage. When nil, the framework uses CBOR encoding (RFC 8949)
	// which handles any struct with exported fields. Implement
	// [SessionCodec] when you need encryption, a specific wire
	// format, or custom handling of complex types. Only used when
	// SessionStore is set.
	Codec SessionCodec[S]

	// OnRestore is called when a session is restored from external
	// storage (crash recovery or node migration). The session's
	// state S has been deserialised and is available via State().
	// Use this to re-establish runtime resources: rejoin groups,
	// restart timers, re-subscribe to buses.
	//
	// OnRestore fires instead of OnConnect for restored sessions.
	// If nil, OnConnect fires as a fallback - suitable for apps
	// where setup is identical for new and restored sessions.
	OnRestore func(session *StatefulSession[S])

	// OnPanic is called when a panic occurs during Handle or Update.
	// When nil (the default), the session is destroyed immediately
	// because the state may contain partially mutated maps or slices
	// that cannot be trusted. The client is disconnected and must
	// reload to get a fresh session.
	//
	// Set this to opt into custom recovery. The callback receives the
	// session and the recovered error. If set, the session is kept
	// alive after the callback returns - the developer assumes
	// responsibility for the integrity of the state. This is useful
	// during development (e.g. hot reload) but should not be used in
	// production unless you are certain your state contains no
	// reference types that could be corrupted by a partial mutation.
	//
	// The callback runs inside the session's command loop. Keep it
	// fast. Optional.
	OnPanic func(session *StatefulSession[S], err error)

	// OnCommandDropped is called when a session's command buffer and
	// overflow semaphore are both full and a command must be
	// discarded. When nil (the default), the session is destroyed
	// because a client that cannot keep up will silently drift out
	// of sync. The client reconnects and gets a fresh page load.
	//
	// Set this to opt into custom handling. The callback runs in
	// the caller's goroutine (not the session's command loop, which
	// is backed up). Keep it fast. If set, the session is kept
	// alive after the callback returns. Optional.
	OnCommandDropped func(session *StatefulSession[S])

	// Freeze enables frozen mode for disconnected sessions. When
	// set, a session that loses its transport persists state S to
	// the [SessionStore], releases the state and differ from
	// memory, and exits the command loop. The session becomes a
	// lightweight stub holding only its ID and metadata. On
	// reconnect, the framework loads state from the store, starts
	// a fresh loop, and fires the restore callback.
	//
	// This dramatically reduces memory for disconnected sessions
	// at the cost of commands (Update, broadcasts, timer callbacks)
	// being discarded while frozen (a [CommandDiscarded] diagnostic
	// is emitted for each). Enable this when sessions do not need
	// background processing during disconnect.
	//
	// Requires [SessionStore] to be configured. The [FreezeMode]
	// value controls how the session is restored on reconnect:
	//
	//   - [FreezeWithRestore] requires [OnRestore] to be set. The
	//     developer must re-fetch authoritative state from the
	//     database or other source. The framework refuses to start
	//     if OnRestore is nil.
	//   - [FreezeWithConnect] falls back to [OnConnect] on thaw.
	//     Use this when OnConnect already performs full setup and a
	//     dedicated OnRestore is not needed.
	//
	// Zero (the default) disables freeze entirely.
	Freeze FreezeMode

	// Memoise enables subtree memoisation for the render/diff pipeline.
	// When true, the session uses a [jit.Memoiser] instead of a
	// [jit.Differ]. Render functions that use [node.Memoise] nodes will
	// skip unchanged subtrees entirely - the closure never runs and
	// no HTML is rendered for regions whose memoisation key matches the
	// previous render.
	//
	// This is an opt-in performance optimisation for pages with
	// expensive render functions. Every Dynamic region should contain
	// a [node.Memoise] child with a cache key. Dynamic regions without
	// a memoised child are always re-rendered (treated as a miss).
	//
	// When false (the default), the session uses the standard Differ
	// which renders and compares every Dynamic region on each cycle.
	// This is correct for all render functions and requires no
	// developer effort.
	Memoise bool
}

StatefulConfig wires together all the pieces of a stateful page: how to create initial state, how to render it, and how to handle events. The type parameter S is the session state - typically a struct, but it can be any type. Each connected browser tab gets its own independent copy of S, so state is never shared across sessions unless you explicitly coordinate via Group or external storage.

A stateful page maintains a persistent connection (WebSocket or SSE) between browser and server. State survives across interactions and the server can push updates at any time. For traditional request/response pages, use StatelessConfig with Stateless instead.

At minimum, set InitialState, Render, and Handle. Everything else is optional and has sensible defaults - including transports, which default to WebSocket with SSE fallback.

type StatefulSession

type StatefulSession[S any] struct {
	// contains filtered or unexported fields
}

StatefulSession represents a single connected client. Each browser tab gets its own StatefulSession with independent state, a dedicated diff engine, and a command-loop goroutine that serialises all state mutations.

All exported methods are safe to call from any goroutine - including from within Handle. The command loop processes them in order; there is no mutex and no deadlock risk.

func (*StatefulSession[S]) Announce

func (s *StatefulSession[S]) Announce(text string)

Announce sends text to a screen-reader-accessible live region on the client. Inside Handle the text is buffered; outside it is sent as a standalone update.

func (*StatefulSession[S]) Close

func (s *StatefulSession[S]) Close()

Close terminates the session by closing its transport. The reader goroutine exits, which closes the events channel, which the loop handles via onTransportClose. Safe to call from any goroutine; safe to call more than once.

func (*StatefulSession[S]) Context

func (s *StatefulSession[S]) Context() context.Context

Context returns a context that is cancelled when the session is permanently destroyed (reaped or shutdown). The context survives temporary disconnects and reconnects - use it for background goroutines that should keep running while the client is away.

func (*StatefulSession[S]) Download added in v0.2.0

func (s *StatefulSession[S]) Download(url string)

Download triggers a file download in the browser from the given URL. The browser makes a normal HTTP GET - the file is served via standard HTTP, not over the WebSocket. Inside Handle the URL is buffered; outside it is sent as a standalone update.

func (*StatefulSession[S]) Flash

func (s *StatefulSession[S]) Flash(selector, text string)

Flash sends a one-time notification to the client. The selector is a CSS selector for the target element; the text is displayed for 5 seconds. Inside Handle the flash is buffered; outside it is sent as a standalone update.

func (*StatefulSession[S]) Go

func (s *StatefulSession[S]) Go(fn func(ctx context.Context))

Go launches a goroutine bound to the transport's lifetime. The context passed to fn is cancelled when the client disconnects or the session freezes. Use this in OnConnect for background work like tickers, watchers, or change listeners that should stop when the client is no longer connected.

On reconnect or thaw, OnConnect/OnRestore fires again and can spawn fresh goroutines. This prevents duplicate goroutines from accumulating across disconnect/reconnect cycles.

For goroutines that must survive disconnects (rare), use StatefulSession.Context directly: go fn(sess.Context()).

The goroutine must respect context cancellation. A goroutine that ignores the context will leak.

func (*StatefulSession[S]) ID

func (s *StatefulSession[S]) ID() string

ID returns the unique session identifier. This is a cryptographically random string generated when the session is created. It can be used for logging, metrics, or as a key in external storage.

func (*StatefulSession[S]) Morph added in v0.2.2

func (s *StatefulSession[S]) Morph(keys ...string)

Morph is a no-op on stateful sessions. The differ handles targeted patches automatically via Dynamic keys - there is no need for the developer to declare which keys changed. Calling Morph on a stateful session indicates a misunderstanding of the model and emits a dev warning.

func (*StatefulSession[S]) Navigate

func (s *StatefulSession[S]) Navigate(rawURL string)

Navigate pushes a URL change to the client (history.pushState). Inside Handle the URL is buffered; outside it is sent as a standalone update.

func (*StatefulSession[S]) Patch added in v0.2.1

func (s *StatefulSession[S]) Patch(key string, fn func(S) (S, node.Node))

Patch applies a targeted state change and re-renders a single Dynamic key. Unlike [Update] which re-renders the full tree, Patch only diffs the targeted key against the stored snapshot. Over 1,000x faster than Update for targeting one key out of many.

Patch works with either engine (Differ or Memoiser). It does not require StatefulConfig.Memoise to be true. Any handler with Dynamic keys can use it.

The closure receives the current state and returns the new state plus the rendered subtree for the target key. Both come from the same closure so they cannot get out of sync.

Inside Handle, prefer returning the new state directly - Handle already triggers a full render. Patch is for server-initiated updates where a full render is unnecessary.

In dev mode, a warning is emitted if the key has no stored snapshot, which usually indicates a typo.

Safe to call from any goroutine.

func (*StatefulSession[S]) Push

func (s *StatefulSession[S]) Push(n push.Notification) error

Push sends a Web Push notification to the browser. Only works when the session has an active push subscription and a push.Sender is configured. Returns an error if either is missing.

Safe to call from any goroutine - pushSender is immutable and pushSub is an atomic pointer, so no command-channel round-trip is needed.

func (*StatefulSession[S]) ReplaceURL

func (s *StatefulSession[S]) ReplaceURL(rawURL string)

ReplaceURL updates the browser URL without a history entry (history.replaceState). Inside Handle the URL is buffered; outside it is sent as a standalone update.

func (*StatefulSession[S]) ScrollTo added in v0.2.0

func (s *StatefulSession[S]) ScrollTo(selector string)

ScrollTo scrolls the matched element into view on the client. Inside Handle the command is buffered; outside it is sent as a standalone update.

func (*StatefulSession[S]) SetTitle

func (s *StatefulSession[S]) SetTitle(title string)

SetTitle updates the browser's document title. Inside Handle the title is buffered; outside it is sent as a standalone update.

func (*StatefulSession[S]) Signal

func (s *StatefulSession[S]) Signal(key string, value any)

Signal pushes a reactive value to the client. Elements bound to the signal name via [BindText], [BindShow], [BindClass], or [BindAttr] update instantly - no render cycle, no diff, no HTML. Inside Handle the signal is buffered and sent atomically with the state diff. Outside Handle it is sent as a standalone update.

Signals are ideal for high-frequency updates (counters, status indicators, progress bars) where the full render/diff pipeline is unnecessary overhead.

s.Signal("count", 42)
s.Signal("status", "online")

func (*StatefulSession[S]) Signals

func (s *StatefulSession[S]) Signals(signals map[string]any)

Signals pushes multiple reactive values to the client in a single update. This is a batch variant of [Signal] - use it when setting several signals at once to avoid sending one message per key. Inside Handle all keys are merged into the buffered effects. Outside Handle a single update is sent with all keys.

s.Signals(map[string]any{"count": 42, "status": "online"})

func (*StatefulSession[S]) State

func (s *StatefulSession[S]) State() S

State returns the current session state. Never blocks.

When the loop is active, State returns an atomic snapshot updated after every state mutation (Handle return, Update callback). The snapshot is lock-free and safe to call from any goroutine at any time.

Do not call State() inside Handle - the snapshot is stale (it reflects the state before Handle was called). Use the state parameter passed to Handle instead. In dev mode, a warning is emitted if State() is called during Handle to help catch this mistake early.

When the loop is not active (before startup, after destruction, or while frozen), the state field is returned directly - no concurrent mutations are possible.

func (*StatefulSession[S]) Toast

func (s *StatefulSession[S]) Toast(text string)

Toast sends a global notification to the client. Inside Handle the toast is buffered and sent atomically with the state diff. Outside Handle it is sent as a standalone update.

func (*StatefulSession[S]) Update

func (s *StatefulSession[S]) Update(fn func(S) S)

Update applies a state change and pushes the resulting diff to the client. This is the primary way to push server-initiated updates - call it from timers, database change listeners, message queue consumers, or Group.Broadcast.

The state mutation is queued as a command and runs after the current event (if any) has been fully processed. Multiple rapid Updates (e.g. from a broadcast storm) are coalesced - mutations execute sequentially but only one render-diff-send cycle runs for the batch. This means that when called inside Handle, the update does not take effect until Handle returns - the Handle return value is always authoritative for the triggering event. Non-blocking - returns immediately after queuing.

The diff engine only tracks elements marked with .Dynamic("key"). If the state change affects elements that lack a Dynamic key, the diff will produce no patches and the client will not update. Wrap state-dependent content in a container with a stable Dynamic key:

div.New(uploadList(s.Files)).Dynamic("uploads")

In DevMode, a warning is logged when Update produces no patches.

Inside Handle, prefer returning the new state directly rather than calling Update. Update is designed for side-effects like broadcasts where the caller does not control Handle's return value.

Safe to call from any goroutine, including from within Handle.

type StatelessConfig

type StatelessConfig[S any] struct {
	// InitialState returns the starting state for each request. Called
	// on every request (GET and POST). Derive state from the URL,
	// cookies, headers, or a database - not from r.Body, which
	// contains the event JSON on POST requests.
	InitialState func(r *http.Request) S

	// Render builds a node tree from the current state. Same type as
	// [StatefulConfig].Render - a pure function that returns a Fluent node
	// tree. The same render function can be used for both live and
	// stateless pages.
	Render RenderFunc[S]

	// Handle processes a client event and returns the new state. Side
	// effects (toast, navigate, title, etc.) are expressed as calls on
	// the [Session] parameter - the same interface used by
	// [StatefulConfig].OnNavigate. The effects are included in the JSON
	// response so the client can apply them atomically.
	Handle func(session Session, state S, event Event) S

	// Middleware wraps the Handle function with cross-cutting behaviour
	// such as logging, authentication, or metrics. Applied
	// outermost-first, matching [StatefulConfig].Middleware. Optional.
	Middleware []Middleware[S]

	// OnNavigate processes URL parameters on every request. Called
	// after State on both GET and POST. Same signature and redirect
	// behaviour as [StatefulConfig].OnNavigate - redirects via
	// [Session.Navigate] are resolved inline. Optional.
	OnNavigate func(session Session, state S, params Params) S

	// Layout wraps the page content in a full HTML document. Runs on
	// every GET request (stateless pages reconstruct state each time).
	// Signal bindings in the Layout shell work document-wide - see
	// [StatefulConfig].Layout for details. Optional.
	Layout func(state S, content node.Node) node.Node

	// Limits groups capacity constraints. Only MaxEventBytes is
	// relevant for stateless pages.
	Limits Limits

	// Components declares component mounts for automatic event
	// routing, matching [StatefulConfig].Components. Events whose action
	// matches a mount's prefix are dispatched to the component
	// before Handle runs. Optional.
	Components []ComponentMount[S]

	// Name identifies this page handler in log output. Appears in the
	// "tether: ready" startup line. Optional.
	Name string
}

StatelessConfig wires together a stateless page: how to reconstruct state from each request, how to render it, and how to handle events. Unlike StatefulConfig, there is no persistent transport, no session pool, and no command loop - each request is independent. The server reconstructs state, renders HTML, and returns the response.

State is reconstructed from each request - nothing persists between interactions. For pages with persistent connections and session state, use StatefulConfig with Stateful instead.

GET requests render the full page. POST requests handle a client event, render the new state, and return a JSON update (the same wire format as stateful mode) with a root morph and any side effects.

At minimum, set InitialState, Render, and Handle. Everything else is optional and has sensible defaults. Shared settings (DevMode, Logger, Client, Security, Assets) live on App.

type Status

type Status int32

Status represents the lifecycle state of a session. Each session transitions through these states exactly once in forward order, except Frozen which can return to Active on reconnect.

Pending → Active → Frozen → Active (thaw on reconnect)
                 → Destroyed (timeout, shutdown, or explicit close)
          Active → Destroyed (direct, no reconnect)
const (
	// Pending means the session has been created (pre-warmed on
	// initial GET) but the transport has not yet connected.
	Pending Status = iota + 1

	// Active means the session's command loop is running and a
	// transport may be attached. Commands, effects, and events
	// are all processed.
	Active

	// Frozen means the session's state has been persisted to the
	// SessionStore and the command loop has exited. The session
	// holds only its ID and metadata - S and the differ have been
	// released. Commands and effects are discarded with a
	// [CommandDiscarded] diagnostic. A reconnecting client thaws
	// the session by loading state from the store and starting a
	// new loop.
	Frozen

	// Destroyed means the session is permanently gone. The context
	// is cancelled, the loop has exited, and all resources have
	// been released.
	Destroyed
)

func (Status) String

func (s Status) String() string

String returns a human-readable name for the status.

type StructuralChange

type StructuralChange struct {
	Added     []string // keys present in the new tree but not the old
	Removed   []string // keys present in the old tree but not the new
	Reordered bool     // same keys, different order
	Bytes     int      // size of the re-rendered HTML sent as a root morph
}

StructuralChange describes a diff result where the render tree's Dynamic key set changed - keys were added, removed, or reordered. This forces a full root morph instead of targeted patches. The fields mirror jit.StructuralChange so callers don't need to import the diff engine package.

type Timeouts

type Timeouts struct {
	// Idle closes sessions that receive no client events within this
	// duration. Zero means no idle timeout.
	Idle time.Duration

	// MaxLifetime closes sessions after this duration regardless of
	// activity. Zero means no maximum lifetime.
	MaxLifetime time.Duration

	// Reconnect is how long a disconnected session is kept so the
	// client can reattach. Zero defaults to 30 seconds. Ignored when
	// DisableReconnect is true.
	Reconnect time.Duration

	// DisableReconnect destroys sessions immediately on disconnect
	// instead of keeping them for the Reconnect duration. Use this
	// when every connection should start fresh.
	DisableReconnect bool

	// Pending is how long a pre-warmed session waits for the browser
	// to open a transport connection. If the browser never connects
	// (e.g. the user closes the tab before the JS loads), the session
	// is discarded after this duration. Zero defaults to 30 seconds.
	Pending time.Duration

	// Heartbeat controls how often transports send keep-alive frames.
	// SSE sends comment lines to prevent proxy timeouts. WebSocket
	// sends ping frames and sets read deadlines to detect silently
	// dropped connections. Zero defaults to 20 seconds. Ignored when
	// DisableHeartbeat is true.
	Heartbeat time.Duration

	// DisableHeartbeat stops transports from sending periodic
	// keep-alive frames. For SSE this risks proxy timeouts. For
	// WebSocket this disables dead-connection detection - sessions
	// with no idle timeout may hang indefinitely if a middlebox
	// silently drops the connection.
	DisableHeartbeat bool

	// Retry is the initial delay before the client JS attempts to
	// reconnect after a transport close. The delay grows by
	// BackoffMultiplier on each failed attempt up to MaxRetry.
	// Zero defaults to 500 milliseconds.
	Retry time.Duration

	// MaxRetry caps the exponential backoff for client reconnection
	// attempts. Zero defaults to 10 seconds.
	MaxRetry time.Duration

	// BackoffMultiplier controls how aggressively the retry delay
	// grows after each failed reconnection attempt. The delay after
	// attempt N is: Retry * BackoffMultiplier^N, capped at MaxRetry.
	// Zero defaults to 1.5. Values below 1 are treated as the
	// default.
	BackoffMultiplier float64

	// DisableJitter turns off the randomisation applied to each retry
	// delay. Without jitter, all clients that disconnect at the same
	// time will reconnect in lockstep (thundering herd). With jitter
	// (the default), each delay is multiplied by a random factor in
	// [0.5, 1.0), spreading clients across time.
	DisableJitter bool

	// PendingCheck controls how often the background goroutine scans
	// for expired pending sessions (pre-warmed sessions whose browser
	// never connected). This is the only polling in the framework -
	// active and disconnected sessions use per-session timers. Zero
	// defaults to 10 seconds.
	PendingCheck time.Duration

	// SlowRender emits a [SlowRender] diagnostic when a render+diff
	// cycle exceeds this duration. Use this to detect expensive render
	// functions in production and identify candidates for memoisation.
	// Zero disables the diagnostic.
	SlowRender time.Duration

	// MemoiseMissThreshold emits a [HighMemoiseMissRate] diagnostic when
	// the proportion of memoisation cache misses in a render cycle exceeds
	// this value. For example, 0.8 fires when more than 80% of memoised
	// nodes miss. Only applies when [StatefulConfig].Memoise is true.
	// Zero disables the diagnostic.
	MemoiseMissThreshold float64
}

Timeouts groups duration-based settings for session lifecycle, reconnection, and transport keep-alive.

type Transport

type Transport = xport.Transport

Transport abstracts the persistent connection between server and client. The session event loop calls ReceiveEvent in a tight loop and calls Send after each state change. Implementations must be safe for concurrent use: Send may be called from any goroutine (via [Session.Update]), while ReceiveEvent is only called from the event loop goroutine.

Send receives pre-encoded bytes - the session handles all encoding (via wire.Encoder) so transports only deal with raw bytes.

See the ws sub-package for WebSocket and the sse sub-package for Server-Sent Events.

type Upload

type Upload struct {
	// Action is the value from bind.Upload - the application-defined
	// name that identifies this upload (e.g. "avatar", "document").
	Action string

	// Name is the original filename as reported by the browser.
	Name string

	// Size is the file size in bytes.
	Size int64

	// ContentType is the MIME type from the part's Content-Type header.
	ContentType string
	// contains filtered or unexported fields
}

Upload represents a single uploaded file delivered to the UploadConfig.Handle callback.

func (Upload) Open

func (u Upload) Open() (multipart.File, error)

Open returns the uploaded file for reading. The caller must close the returned file when done.

type UploadConfig

type UploadConfig[S any] struct {
	// Handle is called when a file upload completes. The callback
	// runs in its own goroutine so it is safe to perform I/O (e.g.
	// writing to disk or S3). Use [Session.Update] to re-render
	// after processing the file.
	Handle func(session *StatefulSession[S], upload Upload) error

	// MaxSize is the maximum upload size in bytes. Requests
	// exceeding this limit are rejected with 413. Default 10 MB.
	MaxSize int64

	// MaxMemory is the maximum number of bytes held in RAM during
	// multipart parsing. Data beyond this threshold is spilled to
	// temporary files on disk. This is deliberately separate from
	// MaxSize so large uploads don't consume unbounded RAM. Zero
	// defaults to 32 MB.
	MaxMemory int64

	// Accept is a list of allowed MIME type patterns (e.g.
	// "image/*", "application/pdf"). When empty, all types are
	// accepted. The server validates the file's Content-Type
	// header after the upload arrives.
	Accept []string
}

UploadConfig enables file upload support. When set on StatefulConfig, the handler accepts multipart POST requests from the upload extension JS and delivers each file to the Handle callback.

type Value

type Value[V any] struct {
	// contains filtered or unexported fields
}

Value is a thread-safe container for shared state that notifies observers when it changes. Built on top of Bus internally - when Store or Update is called, the new value is published to all observers registered via Observe.

Load is lock-free (atomic load). Store and Update serialise writes under a mutex and store the new value atomically, so concurrent readers never block.

When created with an optional topic name, Value publishes changes to the cluster (if configured on App) and receives changes from other nodes. Without a topic, Value is local-only.

Use Value for state that multiple sessions need to stay in sync with (online counts, shared configuration, room membership). For discrete domain events, use Bus directly.

var onlineCount = tether.NewValue(0, "online-count")

// From any goroutine:
onlineCount.Update(func(n int) int { return n + 1 })

// In OnConnect:
tether.Observe(s, onlineCount, func(count int, state State) State {
    state.OnlineUsers = count
    return state
})

func NewValue

func NewValue[V any](initial V, topic ...string) *Value[V]

NewValue creates a Value with an initial state. An optional topic name enables cluster synchronisation - when provided, Store and Update publish changes to other nodes. Without a topic, the Value is local-only.

var onlineCount = tether.NewValue(0, "online-count")
var localState  = tether.NewValue(State{})

func (*Value[V]) Len

func (v *Value[V]) Len() int

Len returns the number of active observers.

func (*Value[V]) Load

func (v *Value[V]) Load() V

Load returns the current value. Lock-free. Returns the zero value of V if the Value was not created via NewValue.

func (*Value[V]) Store

func (v *Value[V]) Store(val V)

Store writes a new value and publishes it to all observers. If the Value has a cluster topic and a cluster is configured, the change is also published to other nodes.

func (*Value[V]) Update

func (v *Value[V]) Update(fn func(V) V)

Update performs an atomic read-modify-write. It reads the current value, applies fn, writes the result, and publishes it to all observers. Useful for counters and accumulators where the new value depends on the old. If the Value has a cluster topic and a cluster is configured, the change is also published to other nodes.

type Versioned added in v0.2.1

type Versioned[T any] struct {
	// Val is the wrapped data. Read it directly in render functions
	// and Handle. To update, use [With] which returns a new
	// Versioned with an incremented version.
	Val T
	// contains filtered or unexported fields
}

Versioned wraps a value with an automatic version counter for use with node.Memoise. The version increments on every call to [With], ensuring the memoisation key changes when the data changes.

Use Versioned for state fields that back memoised Dynamic regions. The version is the memoisation key - when it matches the previous render, the Memoiser skips the subtree entirely.

type State struct {
    Items tether.Versioned[[]Item]
    Count int
}

Read the data directly via Val:

renderTable(s.Items.Val)

Update the data via With (version increments automatically):

s.Items = s.Items.With(append(s.Items.Val, newItem))

Use the version as the memoisation key in Render:

node.Memoise(s.Items.Version(), func() node.Node {
    return renderTable(s.Items.Val)
})

Versioned is a value type. It works naturally with tether's state model where Handle receives S by value and returns a new S. The zero value is valid - version starts at 0.

func NewVersioned added in v0.2.1

func NewVersioned[T any](data T) Versioned[T]

NewVersioned creates a Versioned wrapping the given initial data. The version starts at 1 to distinguish initialised values from zero values.

func (Versioned[T]) Version added in v0.2.1

func (v Versioned[T]) Version() int

Version returns the current version counter. Use this as the key for node.Memoise:

node.Memoise(s.Items.Version(), func() node.Node { ... })

func (Versioned[T]) With added in v0.2.1

func (v Versioned[T]) With(data T) Versioned[T]

With returns a new Versioned with the given data and an incremented version. This is the only way to update the data and have the version track the change.

s.Items = s.Items.With(append(s.Items.Val, newItem))

type Watcher

type Watcher[S any] interface {
	// contains filtered or unexported methods
}

Watcher subscribes a session to a reactive source when it connects. Create watchers with WatchValue and WatchBus.

Watchers listed in [StatefulConfig.Watchers] are subscribed automatically before [StatefulConfig.OnConnect] runs, so the session receives updates from the moment it connects. The subscriptions are cleaned up when the session is destroyed.

func WatchBus

func WatchBus[S any, E any](bus *Bus[E], mapper func(E, S) S) Watcher[S]

WatchBus creates a Watcher that subscribes a session to a Bus. Published events from other sessions are folded into the subscriber's state via the mapper function. Sender filtering is automatic - events emitted by this session via Bus.Emit are skipped.

tether.WatchBus(messages, func(msg Message, s State) State {
    s.Messages = append(s.Messages, msg)
    return s
})

func WatchValue

func WatchValue[S any, V any](val *Value[V], mapper func(V, S) S) Watcher[S]

WatchValue creates a Watcher that observes a Value. The current value is delivered immediately on connect; future changes are mapped into the session state via the mapper function.

tether.WatchValue(onlineCount, func(n int, s State) State {
    s.OnlineCount = n
    return s
})

Directories

Path Synopsis
Package bind provides element annotation helpers for tether.
Package bind provides element annotation helpers for tether.
Package dev provides tether's internal logging and development mode.
Package dev provides tether's internal logging and development mode.
Package event defines the event types that the client JS sends to the server.
Package event defines the event types that the client JS sends to the server.
internal
transport
Package transport defines the contract between the tether framework and its transport implementations (WebSocket, SSE).
Package transport defines the contract between the tether framework and its transport implementations (WebSocket, SSE).
Package mode selects the wire protocol between server and browser.
Package mode selects the wire protocol between server and browser.
Package protocol selects the HTTP protocol the server uses.
Package protocol selects the HTTP protocol the server uses.
Package push sends Web Push notifications to browsers using the Web Push protocol (RFC 8291, RFC 8292).
Package push sends Web Push notifications to browsers using the Web Push protocol (RFC 8291, RFC 8292).
Package router provides multi-page routing for tether.
Package router provides multi-page routing for tether.
Package sse provides an SSE transport for tether.
Package sse provides an SSE transport for tether.
tetheredis module
Package tethertest provides a test harness for tether Handle functions.
Package tethertest provides a test harness for tether Handle functions.
Package window provides a server-side virtual scrolling helper for tether.
Package window provides a server-side virtual scrolling helper for tether.
Package wire defines the encoding abstraction for server-to-client updates.
Package wire defines the encoding abstraction for server-to-client updates.
Package ws provides a WebSocket transport for tether.
Package ws provides a WebSocket transport for tether.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL