live

package
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: Apache-2.0 Imports: 30 Imported by: 0

Documentation

Overview

Package live serves server-driven live user interfaces from Go.

A live application keeps its state and its rendering in the Go process. The browser holds one WebSocket per tab; interactions travel up as events and re-rendered HTML fragments travel back down, where a morph applies them to the DOM in place. No application state is mirrored in the browser and none is serialized: what the user sees is a projection of state the server owns.

The shape of an application

An application is declared as a Config, validated by New, and mounted as an ordinary http.Handler. The three parts a caller supplies are a mount hook that produces the initial state, a pure reducer that advances it, and a set of fragments that render it.

Purity is not a style preference. Because the reducer is a pure function of (state, event) and rendering is a pure function of state, an event log replays to a byte-identical result, a render that cannot be sent is skipped rather than queued, and a panic leaves the pre-transition state intact and correct. Package live/livetest ships the harness that holds callers to it.

Concurrency

One goroutine owns each session's state and it is the only writer. That goroutine has three typed inputs — a bounded mailbox, a bounded acknowledgement channel, and a heartbeat ticker — and only the mailbox can reach a reducer. Application code therefore never needs a mutex to protect session state, and no session's state is reachable from another session's goroutine. An App is safe for concurrent use; a state value handed to a reducer is not, and must not be retained or mutated after the reducer returns.

Delivery semantics

Events are at-most-once: an interaction that was in flight when a connection dropped is not retried, and the user sees server truth after the reconnect resynchronises. Patches are exactly-once and in order, or the client detects the gap and the server answers with a full snapshot. One consequence is worth stating plainly: an effect may have executed even though the user never saw its result. Applications that need more than that put the idempotency key in their own domain.

A session lives exactly as long as its connection. There are no resumable sessions and no grace window; a reconnect mounts a fresh session and receives a fresh snapshot, which is the same path a deploy or a restart takes.

A failed or panicking effect is delivered to the reducer as an ordinary event named EffectFailedEvent, rather than being logged and dropped. One thing about it needs saying here rather than being discovered: the EffectFailedErrorField field carries the error's own message, or the panic value, verbatim — in production, unredacted, and ungated by Config.Dev. That is deliberate, because a reducer is server code and an operator-facing detail is what makes the failure actionable. It also means the string may hold anything an upstream library chose to put in an error: a connection string, a query, an internal hostname, a stack-shaped panic value.

The consequence is that rendering that field into a fragment publishes it to the browser. Error frames are held to a stricter rule for exactly this reason and carry a fixed generic message in production; the failure event is a second path to the same disclosure and carries no such discipline, because only the application knows what its own effects put in their errors. EffectFailedSourceField is the value that is safe to render: it is a name the application itself chose, from Effect.Source.

Error boundaries

A panic in a reducer, in a fragment's render, or in a fragment's Dirty declaration is recovered, contained to the session it happened in, logged at error level with the causal identifiers and the stack, counted against that session's panic budget, and answered on the wire with an Error frame naming the event that caused it — or naming nothing, when the server started the transition itself. A reducer panic leaves the pre-transition state intact and emits no patch; a render panic leaves one region stale and lets every other fragment in the same transition patch normally. Neither closes the session on its own. A site that panics Limits.PanicBudget times in one session does close it, and no other session is affected either way.

One Error frame is emitted per render pass, not per broken fragment: the message a client receives in production is a fixed string, so repeating it once per fragment would add no information, and the per-fragment record is the log line and the panic metric, which are still one apiece.

A panicking effect is the exception, by design: it becomes an EffectFailedEvent rather than an Error frame, because a failure the reducer can see is replayable and one that only reaches the wire is not.

Config.Dev is what decides how much of a panic reaches the browser, and it is the only thing that field does. Everything else about the boundary is the same in both modes.

Status

The server core is implemented: connection lifecycle, the session actor, the reducer and render contracts, event dispatch with per-event authorization, the acknowledged window and its backpressure stages, resync, and the instrumentation catalogue.

So is everything this section used to list as absent. Three examples ship — counter, chat and dashboard — and each is built, vetted and race-tested in CI. Forms go through the same helpers as any other event, which is what FR-55 means by first-class and is why there is no form type to look for: On, OnWith with Bind, OnAll and Preserve are the whole vocabulary, and validation feedback is reducer output the application renders. The error boundary is the section above, and it is behaviour rather than a component an application declares: a panic is contained to its session, told to the client as an Error frame or an EffectFailedEvent depending on where it happened, and counted. No error-boundary component type is planned; if one ever is, it will arrive as a requirement before it arrives here.

What is not here is the Phase 5 work: the published bench report and the comparison it is measured against.

Example

Example is the whole shape of a live application: a state type, a pure reducer, a fragment that renders it, and an ordinary http.Handler to mount.

It is the package overview in runnable form, and the three things it shows are the three a reader gets wrong first.

The reducer is a plain function of (state, event). It can be called and asserted on with no server, no socket and no browser, which is what the determinism helpers in live/livetest are built on.

The mount path is the prefix the BROWSER sees, and it is written once and used twice: App.Mux routes with it and Script renders it into the tag. Those two calls happen on different requests, so no check inside the library can observe a disagreement between them — which is why both are told the prefix rather than deriving it, and why one constant is better than two literals.

There is no http.StripPrefix anywhere here, at any prefix. The handler routes by path SUFFIX and never learns where it was mounted; stripping turns the upgrade into a redirect a WebSocket client cannot follow. This example used to demonstrate the opposite, above a sentence saying a router strips the prefix before the handler is reached, which was never true (QA-1's F-4 handoff, item 2).

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"

	"github.com/a-h/templ"

	"github.com/candacelabs/csf/pkg/gotth/live"
)

func main() {
	type state struct{ Count int }

	// In a .templ file the attribute is written { live.Region("counter")... };
	// spelled out here so the example is one file of ordinary Go.
	render := func(s state) templ.Component {
		return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
			_, err := fmt.Fprintf(w, `<b data-gotth-region="counter">%d</b>`, s.Count)
			return err
		})
	}

	reduce := func(s state, ev live.Event) (state, []live.Effect[live.AnonymousIdentity]) {
		if ev.Name == "counter.increment" {
			s.Count++
		}
		return s, nil
	}

	app, err := live.New(live.Config[state, live.AnonymousIdentity]{
		Init: func(ctx context.Context, session live.Session[live.AnonymousIdentity]) (state, []live.Effect[live.AnonymousIdentity], error) {
			return state{}, nil, nil
		},
		Reduce: reduce,
		Fragments: []live.Fragment[state]{{
			ID:     "counter",
			Render: render,
			Dirty:  func(prev, next state) bool { return prev != next },
		}},
		Events:  []string{"counter.increment"},
		Origins: []string{"https://app.example"},
		// The three security hooks are required, and these are the
		// deliberately greppable opt-outs. An application that meant them
		// would still have had to write them.
		Authenticate: live.Anonymous,
		Authorize:    live.AllowAll[live.AnonymousIdentity],
		CSRF:         live.NoCSRFCheck,
	})
	if err != nil {
		panic(err)
	}
	defer func() {
		if err := app.Close(context.Background()); err != nil {
			panic(err)
		}
	}()

	mux := app.Mux("/live", app.PageHandler(render))

	next, effects := reduce(state{Count: 41}, live.Event{Name: "counter.increment"})
	fmt.Printf("count=%d effects=%d\n", next.Count, len(effects))

	if err := render(next).Render(context.Background(), os.Stdout); err != nil {
		panic(err)
	}
	fmt.Println()

	if err := live.Script("/live").Render(context.Background(), os.Stdout); err != nil {
		panic(err)
	}
	fmt.Println()

	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/live/gotth-live.min.js", nil))
	fmt.Printf("runtime=%d %s\n", rec.Code, rec.Header().Get("Content-Type"))

}
Output:
count=42 effects=0
<b data-gotth-region="counter">42</b>
<script src="/live/gotth-live.min.js" data-gotth-url="/live" defer></script>
runtime=200 text/javascript; charset=utf-8

Index

Examples

Constants

View Source
const (
	// EffectFailedSourceField holds the EffectSource of the effect that failed.
	// It is a name the application chose, so it is the field that is safe to
	// render into a fragment.
	EffectFailedSourceField = "source"

	// EffectFailedErrorField holds the error's message, or the panic value.
	//
	// It is not redacted. The string is whatever the effect's error said, in
	// production, with no relation to Config.Dev — which is right for a
	// reducer, because a reducer is server code and an unredacted detail is
	// what makes a failure actionable. But it is also whatever an upstream
	// library chose to put in an error: a connection string, a query, an
	// internal hostname, a panic value with a type name in it.
	//
	// So rendering this field into a fragment publishes it to the browser.
	// Error frames carry a fixed generic message in production for exactly
	// this reason; this is a second path to the same disclosure, and the
	// library cannot apply the same discipline to it because only the
	// application knows what its own effects put in their errors. Branch on
	// it in the reducer, and render EffectFailedSourceField instead of it.
	//
	// Log it and count it somewhere that is not the reducer. FR-16 makes
	// logging application data I/O, and a reducer may not perform I/O: a log
	// call inside one is not replayable, so the same event log produces a
	// different sequence of records on every run and the determinism the
	// reducer is written for stops meaning anything. The homes that work are
	// Config.Execute, which is already at the actor boundary, and the
	// slog.Handler an application gives Config.Logger.
	//
	// Both paragraphs are here because their absence produced a deviation, and
	// the first one is worded as it is because its earlier wording produced the
	// same deviation a second time: docs/exceptions.md E-2. It opened by
	// telling the reader to log and count this field, and left the constraint
	// to a paragraph below, so a reader who stopped at the first sentence wrote
	// the logging reducer FR-16 forbids — which is what the sample on
	// docs/guide/error-handling.md did.
	EffectFailedErrorField = "error"

	// EffectFailedRetryableField holds the transient-or-terminal classification.
	EffectFailedRetryableField = "retryable"
)

The fields an EffectFailedEvent carries.

EffectFailedRetryableField holds "true" only when the effect classified its own failure as transient with Retryable. Read it with strconv.ParseBool and take the error as false: an unreadable classification is an unclassified one, and unclassified is terminal.

View Source
const (
	SlowClientEvent      = protocol.SourceSlowClient
	ClientRecoveredEvent = protocol.SourceClientRecovered
)

SlowClientEvent and ClientRecoveredEvent name the two events the library synthesizes into a session's own mailbox: SlowClientEvent when the outbound window fills, and ClientRecoveredEvent when an acknowledgement drains it again. Their values are "timer:slow_client" and "timer:client_recovered".

Neither is ever accepted from a client, and neither belongs in Config.Events — registration is what makes a name sendable by a browser, and these two are minted by the library. A reducer handles them in the same switch as everything else, which is what keeps a degradation the application decided on replayable from the event log. Letting a reducer read the transport window instead would make it return different state for the same log under different network conditions.

They are the application half of the defined degradation, and the ordering is worth knowing before writing the reducer: the library has already stopped emitting by the time SlowClientEvent arrives, so a notice set in response to it reaches the browser only once an acknowledgement re-opens the window.

Each is declared as the internal constant the session actually synthesizes, so the module holds one spelling of each string rather than two that agree until somebody edits one. Godoc therefore shows a name from a package a reader cannot import, which is what the two quoted values above are for.

View Source
const AnyOrigin = "*"

AnyOrigin is the sentinel for Config.Origins that disables origin validation. It is a named, greppable value so that auditing every deployment which turned the check off is one search. Never use it outside local development.

