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 +1 -1
- package/README.md +74 -30
- package/files/handler.js +21 -6
- package/index.d.ts +22 -2
- package/index.js +9 -9
- package/package.json +1 -1
- package/plugins/cursor/client.d.ts +54 -34
- package/plugins/cursor/client.js +137 -38
- package/plugins/cursor/server.d.ts +36 -9
- package/plugins/cursor/server.js +197 -29
- package/plugins/presence/client.js +48 -7
- package/testing.js +27 -4
- package/vite.js +25 -4
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.
|
|
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
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2846
|
-
|
|
2847
|
-
t=
|
|
2848
|
-
t=
|
|
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
|
|
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
|
|
3049
|
+
let parsed;
|
|
3042
3050
|
try {
|
|
3043
|
-
|
|
3051
|
+
parsed = JSON.parse(textDecoder.decode(message));
|
|
3044
3052
|
} catch {
|
|
3045
|
-
|
|
3046
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
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,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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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;
|