svelte-adapter-uws 0.5.8 → 0.6.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -395,7 +395,10 @@ adapter({
395
395
  // Lower this if you expect many slow consumers.
396
396
  maxBackpressure: 1024 * 1024, // default: 1 MB
397
397
 
398
- // Enable per-message deflate compression
398
+ // Enable per-message deflate. Default false (byte-identical to no
399
+ // compression). `true` = SHARED_COMPRESSOR; a uWS constant (e.g.
400
+ // uWS.DEDICATED_COMPRESSOR_4KB) for finer control. Applied per frame,
401
+ // not blanket - see "WebSocket compression" below.
399
402
  compression: false, // default: false
400
403
 
401
404
  // Automatically send pings to keep the connection alive
@@ -430,6 +433,8 @@ These options control how the server handles misbehaving or slow clients at the
430
433
 
431
434
  **`maxBackpressure`** (default: 1 MB) - the per-connection outbound send buffer, AND the threshold above which `publish` / `send` / `publishBatched` silently skip a subscriber. When a specific subscriber's buffer is over this size, uWS drops that frame *for that subscriber only* while continuing to deliver to every non-backpressured subscriber. This makes `publish` / `send` / `publishBatched` volatile-by-default for slow consumers (the right behavior for cursor positions, typing indicators, presence pings - see "Volatile / fire-and-forget delivery" below). The `drain` hook fires per-connection when the buffer empties again. Lower this if you want subscribers shed sooner; raise it if you prefer to keep the connection queued and absorb temporary slowness. uWS's own default is 64 KB; this adapter sets 1 MB to favor keeping the connection alive under pub/sub spikes.
432
435
 
436
+ **`compression`** (default: `false`) - per-message deflate for outbound frames. The default is byte-identical to no compression. Set `true` for `SHARED_COMPRESSOR` (one shared sliding window across all sockets - the right choice for a many-connection server), or pass a uWS constant like `uWS.DEDICATED_COMPRESSOR_4KB` (a per-socket window: slightly better compression for a few high-throughput connections, but memory grows with connection count). When a compressor is configured, compression is applied **per frame, not blanket**: text frames (`publish` / `send`) compress by default, binary codec frames (`publishWire` / `sendWire`) are opt-in, the **cursor** plugin stays uncompressed (its 60 Hz hot path), and the **presence** plugin opts in (low-frequency). This split matters because permessage-deflate CPU scales **per subscriber** - uWS does not compress-once-and-fan-out, even for `SHARED_COMPRESSOR` - so compressing a high-frequency broadcast to many subscribers is expensive (a coalesced cursor frame fanned to 1000 subscribers at 60 Hz can cost more than a full CPU core per topic). For a high-frequency, high-fan-out **text** topic, pass `{ compress: false }` to `publish` / `send` to opt it out. None of this applies until you enable compression.
437
+
433
438
  **`upgradeRateLimit`** (default: 10 per 10s window) - sliding-window rate limit on WebSocket upgrade requests per client IP. Clients exceeding the limit get a `429 Too Many Requests` response. The IP rate map is capped at 10,000 entries with LRU eviction by activity score, so sustained connection floods from many IPs don't cause unbounded memory growth.
434
439
 
435
440
  **`upgradeAdmission`** (default: disabled) - two-layer admission control on the upgrade path, both opt-in:
@@ -1255,6 +1260,20 @@ export async function GET({ platform, params }) {
1255
1260
  }
1256
1261
  ```
1257
1262
 
1263
+ ### `platform.forEachSubscriber(topic, fn)`
1264
+
1265
+ Where `subscribers(topic)` returns a count, `forEachSubscriber(topic, fn)` yields the sockets themselves - it invokes `fn(ws, userData)` once for every connection on this instance subscribed to `topic`. Use it when a single shared `publish` cannot express the fan-out: send each subscriber a different slice (per-viewport cursor culling), skip a back-pressured consumer, or vary the payload per recipient.
1266
+
1267
+ ```js
1268
+ // Backpressure-aware per-subscriber cursor fan-out:
1269
+ platform.forEachSubscriber(`__cursor:${board}`, (ws) => {
1270
+ if (platform.bufferedAmount(ws) > maxQueued) return; // skip a slow consumer; it catches up next flush
1271
+ platform.send(ws, `__cursor:${board}`, 'bulk', sliceFor(ws));
1272
+ });
1273
+ ```
1274
+
1275
+ The walk is O(connections) and synchronous, and is paid only by the caller, so reserve it for the topics that genuinely need per-subscriber treatment; the zero-config `publish` path never calls it. Pair it with `platform.send` (closed-WS safe) and `platform.bufferedAmount` inside `fn`. In clustered mode each instance holds only its own connections, so the walk is per-instance - the same locality the Redis-backed cursor / presence variants rely on.
1276
+
1258
1277
  ### `platform.assertions`
1259
1278
 
1260
1279
  Per-category counter of framework invariant violations. The adapter ships internal hard-asserts at ~30 invariant sites (envelope build, WebSocket lifecycle, subscription bookkeeping, cross-worker IPC payloads, server-initiated request entry shape, sendCoalesced state). When one fires, the counter for that category increments and a structured `[adapter-uws/assert]` line is logged.
@@ -1298,6 +1317,7 @@ Worker-local backpressure signal. The adapter samples once per second (configura
1298
1317
  platform.pressure
1299
1318
  // {
1300
1319
  // active: false,
1320
+ // value: 0, // 0..1 saturation scalar (0 idle, 1 saturated)
1301
1321
  // subscriberRatio: 12.4, // total subscriptions / connections, on this worker
1302
1322
  // publishRate: 240, // platform.publish() calls/sec, last sample
1303
1323
  // memoryMB: 128, // process.memoryUsage().rss in MB
@@ -1305,7 +1325,7 @@ platform.pressure
1305
1325
  // }
1306
1326
  ```
1307
1327
 
1308
- Reading `platform.pressure` is a property access - safe in hot paths, no I/O. Use it for synchronous shed decisions in request handlers:
1328
+ `value` folds the worst of the threshold signals and per-connection send-pressure into one number, so `platform.pressure.value > 0.8` is a coarse load gauge when you do not need to branch on the specific `reason`. Reading `platform.pressure` is a property access - safe in hot paths, no I/O. Use it for synchronous shed decisions in request handlers:
1309
1329
 
1310
1330
  ```js
1311
1331
  // src/routes/api/heavy-write/+server.js
@@ -2397,6 +2417,7 @@ export const presence = createPresence({
2397
2417
  // heartbeat: 30_000 (default) - broadcast every 30s; clients refresh maxAge / re-add aged-out entries
2398
2418
  // maxConnections: 1_000_000 (default) - hard cap on tracked connections
2399
2419
  // maxTopics: 1_000_000 (default) - hard cap on active topic registry
2420
+ // binary: true (default) - send compact 0x03 frames to binary-capable clients (presence.protocol:1); false forces JSON for all
2400
2421
  });
2401
2422
  ```
2402
2423
 
@@ -2414,10 +2435,10 @@ export function upgrade({ cookies }) {
2414
2435
  return { id: user.id, name: user.name };
2415
2436
  }
2416
2437
 
2417
- export const { subscribe, unsubscribe, close } = presence.hooks;
2438
+ export const { subscribe, unsubscribe, message, close } = presence.hooks;
2418
2439
  ```
2419
2440
 
2420
- The `hooks` object handles everything: `subscribe` calls `join()` for regular topics and sends the current presence snapshot for `__presence:*` topics, `close` calls `leave()`. If you need custom logic (auth gating, topic filtering), wrap the hook:
2441
+ The `hooks` object handles everything: `subscribe` calls `join()` for regular topics and sends the current presence snapshot for `__presence:*` topics, `message` answers the client's reconnect/late-join snapshot request (so a reconnecting client re-binds its roster instead of waiting for the next diff), `close` calls `leave()`. Wire `message` in - omitting it leaves board-scoped presence stale across reconnects. If you need custom logic (auth gating, topic filtering), wrap the hook:
2421
2442
 
2422
2443
  ```js
2423
2444
  export function subscribe(ws, topic, ctx) {
@@ -2425,9 +2446,15 @@ export function subscribe(ws, topic, ctx) {
2425
2446
  presence.hooks.subscribe(ws, topic, ctx);
2426
2447
  }
2427
2448
 
2428
- export const { unsubscribe, close } = presence.hooks;
2449
+ export const { unsubscribe, message, close } = presence.hooks;
2429
2450
  ```
2430
2451
 
2452
+ Like `subscribe`, `message` does not gate topic access - a client can request any topic's roster (the roster carries only `select`-stripped public fields, never credentials). If a topic must be limited to a subset of users, wrap `message` the same way you wrap `subscribe`.
2453
+
2454
+ #### Binary wire mode
2455
+
2456
+ Presence frames ride a compact **binary wire** for capable clients by default (`presence.protocol:1`): `state` / `diff` / `heartbeat` are encoded as a `0x03` frame instead of a JSON envelope whenever the client supports it, and sent as the identical JSON to everyone else - from one publish. Fully transparent: the `presence()` store decodes back to the same `{ event, data }`. Unlike the cursor wire, the codec is **stateless** (no per-connection dictionary): a presence value is arbitrary user data carried as a length-prefixed JSON string, so the win is the `0x03` framing, not the value bytes - a modest but durable reduction (roughly 2-13% depending on roster size) that holds up under the real uWS permessage-deflate compressors, measured in `bench/ws-compression-ab.mjs`. `createPresence({ binary: false })` forces JSON for every client; a value the codec cannot represent falls back to JSON for that one frame. The same `platform.publishWire` / `registerWireCodec` mechanism documented under the cursor plugin powers it.
2457
+
2431
2458
  Use it on the client:
2432
2459
 
2433
2460
  ```svelte
@@ -2527,6 +2554,34 @@ If Alice's data changes between connections (for example she updates her avatar
2527
2554
 
2528
2555
  If no `key` field is found in the selected data (e.g. no auth), each connection is tracked separately.
2529
2556
 
2557
+ #### Field-level updates and transient fields
2558
+
2559
+ `presence.update(ws, topic, fields, platform)` sets dynamic fields on the present user as a field-level delta - only fields whose value actually changed are merged into the user and broadcast in the next `diff` under `updates[key]`. A typing toggle sends `{ typing: true }`, not the whole user object. The update applies to the user (per dedup key), so any of a multi-tab user's connections may call it and every observer sees one change. A connection that is not present on the topic, or an update where nothing changed, is a no-op.
2560
+
2561
+ ```js
2562
+ // server
2563
+ presence.update(ws, 'room', { typing: true }, platform);
2564
+ ```
2565
+
2566
+ ```svelte
2567
+ <!-- client: the field is merged into the existing user object -->
2568
+ {#each $users as u (u.id)}
2569
+ <span>{u.name}{#if u.typing} is typing…{/if}</span>
2570
+ {/each}
2571
+ ```
2572
+
2573
+ Fields named in the `transient` option are broadcast live to the subscribers connected at the moment they change, but are **excluded from the `state` snapshot and the heartbeat roster**. So a (re)joining or swept-then-readded client never inherits a possibly-stale transient value - a disconnected typer leaves no stuck indicator. Identity fields (from `select`) and durable `update()` fields not listed in `transient` ride the snapshot normally.
2574
+
2575
+ ```js
2576
+ const presence = createPresence({
2577
+ key: 'id',
2578
+ select: (ud) => ({ id: ud.id, name: ud.name }),
2579
+ transient: ['typing', 'selection'] // live-only; never in the snapshot
2580
+ });
2581
+ ```
2582
+
2583
+ The wire stays additive: a deployment that never calls `update()` sends the exact `{ joins, leaves }` diff as before, and an old client ignores the `updates` field. (The field-level `updates` map rides the JSON form; pure join/leave diffs keep the binary wire.)
2584
+
2530
2585
  #### Limitations
2531
2586
 
2532
2587
  - **In-memory only.** Same as replay - server restart clears presence. On restart, clients reconnect and re-subscribe, so the list rebuilds within seconds.
@@ -2801,6 +2856,35 @@ Both `throttle` and `topicThrottle` default to 16 ms (~60 Hz). For a 120 Hz demo
2801
2856
 
2802
2857
  `topicThrottle` is the bandwidth lever for crowded rooms: rather than fan out one frame per cursor per tick, the server emits one `bulk` array per topic per window carrying every cursor that moved in that window. Bandwidth per peer scales with active-mover count, not with mover-count times per-mover rate.
2803
2858
 
2859
+ #### Cutting cursor volume (opt-in reducers)
2860
+
2861
+ `topicThrottle` shrinks each frame; three opt-in reducers cut volume further - one at ingest, two at fan-out:
2862
+
2863
+ ```js
2864
+ export const cursors = createCursor({
2865
+ minMove: 1, // jitter filter: drop a move smaller than 1 unit (here: exact repeats)
2866
+ viewport: true, // viewport culling, defaults (shorthand for { enabled: true })
2867
+ backpressure: true // backpressure drop, default 1 MiB cap
2868
+ // viewport: { enabled: true, padding: 256, cell: 256 } to tune
2869
+ // backpressure: { enabled: true, maxBufferedBytes: 1024 * 1024 } to tune
2870
+ // position: (data) => ({ x: data.x, y: data.y }) if coords are nested elsewhere
2871
+ });
2872
+ ```
2873
+
2874
+ All three default off and are independent. `viewport: true` / `backpressure: true` are shorthand for `{ enabled: true }`; setting a tuning key (`padding`, `cell`, `maxBufferedBytes`) without `enabled` throws rather than silently doing nothing.
2875
+
2876
+ **Jitter filter (`minMove`)** drops a cursor move at ingest - before it reaches the flush - when it hasn't moved at least `minMove` (Chebyshev distance, in the units `position` returns) from the **last broadcast** position, so a burst of wobble around a point is never fanned out. When movement then stops, a debounced settle delivers the final resting position once - even if it is within `minMove` of the last broadcast - so a still cursor is never left stranded at a stale point (an exact repeat stays dropped: the settle sends nothing when the rest position is unchanged). It is off by default (`0`); for integer-pixel cursor data `minMove: 1` drops exact-repeat frames at no visual cost, and `2`-`4` suppresses sub-pixel wobble from high-DPI input. Pick the value for your coordinate scale (1 board unit can be many on-screen pixels when zoomed in), which is why there is no default. A dropped frame is still kept as the latest value, so `list()` / `snapshot()` (SSR, late joiners) see the true current position.
2877
+
2878
+ **Viewport culling** pairs with the client's `cursor(topic, { viewport })` (see Client usage below). A subscriber that reports its visible region receives only the cursors moving inside it (widened by `padding`, in board units, so a cursor just off-screen is already present when the user pans toward it; the overscan grows with `1 / zoom` when zoomed out). A subscriber that never reports a viewport is treated as **whole-board and is never culled** - culling is opt-in per subscriber and can never blank a board. On a spread-out board this cuts per-subscriber cursor traffic by roughly the ratio of the whole board to one viewport (tens of x in practice). `position` returning `null` (or throwing) opts a single frame out of culling - it is delivered to everyone - so a coordinate-less frame is never culled to nothing.
2879
+
2880
+ > **Coordinate space.** The reported viewport rect and your `move()` data must be in the **same** space - the board's. A scroll container reports `{ x: scrollLeft, y: scrollTop, w: clientWidth, h: clientHeight }` (board coordinates), so a `move()` that sends raw `e.clientX/clientY` (screen coordinates) will be culled away as soon as the user scrolls. Send board coordinates: `move('board', { x: e.clientX + board.scrollLeft, y: e.clientY + board.scrollTop })`, or report a screen-space rect if you send screen coordinates. Getting this wrong is the one way culling can hide cursors.
2881
+
2882
+ **Backpressure** reads each subscriber's queued bytes (`platform.bufferedAmount`) and skips one whose queue exceeds `maxBufferedBytes` for the current flush. Cursors are latest-value, so a skipped subscriber catches up on the next flush with the latest coalesced positions - it renders one cadence later, never a backlog - and a stalled consumer's write queue can never exceed the cap plus one flush of cursor bytes. It is independent of culling: enable it alone to get the memory bound without viewport reporting.
2883
+
2884
+ The two fan-out reducers (culling and backpressure) use a per-subscriber walk (`O(connections)` per flush), so enable them on high-fan-out topics; the jitter filter has no such cost (it drops at ingest). Culling pays for the walk lazily: a viewport-enabled topic stays on the shared C++ fan-out until at least one of its subscribers reports a viewport, so enabling it globally costs nothing on topics whose clients never report. These two also assume a non-zero `topicThrottle` (the default) - with `topicThrottle: 0` every individual update triggers a full walk, defeating the coalescing the walk relies on.
2885
+
2886
+ **Is it working?** `cursors.stats()` exposes `viewportsReported`, `perSubscriberFlushes`, `bpSkips`, `culledEntriesDropped`, and `jitterDropped` (moves the `minMove` filter dropped at ingest; `0` unless `minMove > 0`). If culling seems to do nothing, read them in order: `viewportsReported === 0` means no client is reporting a viewport (you forgot `cursor(topic, { viewport })`, or the element is unmounted); `viewportsReported > 0` but `culledEntriesDropped === 0` means clients report but nothing is being culled - usually a coordinate-space mismatch (see above) or a `position` extractor returning `null` for your data shape. `remove` (a cursor leaving) is always broadcast to everyone, so a departing cursor is never stuck on a culled screen.
2887
+
2804
2888
  #### Wire shape
2805
2889
 
2806
2890
  Positions live on the `update` / `bulk` channel; user metadata lives on the `catalog` / `join` channel. The split keeps per-frame wire bytes minimal: a position frame is ~16 bytes per cursor (key + coords), and the user object (name, color, avatar, etc.) flows only when a user first appears.
@@ -2815,6 +2899,21 @@ Positions live on the `update` / `bulk` channel; user metadata lives on the `cat
2815
2899
 
2816
2900
  The cluster-aware [extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) Redis-backed cursor speaks the same wire format, so the same client bundle works against either backend.
2817
2901
 
2902
+ #### Binary wire mode
2903
+
2904
+ Cursor frames ride a compact **binary wire** by default. The events above are the same; on the wire they are encoded as a binary `0x03` frame instead of a JSON envelope whenever the client supports it. This is fully transparent: `cursor()` / `move()` are unchanged, the store still yields `Map<key, { user, data }>`, and the decode happens in the framework before your code sees the event.
2905
+
2906
+ There are two binary wire forms, negotiated per connection by capability:
2907
+
2908
+ - **Full-string keys (`cursor.protocol:2`, schema 1).** Every frame carries each cursor's key string. A 221-cursor coalesced `bulk` is **~83% smaller** than JSON and decodes ~4-5x faster.
2909
+ - **Short-id dictionary (`cursor.protocol:3`, schema 2), the default for capable clients.** Each cursor key is announced once, then referenced by a 1-2 byte per-connection id, so the key bytes leave the wire after the first frame and the decoder resolves the id from a cached map - **no per-entry string decode**. For realistic clustered keys this lands a warm `bulk` at **~88-89% smaller than JSON** and decodes **~16-18x faster than `JSON.parse` (~4.4x faster than the full-string wire)**. No `JSON.parse` on the cursor receive path at all.
2910
+
2911
+ - **Capability-gated, never breaking.** The client advertises both `cursor.protocol:2` and `cursor.protocol:3` in its `hello` frame; the server sends the dictionary form to clients that advertised it and the full-string form to older binary clients, and a client with neither receives JSON unchanged. The frame's 1-byte schema version tells the decoder which form it is. Old client <-> new server and new client <-> old server both keep working.
2912
+ - **`binary: false` to disable, `dictionary: false` to keep the full-string wire.** `createCursor({ binary: false })` forces JSON for every client (e.g. to keep DevTools' WS inspector readable). `createCursor({ dictionary: false })` keeps binary but uses the full-string form for everyone, encoded once and fanned out to all subscribers. The dictionary is per-connection stateful, so each capable subscriber's frame is encoded independently; a warm dictionary encode is much cheaper than a full-string encode, so the default is a net win (cheaper CPU and smaller frames) for typical per-process fan-out - reach for `dictionary: false` only on a single process serving very high per-topic subscriber counts (hundreds-plus on one worker), where the per-subscriber encode would cost more CPU than the bandwidth saving is worth. The wire format is the server's decision; the library reads no URL parameter and a client cannot force its own connection back to JSON.
2913
+ - **Positions are `float32`, keys are strings.** Fractional positions (e.g. `clientX - getBoundingClientRect().left`) are carried losslessly enough for cursors (sub-0.01 px at screen scale). Cursor `data` that is not exactly `{ x, y }` numeric - extra fields, non-numeric values - transparently falls back to JSON for that frame, so richer cursor payloads keep working.
2914
+
2915
+ Writing your own high-throughput plugin? The same mechanism is available via `platform.publishWire(topic, event, data, wire)` / `platform.sendWire(...)` on the server (where `wire = { capability, schemaVersion, encode(event, data, state?), state? }` and `encode` returns a `Uint8Array` payload or `null` to fall back to JSON), plus `registerWireCodec(prefix, { capability, capabilities?, sink?, state?, decode })` from `svelte-adapter-uws/client` on the client. The optional `wire.state` slot gives the codec one object per connection (`onAttach(ws)` / `onDetach(ws, state)`) for a stateful wire like the cursor dictionary; the per-connection `state` is reset on reconnect. A codec marked `sink: true` applies each frame in place inside `decode` (e.g. into a local document replica that drives its own reactive surface) instead of returning a `{ event, data }` store event - its return is ignored and nothing is dispatched, so a frame that mutated local state never also fans out as a store update. JSON-only deployments pay nothing - `publishWire` takes the same single broadcast as `publish` when no connected client wants binary.
2916
+
2818
2917
  #### Server usage
2819
2918
 
2820
2919
  Use the `hooks` helper for zero-config cursor handling. The `message` hook handles `cursor` and `cursor-snapshot` messages automatically, and `close` calls `remove()`. The hooks verify that the sender is subscribed to the `__cursor:{topic}` channel before processing - clients that haven't passed the `subscribe` hook for that topic are silently rejected.
@@ -2876,6 +2975,28 @@ export function close(ws, { platform }) {
2876
2975
 
2877
2976
  `move(topic, data)` is the recommended path for sending cursor updates. Calls are coalesced via `requestAnimationFrame` so even a 1000 Hz high-DPI mouse collapses to at most one send per repaint, matching the server-side `topicThrottle` default. Multi-topic callers do not clobber each other. No-op in non-browser environments.
2878
2977
 
2978
+ **Reporting a viewport (for cursor culling).** Pass a `viewport` source to `cursor(topic, { viewport })` and the store reports the visible region automatically - on scroll, resize, zoom, and even a late-bound element - while subscribed, sending a frame only when the rect actually changes. No manual `onscroll` / `ResizeObserver` wiring.
2979
+
2980
+ ```svelte
2981
+ <script>
2982
+ import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
2983
+ let board;
2984
+ // Auto-reports board's visible region; omit `viewport` to see all cursors.
2985
+ const cursors = cursor('board', { viewport: () => board });
2986
+ </script>
2987
+
2988
+ <div bind:this={board}
2989
+ onpointermove={(e) => move('board', { x: e.clientX + board.scrollLeft, y: e.clientY + board.scrollTop })}>
2990
+ {#each [...$cursors] as [key, { user, data }] (key)}
2991
+ <div class="cursor" style="left:{data.x}px; top:{data.y}px">{user.name}</div>
2992
+ {/each}
2993
+ </div>
2994
+ ```
2995
+
2996
+ Note the `move()` data is in **board coordinates** (`clientX + scrollLeft`), matching the reported rect's space - see the coordinate-space note in [Viewport culling](#cutting-cursor-volume-opt-in-reducers). The `viewport` source can be a scroll-container element, an explicit `{ x, y, w, h, zoom? }` rect (virtualized canvas), or a getter returning either. For an advanced case the lower-level `reportViewport(topic, source)` is also exported.
2997
+
2998
+ Reporting is **per-subscriber and opt-in**: a subscriber that never reports a viewport is treated as whole-board and is never culled, so this can never blank a board by accident. The server records the latest rect per `(subscriber, topic)`; turn on [viewport culling](#cutting-cursor-volume-opt-in-reducers) (`viewport: true`) so the server sends each reporter only the cursors inside its rect. On the server, `cursors.hooks.message` handles the `cursor-viewport` frame automatically alongside `cursor` and `cursor-snapshot`; `cursors.viewportFor(ws, topic)` reads the recorded rect (or `null` if the subscriber never reported one).
2999
+
2879
3000
  The client store is a `Readable<Map<string, { user, data }>>`. The Map updates when cursors move, join, or disconnect. Internally the store merges the `catalog`/`join` stream (user metadata) with the `update`/`bulk` stream (positions); positions whose user has not yet been seen are withheld until the matching join arrives - they appear on the next render once the catalog catches up.
2880
3001
 
2881
3002
  **Initial sync and reconnect.** The `cursor(topic)` store sends a `{ type: 'cursor-snapshot', topic }` message every time the WebSocket connection opens - both on first connect and on every reconnect. The server calls `cursors.snapshot(ws, topic, platform)` in its `message` handler, which sends a `catalog` event (roster) followed by a `bulk` event (positions) back to the requesting client. Late joiners see existing cursors immediately. Wire `cursors.snapshot()` in your message handler as shown in the server example above.
@@ -2894,6 +3015,9 @@ const positions = cursor('canvas', { maxAge: 30_000 });
2894
3015
  | `cursors.remove(ws, platform)` | Remove from all topics, broadcast `remove` per topic |
2895
3016
  | `cursors.snapshot(ws, topic, platform)` | Send current positions to one connection as `catalog` + `bulk` (initial sync) |
2896
3017
  | `cursors.list(topic)` | Current positions (for SSR) |
3018
+ | `cursors.viewport(ws, topic, rect)` | Record a subscriber's viewport rect (called for you by `hooks.message` on a `cursor-viewport` frame) |
3019
+ | `cursors.viewportFor(ws, topic)` | The subscriber's last reported rect, or `null` if it never reported one |
3020
+ | `cursors.stats()` | Scheduler + fan-out health: `flushes`, `driftMeanMs`/`driftMaxMs`, `dirtyTopicsCurrent`, `activeTopicsTotal`, `viewportsReported`, `perSubscriberFlushes`, `bpSkips`, `culledEntriesDropped` |
2897
3021
  | `cursors.clear()` | Reset all state and timers |
2898
3022
 
2899
3023
  #### How throttle works
package/client.d.ts CHANGED
@@ -640,3 +640,58 @@ export interface WSConnection {
640
640
  * ```
641
641
  */
642
642
  export function connect(options?: ConnectOptions): WSConnection;
643
+
644
+ /**
645
+ * Register a client-side binary wire codec for a topic-name prefix. This is a
646
+ * plugin-author surface, not an app-author one - a plugin (e.g. the cursor
647
+ * client) calls it at import time. The connection then advertises
648
+ * `codec.capability` in its `hello` frame and routes inbound binary `0x03`
649
+ * frames whose resolved topic starts with `prefix` to `codec.decode`, which
650
+ * must return the same `{ event, data }` the JSON path would have dispatched
651
+ * (or `null` to drop a malformed frame). Idempotent per prefix. A client
652
+ * always advertises what it can decode; whether a topic is actually sent as
653
+ * binary is the server's decision (the plugin's codec, or `binary: false`).
654
+ *
655
+ * A codec may advertise more than one capability via `capabilities` (e.g. a
656
+ * cursor client that decodes both the full-string and the short-id dictionary
657
+ * wire advertises both tokens, negotiating the best the server offers while an
658
+ * older server still sends the form it knows). A codec may also declare a
659
+ * per-connection `state` factory (`state.onAttach` / `state.onDetach`) for a
660
+ * stateful wire (the cursor short-id dictionary); `decode` then receives that
661
+ * per-connection state and the frame's `schemaVersion` so it can resolve
662
+ * references and dispatch between schema revisions. The state is reset on every
663
+ * (re)connect, in lock-step with the server's matching encoder state.
664
+ *
665
+ * A codec marked `sink: true` applies each frame in place inside `decode` (e.g.
666
+ * into a local document replica that drives its own reactive surface) rather
667
+ * than returning a store event. Its `decode` return value is ignored and no
668
+ * `{ event, data }` is dispatched, so a frame that mutated local state never
669
+ * also fans out as a store update. The default codec (`sink` absent) returns
670
+ * `{ event, data }` for the shared store ladder; a `null` return there is a
671
+ * decode miss that drops the frame. Because a sink dispatches no store event,
672
+ * the framework does NOT track `lastSeenSeqs` for a sink codec's topic, so a
673
+ * sink codec that needs resume must recover its own state (e.g. a CRDT codec
674
+ * resyncs via a state-vector diff, not seq replay). `decode` receives the
675
+ * frame's `seq` as a fourth argument for codecs that want it.
676
+ *
677
+ * @param prefix - topic-name prefix the codec owns (e.g. `'__cursor:'`)
678
+ * @param codec - `{ capability, capabilities?, sink?, state?, decode }`
679
+ */
680
+ export function registerWireCodec(
681
+ prefix: string,
682
+ codec: {
683
+ capability: string;
684
+ capabilities?: string[];
685
+ sink?: boolean;
686
+ state?: {
687
+ onAttach?: () => unknown;
688
+ onDetach?: (state: unknown) => void;
689
+ };
690
+ decode: (
691
+ payload: Uint8Array,
692
+ state?: unknown,
693
+ schemaVersion?: number,
694
+ seq?: number
695
+ ) => { event: string; data: unknown } | null | void;
696
+ }
697
+ ): void;