Skip to content

Wire protocol

Relative to the mount point, /_alacris/ by default.

MethodPathPurpose
GETlive.jsThe client script.
GETlive?p=<page>The patch stream, text/event-stream.
POSTliveOne action.
OPTIONSlivePreflight, for a cross-origin deployment.

A request is authorised by a pair:

Where it livesWhat it is
Client tokenan HttpOnly cookiethe capability
Page idthe page’s markup, and the stream’s query stringwhich of a browser’s pages is talking

The cookie is never in a URL, a page or a log, and script cannot read it. The page id is in a URL and therefore in access logs. On its own it reaches nothing, which is why they are split.

It is a pair rather than one secret because a cookie is per-browser while a session is per page render. Two tabs share a cookie, so a cookie carrying the session id would have the second tab’s render overwrite the first tab’s session.

GET /_alacris/live?p=PAZlOnTCMes0TtjTNqisWV0V
Cookie: alacris_live=<client token>
HTTP/1.1 200 OK
Vary: Cookie
Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no
retry: 2000
data: [{"o":"p","i":"board","k":"items","v":[{"id":1,"text":"Cut the trailer","column":"todo"}]}]
: ping
data: [{"o":"p","i":"board","k":"items","v":[{"id":1,"text":"Cut the trailer","column":"doing"}]},{"o":"c","i":"board","k":"is-busy","v":false}]
  • Each data: line is a JSON array of patches, applied in order. A batch is one array.
  • : ping is a comment, written every Heartbeat (25s by default) so idle connections are not reaped.
  • retry: 2000 asks the browser to wait 2s before reconnecting.
  • JSON escapes newlines, so a payload is always exactly one line.

Status codes: 404 for an unknown page, an expired session, or a page this browser does not own. All the same answer, because telling them apart would make the endpoint an oracle for which page ids exist. 403 for a disallowed origin, 410 if the session closed between lookup and subscribe, 500 if the response does not support flushing.

{ "o": "p", "i": "board", "k": "items", "v": [ ... ] }
FieldMeaning
oThe operation.
iThe target element’s id. Absent targets the document element.
kProperty, attribute, slot or class name, depending on o. Always present.
vThe value. Absent means null.
oNameWhat the client does
ppropel[k] = v
aattributeel.setAttribute(k, v), or removeAttribute(k) when v is null
hhtmlReplaces the light-DOM children assigned to slot k
cclassel.classList.toggle(k, !!v)
xreloadlocation.reload()

p is the one that matters. Because an alacris prop is a signal, writing the property wakes exactly the bindings that read it. No diffing, and nothing else on the page is touched. It also uses the JavaScript prop name from define(), not the kebab-cased attribute.

Values assigned before an element upgrades are replayed by define()’s constructor, so a patch that arrives early is not lost.

A patch for an element the page does not have is ignored, not an error: pages drift, and a missing element is not worth breaking the stream over.

POST /_alacris/live
Content-Type: application/json
Cookie: alacris_live=<client token>
{"p":"PAZlOnTCMes0TtjTNqisWV0V","a":"add-card","i":"board","d":{"text":"Cut the trailer","column":"todo"}}
FieldMeaning
pPage id. Required, and checked against the cookie.
aAction name, the right-hand side of a data-ala-on pair.
iThe id of the element that emitted the event, or "".
dThe CustomEvent detail, or null.

Content-Type: application/json is required, not merely expected. See below.

Responses: 204 on success, 400 for a malformed body, 403 for a disallowed origin, 404 for an unknown session, a page the cookie does not own, or an unhandled action, 413 past MaxDetail, 415 for any other content type, 500 when the handler returns an error.

The capability is a cookie, so the browser attaches it to whatever a page can cause. CSRF exploits that. Three things replace the secret that used to sit in the body:

  1. SameSite=Lax on the cookie. A cross-site POST is not a safe method, so the browser does not attach the cookie at all and the request arrives unauthenticated. This is the one that does the work.
  2. The JSON content type. A cross-site form can only send text/plain, application/x-www-form-urlencoded or multipart/form-data without a preflight, and the preflight only succeeds for an origin AllowOrigin named. A form cannot reach a handler even if a cookie were attached.
  3. The page id. It is in markup the same-origin policy keeps a hostile page from reading, so requiring it is a double-submit token, and it is checked against the cookie, so one without the other is refused.

Any one of them is enough against the ordinary case. All three are there because the first depends on browser behaviour rather than on anything this server controls.

<ala-board id="board" data-ala-on="add:add-card move:move-card"></ala-board>

Space-separated event:action pairs. The client attaches one listener per event type to the document and walks composedPath() to find the element carrying the declaration, so it works across shadow boundaries and for elements added later, with one listener regardless of how many elements declare it.

The script tag carries its own configuration:

<script type="module" src="/_alacris/live.js"
data-endpoint="/_alacris/live"
data-page="PAZlOnTCMes0TtjTNqisWV0V"></script>

There is no capability in that tag, and the client never handles one: it connects with withCredentials and credentials: 'include' and lets the browser attach the cookie.

It exports apply and send, so a page can drive it directly. The demos on this site feed apply the same frames a Go server would send, rather than reimplementing it.

It dispatches alacris:live on window as its connection state changes:

window.addEventListener('alacris:live', (e) => {
e.detail.state; // 'open' | 'closed' | 'error'
});

Useful for showing a reconnecting indicator without touching the library.

Everything above is the contract. A client needs to:

  1. Open the stream with the page id in ?p=, sending the cookie.
  2. Parse each data: line as an array and apply the patches in order.
  3. POST actions in the shape above, with the JSON content type and the page id in the body.
  4. Let the browser handle reconnects, and expect the server to restate the world via its OnOpen hook rather than trying to replay what was missed.

Point 4 is the one to get right: there is no message id and no replay buffer, because the server is authoritative and re-sending the current state is both simpler and more correct than reconstructing a history.