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 ¶
- Constants
- Variables
- func Client() []byte
- func Mount(mux *http.ServeMux, base string, srv *Server)
- func On[T any](s *Server, action string, h func(*Ctx, T) error)
- func OnSession[T any](s *Session, action string, h func(*Ctx, T) error)
- type Ctx
- type Handle
- type Handler
- type Op
- type Options
- type Patch
- type Secure
- type Server
- func (s *Server) Broadcast(patches ...Patch)
- func (s *Server) Close()
- func (s *Server) NewSession(w http.ResponseWriter, r *http.Request) *Session
- func (s *Server) On(action string, h Handler)
- func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)
- func (s *Server) Session(id string) (*Session, bool)
- func (s *Server) Sessions() []*Session
- type Session
- func (s *Session) Batch(fn func())
- func (s *Session) Close()
- func (s *Session) Closed() bool
- func (s *Session) Connected() bool
- func (s *Session) Context() context.Context
- func (s *Session) Dispatch(r *http.Request, action, element string, detail json.RawMessage) error
- func (s *Session) Element(id string) Handle
- func (s *Session) Get(key any) (any, bool)
- func (s *Session) ID() string
- func (s *Session) On(action string, h Handler)
- func (s *Session) OnOpen(fn func(*Session))
- func (s *Session) Send(patches ...Patch)
- func (s *Session) Set(key, value any)
- func (s *Session) Subscribe() (frames <-chan []Patch, backlog []Patch, release func(), err error)
Examples ¶
Constants ¶
const ( DefaultTTL = 5 * time.Minute DefaultBuffer = 256 DefaultMaxDetail = 64 << 10 DefaultHeartbeat = 25 * time.Second DefaultMaxSessions = 10_000 )
Defaults for Options.
const DefaultCookieName = "alacris_live"
DefaultCookieName is the cookie the client token is stored in.
Variables ¶
var ErrClosed = errors.New("live: session is closed")
ErrClosed is returned when a session has been closed or has expired.
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 ¶
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 ¶
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
})
}
Output:
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 ¶
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.
type Handle ¶
type Handle struct {
// contains filtered or unexported fields
}
A Handle addresses one element in one session.
type Handler ¶
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 HTML ¶
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.
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 (*Server) NewSession ¶
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()}.
})
}
Output:
func (*Server) On ¶
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
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) Context ¶ added in v0.1.1
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
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) ID ¶
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 ¶
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 ¶
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 ¶
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 ¶
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
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.