Skip to content

Deploying

The first two layers deploy like any Go server. The live layer has extra requirements.

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 Version
Cache-Control: public, no-cache Cache-Control: public, max-age=31536000, immutable

Without 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.

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.

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.

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")

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.
  • WriteTimeout on 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;
}

Sessions live in the memory of the process that created them. Two consequences:

  1. Session affinity is required. The GET that opens the stream and every POST that 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.

  2. Broadcast reaches one instance’s sessions. For a change everyone should see across instances, publish it on something they all read (Postgres LISTEN/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)
    }
    }
OptionDefaultRaise it when
TTL5mUsers background tabs for long stretches.
Buffer256You push bursts while pages are disconnected.
MaxDetail64 KiBAn action legitimately carries a large payload.
Heartbeat25sLower 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.

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.

  • Config.Version set from the build.
  • Pages that set the live cookie served no-store.
  • TLS terminated, or CookieSecure: live.SecureAlways if the proxy does it without setting X-Forwarded-Proto.
  • No WriteTimeout; proxy buffering off on the live route.
  • Session affinity configured, or only one instance.
  • trusted-types alacris alacris-live in the CSP if you enforce it.
  • alacris-go check in CI, so a component change cannot ship ungenerated.