View Source
const EffectFailedEvent = "gotth.effect_failed"

EffectFailedEvent is the name of the event a failed or panicking effect becomes, so that a reducer sees a deterministic failure rather than silence.

It is not in Config.Events and must not be: registration is what makes a name sendable by a browser, and this one is minted by the library. A reducer handles it in the same switch as everything else, which is the whole point — a failure that arrives as an event is replayable, and one that arrives as a log line is not.

It is spelled here because the constant the library emits lives under internal/ and an application cannot import it. Before this existed the only way to handle a failed effect was to hard-code the string, and the counter example duly hard-coded the wrong one — shipping a failure path that had never run.

View Source
const NoRuntime = "no-runtime"

NoRuntime is the mountPath value that tells App.Document this page is deliberately not live.

It exists because "omit the runtime" cannot be spelled as an absence. A missing mount path, an empty string or a nil field would all mean "the author forgot", and the resulting page loads perfectly and does nothing — the exact silent failure Script refuses a default in order to prevent. So the omission is a value somebody wrote down, in the shape this library already uses for every other opt-out: AnyOrigin, Anonymous, AllowAll and NoCSRFCheck are all named, greppable symbols for the same reason, and auditing every page in a deployment that carries no runtime is one search for this identifier.

It is not a path and no router will ever register it: it does not begin with "/", so every other function here that takes a mount path refuses it.

A document with no runtime also carries no inspector and no dev-reload tag. That is not an oversight and it is not a limit of the implementation: all three address the mount, and NoRuntime is the statement that this page does not talk to the live handler at all. An application that wants the dev-reload tag on a page that is otherwise static can pass (*App).DevReloadScript(mountPath) as head content — its position on the page is documented not to matter, which is what makes that safe.

Variables

View Source
var ErrServiceStarted = errors.New("gotth-live: the live UI service is already started: " +
	"mount one App into one host runtime, once")

ErrServiceStarted is returned by a second App.Start: the service mounts into one host runtime, once.

View Source
var ErrServiceStopped = errors.New("gotth-live: the live UI service has stopped and is not reusable: " +
	"build a new App with live.New")

ErrServiceStopped is returned by App.Start after the service has stopped, by Close or by an earlier host runtime's stop. An App is not reusable.

Functions

func AllowAll

func AllowAll[I IIdentity](ctx context.Context, session Session[I], event Event) error

AllowAll is a Config.Authorize implementation permitting every event. It is the explicit opt-out from per-event authorization.

It carries the identity type parameter because the hook it satisfies does, and it must be INSTANTIATED at the call site — `live.AllowAll[Member]`, not `live.AllowAll`. Go infers type arguments for a generic function assigned to a variable of function type but not for one assigned to a composite literal's field, which is exactly how a Config is written. Naming the type is two words and the alternative is an error message about instantiation.

func IsRetryable

func IsRetryable(err error) bool

IsRetryable reports whether err carries the mark set by Retryable.

It is the symmetric partner of Retryable, and it exists because the library itself asks this question of an error it did not produce — the actor calls exactly this function to fill EffectFailedRetryableField — while an application holding the same error had no way to ask it. A setter whose mark nothing exported can read is a one-way door: a value can be created and not inspected.

The reader most applications want is still the field on the failure event, because what a reducer holds is an event. This is for the code that holds the error: an executor deciding between its own retry and handing the decision up, and the spec that checks it decided correctly. Asserting on the mark by asking whether the error wraps anything — the workaround this replaces — is an assertion about wrapping standing in for one about classification, and it passes for any error that happens to wrap.

IsRetryable(nil) is false, as is IsRetryable(Retryable(nil)), because Retryable(nil) is nil. The mark is found through errors.As, so it survives arbitrary wrapping with %w in either direction.

func NoCSRFCheck

func NoCSRFCheck(request *http.Request) error

NoCSRFCheck is a Config.CSRF implementation performing no check. It is the explicit opt-out, and it is only safe when Config.Origins is a real allowlist, since the origin check is then the whole of the CSRF posture.

func On

func On(domEvent, eventName string) templ.Attributes

On binds a DOM event to a server event.

On a form, submission sends the form's fields; on a named control, the control's name and value are sent. Several bindings on one element combine — the client matches them in order and the first match wins — and OnAll is how they are spelled, because two spreads of the same attribute are one attribute in the browser and the second one is dropped.

It panics on an argument the binding grammar cannot carry

A ":" or a ";" in either argument, and an empty eventName. ":" separates the components of one binding and ";" separates the bindings on an element, so either character renders as an extra component and shifts every component behind it — turning a declared Bind.Debounce into a Throttle. An empty eventName is worse: it renders a spec that matches, ends the client's match loop, and silences every binding behind it on the same DOM event.

It is a panic rather than an error because this function returns templ.Attributes — a map[string]any with no error channel — and templ.RenderAttributes has no default case, so an error value put in the map would be dropped in silence and the element would render with no binding at all. A panic is what this library already does for a nil page handler or a mount path of "/": each of these is a literal in the caller's source, so it fails on the first render of the view rather than on a visitor's request.

func OnAll

func OnAll(bindings ...templ.Attributes) templ.Attributes

OnAll combines several bindings on one element.

It exists because the client has always supported several bindings per element and nothing could emit them: templ renders each spread separately, two spreads of On produce the same attribute twice, and an HTML parser keeps the first and discards the second. So the second binding vanished silently, which is how a composer bound for input could not also be bound for a key, and how the two bindings a keyboard-driven counter needs on one focused element could not be written at all.

The bindings are matched by the client in the order given and the first match wins, so a filtered binding must come before an unfiltered one for the same DOM event or it can never be reached.

Every other option in a Bind — Fields, Debounce, Throttle — belongs to the binding that declared it and to no other, so composing bindings here changes none of them. A binding rendered by OnAll is byte-identical to the same binding rendered alone.

The merge rule that used to be here, and why it is gone

Fields, Debounce and Throttle used to be attributes of the ELEMENT, so this function had to reconcile them, and the rule was that where two bindings disagreed the FIRST was kept. That rule was not arbitrary — it is what an HTML parser already did with the duplicate attribute this function replaces, and it existed so that moving a page from two spreads to one OnAll could not silently change which debounce was in force for the binding that survived.

Per-binding scoping keeps that property and extends it: composition now changes nothing about ANY binding, not merely about the first. So there is no longer a disagreement to resolve, and the rule is vacuous rather than changed. What remains here is a defensive carry-through — On and OnWith emit exactly one attribute each, and anything else spread in is copied with the first occurrence winning.

The property is worth stating in the direction a reader will need it: two bindings that ask for different intervals now get different intervals. Before 2026-08-05 the second one silently got the first one's, which on the guide's own composer meant an Escape binding inherited a 150 ms debounce and the next keystroke destroyed the pending clear outright. Bind carries the measurement.

Example

ExampleOnAll puts two bindings on one element.

It is the case that could not be written before OnAll existed: templ renders each attribute spread separately, two spreads of On produce the same attribute twice, and an HTML parser keeps the first and discards the second — so the second binding vanished with no error anywhere.

Order is load-bearing. The client matches in the order given and the first match wins, so the key-filtered binding has to come first or nothing can ever reach it.

Debounce, Throttle and Fields belong to the binding that asked for them and travel inside it — the components after the key, trailing empties trimmed. So the Enter binding here is not debounced and the input binding is, which is the point: until 2026-08-05 those were attributes of the ELEMENT, both bindings read the same 150 ms, and a keystroke inside the window destroyed the pending Enter outright.

package main

import (
	"fmt"
	"time"

	"github.com/candacelabs/csf/pkg/gotth/live"
)

func main() {
	attrs := live.OnAll(
		live.OnWith("keydown", "composer.send", live.Bind{Keys: []string{"Enter"}}),
		live.OnWith("input", "composer.type", live.Bind{
			Fields:   map[string]string{"room": "general"},
			Debounce: 150 * time.Millisecond,
		}),
	)
	fmt.Println(attrs)

}
Output:
map[data-gotth-on:keydown:composer.send:Enter;input:composer.type::150::room=general]

func OnWith

func OnWith(domEvent, eventName string, b Bind) templ.Attributes

OnWith is On with static extra fields, a debounce, a throttle, a key filter, a no-modifier restriction, or a preventDefault.

It emits exactly one attribute — data-gotth-on — whatever the Bind holds. See Bind for why the options are inside the binding rather than beside it.

It panics on everything On panics on, and additionally on a Bind.Keys entry containing ":" or ";" — the two characters Bind.Keys has said since 591c275a a key cannot be. See On for why it is a panic.

func Preserve

func Preserve() templ.Attributes

Preserve marks an element and its subtree as never morphed.

It is the sanctioned way to host HTMX- or third-party-JS-owned DOM inside a live region. The rule is innermost-declaration-wins: an hx-* element inside a live fragment without Preserve is server-owned, morph will overwrite it, and any swap into it will be reverted by the next patch.

func Region

func Region(id string) templ.Attributes

Region marks an element as the root of the named live fragment.

Morph never touches anything outside a region, which is what makes an HTMX-driven or third-party-owned region on the same page safe by construction rather than by care.

func Retryable

func Retryable(err error) error

Retryable marks an error returned from Config.Execute as transient, so that the failure event carries the classification and the reducer can decide to schedule another attempt.

The unmarked default is terminal, deliberately. An effect may have committed externally before it failed — the message was published, the row was written — so retrying a failure nobody classified risks doing that twice, and retrying is a claim about idempotence that only the code which performed the effect is in a position to make. A failure never retried costs a change that does not happen, and shows up as a session that stops updating. A failure retried blindly costs a change that happens twice, and shows up as corrupt data somebody else owns. Between a visible omission and an invisible duplicate, the default belongs on the omission.

Retryable(nil) is nil, so a result can be wrapped unconditionally. The mark survives wrapping with %w and is invisible in the error's message.

Example

ExampleRetryable classifies an effect's failure, and shows what the unmarked default means.

The default is terminal, and that is the direction worth demonstrating rather than asserting: an effect may have committed externally before it failed — the message was published, the row was written — so re-running one nobody classified risks doing it twice. A failure never retried costs a change that does not happen and shows up as a session that stops updating. A failure retried blindly costs a change that happens twice and shows up as corrupt data somebody else owns.

The mark is invisible in the error's message and survives wrapping in either direction, so an executor can classify at the bottom and a reducer can ask at the top.

package main

import (
	"errors"
	"fmt"

	"github.com/candacelabs/csf/pkg/gotth/live"
)

func main() {
	terminal := errors.New("the card was declined")
	transient := live.Retryable(errors.New("the payment gateway timed out"))

	fmt.Println("unmarked: ", live.IsRetryable(terminal))
	fmt.Println("marked:   ", live.IsRetryable(transient))
	fmt.Println("wrapped:  ", live.IsRetryable(fmt.Errorf("charging the card: %w", transient)))
	fmt.Println("nil:      ", live.Retryable(nil), live.IsRetryable(live.Retryable(nil)))
	fmt.Println("message:  ", transient)

}
Output:
unmarked:  false
marked:    true
wrapped:   true
nil:       <nil> false
message:   the payment gateway timed out

func Script

func Script(mountPath string) templ.Component

Script renders the script tag for the embedded client runtime, for an application whose handler is mounted at mountPath. There is no CDN and no build step: the runtime is compiled into the binary and served by the same handler that serves the connection.

