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

91 lines
4.1 KiB
Markdown

<!-- Ground truth: internal/sync/websocket.go (ConnectionManager, message
constants, broadcast fan-out + drop-on-full backpressure),
internal/handlers/websocket.go (dual auth, initial_state on connect,
30s ping / 90s read deadline, handleMessage logs-only),
internal/router/sync.go (route group), internal/services/worker.go
(scan event emitters). Re-verify against these when the wire
changes. Rewritten 2026-11-02 from source; the Feb-era version
documented client->server messages that the server never acted on. -->
# 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:
```json
{
"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.