The Feb-era 700-line page documented client->server JSON messages the
server never acted on (handleMessage logs only), a wrong ping/pong
exchange, and missed every message type added since. Rewritten tight
(~100 lines), every claim pinned: dual auth (JWT ?token= or device
Bearer header), initial_state on connect under the connecting
identity's RLS scope, the full 12-type message catalog (including
collection_updated, found per-user-targeted in the collections handler
and defined as a raw string outside the sync constants), WS-frame
keepalive (30s server ping, 90s read deadline — browsers answer
automatically), and the backpressure contract clients must honor:
bounded per-connection queues skip messages when full, so the socket
is a hint and REST refetch + fresh initial_state is the gap-healer.
Fan-out documented precisely: sync/scan events instance-wide,
collection events per-user.
Also corrected the gated endpoint doc (api/websocket/protocol.md):
its 'client sends {type:ping}, server responds pong' section
contradicted the handler; replaced with the true keepalive story.
Route pin untouched — the drift gates stay green.
90 lines
4.0 KiB
Markdown
90 lines
4.0 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 the one per-user-targeted event — it's emitted
|
|
with the connecting user's id directly. It's also 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
|
|
|
|
- Sync and scan events fan out **instance-wide**: every connected client
|
|
receives progress, annotation, conflict, and scan events. The
|
|
exception is `collection_updated`, which is targeted to one user's
|
|
connections. (A per-user targeting helper exists and is used for
|
|
collections only — the sync/scan emitters do not use it.)
|
|
- Every connection has a bounded send queue. **If a client falls behind,
|
|
messages for that connection are skipped, not queued** (and the global
|
|
broadcast ring drops messages when saturated). 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.
|