Sessions and reconnects
A session is one page’s connection to the server. It holds the queue of patches for that page, the handlers registered for it, and whatever per-page state you put on it.
// NewSession needs the exchange: it reads the browser's cookie, or sets one.// Call it before anything is written to w.sess := srv.NewSession(w, r)
sess.ID() // the page id; put this in the pagesess.Connected() // is a browser attached right now?sess.Closed()@alacris.Scripts(alacris.Config{Live: true, Page: sess.ID()})The capability is a cookie
Section titled “The capability is a cookie”Why two values
Section titled “Why two values”A cookie is per-browser. A session is per page render. If the cookie carried the session id, opening a second tab would overwrite the first tab’s session, and the first tab would start receiving the second one’s patches.
One cookie and many page ids keeps tabs independent, and neither value is sufficient on its own.
Configuring the cookie
Section titled “Configuring the cookie”live.New(live.Options{ CookieName: "alacris_live", // default CookiePath: "/_alacris/", // Mount sets this to match its base CookieSecure: live.SecureAuto, // Secure for requests that arrived over TLS CookieSameSite: http.SameSiteLaxMode, // default})SecureAlways is for a proxy that terminates TLS without setting
X-Forwarded-Proto. SameSiteNoneMode is only for a page and a live endpoint
on different origins; browsers require Secure with it, and you also
need AllowOrigin to name the origins you trust.
w.Header().Set("Cache-Control", "no-store, private")Reconnects, and why OnOpen matters
Section titled “Reconnects, and why OnOpen matters”EventSource reconnects on its own after a dropped connection, a sleeping
laptop, or a proxy timeout. When it comes back it has missed everything sent
while it was away, and it has no way to know what it missed.
Only the server knows what the page should look like. OnOpen is where you say
so:
sess.OnOpen(func(s *live.Session) { s.Batch(func() { s.Element("board").Set("items", list.Items()) s.Element("board").Set("columns", list.Columns()) })})It runs on every attach, including the first, so it doubles as the initial push and there is no separate path to keep in step.
Which context to use
Section titled “Which context to use”Inside an action handler, c.Context() is the action request’s context and is
correct for work that belongs to that request: a database read whose result the
user is waiting on. For the patch itself, prefer c.Session.Context().
Before the browser arrives
Section titled “Before the browser arrives”A page renders, the server changes something, and only then does the browser open its stream. Patches sent in that window are buffered, so nothing is lost.
The buffer is bounded (Options.Buffer, 256 by default). Past it the oldest
are dropped, because a stream of writes to one prop only really needs its last
value. OnOpen covers the rest.
Expiry
Section titled “Expiry”A session with no browser attached survives Options.TTL (five minutes by
default), which covers a reload, a flaky connection, and a tab that is
backgrounded for a while. After that a collector closes it.
srv := live.New(live.Options{ TTL: 15 * time.Minute, Buffer: 1024,})Sending to a closed session is a no-op rather than an error:
sess.Close()sess.Element("board").Set("items", items) // discarded, no panic, no errorThe page it was for is gone. Making every call site handle that would put error checks around code whose only correct response is to carry on.
When the session is gone: recovery
Section titled “When the session is gone: recovery”Sessions live in the server’s memory, so a restart (a deploy, a dev-loop
rebuild) forgets all of them, and an expired session is forgotten on purpose.
An open page is then holding a page id the server has never heard of, and the
EventSource spec makes the resulting non-200 response a permanent
failure: the browser will never retry it.
The live client recovers on its own. When the stream fails permanently, or an
action comes back 404 unknown session, it probes the endpoint with backoff:
- Server unreachable, or answering 5xx: keep probing. This is the window where a dev server is recompiling; waiting it out is useful.
- 200: the session still exists; attach a fresh stream and carry on.
- 404: the server is up and the session is gone. The state the server held for this page is gone with it, so the client reloads the page once for a fresh render and a fresh session. If fresh sessions are dying too, it stops rather than loop.
In development this is the difference between “restart, and every tab catches up by itself” and “restart, and every tab is silently dead.”
Each step announces itself as an alacris:live window event (state is
open, closed, recovering, reloading, or error) so a page can show a
connection indicator:
window.addEventListener('alacris:live', (e) => { document.body.dataset.live = e.detail.state;});An application that wants to own the decision (show a banner instead of reloading, say) turns recovery off and listens for the events itself:
alacris.Config{Live: true, Page: sess.ID(), NoRecover: true}Two connections to one session
Section titled “Two connections to one session”A reload can open the new stream before the old one has finished dying. The newest connection wins and the older one is closed, rather than the new one being turned away. Otherwise a reload could leave the page permanently disconnected.
Per-page state
Section titled “Per-page state”type filterKey struct{}
sess.Set(filterKey{}, "all")if v, ok := sess.Get(filterKey{}); ok { filter := v.(string)}An unexported key type keeps two packages from colliding, the same way
context.Value keys work.
Use it for state that belongs to one page. Shared state belongs in your own model, where it can be looked at without a session in hand.
Cleaning up
Section titled “Cleaning up”srv := live.New()defer srv.Close() // ends every session and stops the collectorOn shutdown, Close ends the sessions and every open stream returns. Give your
http.Server a Shutdown with a timeout and do not set a WriteTimeout. An
SSE stream is meant to stay open, and a write deadline will cut it.
srv := &http.Server{ Handler: mux, ReadHeaderTimeout: 10 * time.Second, // No WriteTimeout: it would kill every live stream on a timer.}