Skip to content

LiveConnection WS Client

src/lib/offline/live.ts is the client end of event push (ADR-011). It holds the live WebSocket to the user’s SyncInbox and, on each signal, triggers the existing delta-read reconcilers — the socket replaces the polling timer, not the data path.

The diagram below shows the client pieces and how an inbound signal becomes a refresh.

flowchart LR
  CLC["createLiveConnection(deps)<br/>live.ts"] -->|"opens WS<br/>SYNC_STREAM_PATH"| WS{{"GET /api/v1/sync/stream"}}
  WS --> DO[["SyncInbox DO"]]
  WS -->|"SyncSignal"| MSG["onMessage"]
  MSG --> SUP["takeUnsuppressedTags()"]
  MSG --> MAP["mapSurfaceTags(tags, active)"]
  MAP --> COA["createSignalCoalescer()<br/>~500 ms debounce"]
  COA -->|"refresh(surfaces)"| PC["PollController<br/>poll.ts"]
  CLC -->|"on close"| RD["reconnectDelayMs()<br/>backoff + jitter"]
  CLC --> ST["getLiveState()<br/>subscribeLiveState()"]
  • createLiveConnection(deps) — opens/closes the socket on lifecycle (foreground + online + signed-in), runs a catch-up ?since read on connect, and reconnects with backoff.
  • reconnectDelayMs(...) — backoff with jitter, capped at RECONNECT_CAP_MS (30 s).
  • createSignalCoalescer(...) — debounces bursts per surface (~500 ms) so many signals collapse into one refresh.
  • mapSurfaceTags(tags, active) — maps signal surface tags to the surfaces the client should refresh.
  • Self-mutation suppressionnoteLocalMutation(tags) records the surfaces a local write just touched; takeUnsuppressedTags(tags) filters those out of an incoming signal so a client does not redundantly re-read its own change. SYNC_STREAM_PATH is /api/v1/sync/stream.
  • StategetLiveState() / subscribeLiveState(listener) expose transport state (live / reconnecting-fallback / offline) for the status UI.

While the socket is down, a slow polling fallback (PollController, src/lib/offline/poll.ts) drives refreshes, and every reconnect performs a catch-up read — so convergence and offline-first are preserved regardless of socket health (ADR-006 → ADR-011).

The connection lifecycle — open, signal-driven refresh, drop and backoff — looks like this:

sequenceDiagram
  participant L as "createLiveConnection()"
  participant DO as "SyncInbox DO"
  participant P as "PollController"
  L->>DO: "open WS (foreground + online)"
  DO-->>L: "open"
  Note over L: "onOpen — setTransport('live')"
  L->>P: "refresh(catchUpSurfaces()) — catch-up read"
  DO-->>L: "SyncSignal {surfaces}"
  Note over L: "takeUnsuppressedTags → mapSurfaceTags → coalesce"
  L->>P: "refresh(surfaces)"
  DO-->>L: "close / error"
  Note over L: "setTransport('fallback')"
  L->>L: "scheduleReconnect — reconnectDelayMs(attempt)"
  L->>DO: "reconnect → catch-up read again"

Related: SyncInbox Durable Object (the server end), Sync-Core Reconcilers (what a signal triggers), Poll Controller (the fallback).