svelte-adapter-uws 0.5.0 → 0.5.2

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/MIGRATION.md CHANGED
@@ -104,7 +104,7 @@ These change observable runtime behavior. Most apps are unaffected; a few will n
104
104
 
105
105
  ### Default `maxPayloadLength` raised from 16 KB to 1 MB
106
106
 
107
- **What changed.** The default cap on a single inbound WebSocket frame moved from 16 KB to 1 MB. uWS itself defaults to 16 MB; 16 KB was excessively conservative and forced chunked-upload frameworks to use ~12 KB chunks (~9000 chunks for a 100 MB file after typical 90% headroom). Apps that were chunking large payloads to fit under 16 KB will now accept them in fewer chunks (or in a single frame).
107
+ **What changed.** The default cap on a single inbound WebSocket frame moved from 16 KB to 1 MB. The adapter previously matched uWS's own 16 KB default, which was excessively conservative for typical app payloads - it forced chunked-upload frameworks to use ~12 KB chunks (~9000 chunks for a 100 MB file after typical 90% headroom). Apps that were chunking large payloads to fit under 16 KB will now accept them in fewer chunks (or in a single frame).
108
108
 
109
109
  **How to migrate.** No action needed for most apps. To pin the previous cap, set `websocket.maxPayloadLength: 16 * 1024` in `svelte.config.js`. To pin any other value, set the option to that byte count. DoS protection remains layered: `upgradeAdmission.maxConcurrent` caps connection count, `maxBackpressure` caps per-connection outbound queue size.
110
110
 
