Deploying
The first two layers deploy like any Go server. The live layer has extra requirements.
Caching assets
Section titled “Caching assets”Set Config.Version to anything that changes with a deploy. Each release
becomes a distinct URL, and the handler switches from revalidating to caching
for a year:
alacris.Config{Version: build.Revision}# without Version # with VersionCache-Control: public, no-cache Cache-Control: public, max-age=31536000, immutableWithout it every page load revalidates each asset: one conditional request
that almost always answers 304. Correct, but a needless round trip you can
delete with one field.
The live cookie behind a proxy
Section titled “The live cookie behind a proxy”SecureAuto looks at r.TLS and X-Forwarded-Proto. A proxy that terminates
TLS and forwards plain HTTP without setting that header will produce a cookie
with no Secure attribute. Set CookieSecure: live.SecureAlways rather than
relying on the header being there.
If the page and the live endpoint are on different origins you need three things together, and browsers enforce all of them:
live.New(live.Options{ CookieSameSite: http.SameSiteNoneMode, // required to send it cross-site CookieSecure: live.SecureAlways, // None is refused without Secure AllowOrigin: func(origin string, _ *http.Request) bool { return origin == "https://app.example" },})Without AllowOrigin naming the origin, no CORS headers are sent and the
browser discards the response, which looks exactly like a server that never
answers.
Content Security Policy
Section titled “Content Security Policy”alacris registers a Trusted Types policy named alacris for template parsing.
The live client registers alacris-live, which it uses only for SetHTML.
Neither contains eval.
Content-Security-Policy: trusted-types alacris alacris-live; require-trusted-types-for 'script'; script-src 'nonce-{{nonce}}';Put the nonce on the context and every script tag this library emits carries it:
ctx := templ.WithNonce(r.Context(), nonce)page(v).Render(ctx, w)Or set it explicitly with Config.Nonce.
Serving pages that carry a session
Section titled “Serving pages that carry a session”The live capability is a cookie, so the response that sets it is for exactly one browser and must not be held by anything in between:
w.Header().Set("Cache-Control", "no-store, private")Proxies and the SSE stream
Section titled “Proxies and the SSE stream”The live stream is a long-lived text/event-stream response. Three things
routinely break it:
- Response buffering. The handler sends
X-Accel-Buffering: no, which nginx honours. Other proxies need their own setting; without it nothing arrives until the stream ends, which is never. - Idle timeouts. A comment is written every
Options.Heartbeat(25s by default) to keep the connection from being reaped. Lower it if your proxy is more impatient than that. WriteTimeouton your own server. It applies to the whole response, so it will cut every live stream on a timer. Leave it unset and bound the request side instead.
srv := &http.Server{ Handler: mux, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, // No WriteTimeout.}location /_alacris/live { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_read_timeout 1h;}reverse_proxy /_alacris/live app:8080 { flush_interval -1 transport http { read_timeout 1h }}flush_interval -1 disables buffering, which is the one that matters.
Streaming works, but buffering and timeouts vary by plan and by route configuration. Test the stream on the deployed URL, not just locally: a silently buffered stream looks exactly like a server that is not sending anything.
Running more than one instance
Section titled “Running more than one instance”Sessions live in the memory of the process that created them. Two consequences:
-
Session affinity is required. The
GETthat opens the stream and everyPOSTthat follows must reach the instance that minted the session, or the session will not be found. Any sticky-session mechanism works; the id is already in the page and on every request. -
Broadcastreaches one instance’s sessions. For a change everyone should see across instances, publish it on something they all read (PostgresLISTEN/NOTIFY, Redis pub/sub, NATS) and have each instance turn the message into local patches:for msg := range bus.Subscribe(ctx, "board") {items := decode(msg)for _, s := range srv.Sessions() {s.Element("board").Set("items", items)}}
Sizing
Section titled “Sizing”| Option | Default | Raise it when |
|---|---|---|
TTL | 5m | Users background tabs for long stretches. |
Buffer | 256 | You push bursts while pages are disconnected. |
MaxDetail | 64 KiB | An action legitimately carries a large payload. |
Heartbeat | 25s | Lower it when a proxy reaps idle connections sooner. |
Each session holds its buffered patches and its handler map. Memory is roughly “open pages × what you queue for them”, so the buffer bound is what keeps a disconnected tab from growing without limit.
Shutdown
Section titled “Shutdown”srv := live.New()defer srv.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)defer stop()
go httpSrv.ListenAndServe()<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()_ = httpSrv.Shutdown(shutdown)live.Close ends every session, which returns every open stream, which lets
Shutdown finish rather than waiting out its timeout on connections that were
never going to close on their own.
A checklist
Section titled “A checklist”Config.Versionset from the build.- Pages that set the live cookie served
no-store. - TLS terminated, or
CookieSecure: live.SecureAlwaysif the proxy does it without settingX-Forwarded-Proto. - No
WriteTimeout; proxy buffering off on the live route. - Session affinity configured, or only one instance.
trusted-types alacris alacris-livein the CSP if you enforce it.alacris-go checkin CI, so a component change cannot ship ungenerated.