mountPath is the prefix the handler is reachable at as the *browser* sees it — "/live", "/app/live" — and it is a parameter because it is knowledge only the caller has. App.Handler is an http.Handler; the router strips the prefix before the handler sees a request, and this renders on a different request entirely, so no check inside this library can observe a mismatch. A default was worse than no default: mounted at "/app/", the tag pointed at "/live/gotth-live.min.js", the page loaded, the script 404'd, and nothing was live with no server-side error anywhere — the same silent-no-op failure the attribute vocabulary above exists to prevent, eighty lines below the comment saying so.

It is a path-only, same-origin reference — that prefix and nothing else — emitted unchanged apart from trimming at most one trailing "/", so "/live" and "/live/" render identically.

Anything else makes Render return an error and emit no tag: a mountPath that is empty or does not begin with "/", or that contains "//" anywhere, "\", "?", "#", or a byte below 0x20 or equal to 0x7F. Each is a string a browser reads as something other than a path. "//" and "\" begin an authority, so "/"+prefix+"/live" with an empty prefix names a host called "live" and sends both the runtime fetch and the session's WebSocket there; "?" and "#" end the path, so the runtime filename appended to it is never fetched, and "#" makes the WebSocket constructor throw outright; and browsers strip control bytes from a URL before parsing it, so the path requested is not the path written. The error lands on the page request, where a handler already has an error path. A 500 is a better answer than a blank page.

Percent-encoding, ".." segments and spaces are accepted: they are the caller's business and a browser resolves them to a same-origin path.

One place it refuses to render at all

Rendered inside the head content of App.Document, this returns an error and emits no tag. That component already renders this tag, and it renders it below App.InspectorScript's, which is the order the inspector needs; a second tag from the head content would land ABOVE the inspector's, and since both are deferred and deferred scripts run in document order, the runtime would open its socket before the inspector wrapped WebSocket. The inspector would then show nothing, silently, which is the failure that component exists to make unwritable. So the mistake is a 500 on the page request instead — App.PageHandler renders into a buffer, so nothing half-written reaches the browser.

Nowhere else is affected. A hand-written shell calls this under a context App.Document never touched, and a document given NoRuntime renders no runtime tag of its own and therefore refuses none of yours.

Example

ExampleScript renders the tag that loads the client runtime. The mount path is the prefix the handler is reachable at as the BROWSER sees it, and it is a parameter because no check inside the library can observe a mismatch: this tag renders on the page request, and the handler that would notice is only reached on a different request that may never come.

It is not the handler's own path. App.Handler routes by path SUFFIX and needs no http.StripPrefix at any prefix, so the handler never learns where it was mounted and cannot supply this value.

package main

import (
	"context"
	"os"

	"github.com/candacelabs/csf/pkg/gotth/live"
)

func main() {
	if err := live.Script("/app/live").Render(context.Background(), os.Stdout); err != nil {
		panic(err)
	}

}
Output:
<script src="/app/live/gotth-live.min.js" data-gotth-url="/app/live" defer></script>

Types

type AnonymousIdentity

type AnonymousIdentity struct{}

AnonymousIdentity is the identity Anonymous produces, and the type an application with no accounts instantiates its Config on.

It is a concrete struct rather than the interface, and it is exported for that reason alone: since 2026-09-03 a Config carries its identity type as a type parameter, so an application that opts out of authentication still has to name a type — and the type it names must not be `live.IIdentity`, because naming the interface there is the erasure the ruling removed.

func Anonymous

func Anonymous(request *http.Request) (AnonymousIdentity, error)

Anonymous is a Config.Authenticate implementation binding every session to a single anonymous identity. It is the explicit opt-out from authentication, named rather than implied by a nil hook.

func (AnonymousIdentity) Subject

func (AnonymousIdentity) Subject() string

Subject is the one subject every anonymous session shares.

type App

type App[S any, I IIdentity] struct {
	// contains filtered or unexported fields
}

App is the gotth-live service: the in-process capability that manages live UI connections and every goroutine they need for a host binary. It is safe for concurrent use, and one App serves any number of sessions.

It is a runtime.IService. A binary mounts it into its host runtime after everything its sessions read and before the HTTP listener that serves its App.Handler, so the runtime's reverse shutdown stops new upgrades first, then drains and joins every session, then stops what the sessions used:

host.Mount("live", app)
host.Mount("http", listener) // serves app.Handler()

Every goroutine the service starts — each connection's read pump, its actor and its effects — starts in the service's own sessions scope, and the service's stop joins that scope: when the host runtime reports the service stopped, none of them is still running.

func MustNew

func MustNew[S any, I IIdentity](cfg Config[S, I]) *App[S, I]

MustNew is New for a caller that has nowhere to put the error: it returns the application, or panics with the *ConfigError New would have returned.

It is for main and for package-level initialisation, which is where a Config is a literal somebody wrote and every failure New can report is a mistake in that literal — a missing hook, a duplicate fragment identifier, an event name the protocol cannot carry, a limit outside its range. A process that cannot construct its own application has nothing to serve, so the choice at such a call site is between panicking and printing the same message before exiting, and this spells the first in one line rather than four. template.Must and regexp.MustCompile are the same helper for the same reason and this follows their naming.

The panic value is the error itself, so what a reader sees is the *ConfigError naming the field and what to set it to, above a stack naming the Config it came from. Nothing is lost but the choice of what to do next.

Use New anywhere that choice exists: a server composing applications, a test that expects a rejection, anything building a Config out of configuration rather than out of source.

Example

ExampleMustNew constructs an application from a Config that is a literal in the source, which is the only place this helper belongs.

Nothing is lost but the choice of what to do next: the panic value is the *ConfigError New would have returned, naming the field and what to set it to.

package main

import (
	"context"
	"fmt"
	"io"

	"github.com/a-h/templ"

	"github.com/candacelabs/csf/pkg/gotth/live"
)

// exampleState is the state of the smallest application that can hold a
// fragment.
type exampleState struct{ N int }

// exampleRender is that state's one fragment, named so the examples below can
// hand the same function to Fragment.Render and to App.PageHandler — which is
// the discipline PageHandler exists to enforce: the page and the fragment
// render the same component from the same state.
//
// In a .templ file this is a templ block and the attribute is written
// { live.Region("counter")... }; spelled out here so the examples are ordinary
// Go in one file.
func exampleRender(s exampleState) templ.Component {
	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
		_, err := fmt.Fprintf(w, `<b %s="counter">%d</b>`, "data-gotth-region", s.N)
		return err
	})
}

func main() {
	app := live.MustNew(live.Config[exampleState, live.AnonymousIdentity]{
		Reduce: func(s exampleState, _ live.Event) (exampleState, []live.Effect[live.AnonymousIdentity]) {
			return s, nil
		},
		Fragments:    []live.Fragment[exampleState]{{ID: "counter", Render: exampleRender}},
		Events:       []string{"counter.increment"},
		Origins:      []string{"https://app.example"},
		Authenticate: live.Anonymous,
		Authorize:    live.AllowAll[live.AnonymousIdentity],
		CSRF:         live.NoCSRFCheck,
	})
	fmt.Println("mounted:", app.Handler() != nil)

	// The same call with a field missing. Recovered here so the example can
	// print the message; in a main there is nothing to recover it and the
	// process stops, which is the point.
	func() {
		defer func() { fmt.Println("refused:", recover()) }()
		live.MustNew(live.Config[exampleState, live.AnonymousIdentity]{})
	}()

}
Output:
mounted: true
refused: gotth-live: Config.Reduce is invalid: set the reducer that advances state

func New

func New[S any, I IIdentity](cfg Config[S, I]) (*App[S, I], error)

New validates a Config and returns a mounted application.

It reports a *ConfigError naming the offending field for a missing hook, a missing or duplicated fragment identifier, an unregistered event name, or an application that returns effects with no executor. Failing here rather than at the first connection is deliberate: every one of those is a startup mistake, and finding it at startup is the difference between a failed deploy and a session that misbehaves in production.

The one field New fills in rather than refusing is Config.Init; see that field for the default and the argument for it. Everything else a Config must state, it must state.

func (*App[S, I]) ActiveConnections

func (a *App[S, I]) ActiveConnections() int

ActiveConnections reports this application's registered WebSocket connections, including connections still cleaning up. It counts neither users nor tabs; reconnects can briefly overlap. It is safe to call concurrently with Close.

func (*App[S, I]) Close

func (a *App[S, I]) Close(ctx context.Context) error

Close drains every session, closing each with the going-away code, waits for each session's read pump, actor and effects up to the context's deadline, and then joins the sessions scope.

It is the explicit stop for a binary that does not mount the App into a host runtime. A mounted App is stopped by its scope instead (see App.Start), with no deadline of its own.

"Every session" is exact and is held by a spec rather than by this sentence (C-34): a connection that has been admitted but not yet registered when Close begins is REFUSED and closed with the going-away code rather than being allowed to start, so there is no interval in which Close returns nil over a session it did not touch. When Close returns nil, no session remains registered and every client that had one has been sent a close frame.

Close returns an error if the context's deadline passes before every session has ended; the sessions scope is then not joined, and a later Close may try again. It does not wait for a client to answer the close handshake beyond that deadline.

After Close, the handler refuses new upgrades. It is not reusable.

func (*App[S, I]) DevReloadScript

func (a *App[S, I]) DevReloadScript(mountPath string) templ.Component

DevReloadScript renders the script tag for the dev-reload client, for an application whose handler is mounted at mountPath.

It is the browser half of FR-57: after a Go or templ change is rebuilt and the process restarts, the page in front of the developer reloads itself. docs/guide/dev-reload.md is the user-facing page.

What it does, and what the runtime already does

A Go change and a templ change are one event — templ generates Go — so both mean a rebuild and a restart. The socket drops either way, and the client runtime's reconnect-and-resync brings the live regions back on its own; that needs nothing from this tag, and it is why restarting the SAME binary does not reload anything here.

What a resync cannot repaint is everything outside a live fragment: the page shell, the head, a fragment whose markup changed while its state did not and which therefore produced no patch. After a rebuild that markup came from a process that no longer exists. This tag polls the build identity below and reloads the document when it changes, which is the only thing that fixes it.

What it does NOT preserve

Server-held session state does not survive the process that held it. The reconnect mounts a NEW session against the new build, so Config.Init runs again and the session's state is whatever Init produces. That is not something this tag could preserve and it does not pretend to: "without losing the session where state permits" means the browser re-establishes itself with no manual refresh, not that a restarted process remembers. Application state kept outside the session — the counter example's store is the one to look at — survives exactly as far as its own lifetime allows, which for an in-process store is not at all.

It renders nothing unless Config.Dev is set

With Dev false — the zero value, and what production must run — this writes zero bytes and returns nil, the route serving its JavaScript answers 404, and so does the build-identity route. Three gates on one switch, all three tested in both positions. A production page therefore names no dev asset, exposes no build identity, and makes no polling request.

Order does not matter here

Unlike (*App[S, I]).InspectorScript, this tag may go anywhere: it wraps nothing, reads nothing the runtime owns, and talks only to its own route over HTTP. Putting all three tags together, inspector first and this one last, is the arrangement the guide shows, and only the inspector's position in it is load-bearing.

mountPath is validated exactly as Script validates it, by the same function, and for the same reason: the prefix as the browser sees it is knowledge only the caller has.

func (*App[S, I]) Document

func (a *App[S, I]) Document(
	mountPath, title string,
	htmlAttrs templ.Attributes,
	head ...templ.Component,
) templ.Component

Document renders the whole HTML document around this application's page content: the doctype, the <html> element, a <head> carrying the character encoding, the title and the client runtime, and a <body> holding the children this component is given.

