svelte-adapter-uws 0.6.0-next.21 → 0.6.0-next.22

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
@@ -3065,6 +3065,50 @@ The `cursor()` function accepts an optional second argument with a `maxAge` opti
3065
3065
  const positions = cursor('canvas', { maxAge: 30_000 });
3066
3066
  ```
3067
3067
 
3068
+ #### Canvas rendering (worker offload)
3069
+
3070
+ At high cursor density the DOM `{#each}` above stops being the bottleneck you can fix: every frame still lands on the main thread, gets parsed there, and re-renders through reactivity. Hand `cursor()` a canvas instead and the entire ingest-decode-merge-paint pipeline moves into a dedicated worker that owns its own WebSocket (subscribed only to the cursor topic) and the canvas's transferred drawing surface. The main thread reads nothing from the cursor stream - at any density.
3071
+
3072
+ ```svelte
3073
+ <script>
3074
+ import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
3075
+ let canvas = $state();
3076
+ $effect(() => cursor('board:42', { canvas }).mount());
3077
+ </script>
3078
+
3079
+ <canvas bind:this={canvas} class="cursor-layer"></canvas>
3080
+ <div onpointermove={(e) => move('board:42', { x: e.clientX, y: e.clientY })}> ... </div>
3081
+ ```
3082
+
3083
+ That is the whole zero-config path. `mount()` returns its teardown, so the `$effect` one-liner is the complete lifecycle; unmounting pauses the pipeline (socket closed, state cleared) and a remount on the same canvas resumes it, same or different topic. `move()` is unchanged - sending stays on the main thread (pointer events only exist there); only receiving and rendering move off it. On a browser without the worker pipeline (no `OffscreenCanvas`, an old Safari) the identical call renders on the main thread through the same renderer backends: same visuals, lower ceiling, no API difference, no thrown error.
3084
+
3085
+ What the worker does for you:
3086
+
3087
+ - **Decodes off the main thread.** Binary cursor frames decode 15-18x faster than `JSON.parse`, and even that cost now happens where it cannot drop an app frame.
3088
+ - **Renders through a density-aware backend.** Canvas2D below 500 in-view cursors (zero GPU setup for quiet boards), automatic promotion to an instanced WebGL2 renderer at the threshold - one draw call per frame at any count. Crossing back down never thrashes backends.
3089
+ - **Culls and reports the viewport.** The worker tracks your `viewport` source (or the canvas element itself), paints only the in-view subset, and reports the rect on its own socket so [server-side culling](#cutting-cursor-volume-opt-in-reducers) also shrinks the wire.
3090
+ - **Reconnects independently.** The cursor socket has its own backoff and liveness check; a cursor-stream hiccup never disturbs your main connection, and vice versa.
3091
+
3092
+ Options, all opt-in:
3093
+
3094
+ ```js
3095
+ const handle = cursor('board:42', {
3096
+ canvas,
3097
+ rendering: 'auto', // 'auto' | 'main' (forces main thread, adds handle.store) | 'worker' (throws if unsupported)
3098
+ gpu: 'auto', // 'auto' | 'canvas2d' | 'webgl2' | 'webgpu' (reserved; throws until it ships)
3099
+ gpuThreshold: 500, // in-view count where 'auto' promotes to the GPU backend
3100
+ mainThreadFeed: { rate: 10 }, // opt-in thinned position feed back to the main thread
3101
+ maxAge: 30_000, // same self-healing sweep as the store
3102
+ viewport: () => board // same sources as the store path; defaults to the canvas element
3103
+ });
3104
+ ```
3105
+
3106
+ `handle.feed` (present only with `mainThreadFeed`) is a `Readable<Map<key, { user, data, colorRGBA }>>` sampled at the feed rate in board coordinates - for the leader badge, the minimap, the "3 people here" pill - not a second rendering path: at 500 in-view cursors a feed tick costs the main thread ~30 microseconds. Apps that genuinely need full reactive cursor data alongside their own canvas use `rendering: 'main'` and read `handle.store`.
3107
+
3108
+ `handle.configure({ colorOf, hide })` sets display config: the callbacks run on the main thread against the live roster (and re-run as users join), and only the resolved per-key results cross to the worker. `colorOf(user)` returns a hex string or packed RGBA integer; anything else keeps the deterministic default palette. A user hidden by `hide` disappears from the canvas and the feed.
3109
+
3110
+ One canvas renders one topic at a time, and a canvas whose surface was transferred belongs to its worker for the element's lifetime - `handle.destroy()` is terminal (leaving the board for good); for component lifecycles rely on the `mount()` teardown. The worker identifies its socket with the `svelte-realtime-cursor` subprotocol, so deployments running the admission gate's cursor lane shed cursor sockets before main connections under load.
3111
+
3068
3112
  #### Server API
3069
3113
 
3070
3114
  | Method | Description |
package/client-runtime.js CHANGED
@@ -82,6 +82,45 @@ export const clearIntervalTimer = (h) => current.timers.clearInterval(h);
82
82
  export const microtask = (cb) => current.timers.queueMicrotask(cb);
83
83
  export const effectiveTimeZone = () => current.tz;
84
84
 
85
+ /**
86
+ * Compute the next reconnect delay using exponential backoff with
87
+ * proportional jitter.
88
+ *
89
+ * The capped delay is `min(base * 2.2^attempt, maxDelay)`. A random factor
90
+ * in `[0.75, 1.25]` is then applied multiplicatively, so the final delay
91
+ * spans +/- 25% of the capped value. Multiplicative jitter keeps spread
92
+ * meaningful at high attempt counts: with 10K clients all reconnecting
93
+ * after a server restart, additive +/- 500ms jitter clusters reconnects
94
+ * inside a 1 second window; proportional jitter spreads them across
95
+ * a window proportional to the current backoff.
96
+ *
97
+ * The 2.2 exponent with a 5 minute cap is aggressive enough to back off
98
+ * fast under sustained server pain (the default 3 second base hits the
99
+ * cap by attempt 6) and gentle enough that a brief restart resolves
100
+ * before the user notices.
101
+ *
102
+ * Pure given an explicit `randFactor`: no I/O, no globals. Pass a fixed
103
+ * value for reproducible assertions in tests.
104
+ *
105
+ * Lives here (not client.js) because every socket owner shares one curve -
106
+ * the main connection and any dedicated secondary socket - and this module
107
+ * is the only client module such a socket owner can import without dragging
108
+ * the whole connection surface (and Svelte) into its bundle. The default
109
+ * `randFactor` is the runtime float source: backoff jitter spreads retries
110
+ * across a fleet, never crosses a trust boundary, and routing it through
111
+ * the runtime lets a seeded harness reproduce the reconnect schedule.
112
+ *
113
+ * @param {number} base base interval in ms (e.g. 3000)
114
+ * @param {number} maxDelay cap in ms (e.g. 300000)
115
+ * @param {number} attempt zero-based attempt counter
116
+ * @param {number} [randFactor] random factor in [0, 1); defaults to randomFloat()
117
+ * @returns {number}
118
+ */
119
+ export function nextReconnectDelay(base, maxDelay, attempt, randFactor = randomFloat()) {
120
+ const capped = Math.min(base * Math.pow(2.2, attempt), maxDelay);
121
+ return capped * (0.75 + randFactor * 0.5);
122
+ }
123
+
85
124
  // Install a virtual environment (the simulator/test harness only). Refuses under
86
125
  // a node production build unless explicitly forced, so a stray call can never
87
126
  // swap the clock under a live deployment; in a real browser there is no process
package/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { writable, derived } from 'svelte/store';
2
2
  import { parseBinaryFrame, requestNFrame } from './files/wire.js';
3
- import { now, randomFloat, setTimer, setIntervalTimer, clearTimer, clearIntervalTimer, microtask } from './client-runtime.js';
3
+ import { now, setTimer, setIntervalTimer, clearTimer, clearIntervalTimer, microtask, nextReconnectDelay } from './client-runtime.js';
4
4
 
5
5
  /** @type {ReturnType<typeof createConnection> | null} */
6
6
  let singleton = null;
@@ -700,43 +700,10 @@ export function classifyCloseCode(code) {
700
700
  return 'RETRY';
701
701
  }
702
702
 
703
- /**
704
- * Compute the next reconnect delay using exponential backoff with
705
- * proportional jitter.
706
- *
707
- * The capped delay is `min(base * 2.2^attempt, maxDelay)`. A random factor
708
- * in `[0.75, 1.25]` is then applied multiplicatively, so the final delay
709
- * spans +/- 25% of the capped value. Multiplicative jitter keeps spread
710
- * meaningful at high attempt counts: with 10K clients all reconnecting
711
- * after a server restart, additive +/- 500ms jitter clusters reconnects
712
- * inside a 1 second window; proportional jitter spreads them across
713
- * a window proportional to the current backoff.
714
- *
715
- * The 2.2 exponent with a 5 minute cap is aggressive enough to back off
716
- * fast under sustained server pain (the default 3 second base hits the
717
- * cap by attempt 6) and gentle enough that a brief restart resolves
718
- * before the user notices.
719
- *
720
- * Pure given an explicit `randFactor`: no I/O, no globals. Pass a fixed
721
- * value for reproducible assertions in tests.
722
- *
723
- * The default `randFactor` is the runtime float source: this value is
724
- * reconnect-backoff jitter, used to spread retries across a fleet so a
725
- * server restart does not hit a thundering-herd. Not security-relevant -
726
- * the randFactor never crosses a trust boundary - so the runtime source is
727
- * the right primitive; routing it through the runtime also lets a seeded
728
- * harness reproduce the reconnect schedule exactly.
729
- *
730
- * @param {number} base base interval in ms (e.g. 3000)
731
- * @param {number} maxDelay cap in ms (e.g. 300000)
732
- * @param {number} attempt zero-based attempt counter
733
- * @param {number} [randFactor] random factor in [0, 1); defaults to randomFloat()
734
- * @returns {number}
735
- */
736
- export function nextReconnectDelay(base, maxDelay, attempt, randFactor = randomFloat()) {
737
- const capped = Math.min(base * Math.pow(2.2, attempt), maxDelay);
738
- return capped * (0.75 + randFactor * 0.5);
739
- }
703
+ // Reconnect backoff curve. Defined in client-runtime.js (the worker-safe
704
+ // module every socket owner can import); re-exported here so the public
705
+ // surface of this module is unchanged.
706
+ export { nextReconnectDelay };
740
707
 
741
708
  /**
742
709
  * @param {import('./client.js').ConnectOptions} options
@@ -1916,6 +1883,12 @@ function createConnection(options) {
1916
1883
  get bufferedAmount() { return ws?.bufferedAmount ?? 0; },
1917
1884
  onRequest,
1918
1885
  _resendHello: resendHello,
1886
+ // Internal: the resolved WebSocket URL this connection dials. A plugin
1887
+ // that opens its own dedicated socket (the cursor render worker) must
1888
+ // reach the same endpoint the main connection negotiated - including a
1889
+ // custom `url` / `path` option - so the derivation is exposed here
1890
+ // rather than re-derived from window.location in the plugin.
1891
+ _url: getUrl,
1919
1892
  // Internal-only subscription to the connection's flow-control health.
1920
1893
  // A boolean (degraded yes/no) is the only thing that crosses this
1921
1894
  // accessor - no window count, deadline, or any internal accounting
package/files/utils.js CHANGED
@@ -668,6 +668,51 @@ export function computeTopPublishers(stats, intervalSec, thresholds) {
668
668
  // alternative was a silent cluster-routing break in production.
669
669
 
670
670
  export const WS_SUBSCRIPTIONS = Symbol.for('adapter-uws.ws.subscriptions');
671
+
672
+ /**
673
+ * Subscribe a socket the way the wire-level subscribe path does: the uWS
674
+ * native call PLUS the connection's subscription registry. The registry is
675
+ * what `platform.publishWire`'s per-subscriber walk delivers by (native
676
+ * membership is not enumerable from JS), so a plugin that subscribes a
677
+ * socket natively but skips the registry silently excludes that socket from
678
+ * every stateful-codec binary publish on the topic. Plugins establishing
679
+ * server-side membership (a snapshot handshake, a presence join) must use
680
+ * this instead of raw `ws.subscribe`.
681
+ *
682
+ * Returns false when the socket is already closed (uWS throws on access).
683
+ *
684
+ * @param {any} ws
685
+ * @param {string} topic
686
+ * @returns {boolean}
687
+ */
688
+ export function trackedSubscribe(ws, topic) {
689
+ try { ws.subscribe(topic); } catch { return false; }
690
+ try {
691
+ const subs = ws.getUserData()[WS_SUBSCRIPTIONS];
692
+ if (subs) subs.add(topic);
693
+ } catch { /* socket died between the calls; close cleanup owns the registry */ }
694
+ return true;
695
+ }
696
+
697
+ /**
698
+ * Unsubscribe counterpart of {@link trackedSubscribe}: native unsubscribe
699
+ * plus registry removal, so the per-subscriber binary walk stops delivering
700
+ * the moment native membership ends.
701
+ *
702
+ * @param {any} ws
703
+ * @param {string} topic
704
+ * @returns {boolean} false when the socket was already closed
705
+ */
706
+ export function trackedUnsubscribe(ws, topic) {
707
+ let ok = true;
708
+ try { ws.unsubscribe(topic); } catch { ok = false; }
709
+ try {
710
+ const subs = ws.getUserData()[WS_SUBSCRIPTIONS];
711
+ if (subs) subs.delete(topic);
712
+ } catch { /* socket died; close cleanup owns the registry */ }
713
+ return ok;
714
+ }
715
+
671
716
  export const WS_COALESCED = Symbol.for('adapter-uws.ws.coalesced');
672
717
  export const WS_SESSION_ID = Symbol.for('adapter-uws.ws.session-id');
673
718
  export const WS_PENDING_REQUESTS = Symbol.for('adapter-uws.ws.pending-requests');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.6.0-next.21",
3
+ "version": "0.6.0-next.22",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
@@ -1,95 +1,220 @@
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
- * Pass a `viewport` source to opt this subscriber into server-side culling: the
20
- * store reports the visible region automatically (on scroll, resize, zoom, and
21
- * late mount) while subscribed, so you write no `reportViewport` wiring yourself.
22
- *
23
- * @example
24
- * ```svelte
25
- * <script>
26
- * import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
27
- *
28
- * let board;
29
- * // Auto-reports board's visible region; omit `viewport` to see all cursors.
30
- * const cursors = cursor('canvas', { viewport: () => board });
31
- * </script>
32
- *
33
- * <div bind:this={board}
34
- * onpointermove={(e) => move('canvas', { x: e.clientX + board.scrollLeft, y: e.clientY + board.scrollTop })}>
35
- * {#each [...$cursors] as [key, { user, data }] (key)}
36
- * <div style="left: {data.x}px; top: {data.y}px">{user.name}</div>
37
- * {/each}
38
- * </div>
39
- * ```
40
- */
41
- export function cursor<UserInfo = unknown, Data = unknown>(
42
- topic: string,
43
- options?: {
44
- /** Drop a cursor that has not updated within this many ms. */
45
- maxAge?: number;
46
- /**
47
- * Opt into server-side viewport culling. Pass a scroll-container element,
48
- * an explicit `{ x, y, w, h, zoom? }` rect, or a getter returning either
49
- * (a getter handles a late-bound `bind:this` element). While subscribed,
50
- * the store reports the resolved region whenever it changes - covering
51
- * scroll, resize, zoom, and mount with no manual wiring. The reported rect
52
- * and your `move()` coordinates must share one coordinate space (the
53
- * board's). Omit it and the subscriber sees all cursors (never culled).
54
- */
55
- viewport?:
56
- | Element
57
- | { x: number; y: number; w: number; h: number; zoom?: number }
58
- | (() => Element | { x: number; y: number; w: number; h: number; zoom?: number } | null | undefined);
59
- }
60
- ): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
61
-
62
- /**
63
- * Send a cursor move on a topic. Frames are coalesced via
64
- * `requestAnimationFrame` so calling `move()` at 1000 Hz (high-DPI
65
- * mouse) collapses to at most one send per repaint, matching the
66
- * server-side `topicThrottle` default. Multi-topic callers do not
67
- * clobber each other.
68
- *
69
- * No-op in non-browser environments.
70
- */
71
- export function move(topic: string, data: unknown): void;
72
-
73
- /**
74
- * Report this subscriber's viewport on a topic so the server can cull cursors
75
- * outside the visible region (once viewport culling is enabled server-side).
76
- * Reporting is per-subscriber and opt-in: a subscriber that never reports a
77
- * viewport is treated as whole-board and is never culled. Frames are coalesced
78
- * via `requestAnimationFrame` (one send per repaint); multi-topic callers do
79
- * not clobber each other.
80
- *
81
- * No-op in non-browser environments and for an unresolvable source.
82
- *
83
- * @param topic
84
- * @param source a scroll-container element (the visible content region is read
85
- * from `scrollLeft` / `scrollTop` / `clientWidth` / `clientHeight`), an
86
- * explicit `{ x, y, w, h, zoom? }` rect (for a virtualized canvas with its
87
- * own transform), or a getter returning either.
88
- */
89
- export function reportViewport(
90
- topic: string,
91
- source:
92
- | Element
93
- | { x: number; y: number; w: number; h: number; zoom?: number }
94
- | (() => Element | { x: number; y: number; w: number; h: number; zoom?: number } | null | undefined)
95
- ): void;
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
+ /** A viewport source: a scroll-container element, an explicit rect in board
11
+ * coordinates, or a getter returning either (a getter handles a late-bound
12
+ * `bind:this` element). */
13
+ export type ViewportSource =
14
+ | Element
15
+ | { x: number; y: number; w: number; h: number; zoom?: number }
16
+ | (() => Element | { x: number; y: number; w: number; h: number; zoom?: number } | null | undefined);
17
+
18
+ /** One entry of the thinned main-thread feed: the classic store's
19
+ * `{ user, data }` join plus the resolved display color. */
20
+ export interface CursorFeedEntry<UserInfo = unknown> {
21
+ user: UserInfo;
22
+ data: { x: number; y: number };
23
+ /** Packed 0xRRGGBBAA, after display-config resolution. */
24
+ colorRGBA: number;
25
+ }
26
+
27
+ /**
28
+ * The handle returned by `cursor(topic, { canvas })`. Rendering starts on
29
+ * `mount()` and the returned teardown stops it, so the natural Svelte 5
30
+ * binding is one line: `$effect(() => cursor(topic, { canvas }).mount())`.
31
+ */
32
+ export interface CursorHandle<UserInfo = unknown> {
33
+ /**
34
+ * Start rendering into the canvas. Idempotent across components: two
35
+ * mounts of the same topic+canvas share one pipeline. Returns the
36
+ * teardown; after the last teardown the pipeline pauses (the worker and
37
+ * the canvas surface are kept, so a later mount on the same element
38
+ * resumes - including on a different topic).
39
+ *
40
+ * Throws when `rendering: 'worker'` is set and the browser cannot
41
+ * provide a worker pipeline, and when the canvas is currently rendering
42
+ * a different topic.
43
+ */
44
+ mount(): () => void;
45
+ /**
46
+ * Replace the tracked viewport source. Rarely needed - the plugin
47
+ * auto-tracks the `viewport` option (or the canvas element itself) every
48
+ * frame; use this for a transform only you can compute, passing the same
49
+ * shapes the `viewport` option accepts.
50
+ */
51
+ viewport(source: ViewportSource): void;
52
+ /**
53
+ * Display config. The callbacks run on the main thread against the
54
+ * current roster and re-run automatically as users join and leave; only
55
+ * the resolved per-key results are shipped to the renderer. `colorOf`
56
+ * returns a hex string ('#rgb', '#rrggbb', '#rrggbbaa') or a packed
57
+ * 32-bit RGBA integer; any other value keeps the deterministic default
58
+ * palette. A user hidden by `hide` is excluded from the canvas AND from
59
+ * the main-thread feed. Throwing callbacks are contained per user.
60
+ */
61
+ configure(config: {
62
+ colorOf?: (user: UserInfo) => string | number | null | undefined;
63
+ hide?: (user: UserInfo) => boolean;
64
+ }): void;
65
+ /**
66
+ * Terminal teardown: stops the pipeline and disposes the worker. The
67
+ * canvas element cannot render cursors again afterwards (its drawing
68
+ * surface was transferred to the disposed worker - a once-per-element
69
+ * operation), so prefer the `mount()` teardown for component lifecycles
70
+ * and reserve `destroy()` for leaving the board entirely.
71
+ */
72
+ destroy(): void;
73
+ /**
74
+ * Present only when `mainThreadFeed` is enabled: the thinned, rate-capped
75
+ * position feed (board coordinates, in-view and non-hidden cursors only),
76
+ * updated at the feed rate rather than the wire rate.
77
+ */
78
+ feed?: Readable<Map<string, CursorFeedEntry<UserInfo>>>;
79
+ /**
80
+ * Present only with `rendering: 'main'`: the classic reactive store, for
81
+ * apps that draw on their own canvas AND need cursor data reactively.
82
+ */
83
+ store?: Readable<Map<string, CursorPosition<UserInfo>>>;
84
+ }
85
+
86
+ /** Options shared by both `cursor()` forms. */
87
+ export interface CursorStoreOptions {
88
+ /** Drop a cursor that has not updated within this many ms. */
89
+ maxAge?: number;
90
+ /**
91
+ * Opt into server-side viewport culling. While subscribed/mounted, the
92
+ * resolved region is reported whenever it changes - covering scroll,
93
+ * resize, zoom, and late mount with no manual wiring. The reported rect
94
+ * and your `move()` coordinates must share one coordinate space (the
95
+ * board's). Omit it and this subscriber sees all cursors (never culled).
96
+ * In canvas mode it additionally drives the render transform and
97
+ * client-side culling; omitting it there tracks the canvas element.
98
+ */
99
+ viewport?: ViewportSource;
100
+ }
101
+
102
+ /** Options accepted when a `canvas` is supplied. */
103
+ export interface CursorCanvasOptions extends CursorStoreOptions {
104
+ /** Render target. Its drawing surface is transferred to a dedicated
105
+ * worker when the browser supports it. */
106
+ canvas: HTMLCanvasElement;
107
+ /**
108
+ * Which thread renders. `'auto'` (default) uses a worker when
109
+ * `Worker` + `OffscreenCanvas` + `transferControlToOffscreen` exist and
110
+ * silently renders on the main thread otherwise (same call, same visual
111
+ * result, lower ceiling). `'worker'` requires the worker pipeline and
112
+ * throws at `mount()` without it - for deployments that refuse to ship
113
+ * an untested fallback. `'main'` forces main-thread rendering and
114
+ * additionally exposes `handle.store` for reactive reads.
115
+ */
116
+ rendering?: 'auto' | 'main' | 'worker';
117
+ /**
118
+ * Which backend draws. `'auto'` (default) starts on Canvas2D and
119
+ * promotes to WebGL2 when the in-view count first reaches
120
+ * `gpuThreshold`; forced values name a backend and fail loudly when it
121
+ * is unavailable.
122
+ */
123
+ gpu?: 'auto' | 'canvas2d' | 'webgl2' | 'webgpu';
124
+ /** In-view cursor count at which `gpu: 'auto'` promotes to a GPU
125
+ * backend (default 500). Crossing back down never downgrades. */
126
+ gpuThreshold?: number;
127
+ /**
128
+ * Opt-in thinned position feed back to the main thread, for the
129
+ * incidental reactive needs of worker mode (a leader badge, a minimap).
130
+ * `true` samples at 10 Hz; `{ rate }` picks the frequency. Off by
131
+ * default: the point of worker mode is that the main thread reads
132
+ * nothing from the cursor stream.
133
+ */
134
+ mainThreadFeed?: boolean | { rate?: number };
135
+ }
136
+
137
+ /**
138
+ * Reactive cursor data for a topic.
139
+ *
140
+ * Returns a `Readable<Map<string, CursorPosition>>` that updates
141
+ * automatically when cursors move, join, or disconnect. Internally merges
142
+ * the `catalog` (user metadata) and `update`/`bulk` (positions) streams;
143
+ * entries are emitted only after both user and position are known.
144
+ *
145
+ * @example
146
+ * ```svelte
147
+ * <script>
148
+ * import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
149
+ *
150
+ * let board;
151
+ * // Auto-reports board's visible region; omit `viewport` to see all cursors.
152
+ * const cursors = cursor('canvas', { viewport: () => board });
153
+ * </script>
154
+ *
155
+ * <div bind:this={board}
156
+ * onpointermove={(e) => move('canvas', { x: e.clientX + board.scrollLeft, y: e.clientY + board.scrollTop })}>
157
+ * {#each [...$cursors] as [key, { user, data }] (key)}
158
+ * <div style="left: {data.x}px; top: {data.y}px">{user.name}</div>
159
+ * {/each}
160
+ * </div>
161
+ * ```
162
+ */
163
+ export function cursor<UserInfo = unknown, Data = unknown>(
164
+ topic: string,
165
+ options: CursorCanvasOptions
166
+ ): CursorHandle<UserInfo>;
167
+
168
+ /**
169
+ * Rendered cursors for a topic: hand `cursor()` a canvas and the whole
170
+ * ingest-decode-merge-paint pipeline moves into a dedicated worker that owns
171
+ * its own WebSocket and the canvas's transferred drawing surface - the main
172
+ * thread reads nothing from the cursor stream at any density. On a browser
173
+ * without the worker pipeline the identical call renders on the main thread
174
+ * instead (no API difference, no thrown error in `'auto'` mode).
175
+ *
176
+ * @example
177
+ * ```svelte
178
+ * <script>
179
+ * import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
180
+ * let canvas = $state();
181
+ * $effect(() => cursor('board:42', { canvas }).mount());
182
+ * </script>
183
+ *
184
+ * <canvas bind:this={canvas} class="cursor-layer"></canvas>
185
+ * <div onpointermove={(e) => move('board:42', { x: e.clientX, y: e.clientY })}> ... </div>
186
+ * ```
187
+ */
188
+ export function cursor<UserInfo = unknown, Data = unknown>(
189
+ topic: string,
190
+ options?: CursorStoreOptions
191
+ ): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
192
+
193
+ /**
194
+ * Send a cursor move on a topic. Frames are coalesced via
195
+ * `requestAnimationFrame` so calling `move()` at 1000 Hz (high-DPI
196
+ * mouse) collapses to at most one send per repaint, matching the
197
+ * server-side `topicThrottle` default. Multi-topic callers do not
198
+ * clobber each other.
199
+ *
200
+ * No-op in non-browser environments.
201
+ */
202
+ export function move(topic: string, data: unknown): void;
203
+
204
+ /**
205
+ * Report this subscriber's viewport on a topic so the server can cull cursors
206
+ * outside the visible region (once viewport culling is enabled server-side).
207
+ * Reporting is per-subscriber and opt-in: a subscriber that never reports a
208
+ * viewport is treated as whole-board and is never culled. Frames are coalesced
209
+ * via `requestAnimationFrame` (one send per repaint); multi-topic callers do
210
+ * not clobber each other.
211
+ *
212
+ * No-op in non-browser environments and for an unresolvable source.
213
+ *
214
+ * @param topic
215
+ * @param source a scroll-container element (the visible content region is read
216
+ * from `scrollLeft` / `scrollTop` / `clientWidth` / `clientHeight`), an
217
+ * explicit `{ x, y, w, h, zoom? }` rect (for a virtualized canvas with its
218
+ * own transform), or a getter returning either.
219
+ */
220
+ export function reportViewport(topic: string, source: ViewportSource): void;