| name | threadline-realtime-event |
| description | Use when changing the Durable Object message protocol in apps/realtime — adding a message type, changing presence or fan-out behavior, altering ticket verification, or touching room event persistence. Covers the workerd runtime constraints that make this tier different from the API. |
Changing the realtime protocol
apps/realtime is one Cloudflare Worker with one RoomDurableObject per room. It is not a second Node service and several things you would reach for reflexively are unavailable or behave differently.
Runtime constraints — read before writing
- This is workerd, not Node. No filesystem, no
process, no Node built-ins beyond what the Workers runtime polyfills. Tests run under @cloudflare/vitest-pool-workers in a real workerd instance, which is why apps/realtime/** is excluded from the root vitest.config.ts and invoked separately by the root test script. If your new test does not run, that exclusion is why.
- The Durable Object hibernates. It uses the WebSocket hibernation API with SQLite-backed storage. In-memory state on the instance does not survive hibernation. Anything that must outlive an idle period goes in storage, not in a field.
worker-configuration.d.ts is generated by wrangler types, which runs as part of the workspace's typecheck script. Never hand-edit it. A diff in it is regeneration noise.
- Room event history is bounded in the database, not in memory — see the
perf(api) commit that made that change and the contract recorded alongside it. Do not reintroduce unbounded in-memory accumulation.
The invariant that matters most
The Durable Object verifies the room ticket itself. It does not trust that apps/api already authorized the connection.
This is the central claim of the whole architecture (docs/security.md). When you add a message type, ask: does the ticket that admitted this connection actually authorize this new capability? If the new message can do something the ticket's scope did not contemplate — write where the ticket only granted read, manage where it granted join — then the check that admitted the socket is not sufficient, and the handler needs its own.
Do not remove a verification because it looks redundant with something apps/api does. That redundancy is the design.
Steps
1. Define the message
Extend the message handling in apps/realtime/src/index.ts — webSocketMessage for inbound, the fan-out path for outbound.
Validate the inbound payload. A message arriving over a WebSocket is untrusted input exactly as much as an HTTP body is, and the fact that the socket was authenticated says nothing about the shape of what arrives on it.
2. Authorize it
Check inside the handler, against what the connection's ticket actually granted. Presence events are a live example of a check that looks redundant and is not: the code asserts that a presence event's userId matches its actor, because a socket authorized as one participant must not be able to emit events attributed to another.
3. Decide what persists
Not every message should be recorded. Ephemeral signalling — ICE candidates, session descriptions, transient presence — should not accumulate in room history. Durable artefacts should.
If it persists, it flows to apps/api through the internal ingest path, which is authenticated with its own secret and verified independently on arrival.
4. Test it
apps/realtime/src/index.test.ts, under @cloudflare/vitest-pool-workers.
Cover the accepted path, the rejected path (a socket that should not be permitted to send this message), and a malformed payload. As with the API tier, the rejection path is the one that only ever runs in the suite.
npm run test --workspace=@threadline/realtime
5. Document it
docs/realtime.md is the protocol's specification. A message type that exists in code but not there will be re-litigated by the next person who reads either one.
If the change alters a trust boundary, docs/security.md needs updating too, and it likely warrants an ADR.
Verify
npm run typecheck
npm run test --workspace=@threadline/realtime
npm run dev:realtime:local
For an end-to-end check, run the full stack (npm run dev) and open two browser sessions in the same room — a protocol change that works against one client and breaks fan-out is not visible with a single tab.
Checklist