templ Page(s State) {
	@app.Document(MountPath, "gotth-live quickstart", templ.Attributes{"lang": "en"}) {
		@Count(s)
	}
}

Why this is a method, and what that buys

Script is a package-level function, and everything about this component says it should be one too — until the dev tags. App.InspectorScript MUST be rendered above Script's tag (both are deferred, deferred scripts run in document order, and the inspector has to wrap the WebSocket constructor before the runtime opens a socket), and both dev tags are methods because what they emit depends on Config.Dev, which is state the application declared once. A package-level shell could emit Script and nothing else, and would then leave the application placing the inspector *relative to a tag it can no longer see* — an ordering it can only get wrong, against a marker this component has taken away. That is a worse page than the hand-written shell it replaces.

So this is a method, it emits all three tags itself, and the application never places any of them. **The ordering invariant is not preserved here, it is made inexpressible**: no argument to this component, in any order, can produce an inspector tag below a runtime tag. That takes two mechanisms rather than one, because the first has a hole in it and the hole is the failure itself:

  • Head content renders ABOVE the three tags, so an application that renders its own App.InspectorScript there still lands above Script. That half falls out of the ordering and needs nothing.
  • Head content that renders a RUNTIME tag of its own would land above the inspector, and that is the ordering failure and not merely a duplicate: both tags are deferred, deferred scripts run in document order, and the runtime would open its socket before the inspector wrapped WebSocket. So it is refused. While this component renders head content it marks the context; Script reads the mark and returns an error; and the page becomes App.PageHandler's 500 with a named reason instead of a page whose inspector silently sees nothing. A whole App.Document nested in the head is refused by the same mark, because its own Script call renders under it.

The mark is set around the head content and nowhere else, and only when this document is emitting the runtime itself. Two things therefore stay expressible, both deliberately:

  • Script among this component's CHILDREN renders. It lands below the inspector, so the ordering holds; what remains is a duplicate runtime tag, which is two sockets on one page — a real defect, with a different shape, and not the one this mark is for.
  • A document given NoRuntime emits none of the three and sets no mark, so placing Script by hand on a page that has declared itself not live works, and there is no inspector for it to be ordered against.

Reaching the App from the page function is the caller's problem and it has a zero-cost answer: App.PageHandler takes a func(state S) templ.Component and gives it no receiver, so the application holds its App wherever it already holds its state type — docs/quickstart.md makes it a package-level var, the examples pass it into the templ component as a parameter. Both are one line that was already there.

What it owns, and what stays the application's

It owns exactly the parts of a document that are the same in every live application and that nothing above it can get right: the doctype, the character encoding declaration (first in the head, where a byte-counting parser needs it), and the placement of the runtime, inspector and dev-reload tags.

Everything else is the application's, and is a parameter here rather than a default:

  • title is required. A document's title is content, this library has no defensible guess at it, and an empty <title> is an accessibility failure that renders as a blank tab rather than as an error. So an empty title is an error and no bytes are written.
  • htmlAttrs are the attributes of the <html> element, and lang is among them. A live-connection library does not choose a document's language: there is no default here, a nil map is a document that says nothing, and nothing is added to what the caller passes. They are rendered by the same function templ's own attribute spread uses, in sorted key order, so one call always produces one byte sequence.
  • head is any number of components rendered into the head after the title and before the runtime tags — the viewport meta, the stylesheet, an application's own script. It is variadic so that a page needing none pays nothing for it, not even a nil.

The <body> element carries no attributes and this component provides no way to give it any. Every hand-written shell in this repository has a bare <body>; if one ever needs otherwise, that is an argument for a parameter, and it should be made rather than worked around by abandoning the component.

Failure

mountPath is validated exactly as Script validates it, by the same function, and both it and the title are checked BEFORE anything is written. A failure therefore emits zero bytes and returns the error, which is what keeps App.PageHandler's buffered render honest: a page that cannot be rendered correctly is a 500 carrying a logged reason, never a 200 carrying half a document. The errors from the head components, from the runtime tags and from the children are returned unchanged for the same reason — this component swallows none of them.

Pass NoRuntime as mountPath for a page in a live application that is deliberately not live, such as a login page: it emits no runtime tag, no inspector and no dev-reload tag, and it is the only spelling that does.

func (*App[S, I]) Handler

func (a *App[S, I]) Handler() http.Handler

Handler returns the http.Handler serving the live connection and the client runtime. It is mountable under any router at any prefix, and it holds no assumption at all about the path it is mounted at, because it routes by path SUFFIX: the four asset names are matched against the end of r.URL.Path and everything else is the upgrade.

So do NOT wrap it in http.StripPrefix, at any prefix. Stripping is what makes a subtree pattern answer the upgrade with a 307 to the trailing-slash form, and a WebSocket client cannot follow a redirect on an upgrade — the page loads, the socket never opens, and the runtime retries forever. Register both the exact pattern and the subtree instead; docs/quickstart.md §2 has the measured table of all four mountings.

Tell Script the same prefix, so the page points the browser back at wherever it was mounted. It is a parameter because this handler cannot supply it: it never learns where it was mounted, which is the same property that makes stripping unnecessary.

The live route returns at the upgrade, and the session outlives the request

ServeHTTP RETURNS once the WebSocket handshake completes; the session then runs on a goroutine of the App's sessions scope, for as long as the connection lasts. It does NOT block for the life of the session, which is what most WebSocket handlers do and what this one used to do. Three consequences a caller can observe, all deliberate:

  • Middleware wrapping this handler completes at the upgrade rather than at the end of the session. A request-scoped logger, timer or metric records a handshake, not a connection — which is the honest boundary for a request that became a connection, and the one that lets a request timeout mean what it says.
  • The session does not observe the REQUEST context's cancellation. It runs under context.WithoutCancel, so values an application or its middleware put on the request context still resolve for the session's whole life, and cancelling the request no longer ends it. In practice nothing changes: that cancellation used to fire when ServeHTTP returned, which was the end of the session.
  • Close, or the host runtime stopping the App, is how a session is ended from outside. There is no request to cancel.

The reason is memory, and it is measured: net/http holds a *conn — with two 4 KiB bufio buffers, a *response carrying a third, and the *Request — for as long as its handler has not returned, and a hijack means none of it goes back to net/http's pools. Under a blocking handler that is per-session memory held for hours. See docs/bench/g2-baseline.md.

func (*App[S, I]) InspectorScript

func (a *App[S, I]) InspectorScript(mountPath string) templ.Component

InspectorScript renders the script tag for the dev session inspector, for an application whose handler is mounted at mountPath.

The inspector is a floating panel showing the causal chain of the session this page is running: every event the browser sent, the event id, transition and state version the server minted for it, and the patches each produced, joined by the causal identifiers the frames already carry (FR-39 through FR-42). It also flags `hx-*` attributes inside an unpreserved live fragment, which morph will overwrite (RFC-0001 §10.3). docs/guide/inspector.md is the user-facing page.

It renders nothing unless Config.Dev is set

With Dev false — the zero value, and what production must run (see that field) — this writes zero bytes and returns nil, and the route serving the inspector's JavaScript answers 404. Those are two independent gates on one switch, and they are what PRD NFR-8's "MUST NOT load in production builds" means here: a production page has no tag naming the file, and a production binary would not serve the file to a browser that asked for it anyway.

A component that renders nothing is normally this library's least favourite shape — Script's own documentation argues at length against a silent no-op. The difference is that here the silence IS the requirement, it is keyed to a field the application set deliberately, and it fails in the safe direction: the mistake this could produce is a developer wondering where their panel went, not a page that is quietly not live.

Order matters, and it is not checkable from here

This tag MUST come BEFORE live.Script's. The inspector reads the session's frames off the WebSocket, which means it must wrap the constructor before the runtime opens a socket; both tags are deferred, and deferred scripts run in document order. Getting it wrong does not break the page — the inspector detects that the runtime booted first and says so in its own panel — but it shows nothing until the next reconnect.

mountPath is validated exactly as Script validates it, by the same function, and the same mount produces the same prefix in both tags. It is a parameter for the reason it is a parameter there: the prefix as the browser sees it is knowledge only the caller has, and a default would point at a file that 404s.

Example

ExampleApp_InspectorScript renders the same page twice, once from an application with Config.Dev set and once from one without.

The template does not change between the two. In dev it carries the inspector; in production it carries no reference to it at all, and the route that would have served the file answers 404 (PRD NFR-8).

The inspector's tag goes ABOVE live.Script's. Both are deferred, deferred scripts run in document order, and the inspector has to wrap the WebSocket constructor before the runtime opens a socket with it.

package main

import (
	"context"
	"fmt"
	"io"
	"os"

	"github.com/a-h/templ"

	"github.com/candacelabs/csf/pkg/gotth/live"
)

// exampleState is the state of the smallest application that can hold a
// fragment.
type exampleState struct{ N int }

// exampleRender is that state's one fragment, named so the examples below can
// hand the same function to Fragment.Render and to App.PageHandler — which is
// the discipline PageHandler exists to enforce: the page and the fragment
// render the same component from the same state.
//
// In a .templ file this is a templ block and the attribute is written
// { live.Region("counter")... }; spelled out here so the examples are ordinary
// Go in one file.
func exampleRender(s exampleState) templ.Component {
	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
		_, err := fmt.Fprintf(w, `<b %s="counter">%d</b>`, "data-gotth-region", s.N)
		return err
	})
}

// exampleApp is the minimum valid Config, mounted, so each example below shows
// the one thing it is about instead of re-declaring an application.
//
// The security hooks are the deliberately greppable opt-outs. An application
// that meant them would still have to write them.
func exampleApp(dev bool) *live.App[exampleState, live.AnonymousIdentity] {
	app, err := live.New(live.Config[exampleState, live.AnonymousIdentity]{
		Init: func(ctx context.Context, session live.Session[live.AnonymousIdentity]) (exampleState, []live.Effect[live.AnonymousIdentity], error) {
			return exampleState{}, nil, nil
		},
		Reduce: func(s exampleState, ev live.Event) (exampleState, []live.Effect[live.AnonymousIdentity]) {
			if ev.Name == "counter.increment" {
				s.N++
			}
			return s, nil
		},
		Fragments: []live.Fragment[exampleState]{{
			ID:     "counter",
			Render: exampleRender,
			Dirty:  func(prev, next exampleState) bool { return prev != next },
		}},
		Events:       []string{"counter.increment"},
		Origins:      []string{"https://app.example"},
		Authenticate: live.Anonymous,
		Authorize:    live.AllowAll[live.AnonymousIdentity],
		CSRF:         live.NoCSRFCheck,
		Dev:          dev,
	})
	if err != nil {
		panic(err)
	}
	return app
}

func main() {
	page := func(app *live.App[exampleState, live.AnonymousIdentity], w io.Writer) error {
		if err := app.InspectorScript("/live").Render(context.Background(), w); err != nil {
			return err
		}
		if err := live.Script("/live").Render(context.Background(), w); err != nil {
			return err
		}
		_, err := io.WriteString(w, "\n")
		return err
	}

	if err := page(exampleApp(true), os.Stdout); err != nil {
		panic(err)
	}
	if err := page(exampleApp(false), os.Stdout); err != nil {
		panic(err)
	}

}
Output:
<script src="/live/gotth-live-inspector.min.js" defer></script><script src="/live/gotth-live.min.js" data-gotth-url="/live" defer></script>
<script src="/live/gotth-live.min.js" data-gotth-url="/live" defer></script>

