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 +44 -0
- package/client-runtime.js +39 -0
- package/client.js +11 -38
- package/files/utils.js +45 -0
- package/package.json +1 -1
- package/plugins/cursor/client.d.ts +220 -95
- package/plugins/cursor/client.js +511 -0
- package/plugins/cursor/cursor-worker.js +526 -0
- package/plugins/cursor/decode.js +6 -1
- package/plugins/cursor/render/canvas2d.js +123 -0
- package/plugins/cursor/render/index.js +215 -0
- package/plugins/cursor/render/webgl2.js +233 -0
- package/plugins/cursor/render/webgpu.js +17 -0
- package/plugins/cursor/server.js +6 -2
- package/plugins/groups/server.js +10 -7
- package/plugins/presence/server.js +9 -7
- package/vite.js +35 -1
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,
|
|
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
|
-
|
|
705
|
-
|
|
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,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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
*/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
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;
|