live

package
v0.11.2 Latest Latest
Warning

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

Go to latest
Published: Aug 22, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Overview

Package live makes the server authoritative over component state.

An alacris prop is a signal and a DOM property, so a change on the server is "set this property on this element": one write, one binding, one DOM node. There is no HTML on the wire after first paint, and nothing that disturbs focus, scroll position or what the user has typed.

Down, over Server-Sent Events:

sess.Element("cart").Set("count", 3)

Up, over an ordinary POST: a component's CustomEvents are forwarded to named server actions by rendering an element with On.

@ui.TodoList(props).ID("todos").On(ui.TodoListEventAdd, "add-todo")

live.On(srv, "add-todo", func(c *live.Ctx, d ui.TodoListAddDetail) error {
    list.Add(d.Text)
    c.Session.Element("todos").Set("items", list.Items())
    return nil
})

This costs a stateful server and session affinity behind a load balancer. The rest of this module does not depend on it: rendering components and generating wrappers are ordinary request/response work.

Security

Driving a page takes two values, and neither is enough alone.

The capability is a client token in an HttpOnly, SameSite, path-scoped cookie. Script cannot read it, it is not sent cross-site, and it never appears in a page, a URL or a log.

The page id says which of a browser's open pages is talking. It travels in the stream's query string, because EventSource cannot set headers, so it does reach access logs. On its own it grants nothing. There is nothing here to scrub.

See cookie.go for why the two are split rather than combined.

Action payloads are input like any other: bound to a Go type, size-limited. Validate the values.

Registering an action makes it callable: any browser holding a valid session can invoke any registered action, with any element id and any detail, regardless of what the page rendered. Authorisation is the handler's job: check the session's own state (who this page belongs to, what it may touch) inside the handler, not the wiring on the page.

Index

Examples

Constants

View Source
const (
	DefaultTTL         = 5 * time.Minute
	DefaultBuffer      = 256
	DefaultMaxDetail   = 64 << 10
	DefaultHeartbeat   = 25 * time.Second
	DefaultMaxSessions = 10_000
)

Defaults for Options.

View Source
const DefaultCookieName = "alacris_live"

DefaultCookieName is the cookie the client token is stored in.

Variables

View Source
var ErrClosed = errors.New("live: session is closed")

ErrClosed is returned when a session has been closed or has expired.

View Source
var ErrNoAction = errors.New("live: no handler for action")

ErrNoAction is returned by Dispatch when nothing handles the action.

Functions

func Client

func Client() []byte

Client returns the live client script, for projects that would rather serve it from their own asset pipeline than from Go.

func Mount

func Mount(mux *http.ServeMux, base string, srv *Server)

Mount registers everything a live page needs on a ServeMux: the alacris runtime, the live client, and the patch and action endpoints.

mux := http.NewServeMux()
live.Mount(mux, alacris.DefaultBase, srv)

base must match the Base in the alacris.Config the page renders with. Passing an empty base uses alacris.DefaultBase.

The routes are ordinary patterns, so mounting them by hand is fine too — this only exists so that the three of them cannot drift apart.

func On

func On[T any](s *Server, action string, h func(*Ctx, T) error)

On registers a handler that receives the event detail already decoded into T, which is what the generated detail types are for:

live.On(srv, "add-todo", func(c *live.Ctx, d ui.TodoListAddDetail) error {
    ...
})
Example

On binds a named action to a handler whose detail is already decoded into the type the generated wrappers declare for the event.

package main

import (
	"github.com/bmartel/alacris-go/live"
)

func main() {
	srv := live.New()
	defer srv.Close()

	type addDetail struct {
		Text string `json:"text"`
	}
	live.On(srv, "add-todo", func(c *live.Ctx, d addDetail) error {
		// One property write is one DOM update on the page.
		c.Session.Element("todos").Set("items", []string{d.Text})
		return nil
	})
}

func OnSession

func OnSession[T any](s *Session, action string, h func(*Ctx, T) error)

OnSession is On for a single session.

Types

type Ctx

type Ctx struct {
	// Session is the page the action came from.
	Session *Session

	// Action is the name declared on the element with On.
	Action string

	// Element is the id of the element that emitted the event, or empty when
	// it had none.
	Element string

	// Detail is the CustomEvent detail, still encoded. Bind is the usual way
	// to read it; the typed On helper does that for you.
	Detail json.RawMessage

	// Request is the POST that delivered the action, for headers, cookies and
	// the request context.
	Request *http.Request
}

A Ctx carries one action from the browser to its handler.

func (*Ctx) Bind

