svelte-adapter-uws 0.5.1 → 0.5.2
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 +56 -25
- package/index.d.ts +2 -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/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
|
|
|
@@ -2755,14 +2755,31 @@ Lightweight fire-and-forget broadcasting for transient state - mouse cursors, te
|
|
|
2755
2755
|
import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
|
|
2756
2756
|
|
|
2757
2757
|
export const cursors = createCursor({
|
|
2758
|
-
throttle:
|
|
2758
|
+
throttle: 16, // per-cursor: at most one broadcast per 16ms (~60 Hz)
|
|
2759
|
+
topicThrottle: 16, // per-topic: coalesce all movers into one frame per 16ms
|
|
2759
2760
|
select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
|
|
2760
2761
|
// maxConnections: 1_000_000 (default) - hard cap on tracked connections
|
|
2761
2762
|
// maxTopics: 1_000_000 (default) - hard cap on active topic registry
|
|
2762
2763
|
});
|
|
2763
2764
|
```
|
|
2764
2765
|
|
|
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
|
|
2766
|
+
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.
|
|
2767
|
+
|
|
2768
|
+
`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.
|
|
2769
|
+
|
|
2770
|
+
#### Wire shape
|
|
2771
|
+
|
|
2772
|
+
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.
|
|
2773
|
+
|
|
2774
|
+
| Event | Payload | Sent by |
|
|
2775
|
+
|---|---|---|
|
|
2776
|
+
| `catalog` | `[{key, user}, ...]` | `snapshot()` - initial roster to a single new subscriber |
|
|
2777
|
+
| `join` | `{key, user}` | first `update()` on a (ws, topic) pair |
|
|
2778
|
+
| `update` | `{key, data}` | single-mover position frame |
|
|
2779
|
+
| `bulk` | `[{key, data}, ...]` | multi-mover coalesced position frame |
|
|
2780
|
+
| `remove` | `{key}` | `remove()` or `hooks.close` |
|
|
2781
|
+
|
|
2782
|
+
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
2783
|
|
|
2767
2784
|
#### Server usage
|
|
2768
2785
|
|
|
@@ -2802,26 +2819,34 @@ export function close(ws, { platform }) {
|
|
|
2802
2819
|
|
|
2803
2820
|
```svelte
|
|
2804
2821
|
<script>
|
|
2805
|
-
import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
2822
|
+
import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
2806
2823
|
|
|
2807
2824
|
const positions = cursor('canvas');
|
|
2825
|
+
|
|
2826
|
+
function onmousemove(e) {
|
|
2827
|
+
move('canvas', { x: e.clientX, y: e.clientY });
|
|
2828
|
+
}
|
|
2808
2829
|
</script>
|
|
2809
2830
|
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2831
|
+
<div on:mousemove={onmousemove}>
|
|
2832
|
+
{#each [...$positions] as [key, { user, data }] (key)}
|
|
2833
|
+
<div
|
|
2834
|
+
class="cursor-dot"
|
|
2835
|
+
style="left: {data.x}px; top: {data.y}px; background: {user.color}"
|
|
2836
|
+
>
|
|
2837
|
+
{user.name}
|
|
2838
|
+
</div>
|
|
2839
|
+
{/each}
|
|
2840
|
+
</div>
|
|
2818
2841
|
```
|
|
2819
2842
|
|
|
2820
|
-
|
|
2843
|
+
`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.
|
|
2821
2844
|
|
|
2822
|
-
|
|
2845
|
+
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.
|
|
2823
2846
|
|
|
2824
|
-
The `cursor()`
|
|
2847
|
+
**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.
|
|
2848
|
+
|
|
2849
|
+
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
2850
|
|
|
2826
2851
|
```js
|
|
2827
2852
|
const positions = cursor('canvas', { maxAge: 30_000 });
|
|
@@ -2831,28 +2856,34 @@ const positions = cursor('canvas', { maxAge: 30_000 });
|
|
|
2831
2856
|
|
|
2832
2857
|
| Method | Description |
|
|
2833
2858
|
|---|---|
|
|
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) |
|
|
2859
|
+
| `cursors.update(ws, topic, data, platform)` | Broadcast position (per-cursor + per-topic throttled). Emits `join` once per (ws, topic). |
|
|
2860
|
+
| `cursors.remove(ws, platform)` | Remove from all topics, broadcast `remove` per topic |
|
|
2861
|
+
| `cursors.snapshot(ws, topic, platform)` | Send current positions to one connection as `catalog` + `bulk` (initial sync) |
|
|
2837
2862
|
| `cursors.list(topic)` | Current positions (for SSR) |
|
|
2838
2863
|
| `cursors.clear()` | Reset all state and timers |
|
|
2839
2864
|
|
|
2840
2865
|
#### How throttle works
|
|
2841
2866
|
|
|
2842
|
-
The cursor plugin uses leading
|
|
2867
|
+
The cursor plugin uses two layers of leading-edge + trailing-edge throttle:
|
|
2868
|
+
|
|
2869
|
+
1. **`throttle`** caps how often a single user broadcasts on a single topic.
|
|
2870
|
+
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
2871
|
|
|
2844
2872
|
```
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
t=
|
|
2848
|
-
t=
|
|
2873
|
+
throttle: 16, topicThrottle: 16
|
|
2874
|
+
|
|
2875
|
+
t=0 A.update({x:0}) --> 'join' A, 'update' {x:0} (leading edge of both)
|
|
2876
|
+
t=4 B.update({x:0}) --> 'join' B (catalog channel)
|
|
2877
|
+
position queued in topic dirty set
|
|
2878
|
+
t=8 A.update({x:5}) --> queued (entry-level throttle says wait until t=16)
|
|
2879
|
+
t=16 [trailing timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
|
|
2849
2880
|
```
|
|
2850
2881
|
|
|
2851
|
-
The trailing
|
|
2882
|
+
The trailing edges ensure you always see where each cursor stopped, even when the user stops moving mid-window.
|
|
2852
2883
|
|
|
2853
2884
|
#### Limitations
|
|
2854
2885
|
|
|
2855
|
-
- **In-memory.** Cursor positions live in the process. In cluster mode, each worker tracks its own connections.
|
|
2886
|
+
- **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
2887
|
- **No persistence.** Positions are lost on restart. This is intentional - cursors are ephemeral.
|
|
2857
2888
|
|
|
2858
2889
|
### Queue (ordered delivery)
|
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;
|
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;
|
package/plugins/cursor/client.js
CHANGED
|
@@ -5,10 +5,23 @@
|
|
|
5
5
|
* a live Map of cursor positions. The server handles throttling and
|
|
6
6
|
* cleanup; this module keeps the client-side state in sync.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Wire shape (catalog / positions split):
|
|
9
|
+
* - `catalog` [{key, user}] - roster sent on snapshot to a fresh
|
|
10
|
+
* subscriber. Replaces local user map.
|
|
11
|
+
* - `join` {key, user} - new user announced on the topic.
|
|
12
|
+
* - `update` {key, data} - single-mover position frame.
|
|
13
|
+
* - `bulk` [{key, data}] - multi-mover coalesced position frame.
|
|
14
|
+
* - `remove` {key} - user gone (catalog + positions cleared).
|
|
15
|
+
*
|
|
16
|
+
* User metadata lives on the catalog channel (catalog + join), positions
|
|
17
|
+
* live on the update/bulk channel. The merge happens here: the public
|
|
18
|
+
* Readable yields `Map<key, {user, data}>`, skipping any position whose
|
|
19
|
+
* user has not yet been seen via catalog/join.
|
|
20
|
+
*
|
|
21
|
+
* When `maxAge` is set, cursor entries that haven't received a position
|
|
22
|
+
* update within that window are automatically removed. This makes
|
|
23
|
+
* clients self-healing when the server fails to broadcast a `remove`
|
|
24
|
+
* event (e.g. mass disconnects overwhelming Redis cleanup).
|
|
12
25
|
*
|
|
13
26
|
* @module svelte-adapter-uws/plugins/cursor/client
|
|
14
27
|
*/
|
|
@@ -26,7 +39,7 @@ const cursorStores = new Map();
|
|
|
26
39
|
*
|
|
27
40
|
* Returns a readable Svelte store containing a Map of connection keys
|
|
28
41
|
* to `{ user, data }` objects. The Map updates automatically when
|
|
29
|
-
* cursors move or disconnect.
|
|
42
|
+
* cursors move, join, or disconnect.
|
|
30
43
|
*
|
|
31
44
|
* @template UserInfo, Data
|
|
32
45
|
* @param {string} topic - Topic to track cursors on
|
|
@@ -36,22 +49,28 @@ const cursorStores = new Map();
|
|
|
36
49
|
* @example
|
|
37
50
|
* ```svelte
|
|
38
51
|
* <script>
|
|
39
|
-
* import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
52
|
+
* import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
40
53
|
*
|
|
41
54
|
* const cursors = cursor('canvas');
|
|
55
|
+
*
|
|
56
|
+
* function onmousemove(e) {
|
|
57
|
+
* move('canvas', { x: e.clientX, y: e.clientY });
|
|
58
|
+
* }
|
|
42
59
|
* </script>
|
|
43
60
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* {
|
|
47
|
-
*
|
|
48
|
-
*
|
|
61
|
+
* <div on:mousemove={onmousemove}>
|
|
62
|
+
* {#each [...$cursors] as [key, { user, data }] (key)}
|
|
63
|
+
* <div style="left: {data.x}px; top: {data.y}px" class="cursor">
|
|
64
|
+
* {user.name}
|
|
65
|
+
* </div>
|
|
66
|
+
* {/each}
|
|
67
|
+
* </div>
|
|
49
68
|
* ```
|
|
50
69
|
*
|
|
51
70
|
* @example
|
|
52
71
|
* ```svelte
|
|
53
72
|
* <script>
|
|
54
|
-
* // Self-healing: cursors expire after 30s without
|
|
73
|
+
* // Self-healing: cursors expire after 30s without a position update.
|
|
55
74
|
* const cursors = cursor('canvas', { maxAge: 30_000 });
|
|
56
75
|
* </script>
|
|
57
76
|
* ```
|
|
@@ -65,8 +84,10 @@ export function cursor(topic, options) {
|
|
|
65
84
|
|
|
66
85
|
const cursorTopic = TOPIC_PREFIX + topic;
|
|
67
86
|
|
|
68
|
-
/** @type {Map<string,
|
|
69
|
-
let
|
|
87
|
+
/** @type {Map<string, any>} */
|
|
88
|
+
let positionMap = new Map();
|
|
89
|
+
/** @type {Map<string, any>} */
|
|
90
|
+
let userMap = new Map();
|
|
70
91
|
/** @type {Map<string, number>} */
|
|
71
92
|
const timestamps = new Map();
|
|
72
93
|
const output = writable(/** @type {Map<string, any>} */ (new Map()));
|
|
@@ -78,6 +99,16 @@ export function cursor(topic, options) {
|
|
|
78
99
|
let refCount = 0;
|
|
79
100
|
let cancelled = false;
|
|
80
101
|
|
|
102
|
+
function emitOutput() {
|
|
103
|
+
const merged = new Map();
|
|
104
|
+
for (const [key, data] of positionMap) {
|
|
105
|
+
const user = userMap.get(key);
|
|
106
|
+
if (user === undefined) continue;
|
|
107
|
+
merged.set(key, { user, data });
|
|
108
|
+
}
|
|
109
|
+
output.set(merged);
|
|
110
|
+
}
|
|
111
|
+
|
|
81
112
|
function sweep() {
|
|
82
113
|
if (!maxAge || maxAge <= 0) return;
|
|
83
114
|
const cutoff = Date.now() - maxAge;
|
|
@@ -85,10 +116,11 @@ export function cursor(topic, options) {
|
|
|
85
116
|
for (const [key, ts] of timestamps) {
|
|
86
117
|
if (ts < cutoff) {
|
|
87
118
|
timestamps.delete(key);
|
|
88
|
-
if (
|
|
119
|
+
if (positionMap.delete(key)) changed = true;
|
|
120
|
+
userMap.delete(key);
|
|
89
121
|
}
|
|
90
122
|
}
|
|
91
|
-
if (changed)
|
|
123
|
+
if (changed) emitOutput();
|
|
92
124
|
}
|
|
93
125
|
|
|
94
126
|
function startListening() {
|
|
@@ -97,44 +129,55 @@ export function cursor(topic, options) {
|
|
|
97
129
|
sourceUnsub = source.subscribe((event) => {
|
|
98
130
|
if (event === null) return;
|
|
99
131
|
|
|
100
|
-
if (event.event === '
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
132
|
+
if (event.event === 'catalog' && Array.isArray(event.data)) {
|
|
133
|
+
userMap = new Map();
|
|
134
|
+
for (const entry of event.data) {
|
|
135
|
+
if (entry && typeof entry.key === 'string') {
|
|
136
|
+
userMap.set(entry.key, entry.user);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
emitOutput();
|
|
105
140
|
return;
|
|
106
141
|
}
|
|
107
142
|
|
|
108
|
-
if (event.event === '
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
143
|
+
if (event.event === 'join' && event.data != null) {
|
|
144
|
+
const { key, user } = event.data;
|
|
145
|
+
if (typeof key === 'string') {
|
|
146
|
+
userMap.set(key, user);
|
|
147
|
+
emitOutput();
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (event.event === 'update' && event.data != null) {
|
|
153
|
+
const { key, data } = event.data;
|
|
154
|
+
if (typeof key === 'string') {
|
|
155
|
+
positionMap.set(key, data);
|
|
156
|
+
timestamps.set(key, Date.now());
|
|
157
|
+
emitOutput();
|
|
116
158
|
}
|
|
117
|
-
output.set(new Map(cursorMap));
|
|
118
159
|
return;
|
|
119
160
|
}
|
|
120
161
|
|
|
121
162
|
if (event.event === 'bulk' && Array.isArray(event.data)) {
|
|
122
163
|
const now = Date.now();
|
|
123
164
|
for (const entry of event.data) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
165
|
+
if (entry && typeof entry.key === 'string') {
|
|
166
|
+
positionMap.set(entry.key, entry.data);
|
|
167
|
+
timestamps.set(entry.key, now);
|
|
168
|
+
}
|
|
127
169
|
}
|
|
128
|
-
|
|
170
|
+
emitOutput();
|
|
129
171
|
return;
|
|
130
172
|
}
|
|
131
173
|
|
|
132
174
|
if (event.event === 'remove' && event.data != null) {
|
|
133
175
|
const { key } = event.data;
|
|
176
|
+
if (typeof key !== 'string') return;
|
|
134
177
|
timestamps.delete(key);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
178
|
+
const hadPosition = positionMap.delete(key);
|
|
179
|
+
const hadUser = userMap.delete(key);
|
|
180
|
+
if (hadPosition || hadUser) emitOutput();
|
|
138
181
|
}
|
|
139
182
|
});
|
|
140
183
|
|
|
@@ -166,7 +209,8 @@ export function cursor(topic, options) {
|
|
|
166
209
|
clearInterval(sweepTimer);
|
|
167
210
|
sweepTimer = null;
|
|
168
211
|
}
|
|
169
|
-
|
|
212
|
+
positionMap = new Map();
|
|
213
|
+
userMap = new Map();
|
|
170
214
|
timestamps.clear();
|
|
171
215
|
// Push the cleared state to the output store so a new subscriber does
|
|
172
216
|
// not see ghost cursors from the previous subscription cycle.
|
|
@@ -196,3 +240,58 @@ export function cursor(topic, options) {
|
|
|
196
240
|
|
|
197
241
|
return store;
|
|
198
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Internal coalesce buffer for `move()`. One entry per topic; latest-
|
|
246
|
+
* wins inside a single animation frame. Flushed on the next rAF tick.
|
|
247
|
+
* @type {Map<string, any>}
|
|
248
|
+
*/
|
|
249
|
+
const movePending = new Map();
|
|
250
|
+
let moveScheduled = false;
|
|
251
|
+
|
|
252
|
+
// Resolve `requestAnimationFrame` at call time so a polyfill installed
|
|
253
|
+
// after this module imports (or a test harness substitution) is honored.
|
|
254
|
+
function scheduleFrame(cb) {
|
|
255
|
+
if (typeof requestAnimationFrame !== 'undefined') return requestAnimationFrame(cb);
|
|
256
|
+
return setTimeout(cb, 16);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Send a cursor move on a topic. Frames are coalesced via
|
|
261
|
+
* `requestAnimationFrame` so calling `move()` at 1000 Hz (high-DPI
|
|
262
|
+
* mouse) collapses to at most one send per repaint, matching the
|
|
263
|
+
* server-side `topicThrottle` default. Multi-topic callers do not
|
|
264
|
+
* clobber each other.
|
|
265
|
+
*
|
|
266
|
+
* No-op in non-browser environments.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} topic
|
|
269
|
+
* @param {any} data
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* ```svelte
|
|
273
|
+
* <script>
|
|
274
|
+
* import { move } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
275
|
+
*
|
|
276
|
+
* function onmousemove(e) {
|
|
277
|
+
* move('canvas', { x: e.clientX, y: e.clientY });
|
|
278
|
+
* }
|
|
279
|
+
* </script>
|
|
280
|
+
*
|
|
281
|
+
* <div on:mousemove={onmousemove}> ... </div>
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
export function move(topic, data) {
|
|
285
|
+
if (typeof window === 'undefined') return;
|
|
286
|
+
movePending.set(topic, data);
|
|
287
|
+
if (moveScheduled) return;
|
|
288
|
+
moveScheduled = true;
|
|
289
|
+
scheduleFrame(() => {
|
|
290
|
+
moveScheduled = false;
|
|
291
|
+
const conn = connect();
|
|
292
|
+
for (const [t, d] of movePending) {
|
|
293
|
+
conn.send({ type: 'cursor', topic: t, data: d });
|
|
294
|
+
}
|
|
295
|
+
movePending.clear();
|
|
296
|
+
});
|
|
297
|
+
}
|
|
@@ -6,14 +6,33 @@ export interface CursorOptions<UserData = unknown, UserInfo = unknown> {
|
|
|
6
6
|
* Minimum milliseconds between broadcasts per user per topic.
|
|
7
7
|
* A trailing-edge timer ensures the final position is always sent.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Lower for high-refresh demos (8 = 120 Hz), higher to conserve
|
|
10
|
+
* bandwidth (33 = 30 Hz). Set to 0 to disable.
|
|
11
|
+
*
|
|
12
|
+
* @default 16 (~60 Hz)
|
|
10
13
|
*/
|
|
11
14
|
throttle?: number;
|
|
12
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Per-topic aggregate coalesce window in ms. Each topic emits at
|
|
18
|
+
* most one frame per window, carrying the latest position for every
|
|
19
|
+
* cursor that moved (a single `update` when one mover is dirty, a
|
|
20
|
+
* `bulk` array otherwise). Bandwidth per peer scales with active-
|
|
21
|
+
* mover count, not with mover-count times per-mover rate.
|
|
22
|
+
*
|
|
23
|
+
* Raise (e.g. 33 = 30 Hz) for high-density rooms where wire bytes
|
|
24
|
+
* dominate. Lower (e.g. 8 = 120 Hz) for high-refresh demos. 0
|
|
25
|
+
* disables coalescing; per-cursor `throttle` then governs broadcast
|
|
26
|
+
* rate.
|
|
27
|
+
*
|
|
28
|
+
* @default 16 (~60 Hz)
|
|
29
|
+
*/
|
|
30
|
+
topicThrottle?: number;
|
|
31
|
+
|
|
13
32
|
/**
|
|
14
33
|
* Extract user-identifying data from a connection's userData.
|
|
15
|
-
* This is
|
|
16
|
-
*
|
|
34
|
+
* This is announced on the `catalog` / `join` channel when a user
|
|
35
|
+
* first appears on a topic, not on every position frame.
|
|
17
36
|
*
|
|
18
37
|
* Defaults to the full userData object.
|
|
19
38
|
*
|
|
@@ -41,8 +60,8 @@ export interface CursorOptions<UserData = unknown, UserInfo = unknown> {
|
|
|
41
60
|
/**
|
|
42
61
|
* Hard cap on the active topic registry. When the cap is reached,
|
|
43
62
|
* the oldest insertion-order topic is dropped on the next `update()`
|
|
44
|
-
* for a new topic; any pending throttle timers on the
|
|
45
|
-
* are cleared first.
|
|
63
|
+
* for a new topic; any pending throttle and coalesce timers on the
|
|
64
|
+
* dropped topic are cleared first.
|
|
46
65
|
*
|
|
47
66
|
* @default 1_000_000
|
|
48
67
|
*/
|
|
@@ -83,7 +102,12 @@ export interface CursorEntry<UserInfo = unknown, Data = unknown> {
|
|
|
83
102
|
|
|
84
103
|
export interface CursorTracker<UserInfo = unknown> {
|
|
85
104
|
/**
|
|
86
|
-
* Broadcast a cursor position update. Throttled per user per topic
|
|
105
|
+
* Broadcast a cursor position update. Throttled per user per topic
|
|
106
|
+
* and optionally coalesced per topic via `topicThrottle`.
|
|
107
|
+
*
|
|
108
|
+
* The first call for a (ws, topic) pair also emits a `join` event
|
|
109
|
+
* carrying the user's catalog entry; subsequent calls emit only
|
|
110
|
+
* positions (`update` or `bulk`).
|
|
87
111
|
*
|
|
88
112
|
* Call this from your `message` hook when you receive cursor data.
|
|
89
113
|
*
|
|
@@ -112,14 +136,16 @@ export interface CursorTracker<UserInfo = unknown> {
|
|
|
112
136
|
list(topic: string): CursorEntry<UserInfo>[];
|
|
113
137
|
|
|
114
138
|
/**
|
|
115
|
-
* Send current cursor positions for a topic to a single connection
|
|
139
|
+
* Send current cursor positions for a topic to a single connection
|
|
140
|
+
* as a `catalog` + `bulk` pair (roster, then positions).
|
|
116
141
|
*
|
|
117
142
|
* Call this from your `message` handler when the client sends a
|
|
118
143
|
* `{ type: 'cursor-snapshot', topic }` request. The `cursor()` client
|
|
119
144
|
* store sends this automatically on subscribe, so late joiners see
|
|
120
145
|
* existing cursors immediately without waiting for the next move event.
|
|
121
146
|
*
|
|
122
|
-
*
|
|
147
|
+
* Sends an empty `catalog` and `bulk` when the topic has no active
|
|
148
|
+
* cursors.
|
|
123
149
|
*
|
|
124
150
|
* @example
|
|
125
151
|
* ```js
|
|
@@ -169,7 +195,8 @@ export interface CursorTracker<UserInfo = unknown> {
|
|
|
169
195
|
* import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
|
|
170
196
|
*
|
|
171
197
|
* export const cursors = createCursor({
|
|
172
|
-
* throttle:
|
|
198
|
+
* throttle: 16, // 60 Hz per-cursor rate (default)
|
|
199
|
+
* topicThrottle: 16, // 60 Hz per-topic coalescing (default)
|
|
173
200
|
* select: (userData) => ({ id: userData.id, name: userData.name })
|
|
174
201
|
* });
|
|
175
202
|
* ```
|
package/plugins/cursor/server.js
CHANGED
|
@@ -9,6 +9,24 @@
|
|
|
9
9
|
* Zero impact on the adapter core - this is a standalone module that
|
|
10
10
|
* uses platform.publish() and platform.send().
|
|
11
11
|
*
|
|
12
|
+
* Wire shape (channel `__cursor:{topic}`):
|
|
13
|
+
* - `catalog` [{key, user}, ...] - sent on snapshot() to a single
|
|
14
|
+
* newly-attaching subscriber.
|
|
15
|
+
* - `join` {key, user} - emitted once per (ws, topic) the
|
|
16
|
+
* first time that ws updates on the
|
|
17
|
+
* topic. Broadcast to all subscribers.
|
|
18
|
+
* - `update` {key, data} - single-mover position update.
|
|
19
|
+
* - `bulk` [{key, data}, ...] - per-topic coalesced positions when
|
|
20
|
+
* `topicThrottle` is enabled and >1
|
|
21
|
+
* mover is pending in the window.
|
|
22
|
+
* - `remove` {key} - user is gone from the topic.
|
|
23
|
+
*
|
|
24
|
+
* User metadata (the `select()`ed userData) lives on the catalog channel
|
|
25
|
+
* (catalog + join), not on every position frame. This matches the
|
|
26
|
+
* cluster-aware Redis-backed variant in the extensions package so a
|
|
27
|
+
* single browser bundle (`plugins/cursor/client`) works against either
|
|
28
|
+
* backend.
|
|
29
|
+
*
|
|
12
30
|
* MULTI-TENANT NOTE
|
|
13
31
|
* Cursor state is keyed by the topic name verbatim. Apps running
|
|
14
32
|
* multiple tenants in one process must namespace topic names with
|
|
@@ -20,16 +38,37 @@
|
|
|
20
38
|
|
|
21
39
|
const TOPIC_PREFIX = '__cursor:';
|
|
22
40
|
|
|
41
|
+
/** Wire-protocol event names. */
|
|
42
|
+
const EVENTS = Object.freeze({
|
|
43
|
+
CATALOG: 'catalog',
|
|
44
|
+
JOIN: 'join',
|
|
45
|
+
UPDATE: 'update',
|
|
46
|
+
BULK: 'bulk',
|
|
47
|
+
REMOVE: 'remove'
|
|
48
|
+
});
|
|
49
|
+
|
|
23
50
|
/**
|
|
24
51
|
* @typedef {Object} CursorOptions
|
|
25
|
-
* @property {number} [throttle=
|
|
26
|
-
* user per topic. A trailing-edge timer fires to ensure the final
|
|
27
|
-
* always sent
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
52
|
+
* @property {number} [throttle=16] - Minimum milliseconds between broadcasts
|
|
53
|
+
* per user per topic. A trailing-edge timer fires to ensure the final
|
|
54
|
+
* position is always sent. Default 16 (~60 Hz) suits collaborative
|
|
55
|
+
* apps; lower (e.g. 8 for 120 Hz) for high-refresh demos, higher to
|
|
56
|
+
* conserve bandwidth.
|
|
57
|
+
* @property {number} [topicThrottle=16] - World-state tick rate, in ms.
|
|
58
|
+
* Per-topic aggregate cap on broadcasts: each topic emits at most one
|
|
59
|
+
* frame per window, carrying the latest position for every cursor that
|
|
60
|
+
* moved (a single `update` when one mover is dirty, a `bulk` array
|
|
61
|
+
* otherwise). Bandwidth per peer scales with active-mover count, not
|
|
62
|
+
* with mover-count times per-mover rate. Default 16 (~60 Hz) suits
|
|
63
|
+
* small-to-medium rooms; raise to 33 (~30 Hz) for high-density rooms
|
|
64
|
+
* where wire bytes dominate. 0 disables the tick; per-cursor `throttle`
|
|
65
|
+
* then governs broadcast rate.
|
|
66
|
+
* @property {(userData: any) => any} [select] - Extract user-identifying data
|
|
67
|
+
* from the connection's userData. This is announced on the `catalog` /
|
|
68
|
+
* `join` channel when a user first appears on a topic. Defaults to the
|
|
69
|
+
* full userData. Should return JSON-serializable data (plain objects,
|
|
70
|
+
* arrays, strings, numbers, booleans, null). The same applies to the
|
|
71
|
+
* `data` argument passed to `update()`.
|
|
33
72
|
*/
|
|
34
73
|
|
|
35
74
|
/**
|
|
@@ -42,8 +81,9 @@ const TOPIC_PREFIX = '__cursor:';
|
|
|
42
81
|
/**
|
|
43
82
|
* @typedef {Object} CursorTracker
|
|
44
83
|
* @property {(ws: any, topic: string, data: any, platform: import('../../index.js').Platform) => void} update -
|
|
45
|
-
* Broadcast a cursor position update. Throttled per user per topic
|
|
46
|
-
* Call this from your `message` hook
|
|
84
|
+
* Broadcast a cursor position update. Throttled per user per topic and
|
|
85
|
+
* optionally coalesced per topic. Call this from your `message` hook
|
|
86
|
+
* when you receive cursor data.
|
|
47
87
|
* @property {(ws: any, platform: import('../../index.js').Platform) => void} remove -
|
|
48
88
|
* Remove a connection's cursor state from all topics and broadcast removal.
|
|
49
89
|
* Call this from your `close` hook.
|
|
@@ -51,6 +91,12 @@ const TOPIC_PREFIX = '__cursor:';
|
|
|
51
91
|
* Get current cursor positions for a topic. Use in load() functions for SSR.
|
|
52
92
|
* Returns deep copies (via structuredClone) when data is JSON-serializable.
|
|
53
93
|
* Falls back to shared references for non-cloneable data.
|
|
94
|
+
* @property {(ws: any, topic: string, platform: import('../../index.js').Platform) => void} snapshot -
|
|
95
|
+
* Send current cursor positions for a topic to a single connection as
|
|
96
|
+
* a `catalog` + `bulk` pair (roster, then positions). Call from your
|
|
97
|
+
* `message` handler when the client sends `{type: 'cursor-snapshot',
|
|
98
|
+
* topic}`. The `cursor()` client store sends this automatically on
|
|
99
|
+
* subscribe so late joiners see existing cursors immediately.
|
|
54
100
|
* @property {() => void} clear -
|
|
55
101
|
* Clear all cursor tracking state and pending timers.
|
|
56
102
|
*/
|
|
@@ -67,13 +113,20 @@ const TOPIC_PREFIX = '__cursor:';
|
|
|
67
113
|
* import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
|
|
68
114
|
*
|
|
69
115
|
* export const cursors = createCursor({
|
|
70
|
-
* throttle:
|
|
116
|
+
* throttle: 16, // 60 Hz per-cursor rate (default)
|
|
117
|
+
* topicThrottle: 16, // 60 Hz per-topic coalescing (default)
|
|
71
118
|
* select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
|
|
72
119
|
* });
|
|
73
120
|
* ```
|
|
74
121
|
*
|
|
75
122
|
* @example
|
|
76
123
|
* ```js
|
|
124
|
+
* // 120 Hz demo: halve both intervals
|
|
125
|
+
* createCursor({ throttle: 8, topicThrottle: 8 });
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```js
|
|
77
130
|
* // src/hooks.ws.js - using hooks helper
|
|
78
131
|
* import { cursors } from '$lib/server/cursors';
|
|
79
132
|
*
|
|
@@ -86,7 +139,8 @@ const TOPIC_PREFIX = '__cursor:';
|
|
|
86
139
|
* ```
|
|
87
140
|
*/
|
|
88
141
|
export function createCursor(options = {}) {
|
|
89
|
-
const throttleMs = options.throttle ??
|
|
142
|
+
const throttleMs = options.throttle ?? 16;
|
|
143
|
+
const topicThrottleMs = options.topicThrottle ?? 16;
|
|
90
144
|
const select = options.select || ((userData) => userData);
|
|
91
145
|
const maxConnections = options.maxConnections ?? 1_000_000;
|
|
92
146
|
const maxTopics = options.maxTopics ?? 1_000_000;
|
|
@@ -96,6 +150,9 @@ export function createCursor(options = {}) {
|
|
|
96
150
|
if (typeof throttleMs !== 'number' || !Number.isFinite(throttleMs) || throttleMs < 0) {
|
|
97
151
|
throw new Error('cursor: throttle must be a non-negative number');
|
|
98
152
|
}
|
|
153
|
+
if (typeof topicThrottleMs !== 'number' || !Number.isFinite(topicThrottleMs) || topicThrottleMs < 0) {
|
|
154
|
+
throw new Error('cursor: topicThrottle must be a non-negative number');
|
|
155
|
+
}
|
|
99
156
|
if (typeof select !== 'function') {
|
|
100
157
|
throw new Error('cursor: select must be a function');
|
|
101
158
|
}
|
|
@@ -116,7 +173,9 @@ export function createCursor(options = {}) {
|
|
|
116
173
|
let connCounter = 0;
|
|
117
174
|
|
|
118
175
|
/**
|
|
119
|
-
* Per-ws state:
|
|
176
|
+
* Per-ws state: connection key, selected user data, and which topics
|
|
177
|
+
* this ws has already announced (the `topics` set doubles as the
|
|
178
|
+
* already-joined set - presence in the set means a `join` has fired).
|
|
120
179
|
* Capped at `maxConnections` - oldest insertion-order entry evicted
|
|
121
180
|
* on new insert at cap. Eviction is rare in practice because user
|
|
122
181
|
* code is expected to call `remove(ws)` on disconnect.
|
|
@@ -125,14 +184,23 @@ export function createCursor(options = {}) {
|
|
|
125
184
|
const wsState = new Map();
|
|
126
185
|
|
|
127
186
|
/**
|
|
128
|
-
* Per-topic cursor
|
|
187
|
+
* Per-topic local cursor state. Drives the per-(ws, topic) throttle
|
|
188
|
+
* and the post-disconnect cleanup. Capped at `maxTopics` - oldest
|
|
129
189
|
* insertion-order topic evicted on new insert at cap. Each evicted
|
|
130
|
-
* topic's pending timers are cleared first
|
|
131
|
-
* a deleted entry.
|
|
190
|
+
* topic's pending throttle and coalesce timers are cleared first.
|
|
132
191
|
* @type {Map<string, Map<string, { user: any, data: any, lastBroadcast: number, timer: any }>>}
|
|
133
192
|
*/
|
|
134
193
|
const topics = new Map();
|
|
135
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Per-topic aggregate throttle state for `topicThrottle` coalescing.
|
|
197
|
+
* Dirty entries are keyed by connection key; latest-wins. When the
|
|
198
|
+
* coalesce window elapses, `dirty.size === 1` sends a single `update`
|
|
199
|
+
* and any other count sends one `bulk` array.
|
|
200
|
+
* @type {Map<string, { lastFlush: number, timer: any, dirty: Map<string, { data: any, platform: any }> }>}
|
|
201
|
+
*/
|
|
202
|
+
const topicFlush = new Map();
|
|
203
|
+
|
|
136
204
|
/**
|
|
137
205
|
* Get or create ws state and return the connection key + user data.
|
|
138
206
|
* @param {any} ws
|
|
@@ -156,15 +224,100 @@ export function createCursor(options = {}) {
|
|
|
156
224
|
}
|
|
157
225
|
|
|
158
226
|
/**
|
|
159
|
-
*
|
|
227
|
+
* Drop the topic's coalesce state (clears any pending timer first).
|
|
228
|
+
* @param {string} topic
|
|
229
|
+
*/
|
|
230
|
+
function clearTopicFlush(topic) {
|
|
231
|
+
const flushState = topicFlush.get(topic);
|
|
232
|
+
if (!flushState) return;
|
|
233
|
+
if (flushState.timer) clearTimeout(flushState.timer);
|
|
234
|
+
topicFlush.delete(topic);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Emit `join` for a (ws, topic) pair the first time the ws moves on
|
|
239
|
+
* the topic. Broadcast (not single-target) so existing subscribers
|
|
240
|
+
* pick up the new user before any position frames arrive.
|
|
241
|
+
*/
|
|
242
|
+
function emitJoin(topic, key, user, platform) {
|
|
243
|
+
platform.publish(TOPIC_PREFIX + topic, EVENTS.JOIN, { key, user });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Publish a single-mover position update.
|
|
160
248
|
* @param {string} topic
|
|
161
249
|
* @param {string} key
|
|
162
|
-
* @param {any} user
|
|
163
250
|
* @param {any} data
|
|
164
251
|
* @param {import('../../index.js').Platform} platform
|
|
165
252
|
*/
|
|
166
|
-
function
|
|
167
|
-
platform.publish(TOPIC_PREFIX + topic,
|
|
253
|
+
function doBroadcast(topic, key, data, platform) {
|
|
254
|
+
platform.publish(TOPIC_PREFIX + topic, EVENTS.UPDATE, { key, data });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Flush all coalesced entries for a topic. One entry -> `update`,
|
|
259
|
+
* many entries -> single `bulk` array.
|
|
260
|
+
* @param {string} topic
|
|
261
|
+
* @param {Map<string, { data: any, platform: any }>} dirty
|
|
262
|
+
*/
|
|
263
|
+
function flushDirty(topic, dirty) {
|
|
264
|
+
if (dirty.size === 0) return;
|
|
265
|
+
if (dirty.size === 1) {
|
|
266
|
+
const [k, v] = dirty.entries().next().value;
|
|
267
|
+
doBroadcast(topic, k, v.data, v.platform);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const entries = [];
|
|
271
|
+
let flushPlatform = null;
|
|
272
|
+
for (const [k, v] of dirty) {
|
|
273
|
+
entries.push({ key: k, data: v.data });
|
|
274
|
+
flushPlatform = v.platform;
|
|
275
|
+
}
|
|
276
|
+
if (flushPlatform) {
|
|
277
|
+
flushPlatform.publish(TOPIC_PREFIX + topic, EVENTS.BULK, entries);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Route a broadcast through the per-topic coalesce window when
|
|
283
|
+
* `topicThrottle` is enabled, or directly publish when disabled.
|
|
284
|
+
*/
|
|
285
|
+
function broadcast(topic, key, data, platform) {
|
|
286
|
+
if (topicThrottleMs <= 0) {
|
|
287
|
+
doBroadcast(topic, key, data, platform);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let state = topicFlush.get(topic);
|
|
292
|
+
if (!state) {
|
|
293
|
+
state = { lastFlush: 0, timer: null, dirty: new Map() };
|
|
294
|
+
topicFlush.set(topic, state);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
state.dirty.set(key, { data, platform });
|
|
298
|
+
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
if (now - state.lastFlush >= topicThrottleMs) {
|
|
301
|
+
if (state.timer) {
|
|
302
|
+
clearTimeout(state.timer);
|
|
303
|
+
state.timer = null;
|
|
304
|
+
}
|
|
305
|
+
state.lastFlush = now;
|
|
306
|
+
flushDirty(topic, state.dirty);
|
|
307
|
+
state.dirty.clear();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!state.timer) {
|
|
312
|
+
state.timer = setTimeout(() => {
|
|
313
|
+
const s = topicFlush.get(topic);
|
|
314
|
+
if (!s) return;
|
|
315
|
+
s.timer = null;
|
|
316
|
+
s.lastFlush = Date.now();
|
|
317
|
+
flushDirty(topic, s.dirty);
|
|
318
|
+
s.dirty.clear();
|
|
319
|
+
}, topicThrottleMs - (now - state.lastFlush));
|
|
320
|
+
}
|
|
168
321
|
}
|
|
169
322
|
|
|
170
323
|
/** @type {CursorTracker} */
|
|
@@ -187,6 +340,7 @@ export function createCursor(options = {}) {
|
|
|
187
340
|
if (dataBytes > maxDataBytes) return;
|
|
188
341
|
}
|
|
189
342
|
const state = getWsState(ws);
|
|
343
|
+
const isFirstOnTopic = !state.topics.has(topic);
|
|
190
344
|
state.topics.add(topic);
|
|
191
345
|
|
|
192
346
|
let topicMap = topics.get(topic);
|
|
@@ -201,12 +355,17 @@ export function createCursor(options = {}) {
|
|
|
201
355
|
}
|
|
202
356
|
}
|
|
203
357
|
topics.delete(oldest);
|
|
358
|
+
clearTopicFlush(oldest);
|
|
204
359
|
}
|
|
205
360
|
}
|
|
206
361
|
topicMap = new Map();
|
|
207
362
|
topics.set(topic, topicMap);
|
|
208
363
|
}
|
|
209
364
|
|
|
365
|
+
if (isFirstOnTopic) {
|
|
366
|
+
emitJoin(topic, state.key, state.user, platform);
|
|
367
|
+
}
|
|
368
|
+
|
|
210
369
|
let entry = topicMap.get(state.key);
|
|
211
370
|
const now = Date.now();
|
|
212
371
|
|
|
@@ -226,20 +385,19 @@ export function createCursor(options = {}) {
|
|
|
226
385
|
entry.timer = null;
|
|
227
386
|
}
|
|
228
387
|
entry.lastBroadcast = now;
|
|
229
|
-
broadcast(topic, state.key,
|
|
388
|
+
broadcast(topic, state.key, data, platform);
|
|
230
389
|
return;
|
|
231
390
|
}
|
|
232
391
|
|
|
233
392
|
// Trailing edge: schedule a broadcast for the end of the window
|
|
234
393
|
if (!entry.timer) {
|
|
235
394
|
const key = state.key;
|
|
236
|
-
const user = state.user;
|
|
237
395
|
entry.timer = setTimeout(() => {
|
|
238
396
|
const e = topicMap.get(key);
|
|
239
397
|
if (e) {
|
|
240
398
|
e.lastBroadcast = Date.now();
|
|
241
399
|
e.timer = null;
|
|
242
|
-
broadcast(topic, key,
|
|
400
|
+
broadcast(topic, key, e.data, platform);
|
|
243
401
|
}
|
|
244
402
|
}, throttleMs - (now - entry.lastBroadcast));
|
|
245
403
|
}
|
|
@@ -259,8 +417,12 @@ export function createCursor(options = {}) {
|
|
|
259
417
|
topicMap.delete(state.key);
|
|
260
418
|
if (topicMap.size === 0) {
|
|
261
419
|
topics.delete(topic);
|
|
420
|
+
clearTopicFlush(topic);
|
|
421
|
+
} else {
|
|
422
|
+
const flushState = topicFlush.get(topic);
|
|
423
|
+
if (flushState) flushState.dirty.delete(state.key);
|
|
262
424
|
}
|
|
263
|
-
platform.publish(TOPIC_PREFIX + topic,
|
|
425
|
+
platform.publish(TOPIC_PREFIX + topic, EVENTS.REMOVE, { key: state.key });
|
|
264
426
|
}
|
|
265
427
|
}
|
|
266
428
|
|
|
@@ -273,30 +435,36 @@ export function createCursor(options = {}) {
|
|
|
273
435
|
const result = [];
|
|
274
436
|
for (const [key, entry] of topicMap) {
|
|
275
437
|
const item = { key, user: entry.user, data: entry.data };
|
|
276
|
-
|
|
438
|
+
try { result.push(structuredClone(item)); } catch { result.push(item); }
|
|
277
439
|
}
|
|
278
440
|
return result;
|
|
279
441
|
},
|
|
280
442
|
|
|
281
443
|
snapshot(ws, topic, platform) {
|
|
282
444
|
const topicMap = topics.get(topic);
|
|
283
|
-
const
|
|
445
|
+
const catalog = [];
|
|
446
|
+
const positions = [];
|
|
284
447
|
if (topicMap) {
|
|
285
448
|
for (const [key, entry] of topicMap) {
|
|
286
|
-
|
|
449
|
+
catalog.push({ key, user: entry.user });
|
|
450
|
+
positions.push({ key, data: entry.data });
|
|
287
451
|
}
|
|
288
452
|
}
|
|
289
|
-
platform.send(ws, TOPIC_PREFIX + topic,
|
|
453
|
+
platform.send(ws, TOPIC_PREFIX + topic, EVENTS.CATALOG, catalog);
|
|
454
|
+
platform.send(ws, TOPIC_PREFIX + topic, EVENTS.BULK, positions);
|
|
290
455
|
},
|
|
291
456
|
|
|
292
457
|
clear() {
|
|
293
|
-
// Clear all timers
|
|
294
458
|
for (const [, topicMap] of topics) {
|
|
295
459
|
for (const [, entry] of topicMap) {
|
|
296
460
|
if (entry.timer) clearTimeout(entry.timer);
|
|
297
461
|
}
|
|
298
462
|
}
|
|
463
|
+
for (const [, state] of topicFlush) {
|
|
464
|
+
if (state.timer) clearTimeout(state.timer);
|
|
465
|
+
}
|
|
299
466
|
topics.clear();
|
|
467
|
+
topicFlush.clear();
|
|
300
468
|
wsState.clear();
|
|
301
469
|
connCounter = 0;
|
|
302
470
|
},
|