svelte-adapter-uws 0.5.1 → 0.5.3

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
 
@@ -699,12 +699,25 @@ export function open(ws, { platform }) {
699
699
  ws.subscribe(`user:${userId}`);
700
700
  }
701
701
 
702
- // Called when a message is received
702
+ // Called when a message is received.
703
703
  // Note: subscribe/unsubscribe messages from the client store are
704
- // handled automatically BEFORE this function is called
705
- export function message(ws, { data, isBinary }) {
706
- const msg = JSON.parse(Buffer.from(data).toString());
707
- console.log('Got message:', msg);
704
+ // handled automatically BEFORE this function is called.
705
+ //
706
+ // `msg` is the JSON-parsed envelope when the adapter parsed the frame
707
+ // for control-message routing but no control type matched (i.e. it
708
+ // looks like `{"type":"<custom>",...}` from a plugin). The adapter
709
+ // already did `TextDecoder + JSON.parse` once during routing, so this
710
+ // avoids a second parse on the dispatch path. `msg` is `undefined`
711
+ // for binary frames, prefix-miss frames, parse failures, or frames
712
+ // that parse to a non-object.
713
+ export function message(ws, { data, isBinary, msg }) {
714
+ if (msg) {
715
+ // Already-parsed JSON object envelope - dispatch by msg.type
716
+ console.log('Got envelope:', msg);
717
+ return;
718
+ }
719
+ // Binary or non-envelope text frame - decode manually
720
+ console.log('Got raw frame, byteLength:', data.byteLength);
708
721
  }
709
722
 
710
723
  // Called when a client tries to subscribe to a topic (optional)
@@ -2755,14 +2768,31 @@ Lightweight fire-and-forget broadcasting for transient state - mouse cursors, te
2755
2768
  import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
2756
2769
 
2757
2770
  export const cursors = createCursor({
2758
- throttle: 50, // at most one broadcast per 50ms per user per topic
2771
+ throttle: 16, // per-cursor: at most one broadcast per 16ms (~60 Hz)
2772
+ topicThrottle: 16, // per-topic: coalesce all movers into one frame per 16ms
2759
2773
  select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
2760
2774
  // maxConnections: 1_000_000 (default) - hard cap on tracked connections
2761
2775
  // maxTopics: 1_000_000 (default) - hard cap on active topic registry
2762
2776
  });
2763
2777
  ```
2764
2778
 
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).
2779
+ 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.
2780
+
2781
+ `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.
2782
+
2783
+ #### Wire shape
2784
+
2785
+ 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.
2786
+
2787
+ | Event | Payload | Sent by |
2788
+ |---|---|---|
2789
+ | `catalog` | `[{key, user}, ...]` | `snapshot()` - initial roster to a single new subscriber |
2790
+ | `join` | `{key, user}` | first `update()` on a (ws, topic) pair |
2791
+ | `update` | `{key, data}` | single-mover position frame |
2792
+ | `bulk` | `[{key, data}, ...]` | multi-mover coalesced position frame |
2793
+ | `remove` | `{key}` | `remove()` or `hooks.close` |
2794
+
2795
+ 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
2796
 
2767
2797
  #### Server usage
2768
2798
 
@@ -2802,26 +2832,34 @@ export function close(ws, { platform }) {
2802
2832
 
2803
2833
  ```svelte
2804
2834
  <script>
2805
- import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
2835
+ import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
2806
2836
 
2807
2837
  const positions = cursor('canvas');
2838
+
2839
+ function onmousemove(e) {
2840
+ move('canvas', { x: e.clientX, y: e.clientY });
2841
+ }
2808
2842
  </script>
2809
2843
 
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}
2844
+ <div on:mousemove={onmousemove}>
2845
+ {#each [...$positions] as [key, { user, data }] (key)}
2846
+ <div
2847
+ class="cursor-dot"
2848
+ style="left: {data.x}px; top: {data.y}px; background: {user.color}"
2849
+ >
2850
+ {user.name}
2851
+ </div>
2852
+ {/each}
2853
+ </div>
2818
2854
  ```
2819
2855
 
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).
2856
+ `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.
2857
+
2858
+ 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.
2821
2859
 
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.
2860
+ **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.
2823
2861
 
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:
2862
+ 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
2863
 
2826
2864
  ```js
2827
2865
  const positions = cursor('canvas', { maxAge: 30_000 });
@@ -2831,28 +2869,34 @@ const positions = cursor('canvas', { maxAge: 30_000 });
2831
2869
 
2832
2870
  | Method | Description |
2833
2871
  |---|---|
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) |
2872
+ | `cursors.update(ws, topic, data, platform)` | Broadcast position (per-cursor + per-topic throttled). Emits `join` once per (ws, topic). |
2873
+ | `cursors.remove(ws, platform)` | Remove from all topics, broadcast `remove` per topic |
2874
+ | `cursors.snapshot(ws, topic, platform)` | Send current positions to one connection as `catalog` + `bulk` (initial sync) |
2837
2875
  | `cursors.list(topic)` | Current positions (for SSR) |
2838
2876
  | `cursors.clear()` | Reset all state and timers |
2839
2877
 
2840
2878
  #### How throttle works
2841
2879
 
2842
- The cursor plugin uses leading edge + trailing edge throttle internally:
2880
+ The cursor plugin uses two layers of leading-edge + trailing-edge throttle:
2881
+
2882
+ 1. **`throttle`** caps how often a single user broadcasts on a single topic.
2883
+ 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
2884
 