func (c *Ctx) Bind(v any) error

Bind decodes the event detail into v.

The detail comes from the browser, which means it comes from whoever is using the browser. A well-behaved component emits what it says it emits; nothing stops a console from emitting something else. Validate what you decode.

func (*Ctx) Context

func (c *Ctx) Context() context.Context

Context returns the request's context.

func (*Ctx) Handle

func (c *Ctx) Handle() Handle

Handle returns a handle for the element that emitted the event.

type Handle

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

A Handle addresses one element in one session.

func (Handle) Class

func (h Handle) Class(name string, on bool)

Class turns a class name on or off.

func (Handle) ID

func (h Handle) ID() string

ID returns the element id this handle targets.

func (Handle) Props

func (h Handle) Props(props map[string]any)

Props writes several props to the same element as one frame.

func (Handle) Set

func (h Handle) Set(name string, value any)

Set writes a component prop. name is the JavaScript prop name from define().

func (Handle) SetAttr

func (h Handle) SetAttr(name string, value any)

SetAttr writes an ordinary attribute; a nil value removes it.

func (Handle) SetHTML

func (h Handle) SetHTML(ctx context.Context, slot string, c templ.Component) error

SetHTML replaces the children assigned to one slot with rendered markup.

type Handler

type Handler func(*Ctx) error

A Handler runs one action.

Returning an error logs it and answers 500. It does not reach the browser: what the user should see is a patch, sent from the handler, in whatever terms the page understands.

type Op

type Op string

Op is the kind of change a Patch describes.

const (
	// OpProp writes a DOM property: el[key] = value.
	//
	// This is the one that matters. An alacris prop is a signal, so a property
	// write updates exactly the bindings that read it, and nothing else on the
	// page is touched.
	OpProp Op = "p"

	// OpAttr writes an attribute, or removes it when the value is nil. Use it
	// for ordinary HTML attributes; props should go through OpProp.
	OpAttr Op = "a"

	// OpHTML replaces the light-DOM children assigned to one slot.
	OpHTML Op = "h"

	// OpClass toggles a class name.
	OpClass Op = "c"

	// OpReload tells the page to reload itself.
	OpReload Op = "x"
)

type Options

type Options struct {
	// TTL is how long a session survives with no browser attached, covering
	// reloads and flaky connections. Defaults to DefaultTTL.
	TTL time.Duration

	// Buffer is how many patches are held for a session that has no browser
	// attached. Past it the oldest are dropped. Defaults to DefaultBuffer.
	Buffer int

	// MaxDetail is the largest action payload accepted, in bytes.
	// Defaults to DefaultMaxDetail.
	MaxDetail int64

	// MaxSessions bounds how many sessions are held at once. Past it, the
	// least recently active session with no browser attached is closed to make
	// room. Defaults to DefaultMaxSessions.
	//
	// A session is usually created per page render, which for most
	// applications means per unauthenticated GET. Without a bound, anything
	// that follows links — a crawler, a scanner, a load test — leaves a
	// session behind for every request, each holding a buffer, none of them
	// expiring until their TTL. This is a backstop, not a substitute for rate
	// limiting the handler that creates them.
	MaxSessions int

	// Heartbeat is how often a comment is written to an idle stream, to stop
	// proxies from closing it. Defaults to DefaultHeartbeat.
	Heartbeat time.Duration

	// CookieName is the cookie the client token is stored in.
	// Defaults to DefaultCookieName.
	CookieName string

	// CookiePath scopes the cookie so it is not sent with every request to the
	// rest of the application. It has to cover the live endpoint. Defaults to
	// alacris.DefaultBase; Mount sets it to match the base it is given.
	CookiePath string

	// CookieDomain is left empty for a host-only cookie, which is what you
	// want unless the page and the live endpoint are on different subdomains.
	CookieDomain string

	// CookieSecure decides whether the cookie carries Secure. Defaults to
	// SecureAuto, which sets it for requests that arrived over TLS.
	CookieSecure Secure

	// CookieSameSite defaults to http.SameSiteLaxMode, which is what stops a
	// cross-site page from making the browser attach the cookie to a request
	// it forged. Only weaken it to None when the page and the live endpoint
	// are genuinely on different origins, and then only with Secure and an
	// AllowOrigin that names the origins you trust.
	CookieSameSite http.SameSite

	// AllowOrigin reports whether an action may be accepted from this Origin.
	// When nil, only same-origin requests and requests with no Origin header
	// are accepted.
	AllowOrigin func(origin string, r *http.Request) bool

	// Logger receives handler-level problems. Defaults to slog.Default().
	Logger *slog.Logger

	// Now overrides the clock, for tests.
	Now func() time.Time
}