package/README.md CHANGED
@@ -426,7 +426,7 @@ adapter({
426
426
 
427
427
  These options control how the server handles misbehaving or slow clients at the WebSocket level:
428
428
 
429
- **`maxPayloadLength`** (default: 1 MB) - the maximum size of a single incoming WebSocket message. If a client sends a message larger than this, uWS closes the connection immediately (not just the message - the entire connection is dropped). Set this based on the largest message your application expects to receive. uWS itself defaults to 16 MB; this adapter sets 1 MB as a balanced default that handles typical app payloads in a single frame without forcing chunked-upload frameworks into ~12 KB chunks (which the previous 16 KB default did). For a stricter cap, pin an explicit value (e.g. `16 * 1024` for 16 KB).
429
+ **`maxPayloadLength`** (default: 1 MB) - the maximum size of a single incoming WebSocket message. If a client sends a message larger than this, uWS closes the connection immediately (not just the message - the entire connection is dropped). Set this based on the largest message your application expects to receive. uWS's own default is 16 KB, which the adapter previously matched; the 1 MB default ships now to handle typical app payloads in a single frame without forcing chunked-upload frameworks into ~12 KB chunks (which the previous 16 KB cap did). For a stricter cap, pin an explicit value (e.g. `16 * 1024` for the uWS-matching 16 KB).
430
430
 
431
431
  **`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
432
 
@@ -2755,14 +2755,31 @@ Lightweight fire-and-forget broadcasting for transient state - mouse cursors, te
2755
2755
  import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
2756
2756
 
2757
2757
  export const cursors = createCursor({
2758
- throttle: 50, // at most one broadcast per 50ms per user per topic
2758
+ throttle: 16, // per-cursor: at most one broadcast per 16ms (~60 Hz)
2759
+ topicThrottle: 16, // per-topic: coalesce all movers into one frame per 16ms
2759
2760
  select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
2760
2761
  // maxConnections: 1_000_000 (default) - hard cap on tracked connections
2761
2762
  // maxTopics: 1_000_000 (default) - hard cap on active topic registry
2762
2763
  });
2763
2764
  ```
2764
2765
 
2765
- The two cap options bound internal Maps that grow with client behaviour. Eviction at cap drops the oldest insertion-order entry; for `maxTopics` the dropped topic's pending throttle timers are cleared first so no callback fires on a deleted entry. In practice eviction is rare because user code is expected to call `remove(ws)` on disconnect (the `cursors.hooks.close` helper does this automatically).
2766
+ Both `throttle` and `topicThrottle` default to 16 ms (~60 Hz). For a 120 Hz demo, halve them to 8. To disable per-topic coalescing entirely (every broadcast goes straight out), pass `topicThrottle: 0`. The two cap options bound internal Maps that grow with client behaviour. Eviction at cap drops the oldest insertion-order entry; for `maxTopics` the dropped topic's pending timers (per-cursor and topic-coalesce) are cleared first.
2767
+
2768
+ `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.
2769
+
2770
+ #### Wire shape
2771
+
2772
+ 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.
2773
+
2774
+ | Event | Payload | Sent by |
2775
+ |---|---|---|
2776
+ | `catalog` | `[{key, user}, ...]` | `snapshot()` - initial roster to a single new subscriber |
2777
+ | `join` | `{key, user}` | first `update()` on a (ws, topic) pair |
2778
+ | `update` | `{key, data}` | single-mover position frame |
2779
+ | `bulk` | `[{key, data}, ...]` | multi-mover coalesced position frame |
2780
+ | `remove` | `{key}` | `remove()` or `hooks.close` |
2781
+
2782
+ 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.
2766
2783
 
2767
2784
  #### Server usage
2768
2785
 
@@ -2802,26 +2819,34 @@ export function close(ws, { platform }) {
2802
2819
 
2803
2820
  ```svelte
2804
2821
  <script>
2805
- import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
2822
+ import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
2806
2823
 
2807
2824
  const positions = cursor('canvas');
2825
+
2826
+ function onmousemove(e) {
2827
+ move('canvas', { x: e.clientX, y: e.clientY });
2828
+ }
2808
2829
  </script>
2809
2830
 
2810
- {#each [...$positions] as [key, { user, data }] (key)}
2811
- <div
2812
- class="cursor-dot"
2813
- style="left: {data.x}px; top: {data.y}px; background: {user.color}"
2814
- >
2815
- {user.name}
2816
- </div>
2817
- {/each}
2831
+ <div on:mousemove={onmousemove}>
2832
+ {#each [...$positions] as [key, { user, data }] (key)}
2833
+ <div
2834
+ class="cursor-dot"
2835
+ style="left: {data.x}px; top: {data.y}px; background: {user.color}"
2836
+ >
2837
+ {user.name}
2838
+ </div>
2839
+ {/each}
2840
+ </div>
2818
2841
  ```
2819
2842
 
2820
- The client store is a `Readable<Map<string, { user, data }>>`. The Map updates when cursors move or disconnect. The store handles `update`, `remove`, `snapshot`, and `bulk` events. The `snapshot` event is authoritative - it replaces all client-side state (used for initial sync and reconnect). The `bulk` event merges entries additively (used by the [extensions repo](https://github.com/lanteanio/svelte-adapter-uws-extensions) topicThrottle feature when flushing coalesced updates).
2843
+ `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.
2821
2844
 
2822
- **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 `snapshot` event back with the current cursor state (or an empty array if nobody is active). The client replaces its entire cursor map with the snapshot contents, clearing any stale entries from before the disconnect. Wire `cursors.snapshot()` in your message handler as shown in the server example above.
2845
+ 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.
2823
2846
 
2824
- The `cursor()` function accepts an optional second argument with a `maxAge` option (in milliseconds). When set, cursor entries that haven't received an update within that window are automatically removed. This makes clients self-healing when the server fails to broadcast `remove` events under load:
2847
+ **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.
2848
+
2849
+ The `cursor()` function accepts an optional second argument with a `maxAge` option (in milliseconds). When set, cursor entries that haven't received a position update within that window are automatically removed. This makes clients self-healing when the server fails to broadcast `remove` events under load:
2825
2850
 
2826
2851
  ```js
2827
2852
  const positions = cursor('canvas', { maxAge: 30_000 });
@@ -2831,28 +2856,34 @@ const positions = cursor('canvas', { maxAge: 30_000 });
2831
2856
 
2832
2857
  | Method | Description |
2833
2858
  |---|---|
2834
- | `cursors.update(ws, topic, data, platform)` | Broadcast position (throttled) |
2835
- | `cursors.remove(ws, platform)` | Remove from all topics, broadcast removal |
2836
- | `cursors.snapshot(ws, topic, platform)` | Send current positions to one connection (initial sync) |
2859
+ | `cursors.update(ws, topic, data, platform)` | Broadcast position (per-cursor + per-topic throttled). Emits `join` once per (ws, topic). |
2860
+ | `cursors.remove(ws, platform)` | Remove from all topics, broadcast `remove` per topic |
2861
+ | `cursors.snapshot(ws, topic, platform)` | Send current positions to one connection as `catalog` + `bulk` (initial sync) |
2837
2862
  | `cursors.list(topic)` | Current positions (for SSR) |
2838
2863
  | `cursors.clear()` | Reset all state and timers |
2839
2864
 
2840
2865
  #### How throttle works
2841
2866
 
2842
- The cursor plugin uses leading edge + trailing edge throttle internally:
2867
+ The cursor plugin uses two layers of leading-edge + trailing-edge throttle:
2868
+
2869
+ 1. **`throttle`** caps how often a single user broadcasts on a single topic.
2870
+ 2. **`topicThrottle`** caps how often a topic emits a frame at all. Multiple movers in the same window coalesce into one `bulk` array; a single mover in the window emits one `update`.
2843
2871
 
2844
2872
  ```
2845
- t=0 update({x:0}) --> broadcasts immediately (leading edge)
2846
- t=20 update({x:5}) --> stored (within 50ms window)
2847
- t=40 update({x:9}) --> stored (overwrites x:5)
2848
- t=50 [timer fires] --> broadcasts {x:9} (trailing edge)
2873
+ throttle: 16, topicThrottle: 16
2874
+
2875
+ t=0 A.update({x:0}) --> 'join' A, 'update' {x:0} (leading edge of both)
2876
+ t=4 B.update({x:0}) --> 'join' B (catalog channel)
2877
+ position queued in topic dirty set
2878
+ t=8 A.update({x:5}) --> queued (entry-level throttle says wait until t=16)
2879
+ t=16 [trailing timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
2849
2880
  ```
2850
2881
 
2851
- The trailing edge ensures you always see where the cursor stopped, even if the user stops moving mid-window.
2882
+ The trailing edges ensure you always see where each cursor stopped, even when the user stops moving mid-window.
2852
2883
 
2853
2884
  #### Limitations
2854
2885
 
2855
- - **In-memory.** Cursor positions live in the process. In cluster mode, each worker tracks its own connections.
2886
+ - **In-memory.** Cursor positions live in the process. In cluster mode, each worker tracks its own connections. For cross-instance cursor sharing use the Redis-backed variant from the [extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) package.
2856
2887
  - **No persistence.** Positions are lost on restart. This is intentional - cursors are ephemeral.
2857
2888
 
2858
2889
  ### Queue (ordered delivery)
package/files/utils.js CHANGED
@@ -375,17 +375,26 @@ export function computeTopPublishers(stats, intervalSec, thresholds) {
375
375
  // Object.keys / JSON.stringify / spread on userData skip these slots
376
376
  // so they do not leak into client serializations.
377
377
  //
378
- // The symbols are exported from this module so handler.js, vite.js,
379
- // and testing.js share the same identity. Each Symbol() call creates
380
- // a unique value, so the adapter's slot is unreachable from user code
381
- // that does not import this module.
378
+ // The symbols use Symbol.for(...) so handler.js, vite.js, and testing.js
379
+ // (and downstream consumers like svelte-adapter-uws-extensions/redis/registry)
380
+ // all resolve to the SAME global symbol regardless of whether utils.js
381
+ // was bundled into a build artifact or loaded from node_modules at runtime.
382
+ // Plain `Symbol(description)` would create a new unique value per file
383
+ // instance, and a bundler that duplicates utils.js (vite's SSR output
384
+ // bundles handler.js + utils.js into build/) would produce two distinct
385
+ // symbols for the same conceptual slot - the handler would stamp under
386
+ // one symbol and a runtime-loaded extension (e.g. the cluster registry)
387
+ // would read under the other, silently dropping every cross-module lookup.
388
+ // The trade-off is that user code that calls Symbol.for('adapter-uws.ws.*')
389
+ // can now reach these slots; that is a deliberate accept since the
390
+ // alternative was a silent cluster-routing break in production.
382
391
 
383
- export const WS_SUBSCRIPTIONS = Symbol('adapter-uws.ws.subscriptions');
384
- export const WS_COALESCED = Symbol('adapter-uws.ws.coalesced');
385
- export const WS_SESSION_ID = Symbol('adapter-uws.ws.session-id');
386
- export const WS_PENDING_REQUESTS = Symbol('adapter-uws.ws.pending-requests');
387
- export const WS_STATS = Symbol('adapter-uws.ws.stats');
388
- export const WS_PLATFORM = Symbol('adapter-uws.ws.platform');
392
+ export const WS_SUBSCRIPTIONS = Symbol.for('adapter-uws.ws.subscriptions');
393
+ export const WS_COALESCED = Symbol.for('adapter-uws.ws.coalesced');
394
+ export const WS_SESSION_ID = Symbol.for('adapter-uws.ws.session-id');
395
+ export const WS_PENDING_REQUESTS = Symbol.for('adapter-uws.ws.pending-requests');
396
+ export const WS_STATS = Symbol.for('adapter-uws.ws.stats');
397
+ export const WS_PLATFORM = Symbol.for('adapter-uws.ws.platform');
389
398
  /**
390
399
  * Set of capabilities the connected client has advertised via a
391
400
  * `{type:'hello', caps: [...]}` frame. Read by `platform.publishBatched`
@@ -393,7 +402,7 @@ export const WS_PLATFORM = Symbol('adapter-uws.ws.platform');
393
402
  * to N individual frames for that connection. Empty / undefined is
394
403
  * the safe default - assume the client has no opt-in features.
395
404
  */
396
- export const WS_CAPS = Symbol('adapter-uws.ws.caps');
405
+ export const WS_CAPS = Symbol.for('adapter-uws.ws.caps');
397
406
 
398
407
  // - Bounded-by-default capacity caps ---------------------------------------
399
408
  // Single source of truth for the per-connection and module-level Map / Set
package/index.d.ts CHANGED
@@ -144,8 +144,8 @@ export interface WebSocketOptions {
144
144
  /**
145
145
  * Max message size in bytes. Connections sending larger messages are closed.
146
146
  * Default 1 MB is balanced for typical app payloads in a single frame; uWS
147
- * itself defaults to 16 MB. Lower this for stricter caps (e.g. `16 * 1024`
148
- * for 16 KB) when payload-size discipline matters.
147
+ * itself defaults to 16 KB. Lower this for stricter caps (e.g. `16 * 1024`
148
+ * for the uWS-matching 16 KB) when payload-size discipline matters.
149
149
  * @default 1048576 (1 MB)
150
150
  */
151
151
  maxPayloadLength?: number;
package/index.js CHANGED
@@ -282,16 +282,16 @@ export default function (opts = {}) {
282
282
  );
283
283
  }
284
284
  const wsOpts = {
285
- // Default raised from 16 KB to 1 MB in next.19. uWS itself
286
- // defaults to 16 MB; 16 KB was excessively conservative and
287
- // forced chunked-upload frameworks to use ~12 KB chunks
288
- // (~9000 chunks for a 100 MB file). 1 MB handles typical app
285
+ // Default raised from 16 KB to 1 MB in 0.5. uWS's own
286
+ // default is also 16 KB, which the adapter previously
287
+ // matched - that was excessively conservative and forced
288
+ // chunked-upload frameworks to use ~12 KB chunks (~9000
289
+ // chunks for a 100 MB file). 1 MB handles typical app
289
290
  // payloads in a single frame without per-app tuning. DoS
290
- // exposure is bounded
291
- // by `upgradeAdmission.maxConcurrent` (connection count)
292
- // and `maxBackpressure` (per-conn outbound queue, also
293
- // 1 MB), so per-frame cost stays predictable. Apps that
294
- // want a stricter cap can pin via
291
+ // exposure is bounded by `upgradeAdmission.maxConcurrent`
292
+ // (connection count) and `maxBackpressure` (per-conn
293
+ // outbound queue, also 1 MB), so per-frame cost stays
294
+ // predictable. Apps that want a stricter cap can pin via
295
295
  // `websocket.maxPayloadLength` in svelte.config.js.
296
296
  maxPayloadLength: websocket?.maxPayloadLength ?? 1024 * 1024,
297
297
  idleTimeout: websocket?.idleTimeout ?? 120,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -1,34 +1,54 @@
1
- import type { Readable } from 'svelte/store';
2
-
3
- export interface CursorPosition<UserInfo = unknown, Data = unknown> {
4
- /** User-identifying data from the server's `select` function. */
5
- user: UserInfo;
6
- /** Latest cursor/position data. */
7
- data: Data;
8
- }
9
-
10
- /**
11
- * Get a reactive store of cursor positions on a topic.
12
- *
13
- * Returns a `Readable<Map<string, CursorPosition>>` that updates
14
- * automatically when cursors move or disconnect.
15
- *
16
- * @example
17
- * ```svelte
18
- * <script>
19
- * import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
20
- *
21
- * const cursors = cursor('canvas');
22
- * </script>
23
- *
24
- * {#each [...$cursors] as [key, { user, data }] (key)}
25
- * <div style="left: {data.x}px; top: {data.y}px">
26
- * {user.name}
27
- * </div>
28
- * {/each}
29
- * ```
30
- */
31
- export function cursor<UserInfo = unknown, Data = unknown>(
32
- topic: string,
33
- options?: { maxAge?: number }
34
- ): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
1
+ import type { Readable } from 'svelte/store';
2
+
3
+ export interface CursorPosition<UserInfo = unknown, Data = unknown> {
4
+ /** User-identifying data from the server's `select` function. */
5
+ user: UserInfo;
6
+ /** Latest cursor/position data. */
7
+ data: Data;
8
+ }
9
+
10
+ /**
11
+ * Get a reactive store of cursor positions on a topic.
12
+ *
13
+ * Returns a `Readable<Map<string, CursorPosition>>` that updates
14
+ * automatically when cursors move, join, or disconnect. Internally
15
+ * merges the `catalog` (user metadata) and `update`/`bulk` (positions)
16
+ * streams; entries are emitted only after both user and position are
17
+ * known.
18
+ *
19
+ * @example
20
+ * ```svelte
21
+ * <script>
22
+ * import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
23
+ *
24
+ * const cursors = cursor('canvas');
25
+ *
26
+ * function onmousemove(e) {
27
+ * move('canvas', { x: e.clientX, y: e.clientY });
28
+ * }
29
+ * </script>
30
+ *
31
+ * <div on:mousemove={onmousemove}>
32
+ * {#each [...$cursors] as [key, { user, data }] (key)}
33
+ * <div style="left: {data.x}px; top: {data.y}px">
34
+ * {user.name}
35
+ * </div>
36
+ * {/each}
37
+ * </div>
38
+ * ```
39
+ */
40
+ export function cursor<UserInfo = unknown, Data = unknown>(
41
+ topic: string,
42
+ options?: { maxAge?: number }
43
+ ): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
44
+
45
+ /**
46
+ * Send a cursor move on a topic. Frames are coalesced via
47
+ * `requestAnimationFrame` so calling `move()` at 1000 Hz (high-DPI
48
+ * mouse) collapses to at most one send per repaint, matching the
49
+ * server-side `topicThrottle` default. Multi-topic callers do not
50
+ * clobber each other.
51
+ *
52
+ * No-op in non-browser environments.
53
+ */
54
+ export function move(topic: string, data: unknown): void;
@@ -5,10 +5,23 @@
5
5
  * a live Map of cursor positions. The server handles throttling and
6
6
  * cleanup; this module keeps the client-side state in sync.
7
7
  *
8
- * When `maxAge` is set, cursor entries that haven't received an update
9
- * within that window are automatically removed. This makes clients
10
- * self-healing when the server fails to broadcast a `remove` event
11
- * (e.g. mass disconnects overwhelming Redis cleanup).
8
+ * Wire shape (catalog / positions split):
9
+ * - `catalog` [{key, user}] - roster sent on snapshot to a fresh
10
+ * subscriber. Replaces local user map.
11
+ * - `join` {key, user} - new user announced on the topic.
12
+ * - `update` {key, data} - single-mover position frame.
13
+ * - `bulk` [{key, data}] - multi-mover coalesced position frame.
14
+ * - `remove` {key} - user gone (catalog + positions cleared).
15
+ *
16
+ * User metadata lives on the catalog channel (catalog + join), positions
17
+ * live on the update/bulk channel. The merge happens here: the public
18
+ * Readable yields `Map<key, {user, data}>`, skipping any position whose
19
+ * user has not yet been seen via catalog/join.
20
+ *
21
+ * When `maxAge` is set, cursor entries that haven't received a position
22
+ * update within that window are automatically removed. This makes
23
+ * clients self-healing when the server fails to broadcast a `remove`
24
+ * event (e.g. mass disconnects overwhelming Redis cleanup).
12
25
  *
13
26
  * @module svelte-adapter-uws/plugins/cursor/client
14
27
  */
@@ -26,7 +39,7 @@ const cursorStores = new Map();
26
39
  *
27
40
  * Returns a readable Svelte store containing a Map of connection keys
28
41
  * to `{ user, data }` objects. The Map updates automatically when
29
- * cursors move or disconnect.
42
+ * cursors move, join, or disconnect.
30
43
  *
31
44
  * @template UserInfo, Data
32
45
  * @param {string} topic - Topic to track cursors on
@@ -36,22 +49,28 @@ const cursorStores = new Map();
36
49
  * @example
37
50
  * ```svelte
38
51
  * <script>
39
- * import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
52
+ * import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
40
53
  *
41
54
  * const cursors = cursor('canvas');
55
+ *
56
+ * function onmousemove(e) {
57
+ * move('canvas', { x: e.clientX, y: e.clientY });
58
+ * }
42
59
  * </script>
43
60
  *
44
- * {#each [...$cursors] as [key, { user, data }] (key)}
45
- * <div style="left: {data.x}px; top: {data.y}px" class="cursor">
46
- * {user.name}
47
- * </div>
48
- * {/each}
61
+ * <div on:mousemove={onmousemove}>
62
+ * {#each [...$cursors] as [key, { user, data }] (key)}
63
+ * <div style="left: {data.x}px; top: {data.y}px" class="cursor">
64
+ * {user.name}
65
+ * </div>
66
+ * {/each}
67
+ * </div>
49
68
  * ```
50
69
  *
51
70
  * @example
52
71
  * ```svelte
53
72
  * <script>
54
- * // Self-healing: cursors expire after 30s without movement
73
+ * // Self-healing: cursors expire after 30s without a position update.
55
74
  * const cursors = cursor('canvas', { maxAge: 30_000 });
56
75
  * </script>
57
76
  * ```
@@ -65,8 +84,10 @@ export function cursor(topic, options) {
65
84
 
66
85
  const cursorTopic = TOPIC_PREFIX + topic;
67
86
 
68
- /** @type {Map<string, { user: any, data: any }>} */
69
- let cursorMap = new Map();
87
+ /** @type {Map<string, any>} */
88
+ let positionMap = new Map();
89
+ /** @type {Map<string, any>} */
90
+ let userMap = new Map();
70
91
  /** @type {Map<string, number>} */
71
92
  const timestamps = new Map();
72
93
  const output = writable(/** @type {Map<string, any>} */ (new Map()));
@@ -78,6 +99,16 @@ export function cursor(topic, options) {
78
99
  let refCount = 0;
79
100
  let cancelled = false;
80
101
 
102
+ function emitOutput() {
103
+ const merged = new Map();
104
+ for (const [key, data] of positionMap) {
105
+ const user = userMap.get(key);
106
+ if (user === undefined) continue;
107
+ merged.set(key, { user, data });
108
+ }
109
+ output.set(merged);
110
+ }
111
+
81
112
  function sweep() {
82
113
  if (!maxAge || maxAge <= 0) return;
83
114
  const cutoff = Date.now() - maxAge;
@@ -85,10 +116,11 @@ export function cursor(topic, options) {
85
116
  for (const [key, ts] of timestamps) {
86
117
  if (ts < cutoff) {
87
118
  timestamps.delete(key);
88
- if (cursorMap.delete(key)) changed = true;
119
+ if (positionMap.delete(key)) changed = true;
120
+ userMap.delete(key);
89
121
  }
90
122
  }
91
- if (changed) output.set(new Map(cursorMap));
123
+ if (changed) emitOutput();
92
124
  }
93
125
 
94
126
  function startListening() {
@@ -97,44 +129,55 @@ export function cursor(topic, options) {
97
129
  sourceUnsub = source.subscribe((event) => {
98
130
  if (event === null) return;
99
131
 
100
- if (event.event === 'update' && event.data != null) {
101
- const { key, user, data } = event.data;
102
- cursorMap.set(key, { user, data });
103
- timestamps.set(key, Date.now());
104
- output.set(new Map(cursorMap));
132
+ if (event.event === 'catalog' && Array.isArray(event.data)) {
133
+ userMap = new Map();
134
+ for (const entry of event.data) {
135
+ if (entry && typeof entry.key === 'string') {
136
+ userMap.set(entry.key, entry.user);
137
+ }
138
+ }
139
+ emitOutput();
105
140
  return;
106
141
  }
107
142
 
108
- if (event.event === 'snapshot' && Array.isArray(event.data)) {
109
- cursorMap = new Map();
110
- timestamps.clear();
111
- const now = Date.now();
112
- for (const entry of event.data) {
113
- const { key, user, data } = entry;
114
- cursorMap.set(key, { user, data });
115
- timestamps.set(key, now);
143
+ if (event.event === 'join' && event.data != null) {
144
+ const { key, user } = event.data;
145
+ if (typeof key === 'string') {
146
+ userMap.set(key, user);
147
+ emitOutput();
148
+ }
149
+ return;
150
+ }
151
+
152
+ if (event.event === 'update' && event.data != null) {
153
+ const { key, data } = event.data;
154
+ if (typeof key === 'string') {
155
+ positionMap.set(key, data);
156
+ timestamps.set(key, Date.now());
157
+ emitOutput();
116
158
  }
117
- output.set(new Map(cursorMap));
118
159
  return;
119
160
  }
120
161
 
121
162
  if (event.event === 'bulk' && Array.isArray(event.data)) {
122
163
  const now = Date.now();
123
164
  for (const entry of event.data) {
124
- const { key, user, data } = entry;
125
- cursorMap.set(key, { user, data });
126
- timestamps.set(key, now);
165
+ if (entry && typeof entry.key === 'string') {
166
+ positionMap.set(entry.key, entry.data);
167
+ timestamps.set(entry.key, now);
168
+ }
127
169
  }
128
- output.set(new Map(cursorMap));
170
+ emitOutput();
129
171
  return;
130
172
  }
131
173
 
132
174
  if (event.event === 'remove' && event.data != null) {
133
175
  const { key } = event.data;
176
+ if (typeof key !== 'string') return;
134
177
  timestamps.delete(key);
135
- if (cursorMap.delete(key)) {
136
- output.set(new Map(cursorMap));
137
- }
178
+ const hadPosition = positionMap.delete(key);
179
+ const hadUser = userMap.delete(key);
180
+ if (hadPosition || hadUser) emitOutput();
138
181
  }
139
182
  });
140
183
 
@@ -166,7 +209,8 @@ export function cursor(topic, options) {
166
209
  clearInterval(sweepTimer);
167
210
  sweepTimer = null;
168
211
  }
169
- cursorMap = new Map();
212
+ positionMap = new Map();
213
+ userMap = new Map();
170
214
  timestamps.clear();
171
215
  // Push the cleared state to the output store so a new subscriber does
172
216
  // not see ghost cursors from the previous subscription cycle.
@@ -196,3 +240,58 @@ export function cursor(topic, options) {
196
240
 
197
241
  return store;
198
242
  }
243
+
244
+ /**
245
+ * Internal coalesce buffer for `move()`. One entry per topic; latest-
246
+ * wins inside a single animation frame. Flushed on the next rAF tick.
247
+ * @type {Map<string, any>}
248
+ */
249
+ const movePending = new Map();
250
+ let moveScheduled = false;
251
+
252
+ // Resolve `requestAnimationFrame` at call time so a polyfill installed
253
+ // after this module imports (or a test harness substitution) is honored.
254
+ function scheduleFrame(cb) {
255
+ if (typeof requestAnimationFrame !== 'undefined') return requestAnimationFrame(cb);
256
+ return setTimeout(cb, 16);
257
+ }
258
+
259
+ /**
260
+ * Send a cursor move on a topic. Frames are coalesced via
261
+ * `requestAnimationFrame` so calling `move()` at 1000 Hz (high-DPI
262
+ * mouse) collapses to at most one send per repaint, matching the
263
+ * server-side `topicThrottle` default. Multi-topic callers do not
264
+ * clobber each other.
265
+ *
266
+ * No-op in non-browser environments.
267
+ *
268
+ * @param {string} topic
269
+ * @param {any} data
270
+ *
271
+ * @example
272
+ * ```svelte
273
+ * <script>
274
+ * import { move } from 'svelte-adapter-uws/plugins/cursor/client';
275
+ *
276
+ * function onmousemove(e) {
277
+ * move('canvas', { x: e.clientX, y: e.clientY });
278
+ * }
279
+ * </script>
280
+ *
281
+ * <div on:mousemove={onmousemove}> ... </div>
282
+ * ```
283
+ */
284
+ export function move(topic, data) {
285
+ if (typeof window === 'undefined') return;
286
+ movePending.set(topic, data);
287
+ if (moveScheduled) return;
288
+ moveScheduled = true;
289
+ scheduleFrame(() => {
290
+ moveScheduled = false;
291
+ const conn = connect();
292
+ for (const [t, d] of movePending) {
293
+ conn.send({ type: 'cursor', topic: t, data: d });
294
+ }
295
+ movePending.clear();
296
+ });
297
+ }
@@ -6,14 +6,33 @@ export interface CursorOptions<UserData = unknown, UserInfo = unknown> {
6
6
  * Minimum milliseconds between broadcasts per user per topic.
7
7
  * A trailing-edge timer ensures the final position is always sent.
8
8
  *
9
- * @default 50
9
+ * Lower for high-refresh demos (8 = 120 Hz), higher to conserve
10
+ * bandwidth (33 = 30 Hz). Set to 0 to disable.
11
+ *
12
+ * @default 16 (~60 Hz)
10
13
  */
11
14
  throttle?: number;
12
15
 
16
+ /**
17
+ * Per-topic aggregate coalesce window in ms. Each topic emits at
18
+ * most one frame per window, carrying the latest position for every
19
+ * cursor that moved (a single `update` when one mover is dirty, a
20
+ * `bulk` array otherwise). Bandwidth per peer scales with active-
21
+ * mover count, not with mover-count times per-mover rate.
22
+ *
23
+ * Raise (e.g. 33 = 30 Hz) for high-density rooms where wire bytes
24
+ * dominate. Lower (e.g. 8 = 120 Hz) for high-refresh demos. 0
25
+ * disables coalescing; per-cursor `throttle` then governs broadcast
26
+ * rate.
27
+ *
28
+ * @default 16 (~60 Hz)
29
+ */
30
+ topicThrottle?: number;
31
+
13
32
  /**
14
33
  * Extract user-identifying data from a connection's userData.
15
- * This is broadcast alongside the cursor data so other clients
16
- * know who the cursor belongs to.
34
+ * This is announced on the `catalog` / `join` channel when a user
35
+ * first appears on a topic, not on every position frame.
17
36
  *
18
37
  * Defaults to the full userData object.
19
38
  *
@@ -41,8 +60,8 @@ export interface CursorOptions<UserData = unknown, UserInfo = unknown> {
41
60
  /**
42
61
  * Hard cap on the active topic registry. When the cap is reached,
43
62
  * the oldest insertion-order topic is dropped on the next `update()`
44
- * for a new topic; any pending throttle timers on the dropped topic
45
- * are cleared first.
63
+ * for a new topic; any pending throttle and coalesce timers on the
64
+ * dropped topic are cleared first.
46
65
  *
47
66
  * @default 1_000_000
48
67
  */
@@ -83,7 +102,12 @@ export interface CursorEntry<UserInfo = unknown, Data = unknown> {
83
102
 
84
103
  export interface CursorTracker<UserInfo = unknown> {
85
104
  /**
86
- * Broadcast a cursor position update. Throttled per user per topic.
105
+ * Broadcast a cursor position update. Throttled per user per topic
106
+ * and optionally coalesced per topic via `topicThrottle`.
107
+ *
108
+ * The first call for a (ws, topic) pair also emits a `join` event
109
+ * carrying the user's catalog entry; subsequent calls emit only
110
+ * positions (`update` or `bulk`).
87
111
  *
88
112
  * Call this from your `message` hook when you receive cursor data.
89
113
  *
@@ -112,14 +136,16 @@ export interface CursorTracker<UserInfo = unknown> {
112
136
  list(topic: string): CursorEntry<UserInfo>[];
113
137
 
114
138
  /**
115
- * Send current cursor positions for a topic to a single connection.
139
+ * Send current cursor positions for a topic to a single connection
140
+ * as a `catalog` + `bulk` pair (roster, then positions).
116
141
  *
117
142
  * Call this from your `message` handler when the client sends a
118
143
  * `{ type: 'cursor-snapshot', topic }` request. The `cursor()` client
119
144
  * store sends this automatically on subscribe, so late joiners see
120
145
  * existing cursors immediately without waiting for the next move event.
121
146
  *
122
- * Does nothing if the topic has no active cursors.
147
+ * Sends an empty `catalog` and `bulk` when the topic has no active
148
+ * cursors.
123
149
  *
124
150
  * @example
125
151
  * ```js
@@ -169,7 +195,8 @@ export interface CursorTracker<UserInfo = unknown> {
169
195
  * import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
170
196
  *
171
197
  * export const cursors = createCursor({
172
- * throttle: 50,
198
+ * throttle: 16, // 60 Hz per-cursor rate (default)
199
+ * topicThrottle: 16, // 60 Hz per-topic coalescing (default)
173
200
  * select: (userData) => ({ id: userData.id, name: userData.name })
174
201
  * });
175
202
  * ```
@@ -9,6 +9,24 @@
9
9
  * Zero impact on the adapter core - this is a standalone module that
10
10
  * uses platform.publish() and platform.send().
11
11
  *
12
+ * Wire shape (channel `__cursor:{topic}`):
13
+ * - `catalog` [{key, user}, ...] - sent on snapshot() to a single
14
+ * newly-attaching subscriber.
15
+ * - `join` {key, user} - emitted once per (ws, topic) the
16
+ * first time that ws updates on the
17
+ * topic. Broadcast to all subscribers.
18
+ * - `update` {key, data} - single-mover position update.
19
+ * - `bulk` [{key, data}, ...] - per-topic coalesced positions when
20
+ * `topicThrottle` is enabled and >1
21
+ * mover is pending in the window.
22
+ * - `remove` {key} - user is gone from the topic.
23
+ *
24
+ * User metadata (the `select()`ed userData) lives on the catalog channel
25
+ * (catalog + join), not on every position frame. This matches the
26
+ * cluster-aware Redis-backed variant in the extensions package so a
27
+ * single browser bundle (`plugins/cursor/client`) works against either
28
+ * backend.
29
+ *
12
30
  * MULTI-TENANT NOTE
13
31
  * Cursor state is keyed by the topic name verbatim. Apps running
14
32
  * multiple tenants in one process must namespace topic names with
@@ -20,16 +38,37 @@
20
38
 
21
39
  const TOPIC_PREFIX = '__cursor:';
22
40
 
41
+ /** Wire-protocol event names. */
42
+ const EVENTS = Object.freeze({
43
+ CATALOG: 'catalog',
44
+ JOIN: 'join',
45
+ UPDATE: 'update',
46
+ BULK: 'bulk',
47
+ REMOVE: 'remove'
48
+ });
49
+
23
50
  /**
24
51
  * @typedef {Object} CursorOptions
25
- * @property {number} [throttle=50] - Minimum milliseconds between broadcasts per
26
- * user per topic. A trailing-edge timer fires to ensure the final position is
27
- * always sent even if the user stops moving.
28
- * @property {(userData: any) => any} [select] - Extract user-identifying data from
29
- * the connection's userData. This is broadcast alongside the cursor data so other
30
- * clients know who the cursor belongs to. Defaults to the full userData.
31
- * Should return JSON-serializable data (plain objects, arrays, strings, numbers,
32
- * booleans, null). The same applies to the `data` argument passed to `update()`.
52
+ * @property {number} [throttle=16] - Minimum milliseconds between broadcasts
53
+ * per user per topic. A trailing-edge timer fires to ensure the final
54
+ * position is always sent. Default 16 (~60 Hz) suits collaborative
55
+ * apps; lower (e.g. 8 for 120 Hz) for high-refresh demos, higher to
56
+ * conserve bandwidth.
57
+ * @property {number} [topicThrottle=16] - World-state tick rate, in ms.
58
+ * Per-topic aggregate cap on broadcasts: each topic emits at most one
59
+ * frame per window, carrying the latest position for every cursor that
60
+ * moved (a single `update` when one mover is dirty, a `bulk` array
61
+ * otherwise). Bandwidth per peer scales with active-mover count, not
62
+ * with mover-count times per-mover rate. Default 16 (~60 Hz) suits
63
+ * small-to-medium rooms; raise to 33 (~30 Hz) for high-density rooms
64
+ * where wire bytes dominate. 0 disables the tick; per-cursor `throttle`
65
+ * then governs broadcast rate.
66
+ * @property {(userData: any) => any} [select] - Extract user-identifying data
67
+ * from the connection's userData. This is announced on the `catalog` /
68
+ * `join` channel when a user first appears on a topic. Defaults to the
69
+ * full userData. Should return JSON-serializable data (plain objects,
70
+ * arrays, strings, numbers, booleans, null). The same applies to the
71
+ * `data` argument passed to `update()`.
33
72
  */
34
73
 
35
74
  /**
@@ -42,8 +81,9 @@ const TOPIC_PREFIX = '__cursor:';
42
81
  /**
43
82
  * @typedef {Object} CursorTracker
44
83
  * @property {(ws: any, topic: string, data: any, platform: import('../../index.js').Platform) => void} update -
45
- * Broadcast a cursor position update. Throttled per user per topic.
46
- * Call this from your `message` hook when you receive cursor data.
84
+ * Broadcast a cursor position update. Throttled per user per topic and
85
+ * optionally coalesced per topic. Call this from your `message` hook
86
+ * when you receive cursor data.
47
87
  * @property {(ws: any, platform: import('../../index.js').Platform) => void} remove -
48
88
  * Remove a connection's cursor state from all topics and broadcast removal.
49
89
  * Call this from your `close` hook.
@@ -51,6 +91,12 @@ const TOPIC_PREFIX = '__cursor:';
51
91
  * Get current cursor positions for a topic. Use in load() functions for SSR.
52
92
  * Returns deep copies (via structuredClone) when data is JSON-serializable.
53
93
  * Falls back to shared references for non-cloneable data.
94
+ * @property {(ws: any, topic: string, platform: import('../../index.js').Platform) => void} snapshot -
95
+ * Send current cursor positions for a topic to a single connection as
96
+ * a `catalog` + `bulk` pair (roster, then positions). Call from your
97
+ * `message` handler when the client sends `{type: 'cursor-snapshot',
98
+ * topic}`. The `cursor()` client store sends this automatically on
99
+ * subscribe so late joiners see existing cursors immediately.
54
100
  * @property {() => void} clear -
55
101
  * Clear all cursor tracking state and pending timers.
56
102
  */
@@ -67,13 +113,20 @@ const TOPIC_PREFIX = '__cursor:';
67
113
  * import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
68
114
  *
69
115
  * export const cursors = createCursor({
70
- * throttle: 50,
116
+ * throttle: 16, // 60 Hz per-cursor rate (default)
117
+ * topicThrottle: 16, // 60 Hz per-topic coalescing (default)
71
118
  * select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
72
119
  * });
73
120
  * ```
74
121
  *
75
122
  * @example
76
123
  * ```js
124
+ * // 120 Hz demo: halve both intervals
125
+ * createCursor({ throttle: 8, topicThrottle: 8 });
126
+ * ```
127
+ *
128
+ * @example
129
+ * ```js
77
130
  * // src/hooks.ws.js - using hooks helper
78
131
  * import { cursors } from '$lib/server/cursors';
79
132
  *
@@ -86,7 +139,8 @@ const TOPIC_PREFIX = '__cursor:';
86
139
  * ```
87
140
  */
88
141
  export function createCursor(options = {}) {
89
- const throttleMs = options.throttle ?? 50;
142
+ const throttleMs = options.throttle ?? 16;
143
+ const topicThrottleMs = options.topicThrottle ?? 16;
90
144
  const select = options.select || ((userData) => userData);
91
145
  const maxConnections = options.maxConnections ?? 1_000_000;
92
146
  const maxTopics = options.maxTopics ?? 1_000_000;
@@ -96,6 +150,9 @@ export function createCursor(options = {}) {
96
150
  if (typeof throttleMs !== 'number' || !Number.isFinite(throttleMs) || throttleMs < 0) {
97
151
  throw new Error('cursor: throttle must be a non-negative number');
98
152
  }
153
+ if (typeof topicThrottleMs !== 'number' || !Number.isFinite(topicThrottleMs) || topicThrottleMs < 0) {
154
+ throw new Error('cursor: topicThrottle must be a non-negative number');
155
+ }
99
156
  if (typeof select !== 'function') {
100
157
  throw new Error('cursor: select must be a function');
101
158
  }
@@ -116,7 +173,9 @@ export function createCursor(options = {}) {
116
173
  let connCounter = 0;
117
174
 
118
175
  /**
119
- * Per-ws state: their key and which topics they have cursor state on.
176
+ * Per-ws state: connection key, selected user data, and which topics
177
+ * this ws has already announced (the `topics` set doubles as the
178
+ * already-joined set - presence in the set means a `join` has fired).
120
179
  * Capped at `maxConnections` - oldest insertion-order entry evicted
121
180
  * on new insert at cap. Eviction is rare in practice because user
122
181
  * code is expected to call `remove(ws)` on disconnect.
@@ -125,14 +184,23 @@ export function createCursor(options = {}) {
125
184
  const wsState = new Map();
126
185
 
127
186
  /**
128
- * Per-topic cursor positions. Capped at `maxTopics` - oldest
187
+ * Per-topic local cursor state. Drives the per-(ws, topic) throttle
188
+ * and the post-disconnect cleanup. Capped at `maxTopics` - oldest
129
189
  * insertion-order topic evicted on new insert at cap. Each evicted
130
- * topic's pending timers are cleared first so no callback fires on
131
- * a deleted entry.
190
+ * topic's pending throttle and coalesce timers are cleared first.
132
191
  * @type {Map<string, Map<string, { user: any, data: any, lastBroadcast: number, timer: any }>>}
133
192
  */
134
193
  const topics = new Map();
135
194
 
195
+ /**
196
+ * Per-topic aggregate throttle state for `topicThrottle` coalescing.
197
+ * Dirty entries are keyed by connection key; latest-wins. When the
198
+ * coalesce window elapses, `dirty.size === 1` sends a single `update`
199
+ * and any other count sends one `bulk` array.
200
+ * @type {Map<string, { lastFlush: number, timer: any, dirty: Map<string, { data: any, platform: any }> }>}
201
+ */
202
+ const topicFlush = new Map();
203
+
136
204
  /**
137
205
  * Get or create ws state and return the connection key + user data.
138
206
  * @param {any} ws
@@ -156,15 +224,100 @@ export function createCursor(options = {}) {
156
224
  }
157
225
 
158
226
  /**
159
- * Broadcast a cursor update for a specific entry.
227
+ * Drop the topic's coalesce state (clears any pending timer first).
228
+ * @param {string} topic
229
+ */
230
+ function clearTopicFlush(topic) {
231
+ const flushState = topicFlush.get(topic);
232
+ if (!flushState) return;
233
+ if (flushState.timer) clearTimeout(flushState.timer);
234
+ topicFlush.delete(topic);
235
+ }
236
+
237
+ /**
238
+ * Emit `join` for a (ws, topic) pair the first time the ws moves on
239
+ * the topic. Broadcast (not single-target) so existing subscribers
240
+ * pick up the new user before any position frames arrive.
241
+ */
242
+ function emitJoin(topic, key, user, platform) {
243
+ platform.publish(TOPIC_PREFIX + topic, EVENTS.JOIN, { key, user });
244
+ }
245
+
246
+ /**
247
+ * Publish a single-mover position update.
160
248
  * @param {string} topic
161
249
  * @param {string} key
162
- * @param {any} user
163
250
  * @param {any} data
164
251
  * @param {import('../../index.js').Platform} platform
165
252
  */
166
- function broadcast(topic, key, user, data, platform) {
167
- platform.publish(TOPIC_PREFIX + topic, 'update', { key, user, data });
253
+ function doBroadcast(topic, key, data, platform) {
254
+ platform.publish(TOPIC_PREFIX + topic, EVENTS.UPDATE, { key, data });
255
+ }
256
+
257
+ /**
258
+ * Flush all coalesced entries for a topic. One entry -> `update`,
259
+ * many entries -> single `bulk` array.
260
+ * @param {string} topic
261
+ * @param {Map<string, { data: any, platform: any }>} dirty
262
+ */
263
+ function flushDirty(topic, dirty) {
264
+ if (dirty.size === 0) return;
265
+ if (dirty.size === 1) {
266
+ const [k, v] = dirty.entries().next().value;
267
+ doBroadcast(topic, k, v.data, v.platform);
268
+ return;
269
+ }
270
+ const entries = [];
271
+ let flushPlatform = null;
272
+ for (const [k, v] of dirty) {
273
+ entries.push({ key: k, data: v.data });
274
+ flushPlatform = v.platform;
275
+ }
276
+ if (flushPlatform) {
277
+ flushPlatform.publish(TOPIC_PREFIX + topic, EVENTS.BULK, entries);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Route a broadcast through the per-topic coalesce window when
283
+ * `topicThrottle` is enabled, or directly publish when disabled.
284
+ */
285
+ function broadcast(topic, key, data, platform) {
286
+ if (topicThrottleMs <= 0) {
287
+ doBroadcast(topic, key, data, platform);
288
+ return;
289
+ }
290
+
291
+ let state = topicFlush.get(topic);
292
+ if (!state) {
293
+ state = { lastFlush: 0, timer: null, dirty: new Map() };
294
+ topicFlush.set(topic, state);
295
+ }
296
+
297
+ state.dirty.set(key, { data, platform });
298
+
299
+ const now = Date.now();
300
+ if (now - state.lastFlush >= topicThrottleMs) {
301
+ if (state.timer) {
302
+ clearTimeout(state.timer);
303
+ state.timer = null;
304
+ }
305
+ state.lastFlush = now;
306
+ flushDirty(topic, state.dirty);
307
+ state.dirty.clear();
308
+ return;
309
+ }
310
+
311
+ if (!state.timer) {
312
+ state.timer = setTimeout(() => {
313
+ const s = topicFlush.get(topic);
314
+ if (!s) return;
315
+ s.timer = null;
316
+ s.lastFlush = Date.now();
317
+ flushDirty(topic, s.dirty);
318
+ s.dirty.clear();
319
+ }, topicThrottleMs - (now - state.lastFlush));
320
+ }
168
321
  }
169
322
 
170
323
  /** @type {CursorTracker} */
@@ -187,6 +340,7 @@ export function createCursor(options = {}) {
187
340
  if (dataBytes > maxDataBytes) return;
188
341
  }
189
342
  const state = getWsState(ws);
343
+ const isFirstOnTopic = !state.topics.has(topic);
190
344
  state.topics.add(topic);
191
345
 
192
346
  let topicMap = topics.get(topic);
@@ -201,12 +355,17 @@ export function createCursor(options = {}) {
201
355
  }
202
356
  }
203
357
  topics.delete(oldest);
358
+ clearTopicFlush(oldest);
204
359
  }
205
360
  }
206
361
  topicMap = new Map();
207
362
  topics.set(topic, topicMap);
208
363
  }
209
364
 
365
+ if (isFirstOnTopic) {
366
+ emitJoin(topic, state.key, state.user, platform);
367
+ }
368
+
210
369
  let entry = topicMap.get(state.key);
211
370
  const now = Date.now();
212
371
 
@@ -226,20 +385,19 @@ export function createCursor(options = {}) {
226
385
  entry.timer = null;
227
386
  }
228
387
  entry.lastBroadcast = now;
229
- broadcast(topic, state.key, state.user, data, platform);
388
+ broadcast(topic, state.key, data, platform);
230
389
  return;
231
390
  }
232
391
 
233
392
  // Trailing edge: schedule a broadcast for the end of the window
234
393
  if (!entry.timer) {
235
394
  const key = state.key;
236
- const user = state.user;
237
395
  entry.timer = setTimeout(() => {
238
396
  const e = topicMap.get(key);
239
397
  if (e) {
240
398
  e.lastBroadcast = Date.now();
241
399
  e.timer = null;
242
- broadcast(topic, key, user, e.data, platform);
400
+ broadcast(topic, key, e.data, platform);
243
401
  }
244
402
  }, throttleMs - (now - entry.lastBroadcast));
245
403
  }
@@ -259,8 +417,12 @@ export function createCursor(options = {}) {
259
417
  topicMap.delete(state.key);
260
418
  if (topicMap.size === 0) {
261
419
  topics.delete(topic);
420
+ clearTopicFlush(topic);
421
+ } else {
422
+ const flushState = topicFlush.get(topic);
423
+ if (flushState) flushState.dirty.delete(state.key);
262
424
  }
263
- platform.publish(TOPIC_PREFIX + topic, 'remove', { key: state.key });
425
+ platform.publish(TOPIC_PREFIX + topic, EVENTS.REMOVE, { key: state.key });
264
426
  }
265
427
  }
266
428
 
@@ -273,30 +435,36 @@ export function createCursor(options = {}) {
273
435
  const result = [];
274
436
  for (const [key, entry] of topicMap) {
275
437
  const item = { key, user: entry.user, data: entry.data };
276
- try { result.push(structuredClone(item)); } catch { result.push(item); }
438
+ try { result.push(structuredClone(item)); } catch { result.push(item); }
277
439
  }
278
440
  return result;
279
441
  },
280
442
 
281
443
  snapshot(ws, topic, platform) {
282
444
  const topicMap = topics.get(topic);
283
- const entries = [];
445
+ const catalog = [];
446
+ const positions = [];
284
447
  if (topicMap) {
285
448
  for (const [key, entry] of topicMap) {
286
- entries.push({ key, user: entry.user, data: entry.data });
449
+ catalog.push({ key, user: entry.user });
450
+ positions.push({ key, data: entry.data });
287
451
  }
288
452
  }
289
- platform.send(ws, TOPIC_PREFIX + topic, 'snapshot', entries);
453
+ platform.send(ws, TOPIC_PREFIX + topic, EVENTS.CATALOG, catalog);
454
+ platform.send(ws, TOPIC_PREFIX + topic, EVENTS.BULK, positions);
290
455
  },
291
456
 
292
457
  clear() {
293
- // Clear all timers
294
458
  for (const [, topicMap] of topics) {
295
459
  for (const [, entry] of topicMap) {
296
460
  if (entry.timer) clearTimeout(entry.timer);
297
461
  }
298
462
  }
463
+ for (const [, state] of topicFlush) {
464
+ if (state.timer) clearTimeout(state.timer);
465
+ }
299
466
  topics.clear();
467
+ topicFlush.clear();
300
468
  wsState.clear();
301
469
  connCounter = 0;
302
470
  },