Wire protocol
Endpoints
Section titled “Endpoints”Relative to the mount point, /_alacris/ by default.
| Method | Path | Purpose |
|---|---|---|
GET | live.js | The client script. |
GET | live?p=<page> | The patch stream, text/event-stream. |
POST | live | One action. |
OPTIONS | live | Preflight, for a cross-origin deployment. |
Two values, both required
Section titled “Two values, both required”A request is authorised by a pair:
| Where it lives | What it is | |
|---|---|---|
| Client token | an HttpOnly cookie | the capability |
| Page id | the page’s markup, and the stream’s query string | which 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.
The stream
Section titled “The stream”GET /_alacris/live?p=PAZlOnTCMes0TtjTNqisWV0VCookie: alacris_live=<client token>
HTTP/1.1 200 OKVary: CookieContent-Type: text/event-streamCache-Control: no-cache, no-transformConnection: keep-aliveX-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. : pingis a comment, written everyHeartbeat(25s by default) so idle connections are not reaped.retry: 2000asks 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.
A patch
Section titled “A patch”{ "o": "p", "i": "board", "k": "items", "v": [ ... ] }| Field | Meaning |
|---|---|
o | The operation. |
i | The target element’s id. Absent targets the document element. |
k | Property, attribute, slot or class name, depending on o. Always present. |
v | The value. Absent means null. |
Operations
Section titled “Operations”o | Name | What the client does |
|---|---|---|
p | prop | el[k] = v |
a | attribute | el.setAttribute(k, v), or removeAttribute(k) when v is null |
h | html | Replaces the light-DOM children assigned to slot k |
c | class | el.classList.toggle(k, !!v) |
x | reload | location.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.
An action
Section titled “An action”POST /_alacris/liveContent-Type: application/jsonCookie: alacris_live=<client token>
{"p":"PAZlOnTCMes0TtjTNqisWV0V","a":"add-card","i":"board","d":{"text":"Cut the trailer","column":"todo"}}| Field | Meaning |
|---|---|
p | Page id. Required, and checked against the cookie. |
a | Action name, the right-hand side of a data-ala-on pair. |
i | The id of the element that emitted the event, or "". |
d | The 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.
Why the action endpoint is safe
Section titled “Why the action endpoint is safe”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:
SameSite=Laxon the cookie. A cross-sitePOSTis 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.- The JSON content type. A cross-site form can only send
text/plain,application/x-www-form-urlencodedormultipart/form-datawithout a preflight, and the preflight only succeeds for an originAllowOriginnamed. A form cannot reach a handler even if a cookie were attached. - 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.
Declaring an action in the markup
Section titled “Declaring an action in the markup”<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 client
Section titled “The client”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.
Writing another client
Section titled “Writing another client”Everything above is the contract. A client needs to:
- Open the stream with the page id in
?p=, sending the cookie. - Parse each
data:line as an array and apply the patches in order. POSTactions in the shape above, with the JSON content type and the page id in the body.- Let the browser handle reconnects, and expect the server to restate the
world via its
OnOpenhook 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.