2844
2885
  ```
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)
2886
+ throttle: 16, topicThrottle: 16
2887
+
2888
+ t=0 A.update({x:0}) --> 'join' A, 'update' {x:0} (leading edge of both)
2889
+ t=4 B.update({x:0}) --> 'join' B (catalog channel)
2890
+ position queued in topic dirty set
2891
+ t=8 A.update({x:5}) --> queued (entry-level throttle says wait until t=16)
2892
+ t=16 [trailing timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
2849
2893
  ```
2850
2894
 
2851
- The trailing edge ensures you always see where the cursor stopped, even if the user stops moving mid-window.
2895
+ The trailing edges ensure you always see where each cursor stopped, even when the user stops moving mid-window.
2852
2896
 
2853
2897
  #### Limitations
2854
2898
 
2855
- - **In-memory.** Cursor positions live in the process. In cluster mode, each worker tracks its own connections.
2899
+ - **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
2900
  - **No persistence.** Positions are lost on restart. This is intentional - cursors are ephemeral.
2857
2901
 
2858
2902
  ### Queue (ordered delivery)
package/files/handler.js CHANGED
@@ -3035,17 +3035,30 @@ if (WS_ENABLED) {
3035
3035
  // The 8192-byte ceiling is generous enough for subscribe-batch with
3036
3036
  // many topics (N * 256-char names) while keeping the JSON.parse
3037
3037
  // guard against truly large user messages.
3038
+ // `msg` is hoisted to outer scope so it can be forwarded to the user
3039
+ // handler in the fall-through delegation below. When the prefix
3040
+ // matched and JSON.parse produced an object that did NOT match any
3041
+ // known control type, the parsed value reaches plugin-layer
3042
+ // dispatchers (e.g. svelte-realtime's `onJsonMessage`) directly, so
3043
+ // they don't re-run TextDecoder + JSON.parse on every frame.
3044
+ /** @type {any} */
3045
+ let msg;
3038
3046
  if (!isBinary && message.byteLength < 8192 &&
3039
3047
  (new Uint8Array(message))[3] === 0x79 /* 'y' in {"type" */) {
3040
3048
  /** @type {any} */
3041
- let msg;
3049
+ let parsed;
3042
3050
  try {
3043
- msg = JSON.parse(textDecoder.decode(message));
3051
+ parsed = JSON.parse(textDecoder.decode(message));
3044
3052
  } catch {
3045
- // Not valid JSON - fall through to user handler.
3046
- wsModule.message?.(ws, { data: message, isBinary, platform: ws.getUserData()[WS_PLATFORM] });
3053
+ parsed = undefined;
3054
+ }
3055
+ if (parsed === null || typeof parsed !== 'object') {
3056
+ // Not a JSON object envelope (parse failed, or parsed to
3057
+ // null / primitive / array). Forward raw bytes only.
3058
+ wsModule.message?.(ws, { data: message, isBinary, msg, platform: ws.getUserData()[WS_PLATFORM] });
3047
3059
  return;
3048
3060
  }
3061
+ msg = parsed;
3049
3062
  if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
3050
3063
  const ref = hasRef(msg.ref) ? msg.ref : null;
3051
3064
  if (!isValidWireTopic(msg.topic, ALLOW_NON_ASCII_TOPICS)) {
@@ -3230,8 +3243,10 @@ if (WS_ENABLED) {
3230
3243
  return;
3231
3244
  }
3232
3245
  }
3233
- // Delegate everything else to the user's handler (if provided)
3234
- wsModule.message?.(ws, { data: message, isBinary, platform: ws.getUserData()[WS_PLATFORM] });
3246
+ // Delegate everything else to the user's handler (if provided).
3247
+ // `msg` is the JSON-parsed envelope when the prefix matched + parsed
3248
+ // to an object + no control type matched; otherwise undefined.
3249
+ wsModule.message?.(ws, { data: message, isBinary, msg, platform: ws.getUserData()[WS_PLATFORM] });
3235
3250
  },
3236
3251
 
3237
3252
  drain: (ws) => {
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;
@@ -517,6 +517,26 @@ export interface MessageContext {
517
517
  data: ArrayBuffer;
518
518
  /** Whether the message is binary. */
519
519
  isBinary: boolean;
520
+ /**
521
+ * The JSON-parsed envelope, when the adapter parsed the frame for
522
+ * control-message routing (subscribe / unsubscribe / hello / resume /
523
+ * reply / subscribe-batch) but no control type matched.
524
+ *
525
+ * Plugin-layer JSON envelope dispatchers (e.g. svelte-realtime's
526
+ * `createMessage({ onJsonMessage })`) consume this directly instead of
527
+ * re-running `TextDecoder + JSON.parse` on every frame.
528
+ *
529
+ * `undefined` when:
530
+ * - the frame is binary (`isBinary === true`), or
531
+ * - the frame did not start with `{"ty` (byte[3] !== 0x79), or
532
+ * - the frame was larger than 8 KiB, or
533
+ * - `JSON.parse` threw, or
534
+ * - the parsed value was not a plain object (null / array / primitive).
535
+ *
536
+ * The adapter's `websocket.maxPayloadLength` (default 1 MB) is the
537
+ * structural ceiling for frame size; this field adds no separate cap.
538
+ */
539
+ msg?: any;
520
540
  /** The platform API - publish, send, topic helpers, etc. */
521
541
  platform: Platform;
522
542
  }
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.1",
3
+ "version": "0.5.3",
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;