Options configure a Server.

type Patch

type Patch struct {
	Op Op `json:"o"`

	// ID is the id attribute of the target element. Empty targets the
	// document element, which only OpReload has any use for.
	ID string `json:"i,omitempty"`

	// Key is the property name for OpProp, the attribute name for OpAttr, the
	// slot name for OpHTML, or the class name for OpClass.
	//
	// Deliberately not omitempty: the default slot's name is "", and omitting
	// it would make an OpHTML patch for the default slot indistinguishable
	// from one with no slot at all.
	Key string `json:"k"`

	Value any `json:"v,omitempty"`
}

A Patch is one change to one element.

The field names on the wire are single letters because a busy page sends a lot of these and every one carries the same four keys.

func Attr

func Attr(id, name string, value any) Patch

Attr writes an ordinary attribute. A nil value removes it.

func Class

func Class(id, name string, on bool) Patch

Class turns a class name on or off.

func HTML

func HTML(ctx context.Context, id, slot string, c templ.Component) (Patch, error)

HTML replaces the children assigned to one slot of an element with rendered markup. Pass an empty slot name for the default slot.

Props are the better tool almost every time: they are smaller, they do not touch the DOM the user is interacting with, and they keep rendering where the component put it. Reach for this when the server genuinely owns the markup — a rendered document, a chunk of a report.

func Prop

func Prop(id, name string, value any) Patch

Prop writes a component prop.

name is the JavaScript prop name from define() — "maxCount", not "max-count". Server-rendered props go through the attribute, whose name is kebab-cased; a live patch writes the DOM property, which keeps the name the component declared.

func Reload

func Reload() Patch

Reload asks the page to reload. It is the escape hatch for a change too structural to express as props — a schema migration, a deploy — not an everyday update.

type Secure added in v0.2.0

type Secure int

Secure decides whether the session cookie carries the Secure attribute.

const (
	// SecureAuto sets Secure when the request arrived over TLS, directly or
	// through a proxy that said so. It is the default: correct in production,
	// and it does not break plain-HTTP development.
	SecureAuto Secure = iota

	// SecureAlways sets it unconditionally. Use it behind a proxy that
	// terminates TLS without setting X-Forwarded-Proto.
	SecureAlways

	// SecureNever omits it. Only sensible for local development, and never
	// with SameSite=None, which browsers reject without Secure.
	SecureNever
)

type Server

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

A Server holds the live sessions and serves their transport.

It implements http.Handler and, like the runtime handler, resolves requests by the last part of the path, so it works at any mount point:

mux.Handle("/_alacris/", live.New())

func New

func New(opts ...Options) *Server

New returns a running Server. Close it when the process is done with it.

func (*Server) Broadcast

func (s *Server) Broadcast(patches ...Patch)

Broadcast sends patches to every session.

func (*Server) Close

func (s *Server) Close()

Close ends every session and stops the collector.

func (*Server) NewSession

func (s *Server) NewSession(w http.ResponseWriter, r *http.Request) *Session

NewSession creates a session for one page render, and gives the browser the cookie that will authorise it.

It needs the exchange because the session is bound to a client token, and that token is a cookie: read from the request when the browser already has one, minted and set on the response when it does not. Call it before anything is written to w — a cookie cannot be set once the headers are out.

The session's context is derived from context.Background rather than from the request: it has to outlive it, because OnOpen and every action handler run long after the render has finished.

Example

NewSession runs during the page render, before anything is written to the response, because it may need to set the session cookie.

package main

import (
	"net/http"

	"github.com/bmartel/alacris-go/live"
)

func main() {
	srv := live.New()
	defer srv.Close()

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		sess := srv.NewSession(w, r) // before the first byte of the body

		// Register OnOpen and push the full state from it: a reconnecting
		// EventSource missed everything sent while it was away, and this runs
		// on every attach, including reconnects.
		sess.OnOpen(func(s *live.Session) {
			s.Element("cart").Set("count", 0)
		})

		// Render the page with alacris.Config{Live: true, Page: sess.ID()}.
	})
}

func (*Server) On

func (s *Server) On(action string, h Handler)

On registers a server-wide handler for an action.

Registration is the whole precondition: any browser holding a valid session can invoke any registered action by name, with any element id and detail it likes — the data-ala-on wiring on the page is a convenience, not an authorisation. A handler that does something sensitive must check for itself that this session may do it, and must not trust Ctx.Element or the detail to describe what the page really rendered.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP routes the live endpoints:

