Skip to content

Broadcast — pushing changes to open browsers

A list open in one tab shows what another session saves while it is open. The server pushes a broadcast down a stream the tab holds open, and the tab refetches what the broadcast made stale. Nothing polls.

A broadcast is a hint for an open browser tab, never a business event. It carries no record, nothing stores it, and a tab that misses one only refetches later. The server-side business object event never crosses the wire.

An integration that needs every event, in order and after a restart, needs a durable stream. Broadcast is not one and will not become one.

Every broadcast has a type, which is the server-sent event’s name, and data:

type data sent
changed businessObject, businessObjectId after every committed business object event
presence businessObject, businessObjectId when an editor of that record arrives or leaves

Broadcast in @onerp/meta/records is the wire contract. It has Nest’s MessageEvent shape, so the server streams it as it is.

GET /api/broadcast opens a server-sent events stream of the workspace’s broadcasts. The session cookie authenticates it like any other request.

The stream reads the user’s permissions once, when it opens, and leaves out every broadcast about a business object the user may not read. A role change applies from the next stream.

client.broadcast.open() opens one from @onerp/client. Its on(type, handler) returns the listener’s removal, and close() ends the stream. It needs a browser session: EventSource sends no custom headers, so an API key cannot open one.

useBroadcastInvalidation opens the tab’s stream once per workspace, inside WorkspaceProviders. A changed broadcast invalidates ["businessObject", name], exactly what a local save invalidates, so another session’s write refreshes the same queries one’s own does. A presence broadcast invalidates ["presence", name]. A business object with nothing cached costs nothing: invalidating it matches no query.

The echo of one’s own save arrives too and refetches once more. That costs one request and keeps two tabs of the same user in step.

flowchart LR
  A[Committed event] --> B[EventBus outbound bridge]
  B --> C[BroadcastService.publish]
  C --> D[(Redis channel)]
  D --> E[Each API instance]
  E --> F[Streams of that workspace, filtered by read permission]

BroadcastService is the second destination of the outbound bridge, beside the workflow engine. A rolled-back unit of work broadcasts nothing.

Every instance subscribes to one Redis pub/sub channel at boot and shares it among its streams. Each stream keeps its own workspace’s broadcasts. BroadcastModule needs Redis, so it sits beside IdempotencyModule rather than inside CoreModule.

Presence answers who is editing which record. An editor is one open form, named by an id the form mints when it mounts, so two tabs of one user are two editors. The wire shape is Editor — the editor’s id and the user’s name.

route permission does
PUT /api/presence/:namespace/:name/:id/:editor update heartbeat: adds or renews the editor
DELETE /api/presence/:namespace/:name/:id/:editor update removes the editor
GET /api/presence/:namespace/:name read live editors, grouped by record id

PresenceService keeps one Redis hash per business object, a field per record and editor, each holding the moment it lapses: three missed heartbeats (PRESENCE_HEARTBEAT_MS, ten seconds). Only an arrival or a departure is broadcast; a renewal is silent. Nothing announces a lapse, so useEditors refetches on the same interval to drop an editor whose tab died.

useEditing heartbeats while a persisted operational form holds edits, and leaves when they are saved or discarded, the form unmounts, or the tab fires pagehide. Leaving is a fetch with keepalive, which a closing tab still sends.

A form does not take another session’s write while it is open, and a stale save still overwrites the fields it changed. Both wait for a concurrency token on the API, which lets a form tell its own echo from someone else’s write and lets the server refuse a stale save.

A broadcast is fire-and-forget. A publish failure is logged, not retried. A stream that is reconnecting, or a tab that is closed, misses what was sent, and nothing keeps it. That is safe because a broadcast only ever says “refetch”: the next read is the truth, and the local save path never depends on it.

Each tab holds one stream. Over HTTP/1.1 a browser opens at most six connections to one origin, so a seventh tab’s requests stall. An HTTP/2 proxy multiplexes the streams and lifts the limit. Sharing one stream across tabs is the fix if it matters before then.