func (*App[S, I]) Mux

func (a *App[S, I]) Mux(mountPath string, page http.Handler) http.Handler

Mux returns an http.ServeMux with this application and page mounted on it: the WebSocket upgrade at exactly mountPath, the client runtime and the dev-only routes on the subtree under it, and page on the catch-all.

It is the whole routing of a single-application server in one call, and it exists because writing those three registrations by hand has two silent failure modes and both of them are measured in docs/quickstart.md §2:

  • Registering only mountPath and not mountPath+"/" leaves the runtime's URL to the catch-all, which answers it with the page. The browser gets 200 text/html, hands a document to its JavaScript parser, and never attempts a WebSocket. There is no 404 and no server-side error anywhere; the only evidence is one SyntaxError in the browser console.
  • Wrapping the handler in http.StripPrefix, the repair a reader reaches for next, turns the upgrade into a 307 to the trailing-slash form — and a WebSocket client cannot follow a redirect on a handshake, so the page reconnects forever.

Neither is expressible through this method. App.Handler is still the way to mount on a router of your own, and it needs no http.StripPrefix there either; docs/guide/_samples/mounting is the same three registrations written out.

mountPath is the prefix as the BROWSER sees it, and it is the same string Script must be given — this method routes with it, but the tag renders on a different request, so nothing here can check that the two agree.

It panics rather than returning an error, on the precedent of the http.ServeMux method it calls: a mount path is a constant in the caller's source, so a bad one is a startup mistake in a literal rather than a condition a running server can be in. It panics when page is nil, when mountPath is not a path Script would accept — empty, not beginning with "/", or containing "//" anywhere, "\", "?", "#", or a control byte — and when mountPath is "/", which would put the upgrade and the page on one pattern and leave no route for either.

Example

ExampleApp_Mux mounts an application and its page in one call, and shows the registration a hand-written mux forgets.

The live handler needs TWO patterns: the exact mount path, which is the WebSocket upgrade, and the subtree under it, which is the client runtime and the dev-only routes. Register only the first and the catch-all answers the runtime's URL with the page — 200, text/html, no error anywhere on the server, and the browser hands a document to its JavaScript parser. The second column below is that mistake, measured.

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"

	"github.com/a-h/templ"

	"github.com/candacelabs/csf/pkg/gotth/live"
)

// exampleState is the state of the smallest application that can hold a
// fragment.
type exampleState struct{ N int }

// exampleRender is that state's one fragment, named so the examples below can
// hand the same function to Fragment.Render and to App.PageHandler — which is
// the discipline PageHandler exists to enforce: the page and the fragment
// render the same component from the same state.
//
// In a .templ file this is a templ block and the attribute is written
// { live.Region("counter")... }; spelled out here so the examples are ordinary
// Go in one file.
func exampleRender(s exampleState) templ.Component {
	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
		_, err := fmt.Fprintf(w, `<b %s="counter">%d</b>`, "data-gotth-region", s.N)
		return err
	})
}

// exampleApp is the minimum valid Config, mounted, so each example below shows
// the one thing it is about instead of re-declaring an application.
//
// The security hooks are the deliberately greppable opt-outs. An application
// that meant them would still have to write them.
func exampleApp(dev bool) *live.App[exampleState, live.AnonymousIdentity] {
	app, err := live.New(live.Config[exampleState, live.AnonymousIdentity]{
		Init: func(ctx context.Context, session live.Session[live.AnonymousIdentity]) (exampleState, []live.Effect[live.AnonymousIdentity], error) {
			return exampleState{}, nil, nil
		},
		Reduce: func(s exampleState, ev live.Event) (exampleState, []live.Effect[live.AnonymousIdentity]) {
			if ev.Name == "counter.increment" {
				s.N++
			}
			return s, nil
		},
		Fragments: []live.Fragment[exampleState]{{
			ID:     "counter",
			Render: exampleRender,
			Dirty:  func(prev, next exampleState) bool { return prev != next },
		}},
		Events:       []string{"counter.increment"},
		Origins:      []string{"https://app.example"},
		Authenticate: live.Anonymous,
		Authorize:    live.AllowAll[live.AnonymousIdentity],
		CSRF:         live.NoCSRFCheck,
		Dev:          dev,
	})
	if err != nil {
		panic(err)
	}
	return app
}

func main() {
	app := exampleApp(false)
	page := app.PageHandler(exampleRender)

	byMux := app.Mux("/live", page)

	forgotten := http.NewServeMux()
	forgotten.Handle("/live", app.Handler())
	forgotten.Handle("/", page)

	show := func(label string, h http.Handler) {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/live/gotth-live.min.js", nil))
		fmt.Printf("%s %d %s\n", label, rec.Code, rec.Header().Get("Content-Type"))
	}

	show("Mux:      ", byMux)
	show("forgotten:", forgotten)

}
Output:
Mux:       200 text/javascript; charset=utf-8
forgotten: 200 text/html; charset=utf-8

func (*App[S, I]) PageHandler

func (a *App[S, I]) PageHandler(page func(state S) templ.Component) http.Handler

PageHandler returns the http.Handler that serves the first paint: on every request it loads state through Config.Init and renders page from it.

It exists because the obvious spelling is wrong in a way nothing reports. templ.Handler(Page(State{})) builds the component when the argument is evaluated — once, at start-up — and serves those bytes to every visitor for the life of the process. That is correct exactly while Init returns the zero value too, and it stops being correct, silently, the moment Init reads a row, a cookie or a feature flag: every response then carries the zero state, the browser is corrected only once the WebSocket connects, and with JavaScript disabled it is never corrected at all. This handler cannot be given a state value, only the function that renders one, so that mistake is not expressible through it.

What it does per request, in order

  1. Config.Authenticate derives the identity from the page request.
  2. Config.Init is called with the request's context and a Session carrying that identity.
  3. page renders the state Init returned, into a buffer, which is then written with Content-Type text/html; charset=utf-8.

So the page and the session's first snapshot come from one function, and an application that later gives Init something real to do gets a correct first paint from the same edit. It is the rule at docs/guide/fragments-and-dirty-tracking.md — the page and the fragments render the same components, from the same state — held by the type rather than by care.

Init is called once per page request as well as once per session

That is the trade this handler makes, and it is worth stating rather than discovering. Init is a loader: it produces state and RETURNS effects as values for the library to perform later, so calling it here performs none of them — the effects it returns on a page render are discarded, and the session's own Init call is what schedules them. What does run twice is whatever Init does to produce the state, which for a loader is a read. An Init that is not safe to call for a read should not be mounted here; give this handler an application whose Init loads, and put anything else in the startup effects, which is what they are for.

The Session a page render sees

Session.Identity is the identity Authenticate derived from this request, because the page must be painted for the identity the socket will bind to. Session.ID is the zero ID: no session exists yet, and one cannot, because a session is minted at the handshake and this is a different request. An Init that needs to tell the two calls apart compares against the zero ID.

What it answers when a step fails

A failure renders no page, because half a document is worse than none:

  • Authenticate returning an error, or no identity, is 401 — the same status the upgrade gives that visitor. A page whose socket is going to be refused is a page that cannot work, so serving it would be the silent failure this library exists to remove rather than a kindness. An application that wants an unauthenticated visitor to get a page wants Authenticate to return an anonymous identity rather than an error, which is also what makes their upgrade succeed.
  • Init returning an error, or the render failing, is 500.

The body is a fixed generic message and the detail goes to Config.Logger at error level, on the same rule error frames follow: the browser is not where a server-side failure is explained. With Config.Dev set the detail is in the body as well.

It answers any method, as a page handler mounted on a catch-all must, and writes no body for HEAD.

Example

ExampleApp_PageHandler serves the first paint from the mount hook, and shows the defect it exists to make unwritable.

The frozen spelling — templ.Handler(Page(State{})) — evaluates Page once, when main runs, and serves those bytes for the life of the process. It is correct exactly while Init returns the zero value too, and this example gives Init something to load, which is all it takes to make it wrong: the frozen handler serves 0 to every visitor, corrected only once the WebSocket connects, and never at all with JavaScript off. PageHandler cannot be given a state value, only the function that renders one, so it re-loads per request and the two answers agree.

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"

	"github.com/a-h/templ"

	"github.com/candacelabs/csf/pkg/gotth/live"
)

// exampleState is the state of the smallest application that can hold a
// fragment.
type exampleState struct{ N int }

// exampleRender is that state's one fragment, named so the examples below can
// hand the same function to Fragment.Render and to App.PageHandler — which is
// the discipline PageHandler exists to enforce: the page and the fragment
// render the same component from the same state.
//
// In a .templ file this is a templ block and the attribute is written
// { live.Region("counter")... }; spelled out here so the examples are ordinary
// Go in one file.
func exampleRender(s exampleState) templ.Component {
	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
		_, err := fmt.Fprintf(w, `<b %s="counter">%d</b>`, "data-gotth-region", s.N)
		return err
	})
}

func main() {
	loaded := 41

	app, err := live.New(live.Config[exampleState, live.AnonymousIdentity]{
		// The loader. Its answer changes; the frozen page's cannot.
		Init: func(ctx context.Context, session live.Session[live.AnonymousIdentity]) (exampleState, []live.Effect[live.AnonymousIdentity], error) {
			return exampleState{N: loaded}, nil, nil
		},
		Reduce: func(s exampleState, _ live.Event) (exampleState, []live.Effect[live.AnonymousIdentity]) {
			return s, nil
		},
		Fragments:    []live.Fragment[exampleState]{{ID: "counter", Render: exampleRender}},
		Events:       []string{"counter.increment"},
		Origins:      []string{"https://app.example"},
		Authenticate: live.Anonymous,
		Authorize:    live.AllowAll[live.AnonymousIdentity],
		CSRF:         live.NoCSRFCheck,
	})
	if err != nil {
		panic(err)
	}

	frozen := templ.Handler(exampleRender(exampleState{}))
	perRequest := app.PageHandler(exampleRender)

	get := func(h http.Handler) string {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
		return rec.Body.String()
	}

	fmt.Println("frozen: ", get(frozen))
	fmt.Println("loaded: ", get(perRequest))
	loaded = 42
	fmt.Println("frozen: ", get(frozen))
	fmt.Println("loaded: ", get(perRequest))

}
Output:
frozen:  <b data-gotth-region="counter">0</b>
loaded:  <b data-gotth-region="counter">41</b>
frozen:  <b data-gotth-region="counter">0</b>
loaded:  <b data-gotth-region="counter">42</b>

func (*App[S, I]) Start added in v0.2.0

func (a *App[S, I]) Start(scope *runtime.Scope) error

Start implements runtime.IService. It starts one goroutine in scope, which waits for the scope's cancellation and then stops the service: every session is sent the going-away close, every session's read pump, actor and effects are waited for with no deadline, and the sessions scope is joined. The host runtime's join of this service is therefore a join of every goroutine the service ever started.

An effect that ignores its cancelled context holds the stop open. That is the contract rather than a defect of it: the service owns its goroutines and joins them. Limits.EffectDrainTimeout is when such an effect is counted and logged.

Start is called once, by the one host runtime the App is mounted into. A second Start returns ErrServiceStarted, and a Start after the service has stopped — by Close or by an earlier host's stop — returns ErrServiceStopped; neither starts anything.

type Bind

