Verifying the per-user chokepoint end to end surfaced a UI regression it would have caused: web/src/header.ts runs the sitewide scan indicator on scan_progress/scan_complete events in every browser, and routing those to the triggering identity alone froze the indicator for everyone else at its page-load state (active-summary stays the load-time fallback, but live movement died). The source's own comment declares the indicator sitewide by design, and scan payloads carry no user data — job ids, file counts, progress percentages only. Resolution honors both truths: BroadcastToAll is re-added as the ONE sanctioned instance-wide fan-out, its doc comment making the decision explicit (no user-derived data; anything carrying books, notes, or annotations belongs in BroadcastToUser), and the two scan emitters use it. TestBroadcastToAllReachesEveryone pins the exception alongside TestBroadcastIsPerUser — audiences are now named decisions the compiler and the tests both enforce.
4.1 KiB
WebSocket API (real-time events)
One channel, one direction: /ws/sync is a server → client push feed
for sync and scan events. It is not a command channel — clients do all
reading and writing through the REST API, and the server logs (but does
not act on) anything a client sends. The one client obligation is the
standard WebSocket pong, below.
Connecting
WS /ws/sync — two accepted identities:
| Method | How |
|---|---|
| User (JWT) | ?token=<jwt> query parameter |
| Device (KOReader, Kobo) | Authorization: Bearer <device-token> header |
No token → 401. On connect the server immediately sends one
initial_state message: the connecting identity's media items for
sync, read under that identity's own data visibility.
Message envelope
Every server message is a JSON object:
{
"type": "progress_update",
"timestamp": "2026-11-02T12:00:00Z",
"data": { "...": "..." },
"source_device": { "id": "…", "name": "Living-room Kobo", "type": "kobo" }
}
source_device is present on events that originate from a device
(progress and annotation updates), so clients can ignore their own echo.
Message catalog
All ten types defined by internal/sync/websocket.go:
| Type | Emitted | data carries |
|---|---|---|
initial_state |
Once, on connect | The connecting user's media items for sync |
progress_update |
On any device's progress save | book_id, percentage |
annotation_update |
On annotation changes (highlights, notes, bookmarks) | book_id, annotation_type, payload |
conflict |
When a sync conflict is detected | book_id, notification_type, conflict_id |
scan_progress |
During scan jobs | Job progress for the admin scan panel |
scan_complete |
When a scan job finishes | Job summary |
scan_error |
When a scan job fails | Job error |
scan_cancelled |
When a scan job is cancelled | Job identity |
collection_updated |
When books are added to / removed from one of your collections | collection_id, action (books_added / book_removed / books_removed), affected book ids |
heartbeat |
Defined, not currently emitted | — |
sync_complete |
Defined, not currently emitted | — |
(collection_updated is currently the only message type defined as a raw
string in the collections handler rather than in the sync package's
constants block.)
Keepalive
The server sends a WebSocket ping frame every 30 seconds and expects the standard pong within a 90-second read deadline. Browsers and every mainstream WebSocket library answer pings automatically — there is nothing to implement. A connection that stops answering is removed.
Fan-out and backpressure — read this before building a client
- Every event carries an explicit audience. Progress, annotation,
conflict, and collection events are delivered only to the acting
user's connections — the ConnectionManager has no accidental
fan-out, and the compiler forces every emitter to name its audience
(2026-11-02, after cross-user delivery was found and fixed). The one
sanctioned instance-wide fan-out is scan events
(
BroadcastToAll): they carry job ids and counts, never user data, and the sitewide scan indicator in every browser runs on them. - Every connection has a bounded send queue. If a client falls behind,
messages for that connection are skipped, not queued. Clients must
therefore treat the socket as a hint — on reconnect (or any gap),
refetch real state over REST. The fresh
initial_stateon reconnect is designed to be that gap-healer.