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 says what to refetch
Section titled “A broadcast says what to refetch”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.
Opening a stream
Section titled “Opening a stream”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.
What a tab does with it
Section titled “What a tab does with it”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.
From commit to tab
Section titled “From commit to tab”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
Section titled “Presence”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.
Nothing is replayed
Section titled “Nothing is replayed”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.
One connection per tab
Section titled “One connection per tab”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.