type Bind struct {
	// Fields are static values sent with every occurrence of this binding's
	// event, in addition to whatever the element itself contributes.
	Fields map[string]string
	// Debounce delays sending until THIS binding's DOM event has been quiet
	// for this long. Zero means no debounce.
	Debounce time.Duration
	// Throttle sends at most one of THIS binding's events per interval. Zero
	// means no throttle.
	Throttle time.Duration
	// Keys restricts a keyboard binding to these keys, and raises no event for
	// any other. Empty is every key, which is what a keydown binding without
	// this option has always meant.
	//
	// Each entry is compared, exactly and case-sensitively, against the
	// browser's own KeyboardEvent.key value: "Escape" and not "Esc",
	// "ArrowUp" and not "Up", " " and not "Space", "A" and not "a" for a
	// shifted letter. Nothing is normalised, because "a" and "A" are different
	// keys and the name set belongs to the UI Events specification rather than
	// to this library — an unrecognised name is therefore not an error here,
	// it is a filter that matches nothing, and it shows up as a binding that
	// never fires on the first keypress.
	//
	// The key is compared and the modifier state is not — unless the binding
	// also sets NoModifiers, which is the only thing that reads a modifier and
	// is off by default for the reason stated there. A printable key already
	// carries its modifiers (Shift and "=" arrive as "+"), a modifier pressed
	// alone arrives as "Shift", "Control", "Alt" or "Meta" and matches only a
	// filter that names it, and a Ctrl or Meta chord belongs to the browser:
	// this library takes no key away from the browser except where a binding
	// asked for it by name, which is PreventDefault below and is off by
	// default too.
	//
	// A key filter on an event that carries no key — a click, an input — never
	// matches, so the binding never fires. A filter filters.
	//
	// ":" and ";" separate the bindings the client parses, so a key that is
	// one of those two characters cannot be expressed here, and OnWith PANICS
	// on one rather than rendering it. Nothing else is reserved: "," and " "
	// and every other printable key value are carried through unchanged,
	// because a list of keys is emitted as one binding per key rather than as
	// a separated list.
	//
	// That list of two is complete and did not grow when Fields, Debounce and
	// Throttle moved into the binding beside this one. They are further ":"
	// components of a grammar that had already spent ":", so the set of
	// characters a key may not be is exactly what it was: ":" and ";".
	//
	// What the panic replaces is worth saying, because it is what a page
	// written before 2026-08-05 got. Such a key used to render, and rendering
	// it did something neither this comment nor the author asked for: the
	// stray separator became an extra component, so the key filter widened to
	// EVERY key and every option declared beside it landed one slot later than
	// the client reads it — a 150 ms Debounce arrived as a 150 ms Throttle. A
	// ";" was worse: it split the binding in two and left the remainder as a
	// spec of junk. Both were silent.
	//
	// It is a panic and not an error because OnWith returns templ.Attributes
	// and has no error channel — see On.
	Keys []string

	// NoModifiers restricts this binding to presses with NO modifier key
	// held — no Shift, no Control, no Alt, no Meta.
	//
	// False, the zero value, is what every key binding has meant since
	// 591c275a: the key is compared and the modifier state is not. That
	// default is not a legacy, it is required: a printable key already
	// carries its modifiers, and "+" IS Shift and "=" pressed together on
	// most layouts, so a binding that named "+" and silently demanded no
	// modifier would match nothing (F-CTR-6).
	//
	// What it reads is exactly four booleans — the event's shiftKey, ctrlKey,
	// altKey and metaKey — and any one of them held is a press this binding
	// does not match. Two consequences of that are worth naming here, because
	// neither is visible from the option's name:
	//
	//   - AltGr sets BOTH ctrlKey and altKey. So a printable key that needs
	//     AltGr on the member's layout — "@" on many European layouts, "\"
	//     and "|" on others — will NOT match a binding that names it and sets
	//     this option, while the member is typing exactly the character the
	//     binding asked for. Name such a key without this option.
	//   - CapsLock and NumLock are NOT read. They are lock states rather than
	//     held modifiers, they set none of the four booleans, and a binding
	//     filtered here fires with either of them on.
	//
	// It applies whether or not Keys is set, and it is not restricted to
	// keyboard events. An unfiltered keydown binding with this option raises
	// its event for every UNMODIFIED key, which is a filter and not a no-op;
	// and because a mouse event carries the same four booleans, a click
	// binding with this option means a plain click rather than a Ctrl+click
	// or a Shift+click — the two a browser already treats as "open this
	// somewhere else". An event that carries none of the four, such as input
	// or submit, has all four absent, so a binding on one is unaffected.
	//
	// A binding this filters out suppresses nothing and ends nothing: the
	// client goes on to the next binding on the element for the same DOM
	// event. That is what lets Enter and Shift+Enter reach two different
	// bindings, or one binding and none at all, from one composer (F-CHT-3).
	NoModifiers bool

	// PreventDefault calls preventDefault() on the browser event when THIS
	// binding matches, and only then.
	//
	// The library calls it for a recognised form submit and an anchor click
	// already; this is the same act for a binding the author declared
	// explicitly, per binding, defaulting off. It is not a filter: a binding
	// that does not match does not suppress anything, which is what leaves
	// Shift+Enter to the browser.
	//
	// It is NOT called while an IME composition is active, and that ordering
	// is load-bearing rather than incidental. Enter during a composition
	// COMMITS the candidate, so a binding that suppressed it would take the
	// commit key away from every composer that uses one — the population
	// FR-26's composition guard exists for. The client tests that guard
	// first: mid-composition it neither sends the event nor suppresses the
	// default, and the binding fires on the Enter after the commit instead.
	PreventDefault bool
}

Bind carries the extra options OnWith accepts.

Every option here is scoped to the ONE binding it is given to, and travels with that binding rather than being written on the element. That is a property to rely on: composing this binding with another through OnAll changes neither one's behaviour, and neither can read the other's interval, its rate or its fields.

It was not always so, and the correction is FR-54 failure 2. Fields, Debounce and Throttle were attributes of the element until 2026-08-05, so every binding on an element shared one of each and one timer. What that cost was measured rather than argued: on the guide's own composer — an Escape binding composed with a 150 ms input binding — the Escape inherited the interval, and a keystroke inside that window did not delay the pending clear, it destroyed it. No error, no console warning, nothing on the wire. The reverse held too: an Escape inside the window destroyed a pending draft, so the server never learned what was typed while the browser went on showing it. docs/qa/fr-54-debounce-repro.md is the reproduction, in Chromium, against the real runtime.

type Config

type Config[S any, I IIdentity] struct {
	// Init is the mount hook: it produces the session's initial state and any
	// startup effects, such as a pubsub subscription. It runs once per session,
	// as the first transition, before the first snapshot.
	//
	// Optional. Nil means the zero value of S, no startup effects and no error
	// — which is the only total, side-effect-free thing an unwritten mount hook
	// could mean, and it is what an application whose sessions all start empty
	// writes out by hand. Teardown, the hook on the other end of the same
	// session, has always been optional on the same argument.
	//
	// It is the ONE field New fills in rather than refusing, and the line
	// between it and the rest is that the rest cannot be guessed: a reducer, a
	// region, the set of accepted event names and the four security hooks are
	// each something only the application knows, and a library that picked for
	// them would be picking deny-by-default's opposite. Getting this one wrong
	// is also visible on the first run rather than in production — sessions
	// start empty, and so does the page, because [App.PageHandler] renders from
	// this same hook — where a guessed origin allowlist or a guessed
	// authorization rule would not be visible at all.
	//
	// [App.PageHandler] calls it once per page request as well, to render the
	// first paint from the state a session would start at. Init is therefore a
	// loader: it must be safe to call for a read. The effects it returns are
	// performed only for a real session; on a page render they are discarded.
	// See that method.
	Init func(ctx context.Context, session Session[I]) (S, []Effect[I], error)

	// Reduce is the pure state transition. Required.
	Reduce Reducer[S, I]

	// Fragments are the server-owned live regions. Required, non-empty, and
	// every ID must be unique.
	Fragments []Fragment[S]

	// Events are the event names this application accepts. Required.
	//
	// An event whose name is not here is refused with UNKNOWN_EVENT and
	// counted, never dispatched and never ignored: unknown input is
	// default-deny. Declaring the set up front is also what bounds the
	// cardinality of the per-event metric label before the first connection.
	Events []string

	// Teardown runs after the session actor exits, with the final state, for
	// unsubscribing. Optional.
	Teardown func(ctx context.Context, session Session[I], state S)

	// Origins is the allowlist of permitted Origin values, checked on the
	// upgrade request before any per-session memory is allocated. Required
	// unless it contains AnyOrigin. Deny by default: there is no wildcard, no
	// reflection of the request's own Origin, and no pass for a request that
	// sends none.
	Origins []string

	// Authenticate derives the session identity from the upgrade request.
	// Required; use Anonymous to opt out.
	Authenticate func(request *http.Request) (I, error)

	// Authorize runs before the reducer for every event, at the single
	// mailbox ingress, so a new event kind cannot skip it. Required; use
	// AllowAll to opt out.
	//
	// Returning nil allows the event. Returning a *DenyError rejects it
	// without closing the connection. Returning a *FatalDenyError rejects it
	// and closes the connection.
	Authorize func(ctx context.Context, session Session[I], event Event) error

	// CSRF validates a token bound to the authenticated application session.
	// Required; use NoCSRFCheck to opt out.
	CSRF func(request *http.Request) error

	// Limits are the resource bounds. Any zero field takes its documented
	// default.
	Limits Limits

	// Logger is the structured log sink. Nil disables library logging and the
	// provenance log with it, which makes the reverse lookup from a captured
	// patch back to its cause unavailable. The frames still carry the causal
	// chain either way; what is lost is the server-side index.
	Logger *slog.Logger

	// Metrics enables the full metric set with one field. Nil disables it, at
	// a cost of one predictable branch per call site.
	Metrics metric.MeterProvider

	// Tracer enables the full trace set with one field. The provider is taken
	// explicitly rather than read from the OpenTelemetry global, which is what
	// lets this library depend on the trace API submodule rather than the root.
	Tracer trace.TracerProvider

	// Dev turns on developer mode. It must be false in production.
	//
	// It does three things, and nothing else.
	//
	// # 1. Panic detail in the Error frame
	//
	// In
	// production such a frame carries a fixed generic message and the causal
	// identifiers, and nothing else; with Dev set, the same frame also carries
	// the panic value and its stack. The full stack is written to Logger at
	// error level in both modes — dev mode only puts it where a developer with
	// a browser open will see it (FR-23, checklist §5.9).
	//
	// It reaches both sites that produce an Error frame: a panicking reducer,
	// and a panicking fragment render or Dirty declaration. The third site
	// FR-23 names, a panicking effect, deliberately becomes an
	// EffectFailedEvent instead of a frame, and EffectFailedErrorField already
	// carries the panic value in production and in dev alike — see that
	// constant, because it is a disclosure path this field does not gate.
	//
	// What reaches the browser is bounded: protocol.md caps an error frame's
	// message at 512 bytes, so a long stack arrives truncated. The frame is a
	// pointer into the log, not a copy of it.
	//
	// # 2. The dev session inspector (FR-44, NFR-8)
	//
	// With Dev set, App.Handler serves the inspector's JavaScript under the
	// mount and (*App).InspectorScript renders the tag that loads it. With Dev
	// false the route answers 404 and the component writes nothing, which is
	// how NFR-8's "MUST NOT load in production builds" is enforced rather than
	// asserted. The inspector is a separate artifact and costs the shipped
	// runtime nothing at all: it reads the session's frames off the WebSocket
	// and there is no seam for it anywhere in client/runtime.js.
	//
	// The library still logs no HTMX ownership violation, in either mode:
	// RFC-0001 §10.3 chose a documented precedence rule over a server-side
	// scan of rendered HTML for hx-* attributes, so there is nothing
	// server-side to detect. Flagging an hx-* element inside an unpreserved
	// live fragment is the inspector's job, in the browser, where the element
	// actually is.
	//
	// # 3. Dev reload (FR-57)
	//
	// With Dev set, App.Handler serves the dev-reload client's JavaScript and
	// the build-identity route under the mount, and (*App).DevReloadScript
	// renders the tag that loads it; with Dev false all three write nothing or
	// answer 404. A rebuilt-and-restarted process then reloads the page by
	// itself, which is the only way a change outside a live fragment — the
	// page shell, the head, a fragment whose markup moved while its state did
	// not — ever reaches a browser that is already connected.
	//
	// It preserves nothing the process itself did not preserve. See
	// DevBuildID and docs/guide/dev-reload.md, both of which say so in more
	// detail than a field comment can.
	Dev bool

	// DevBuildID overrides the identity gotth-live uses to tell one build of
	// this application from another. It is read only when Dev is set.
	//
	// Leave it empty and the identity is derived, once per process and lazily,
	// from a SHA-256 of the running executable. That default is what makes a
	// restart that rebuilt nothing — a crash loop, a `docker compose restart`,
	// a rebuild of source that did not actually change — leave the page alone
	// and let the client runtime's own reconnect restore it, instead of
	// reloading the document out from under a developer who changed nothing.
	//
	// Set it when the derived value cannot work or is not what you mean: a
	// commit hash injected with -ldflags, a container image digest, or a
	// counter your own reload loop increments. Any value works as long as it
	// CHANGES when the code changes and does not change when it does not; a
	// constant string turns dev reload off without turning Dev off.
	//
	// It is validated at New whatever Dev is set to — at most 128 bytes, no
	// control bytes, no leading or trailing whitespace — because a field
	// checked in only one mode is a field that starts failing on the deploy
	// that flips the mode. The bounds are what the value has to survive: it is
	// rendered into a script tag and returned as the entire body of the poll
	// the browser makes.
	DevBuildID string
}

