svelte-adapter-uws 0.6.0-next.21 → 0.6.0-next.23
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 +66 -1
- package/client-runtime.js +39 -0
- package/client.js +15 -38
- package/files/utils.js +45 -0
- package/package.json +1 -1
- package/plugins/cursor/client.d.ts +239 -95
- package/plugins/cursor/client.js +630 -2
- package/plugins/cursor/codec.js +112 -17
- package/plugins/cursor/cursor-worker.js +604 -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 +29 -13
- package/plugins/groups/server.js +10 -7
- package/plugins/presence/server.js +9 -7
- package/plugins/smooth/clock.js +172 -0
- package/plugins/smooth/interpolate.js +355 -0
- package/vite.js +35 -1
package/README.md
CHANGED
|
@@ -3065,13 +3065,78 @@ 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
|
+
smooth: true, // render-in-the-past interpolation for remote cursors (see below)
|
|
3101
|
+
mainThreadFeed: { rate: 10 }, // opt-in thinned position feed back to the main thread
|
|
3102
|
+
maxAge: 30_000, // same self-healing sweep as the store
|
|
3103
|
+
viewport: () => board // same sources as the store path; defaults to the canvas element
|
|
3104
|
+
});
|
|
3105
|
+
```
|
|
3106
|
+
|
|
3107
|
+
#### Smooth remote cursors (`smooth`)
|
|
3108
|
+
|
|
3109
|
+
Without smoothing, remote cursors paint exactly where the last wire frame put them - at typical coalesced rates that is visibly steppy, and one dropped frame freezes a cursor until the next one lands. `smooth: true` switches remote rendering to buffered playback: each cursor keeps a short history of server-stamped samples, and every render frame paints the position interpolated between the two samples that straddle a render time held a small, self-tuning delay behind the newest data. A single dropped frame becomes invisible (there is still a real pair of samples around the render time), and motion between wire frames is filled in at full display rate.
|
|
3110
|
+
|
|
3111
|
+
The trade is stated once and plainly: **remote cursors render `interpolationMs` behind their newest known position - a larger delay survives more dropped frames but trails further behind.** The `'auto'` default tracks twice the measured update interval, so a stream already arriving at display rate collapses toward the 32ms floor and pays almost nothing, while a coarse stream widens itself just enough. Your own pointer is unaffected (it is drawn by the OS, not the canvas), and the `mainThreadFeed` keeps shipping raw wire positions - smoothing changes pixels, never data.
|
|
3112
|
+
|
|
3113
|
+
```js
|
|
3114
|
+
cursor('board:42', { canvas, smooth: true }); // tuned defaults
|
|
3115
|
+
cursor('board:42', {
|
|
3116
|
+
canvas,
|
|
3117
|
+
smooth: {
|
|
3118
|
+
interpolationMs: 'auto', // or a fixed ms: the render-in-the-past delay
|
|
3119
|
+
extrapolateMs: 250, // dead-reckoning cap when the buffer runs dry
|
|
3120
|
+
snapGapMs: 500 // sample gap snapped (a view re-entry, an idle resume), not smeared
|
|
3121
|
+
}
|
|
3122
|
+
});
|
|
3123
|
+
```
|
|
3124
|
+
|
|
3125
|
+
Under the hood this negotiates one extra capability (`cursor.protocol:4`): the server stamps each position frame with its wall clock (one byte per frame steady-state, delta-coded), the snapshot reply leads with a `time` event that seeds a per-socket server-clock estimator, and the worker (or the main-thread fallback - same code, same visuals) samples each cursor's ring at the estimated server time minus the delay. Old servers and old clients keep working: without the capability the interpolator runs on arrival times, which still smooths but breathes with network jitter.
|
|
3126
|
+
|
|
3127
|
+
`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`.
|
|
3128
|
+
|
|
3129
|
+
`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.
|
|
3130
|
+
|
|
3131
|
+
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.
|
|
3132
|
+
|
|
3068
3133
|
#### Server API
|
|
3069
3134
|
|
|
3070
3135
|
| Method | Description |
|
|
3071
3136
|
|---|---|
|
|
3072
3137
|
| `cursors.update(ws, topic, data, platform)` | Broadcast position (per-cursor + per-topic throttled). Emits `join` once per (ws, topic). |
|
|
3073
3138
|
| `cursors.remove(ws, platform)` | Remove from all topics, broadcast `remove` per topic |
|
|
3074
|
-
| `cursors.snapshot(ws, topic, platform)` | Send current positions to one connection as `catalog` + `bulk` (initial sync) |
|
|
3139
|
+
| `cursors.snapshot(ws, topic, platform)` | Send current positions to one connection as `time` + `catalog` + `bulk` (initial sync; `time` seeds the smoothing clock) |
|
|
3075
3140
|
| `cursors.list(topic)` | Current positions (for SSR) |
|
|
3076
3141
|
| `cursors.viewport(ws, topic, rect)` | Record a subscriber's viewport rect (called for you by `hooks.message` on a `cursor-viewport` frame) |
|
|
3077
3142
|
| `cursors.viewportFor(ws, topic)` | The subscriber's last reported rect, or `null` if it never reported one |
|
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
|
|
@@ -1343,6 +1310,10 @@ function createConnection(options) {
|
|
|
1343
1310
|
if (decoded && !match.codec.sink) {
|
|
1344
1311
|
const out = { topic, event: decoded.event, data: decoded.data };
|
|
1345
1312
|
if (parsed.seq > 0) out.seq = parsed.seq;
|
|
1313
|
+
// Additive codec metadata (e.g. the cursor wire's server
|
|
1314
|
+
// stamp): rides the dispatched event for consumers that
|
|
1315
|
+
// want it; the store merge ignores it.
|
|
1316
|
+
if (decoded.t !== undefined) out.t = decoded.t;
|
|
1346
1317
|
dispatchEvent(out);
|
|
1347
1318
|
}
|
|
1348
1319
|
} else if (debug) {
|
|
@@ -1916,6 +1887,12 @@ function createConnection(options) {
|
|
|
1916
1887
|
get bufferedAmount() { return ws?.bufferedAmount ?? 0; },
|
|
1917
1888
|
onRequest,
|
|
1918
1889
|
_resendHello: resendHello,
|
|
1890
|
+
// Internal: the resolved WebSocket URL this connection dials. A plugin
|
|
1891
|
+
// that opens its own dedicated socket (the cursor render worker) must
|
|
1892
|
+
// reach the same endpoint the main connection negotiated - including a
|
|
1893
|
+
// custom `url` / `path` option - so the derivation is exposed here
|
|
1894
|
+
// rather than re-derived from window.location in the plugin.
|
|
1895
|
+
_url: getUrl,
|
|
1919
1896
|
// Internal-only subscription to the connection's flow-control health.
|
|
1920
1897
|
// A boolean (degraded yes/no) is the only thing that crosses this
|
|
1921
1898
|
// 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,239 @@
|
|
|
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. The feed always ships raw wire
|
|
133
|
+
* positions - smoothing changes pixels, never the data surface.
|
|
134
|
+
*/
|
|
135
|
+
mainThreadFeed?: boolean | { rate?: number };
|
|
136
|
+
/**
|
|
137
|
+
* Render-in-the-past interpolation for remote cursors. `true` selects
|
|
138
|
+
* the tuned defaults; the object form exposes the knobs. Remote cursors
|
|
139
|
+
* render `interpolationMs` behind their newest known position, so a
|
|
140
|
+
* dropped or late frame is invisible (there is almost always a real
|
|
141
|
+
* pair of samples around the render time) at the cost of that small
|
|
142
|
+
* trailing delay. `'auto'` (default) tracks twice the measured update
|
|
143
|
+
* interval and collapses toward a 32ms floor when updates arrive at
|
|
144
|
+
* display rate. `extrapolateMs` caps dead-reckoning when the buffer
|
|
145
|
+
* runs dry (default 250); `snapGapMs` is the sample gap treated as a
|
|
146
|
+
* discontinuity and snapped rather than smeared (default 500). Requires
|
|
147
|
+
* a canvas (the plain store has no render loop). Off by default.
|
|
148
|
+
*/
|
|
149
|
+
smooth?: boolean | {
|
|
150
|
+
interpolationMs?: 'auto' | number;
|
|
151
|
+
extrapolateMs?: number;
|
|
152
|
+
snapGapMs?: number;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reactive cursor data for a topic.
|
|
158
|
+
*
|
|
159
|
+
* Returns a `Readable<Map<string, CursorPosition>>` that updates
|
|
160
|
+
* automatically when cursors move, join, or disconnect. Internally merges
|
|
161
|
+
* the `catalog` (user metadata) and `update`/`bulk` (positions) streams;
|
|
162
|
+
* entries are emitted only after both user and position are known.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```svelte
|
|
166
|
+
* <script>
|
|
167
|
+
* import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
168
|
+
*
|
|
169
|
+
* let board;
|
|
170
|
+
* // Auto-reports board's visible region; omit `viewport` to see all cursors.
|
|
171
|
+
* const cursors = cursor('canvas', { viewport: () => board });
|
|
172
|
+
* </script>
|
|
173
|
+
*
|
|
174
|
+
* <div bind:this={board}
|
|
175
|
+
* onpointermove={(e) => move('canvas', { x: e.clientX + board.scrollLeft, y: e.clientY + board.scrollTop })}>
|
|
176
|
+
* {#each [...$cursors] as [key, { user, data }] (key)}
|
|
177
|
+
* <div style="left: {data.x}px; top: {data.y}px">{user.name}</div>
|
|
178
|
+
* {/each}
|
|
179
|
+
* </div>
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
export function cursor<UserInfo = unknown, Data = unknown>(
|
|
183
|
+
topic: string,
|
|
184
|
+
options: CursorCanvasOptions
|
|
185
|
+
): CursorHandle<UserInfo>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Rendered cursors for a topic: hand `cursor()` a canvas and the whole
|
|
189
|
+
* ingest-decode-merge-paint pipeline moves into a dedicated worker that owns
|
|
190
|
+
* its own WebSocket and the canvas's transferred drawing surface - the main
|
|
191
|
+
* thread reads nothing from the cursor stream at any density. On a browser
|
|
192
|
+
* without the worker pipeline the identical call renders on the main thread
|
|
193
|
+
* instead (no API difference, no thrown error in `'auto'` mode).
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```svelte
|
|
197
|
+
* <script>
|
|
198
|
+
* import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
199
|
+
* let canvas = $state();
|
|
200
|
+
* $effect(() => cursor('board:42', { canvas }).mount());
|
|
201
|
+
* </script>
|
|
202
|
+
*
|
|
203
|
+
* <canvas bind:this={canvas} class="cursor-layer"></canvas>
|
|
204
|
+
* <div onpointermove={(e) => move('board:42', { x: e.clientX, y: e.clientY })}> ... </div>
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
export function cursor<UserInfo = unknown, Data = unknown>(
|
|
208
|
+
topic: string,
|
|
209
|
+
options?: CursorStoreOptions
|
|
210
|
+
): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Send a cursor move on a topic. Frames are coalesced via
|
|
214
|
+
* `requestAnimationFrame` so calling `move()` at 1000 Hz (high-DPI
|
|
215
|
+
* mouse) collapses to at most one send per repaint, matching the
|
|
216
|
+
* server-side `topicThrottle` default. Multi-topic callers do not
|
|
217
|
+
* clobber each other.
|
|
218
|
+
*
|
|
219
|
+
* No-op in non-browser environments.
|
|
220
|
+
*/
|
|
221
|
+
export function move(topic: string, data: unknown): void;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Report this subscriber's viewport on a topic so the server can cull cursors
|
|
225
|
+
* outside the visible region (once viewport culling is enabled server-side).
|
|
226
|
+
* Reporting is per-subscriber and opt-in: a subscriber that never reports a
|
|
227
|
+
* viewport is treated as whole-board and is never culled. Frames are coalesced
|
|
228
|
+
* via `requestAnimationFrame` (one send per repaint); multi-topic callers do
|
|
229
|
+
* not clobber each other.
|
|
230
|
+
*
|
|
231
|
+
* No-op in non-browser environments and for an unresolvable source.
|
|
232
|
+
*
|
|
233
|
+
* @param topic
|
|
234
|
+
* @param source a scroll-container element (the visible content region is read
|
|
235
|
+
* from `scrollLeft` / `scrollTop` / `clientWidth` / `clientHeight`), an
|
|
236
|
+
* explicit `{ x, y, w, h, zoom? }` rect (for a virtualized canvas with its
|
|
237
|
+
* own transform), or a getter returning either.
|
|
238
|
+
*/
|
|
239
|
+
export function reportViewport(topic: string, source: ViewportSource): void;
|