Files
bookhoard/docs/developer/websocket-api.md
T
John O'Keefe dd963993eb fix(sync): scan events keep instance-wide fan-out as the sanctioned BroadcastToAll
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.
2026-11-02 18:17:37 -05:00

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_state on reconnect is designed to be that gap-healer.