Config declares one live application: its state type, its mount hook, its reducer, its fragments, its effect executor, and its security hooks.

The zero value is invalid, and New reports exactly which field is missing and what to set it to. It is a struct rather than a set of functional options because that is the standard library's shape for this — http.Server, tls.Config, net.Dialer — and because it makes the security configuration one object a reviewer can read at a glance.

What S has to be

S is unconstrained at the type level and there is one rule about it that the compiler cannot state: Reduce must RETURN the next state rather than modify the one it was given. A value type gets this for free. A reference type — S = *Foo, a map, a slice — does not, and the failure is quiet in both directions:

  • state_version rises exactly when state changed, and the library decides that by comparing prev with next. For a reference S it cannot: == would ask whether the two are the same object, and a reducer that mutated in place and returned the same handle would answer yes. The library therefore treats every transition of a reference S as a change, which costs a render that may be suppressed and keeps the version honest.
  • Fragment.Dirty is handed prev and next, and if the reducer mutated in place they are the same value. The declaration then compares something against itself, reports no change, and that region is never re-rendered. Nothing can repair this from inside the library.

So a pointer S is allowed and is not refused at construction — used purely it is perfectly correct — but it is the shape in which forgetting the rule is silent. The determinism helpers in live/livetest are what catch a reducer that has forgotten it: replay the same event log twice and the results diverge.

type ConfigError

type ConfigError struct {
	// Field is the Config field at fault.
	Field string
	// Detail says what to set it to.
	Detail string
}

ConfigError reports an invalid Config, naming the offending field and what to set it to.

func (*ConfigError) Error

func (e *ConfigError) Error() string

Error names the field and the fix, in that order, because a construction error is read by the person who wrote the Config and has to change one line of it.

type DenyError

type DenyError struct {
	// Reason is operator-facing. A generic message reaches the client in
	// production, because an authorization reason is an authorization input.
	Reason string
}

DenyError rejects one event without closing the connection. The client is told the event was not permitted, no state changes, and the session continues.

func (*DenyError) Error

func (e *DenyError) Error() string

Error renders the operator-facing reason. This string reaches a log, not a browser: the client is told a generic denial, because the reason an event was refused describes the authorization rule that refused it.

type Effect

type Effect[I IIdentity] struct {
	// Source names the effect for provenance and metrics, in the form
	// "package.action" — it becomes the origin source "effect:<name>" on every
	// patch the effect causes, and it is the value a failure event reports in
	// [EffectFailedSourceField].
	//
	// It is at most the protocol's origin-source budget less the library's
	// "effect:" prefix, and must match ^[a-z][a-z0-9_.:/-]*$. A source that
	// cannot name an origin is refused before Run is called.
	Source string

	// Run performs the effect. It is called once, on a goroutine the library
	// owns and waits for at shutdown, and it is the only place in an
	// application where I/O belongs: a reducer returns this value and performs
	// nothing.
	//
	// It receives the session the effect is acting for — an effect acts on a
	// session's behalf and its identity is an input to what it does — and the
	// [Emitter] that injects the results back into that session as events. A
	// returned error becomes an [EffectFailedEvent] the reducer handles; wrap
	// it in [Retryable] to classify it as transient. A panic is contained,
	// counted, and delivered as the same failure event, classified terminal.
	//
	// Everything the effect needs beyond those three is captured: this is a
	// closure over whatever the application owns — a store, a broker, a
	// connection pool — which is what makes a central executor unnecessary and
	// is why there is no longer a Config.Execute to type-switch in.
	Run func(ctx context.Context, session Session[I], emit Emitter) error
}

Effect is one unit of I/O the library performs at the actor boundary, on a goroutine of its own, after the transition that returned it has committed.

It is a concrete struct and not an interface. Operator ruling, 2026-09-03: a reducer signature handing back a slice of one-method effect interfaces hands a caller a slice of abstractions where every element is one named thing this application decided to do, and CS-8's pass-through exemption covers third-party libraries only — this library is the repository's own, so its contract is the repository's choice. What used to be an implementation of a one-method interface is now a value with two fields: what to call it, and what it does.

Both fields are the effect's whole content, and neither is optional:

  • A zero Effect — no source and no Run — is INERT. It is dropped before it reaches the boundary, exactly as a nil element of the old effect slice was, so `append`ing a conditional effect that turned out not to apply costs nothing and cannot execute anything.
  • An Effect with a source and no Run is a MISTAKE, and it fails deterministically rather than silently succeeding: an effect that never runs is a change that never happens, and the reducer learns about it in an EffectFailedEvent like any other failure.
  • An Effect with a Run and no usable source is refused the same way, by the same boundary check that has always refused an unusable EffectSource: the source becomes an origin the wire cannot carry, so no patch this effect caused could be sent.

What a test can assert on

A function value cannot be compared, so a specification asserts on what the reducer *named* — the Source — rather than on a struct literal's fields. That is a real narrowing from the interface's plain-value contract and it is the price of the ruling: [livetest.ReplayN] compares the source sequence two runs produced, which catches a reducer that scheduled a different effect and no longer catches one that scheduled the same effect with a different argument. Effects worth distinguishing should therefore be worth naming distinctly.

type Emitter

type Emitter func(event Event) error

Emitter injects an event into the session that spawned an effect. It is passed to Config.Execute and is safe to call from the effect's goroutine.

It returns an error when the session is saturated or closing, so an effect learns about backpressure rather than having its event vanish.

type Event

type Event struct {
	// Name is the registered event name.
	Name string
	// FragmentID is the fragment whose markup raised the event.
	FragmentID string
	// Fields are the form values the event carried.
	Fields Fields
	// At is stamped at the actor boundary. A reducer reads it here rather
	// than calling a clock, which is what makes an event log replayable.
	//
	// On an event constructed for an [Emitter] it must be left zero: the
	// boundary stamps it, and a value set here is rejected rather than
	// silently replaced.
	At time.Time

	// ID is the server-minted causal identifier, session-scoped and
	// monotonic. It is zero for the transitions the server started on its
	// own, where the origin source names the cause instead.
	//
	// It is read-only in practice. On an event constructed for an [Emitter]
	// it must be left zero, and a non-zero value is rejected with an error
	// rather than dropped: causal identifiers are minted by the server so that
	// untrusted or mistaken input cannot forge provenance, and an application
	// does not need to set one — the library carries the edge from the event
	// that scheduled an effect to the patches the effect produces, and records
	// it in the patch's contributing events.
	ID uint64

	// Contributing names events of this session whose state changes this
	// event carries, and is the one causal field an application sets rather
	// than reads. It belongs on an event constructed for an [Emitter] and is
	// ignored anywhere else.
	//
	// It exists because an asynchronous fan-out through shared state splits
	// the knowledge in two. The library knows which event scheduled an effect;
	// only the application knows which event produced the value that effect is
	// now delivering, and on a shared store those are different events — the
	// subscription was scheduled at mount, the value came from a click. Listing
	// the click here is what lets an operator holding the patch that changed
	// the number reach the interaction that changed it.
	//
	// It is a contributing claim, never a causal one: the patch's own cause
	// stays the server-minted origin, and these identifiers land in the
	// patch's contributing events beside any the library added. Naming an
	// event of another session is not possible — identifiers are
	// session-scoped — and naming the wrong one of your own is an application
	// bug rather than a way to forge provenance.
	//
	// At most 64 identifiers. The [Emitter] rejects a longer list with an
	// error naming the field and the count, so the effect learns about it and
	// the reducer sees a deterministic effect failure; it is not truncated to
	// fit, because dropping provenance to save room is the failure the
	// coalescing flush exists to prevent. The number is not configurable: the
	// patch's contributing list is bounded by the protocol, this is one event's
	// share of it, and every identifier listed here is one the library may not
	// coalesce. If more than 64 events genuinely contributed to one emission,
	// the claim being made is about the whole session rather than about this
	// value, and the provenance an operator can act on is the narrower one.
	Contributing []uint64
}

Event is one inbound interaction, already past the refinement boundary and past authorization.

A reducer receives it by value and must not retain it: Fields holds a copy of the wire data rather than an alias into it, but the copy is the session's.

type FatalDenyError

type FatalDenyError struct {
	// Reason is operator-facing, as for DenyError.
	Reason string
}

FatalDenyError rejects an event and closes the connection as unauthorized. Return it when the request is not merely disallowed but evidence that the session should not continue.

func (*FatalDenyError) Error

func (e *FatalDenyError) Error() string

Error renders the operator-facing reason and says that the connection is going with it, so a log line distinguishes this from the survivable denial without the reader having to know which type produced it.

type Fields

type Fields struct {
	// contains filtered or unexported fields
}

Fields are the form values carried by an event. It is read-only and holds no alias into wire data.

func NewFields

func NewFields(fields map[string]string) Fields

NewFields returns the Fields an application constructs for itself, ordered by key.

Two callers need it and neither can reach the fields a browser sent. An Emitter injects an event from inside an effect, and a server-initiated transition that cannot carry data is a transition that cannot deliver anything a subscription learned — which is every pubsub push. And a determinism test builds the event log it replays, so without this livetest.ReplayN would be usable only by applications whose events carry no form values.

The ordering is not cosmetic. A Go map has no iteration order, and Fields is compared by value in the replay harness, so an unordered copy would make a reducer that reads its fields fail a determinism check that has found nothing wrong.

func (Fields) All

func (f Fields) All(yield func(key, value string) bool)

All iterates the fields in wire order, stopping early if yield returns false.