GET  .../live.js   the client script
GET  .../live?p=   the patch stream, for the page id in p
POST .../live      an action

func (*Server) Session

func (s *Server) Session(id string) (*Session, bool)

Session looks up a session by its page id.

This is the unauthorised lookup, for server-side code that already knows which page it means. Anything acting on a request has to go through sessionFor, which also checks that the request's cookie owns the page.

func (*Server) Sessions

func (s *Server) Sessions() []*Session

Sessions returns every live session, for broadcasting.

type Session

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

A Session is one page's connection to the server.

It is identified by two values. Its ID names the page and is safe to put in the page and in the stream URL. The capability is the client token in the browser's cookie, which never appears in either. A request needs both, so a page id recovered from a log or a referer reaches nothing on its own.

func (*Session) Batch

func (s *Session) Batch(fn func())

Batch coalesces every patch made inside fn into one frame, so the browser applies them together.

func (*Session) Close

func (s *Session) Close()

Close ends the session. Patches sent afterwards are discarded.

func (*Session) Closed

func (s *Session) Closed() bool

Closed reports whether the session has been closed or has expired.

func (*Session) Connected

func (s *Session) Connected() bool

Connected reports whether a browser is currently attached.

func (*Session) Context added in v0.1.1

func (s *Session) Context() context.Context

Context returns a context that lives as long as the session and is cancelled when it closes.

This is the context to pass to SetHTML, or to anything else started from OnOpen or an action handler that outlives the request it was triggered by. Reaching for the request's context instead is the mistake this exists to prevent: the request that rendered the page has finished by the time OnOpen runs, so its context is already cancelled and the render fails.

func (*Session) Dispatch added in v0.3.0

func (s *Session) Dispatch(r *http.Request, action, element string, detail json.RawMessage) error

Dispatch runs the handler registered for action on this session — the session-scoped one when it exists, the server-wide one otherwise — exactly as an incoming POST would, minus the transport: no origin, cookie or content-type checks, because the caller is the server's own code, which holds the session and needs no capability to prove it.

It is what the livetest package invokes handlers through, and the way to trigger an action from server-side code — a queue consumer, a timer — in the same code path the browser uses. r becomes Ctx.Request; nil is allowed and yields a minimal synthetic request, so handlers that only read Ctx.Context still work.

func (*Session) Element

func (s *Session) Element(id string) Handle

Element returns a handle for patching one element by its id.

func (*Session) Get

func (s *Session) Get(key any) (any, bool)

Get reads a value stored with Set.

func (*Session) ID

func (s *Session) ID() string

ID returns the page id, which is what goes in the page as alacris.Config.Page.

It is not a secret. The secret is the client token in the cookie, which this package sets and the browser sends; it is never exposed here, because nothing outside the package has any use for it.

func (*Session) On

func (s *Session) On(action string, h Handler)

On registers a handler for this session only, taking precedence over the server-wide one. Use it to close over per-page state.

func (*Session) OnOpen

func (s *Session) OnOpen(fn func(*Session))

OnOpen registers a function to run whenever a browser attaches, including after a reconnect.

Register one. A reconnecting EventSource has missed everything sent while it was away, and the server is the only side that knows what the page should look like — so push the full state here rather than assuming the page kept up.

func (*Session) Send

func (s *Session) Send(patches ...Patch)

Send queues patches for the browser.

It never blocks and never fails: a session with no browser attached buffers, and a closed one discards. A page that is not there cannot be updated, and making every call site handle that would put error checks around code whose only correct response is to carry on.

func (*Session) Set

func (s *Session) Set(key, value any)

Set stores a value on the session. It is where per-page server state goes when there is nowhere better for it.

func (*Session) Subscribe added in v0.3.0

func (s *Session) Subscribe() (frames <-chan []Patch, backlog []Patch, release func(), err error)

Subscribe attaches a programmatic subscriber in place of a browser: OnOpen runs, and every Send after that is a frame on the channel. backlog is whatever was buffered while nothing was attached — it precedes the channel's frames chronologically, which is why it is handed over rather than queued. Release detaches; the channel also closes when the session ends or another subscriber (a real browser included) replaces this one.

This is the seam the livetest package records through, and it is equally the way to bridge patches onto a transport this package does not provide. It carries a browser's obligations too: a subscriber that stops draining is treated exactly like a slow browser — the session drops it and relies on OnOpen to restate state to whoever attaches next.

Directories

Path Synopsis
Package livetest makes live action handlers unit-testable.
Package livetest makes live action handlers unit-testable.

Jump to

Keyboard shortcuts

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