func (Fields) Get

func (f Fields) Get(key string) string

Get returns the value for a key, or the empty string. For the difference between an absent key and an empty value — an unchecked checkbox, most often — use Lookup.

func (Fields) Len

func (f Fields) Len() int

Len returns the number of fields.

func (Fields) Lookup

func (f Fields) Lookup(key string) (string, bool)

Lookup returns the value for a key and whether the key was present.

type Fragment

type Fragment[S any] struct {
	// ID is a stable identity matching ^[A-Za-z0-9_:.-]{1,64}$, unique within
	// an application. It is what a patch names, so changing it is a
	// client-visible change.
	ID string

	// Render produces this region's markup from state. It must be a pure
	// function of state: the same state must render byte-identical HTML,
	// across runs and across processes. The known hazard is ranging over a Go
	// map in a template; range a sorted slice instead.
	Render func(state S) templ.Component

	// Dirty optionally declares whether a transition may have changed this
	// fragment. Nil means "re-render on every transition", which is always
	// safe. Over-declaring costs a suppressed render; under-declaring is a
	// correctness bug, and livetest.AssertDirtyComplete is what catches it.
	Dirty func(prev, next S) bool

	// Children declares an ordered, session-local collection of nested regions.
	// Each child ID must start with ID + ":", be unique, and declare no children
	// of its own. Definitions remain fixed; membership is a pure projection of
	// state. Render must include every child, in this order, inside this region.
	//
	// Membership/order changes, snapshots, or this parent's Dirty returning true
	// render the complete parent. Otherwise only dirty children render and patch.
	// Set Dirty to the parent's structural projection; nil still means always.
	// Child Dirty functions receive the same previous/next application states.
	// Events may address only children in the last successfully sent render;
	// reducers must also reject members removed by a queued state transition.
	Children func(state S) []Fragment[S]
}

Fragment declares one server-owned live region and how to render it.

type ID

type ID [16]byte

ID is a session identifier: sixteen bytes minted by the server, carried in every frame in both directions so that one patch captured in isolation is resolvable.

func (ID) String

func (id ID) String() string

String returns the lower-case hex form, which is what appears in logs, span attributes and the provenance stream.

type IIdentity

type IIdentity interface {
	// Subject returns a stable, non-secret identifier, used for logging and
	// for per-identity session limits. It must not be a token.
	Subject() string
}

IIdentity is what this library needs from an application's identity, and since 2026-09-03 it appears in exactly two positions: as the CONSTRAINT on the type parameter every generic declaration here carries, and as the parameter type of the internal admission bookkeeping that calls Subject().

It is never a return type and never a field a getter hands back. Operator ruling, 2026-09-03, on the signature this replaces:

func (s Session) Identity() IIdentity { return s.identity }
RETURN TYPE IS IIDENTITY FUCK YOU

That signature was the last "application-type pass-through" exemption in the library: the concrete type genuinely lives in the caller's package and this package cannot name it — which is the existential case, and generics are what Go has instead of existential types. The type parameter is the answer, and the assertion every application used to write, `sess.Identity().(Member)`, is now a compile-time fact.

type Limits

type Limits struct {
	// MaxInboundFrameBytes caps a decoded frame. It is applied to the
	// connection before any payload is allocated, which is what makes it the
	// authoritative inbound limit rather than a check after the fact.
	// Default 65536.
	//
	// It must be between 1024 and 1048576. The mount snapshot announces it to
	// the client, in a field the schema refines to that interval, so a value
	// outside it is a frame this library builds and then refuses to send. New
	// rejects such a value rather than starting a server every session of
	// which dies at establishment (D-23). Zero takes the default.
	MaxInboundFrameBytes int

	// MaxEventsPerSecond and EventBurst are the inbound event token bucket.
	// Defaults 50 and 100.
	MaxEventsPerSecond float64

	// EventBurst is that bucket's depth: how far a flurry of interactions may
	// run ahead of MaxEventsPerSecond before the limiter starts refusing. A
	// keystroke-per-character field is the case it is sized for.
	EventBurst int

	// MailboxDepth bounds the session's mailbox. A full mailbox rejects with a
	// typed error; it never blocks, because blocking the read pump would stall
	// the connection's own liveness detection.
	//
	// It is also a memory parameter. A Go buffered channel allocates its whole
	// backing array at make time, for the life of the channel, occupied or
	// not. Default 64.
	MailboxDepth int

	// AckChannelDepth bounds the acknowledgement channel. A full channel
	// drops, which is lossless because an acknowledgement is a cumulative
	// high-water mark: the next one supersedes the one dropped and the window
	// re-opens a round trip later. Default 32.
	AckChannelDepth int

	// AckWindow is how many unacknowledged patches may be in flight.
	// Default 16.
	//
	// It must be between 1 and 256, for the reason MaxInboundFrameBytes must:
	// the mount snapshot carries it in a refined field. Zero takes the
	// default, so the floor is reachable only as a deliberate 1.
	AckWindow int

	// CoalesceFlushAt is the size of the contributing-event union at which a
	// coalesced patch is emitted immediately rather than coalesced further, so
	// provenance is never truncated. Default 512.
	//
	// It must be between 1 and 959. The protocol bounds a patch's
	// contributing-event list at 1024 (H-4), and the frame this trigger forces
	// carries more than the trigger counted: the transition being emitted at
	// the time, on top of the ones already deferred, plus whatever the
	// application named in that event's Event.Contributing — at most 64, which
	// is the term that makes the headroom 65 rather than 1. Set above 959 the
	// flush constructs a frame the protocol refuses, and the deferred set is
	// gone by then, so the field whose purpose is to keep provenance is what
	// loses it. New rejects such a value rather than quietly substituting a
	// working one: a limit that silently becomes a different limit is not a
	// limit an operator can reason about.
	//
	// Lower is legal and meaningful — it trades more frames for smaller
	// provenance sets. Zero takes the default.
	CoalesceFlushAt int

	// MinResyncInterval and ResyncBurst are the resync budget, deliberately
	// independent of the event bucket. A resync is the one client frame that
	// triggers work proportional to the whole state. Defaults one second and 3.
	MinResyncInterval time.Duration

	// ResyncBurst is that budget's depth. It is small on purpose: a client
	// that legitimately needs a snapshot needs one, not three, and a client
	// asking repeatedly is either looping or hostile.
	ResyncBurst int

	// WriteDeadline bounds one write; exceeding it with a full window evicts.
	// Default five seconds.
	WriteDeadline time.Duration

	// SlowClientGrace is how long the outbound window may stay continuously
	// full before the session is evicted. Default thirty seconds.
	SlowClientGrace time.Duration

	// HeartbeatInterval must be below the shortest idle timeout in the network
	// path. Default twenty seconds.
	//
	// It must also be between one second and five minutes, for the reason
	// MaxInboundFrameBytes must: the mount snapshot carries it in a refined
	// field, in whole milliseconds. A sub-millisecond interval is out of range
	// however it is spelled, because the wire value is what the predicate
	// applies to.
	HeartbeatInterval time.Duration

	// HeartbeatTimeout is peer-dead detection. Default fifty seconds.
	HeartbeatTimeout time.Duration

	// IdleTimeout evicts a session with no inbound frame other than
	// heartbeats. Default thirty minutes.
	IdleTimeout time.Duration

	// EffectDrainTimeout is how long shutdown waits for in-flight effects
	// before it counts and logs the overrun. Shutdown then keeps waiting:
	// every effect is joined, so an effect must return once its context is
	// cancelled. Default five seconds.
	EffectDrainTimeout time.Duration

	// MaxSessionsPerIdentity bounds one subject's concurrent connections.
	// Default 20.
	MaxSessionsPerIdentity int

	// MaxSessions bounds the process. The default is unlimited, and operators
	// should set it.
	MaxSessions int

	// PanicBudget is how many times one site may panic within a session before
	// the session closes. Other sessions are unaffected either way. Default 3.
	PanicBudget int
}

Limits are the per-connection and per-process resource bounds. Any zero field takes its documented default.

New validates them, and reports a *ConfigError naming the field rather than starting an application whose configuration cannot work. Two kinds of range are checked, and the asymmetry between them is deliberate:

  • No field may be negative. Two of them are channel capacities, and a negative capacity is a runtime panic at the first connection rather than a startup error, which is the worst place to find a typo.
  • Four fields additionally have a range, because four fields have a protocol predicate behind them: CoalesceFlushAt, and the three the mount Snapshot announces to the client as refined wire values — HeartbeatInterval, MaxInboundFrameBytes and AckWindow. The rest do not get invented ones: an operator who sets MailboxDepth to a million has bought a memory bill, which is their decision to make, and a library that capped it would be deciding an operator's capacity for them.

func DefaultLimits

func DefaultLimits() Limits

DefaultLimits returns the defaults, for inspection and for printing.

type Reducer

type Reducer[S any, I IIdentity] func(state S, ev Event) (S, []Effect[I])

Reducer is the pure state transition at the centre of a live application.

Given the same state and the same event it must return the same next state and the same effects, on every run and in every process. It must not perform I/O, read a clock or a random source, start a goroutine, touch a channel, or mutate the state it was given: time and generated identifiers arrive on the event, stamped at the boundary, and effects are returned as values for the library to perform.

The no-mutation rule is not stylistic. It is what makes panic recovery free: if a reducer panics, the pre-transition state is still intact and correct, so the library simply keeps it.

A reducer is called from the session's own goroutine and never concurrently with itself.

type Session

type Session[I IIdentity] struct {
	// contains filtered or unexported fields
}

Session identifies one live connection, typed by the identity the application's own Authenticate hook produced. It is passed to Config.Init, Config.Authorize, Config.Teardown and every Effect.Run, and is safe to copy.

I is the application's own identity type — a struct it declared, not an interface — so Session.Identity hands back the thing rather than an abstraction over it. An application with no meaningful identity instantiates on AnonymousIdentity, which is a small concrete struct for exactly that.

func NewSessionFor

func NewSessionFor[I IIdentity](token livebridge.Token, id ID, identity I) Session[I]

NewSessionFor builds the Session live/livetest hands to a specification.

Why this is exported, and why exporting it is still safe

A Session is the pair bound at the handshake, and the reason nothing downstream can mint one is that its fields are unexported — which is also why livetest, a different package, cannot build the Session a spec needs to drive Config.Init, Config.Authorize, Config.Teardown or an Effect.Run directly.

Until 2026-09-03 that was solved by an assignment: live set a function variable in internal/livebridge and livetest read it, so no identifier appeared here at all. A package-level variable cannot be generic, and Session is generic now, so the indirection cannot hold the constructor any more.

What replaced it keeps the property rather than the mechanism. The token is obtainable only from internal/livebridge, whose import path is internal to this module and whose importers internal/arch asserts are exactly live and live/livetest. A consumer's handler cannot obtain one, so it cannot call this, which is the same guarantee the old design bought with an `any` and a type assertion — stated in the type system instead of in a comment.

It panics on a zero Token rather than returning an error: the only way to hold one is to have composed it from a struct literal, which is a deliberate attempt to reach a constructor this package does not offer.

func (Session[I]) ID

func (s Session[I]) ID() ID

ID returns the session's identifier.

func (Session[I]) Identity

func (s Session[I]) Identity() I

Identity returns the identity bound at the handshake, as the application's own type. No assertion, and nothing to get wrong.

Directories

Path Synopsis
Package livetest provides testing helpers for live applications.
Package livetest provides testing helpers for live applications.

Jump to

Keyboard shortcuts

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