svelte-adapter-uws 0.2.17 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -286,6 +286,8 @@ The Vite plugin is required for WebSocket support in both dev and production (se
286
286
 
287
287
  Changes to your `hooks.ws` file are picked up automatically — the plugin reloads the handler on save, no dev server restart needed.
288
288
 
289
+ **Note:** The dev server does not enforce `allowedOrigins`. Origin checks only run in production. A warning is logged at startup as a reminder.
290
+
289
291
  **vite.config.js**
290
292
  ```js
291
293
  import { sveltekit } from '@sveltejs/kit/vite';
@@ -1241,6 +1243,78 @@ export async function subscribe(ws, topic, { platform }) {
1241
1243
 
1242
1244
  Opt-in modules that build on top of the adapter's public API. They don't change any core behavior -- if you don't import them, they don't exist. Each plugin ships in its own subdirectory under `plugins/` with separate server and client entry points.
1243
1245
 
1246
+ ### Middleware
1247
+
1248
+ Composable message processing pipeline. Chain functions that run on inbound messages before your handler logic. Each middleware receives a context and a `next` function -- call `next()` to continue, skip it to stop the chain.
1249
+
1250
+ #### Setup
1251
+
1252
+ ```js
1253
+ // src/lib/server/pipeline.js
1254
+ import { createMiddleware } from 'svelte-adapter-uws/plugins/middleware';
1255
+
1256
+ export const pipeline = createMiddleware(
1257
+ // logging
1258
+ async (ctx, next) => {
1259
+ console.log(`[${ctx.topic}] ${ctx.event}`);
1260
+ await next();
1261
+ },
1262
+ // auth check
1263
+ async (ctx, next) => {
1264
+ const userId = ctx.ws.getUserData()?.userId;
1265
+ if (!userId) return; // stop chain -- unauthenticated
1266
+ ctx.locals.userId = userId;
1267
+ await next();
1268
+ },
1269
+ // data enrichment
1270
+ async (ctx, next) => {
1271
+ ctx.data = { ...ctx.data, processedAt: Date.now() };
1272
+ await next();
1273
+ }
1274
+ );
1275
+ ```
1276
+
1277
+ #### Usage
1278
+
1279
+ ```js
1280
+ // src/hooks.ws.js
1281
+ import { pipeline } from '$lib/server/pipeline';
1282
+
1283
+ export async function message(ws, data, { platform }) {
1284
+ const msg = JSON.parse(Buffer.from(data).toString());
1285
+ const ctx = await pipeline.run(ws, msg, platform);
1286
+ if (!ctx) return; // chain was stopped (e.g. auth failed)
1287
+
1288
+ // ctx.locals.userId is available here
1289
+ // ctx.data has the enriched data
1290
+ }
1291
+ ```
1292
+
1293
+ #### API
1294
+
1295
+ | Method | Description |
1296
+ |---|---|
1297
+ | `pipeline.run(ws, message, platform)` | Execute the chain. Returns context or `null` if stopped |
1298
+ | `pipeline.use(fn)` | Append a middleware at runtime |
1299
+
1300
+ The context object:
1301
+
1302
+ | Field | Description |
1303
+ |---|---|
1304
+ | `ctx.ws` | The WebSocket connection |
1305
+ | `ctx.message` | Original parsed message |
1306
+ | `ctx.topic` | Message topic (mutable) |
1307
+ | `ctx.event` | Message event (mutable) |
1308
+ | `ctx.data` | Message data (mutable) |
1309
+ | `ctx.platform` | Platform reference |
1310
+ | `ctx.locals` | Scratch space for middleware to share data |
1311
+
1312
+ #### Limitations
1313
+
1314
+ - **Server-side only.** No client component.
1315
+ - **No state.** The middleware itself is stateless -- it's a pure pipeline. Use `ctx.locals` to pass data between middlewares within a single message.
1316
+ - **Double `next()` guard.** Calling `next()` twice in the same middleware is a no-op (the second call does nothing).
1317
+
1244
1318
  ### Replay (SSR gap)
1245
1319
 
1246
1320
  When you combine SSR with WebSocket live updates, there's a gap between server-side data loading and the moment the client's WebSocket connects. Messages published during that window are lost.
@@ -1636,6 +1710,304 @@ t=260 [timer fires, 100ms] --> sends {q:"hel"}
1636
1710
  - **Latest value only.** Intermediate values within an interval are discarded, not queued. If you need every message delivered, don't throttle.
1637
1711
  - **Timer-based.** Uses `setTimeout` internally. Precision depends on Node.js event loop load (typically < 1ms drift).
1638
1712
 
1713
+ ### Rate limiting
1714
+
1715
+ Token-bucket rate limiter for inbound WebSocket messages. Protects against spam, abuse, and runaway clients. Supports per-IP, per-connection, or custom key extraction, with optional auto-ban when a bucket is exhausted.
1716
+
1717
+ Different from throttle -- throttle shapes **outbound** publish rate, rate limiting protects **inbound** against abuse.
1718
+
1719
+ #### Setup
1720
+
1721
+ ```js
1722
+ // src/lib/server/ratelimit.js
1723
+ import { createRateLimit } from 'svelte-adapter-uws/plugins/ratelimit';
1724
+
1725
+ export const limiter = createRateLimit({
1726
+ points: 10, // 10 messages
1727
+ interval: 1000, // per second
1728
+ blockDuration: 30000 // auto-ban for 30s when exhausted
1729
+ });
1730
+ ```
1731
+
1732
+ #### Usage
1733
+
1734
+ ```js
1735
+ // src/hooks.ws.js
1736
+ import { limiter } from '$lib/server/ratelimit';
1737
+
1738
+ export function message(ws, data, { platform }) {
1739
+ const { allowed, remaining, resetMs } = limiter.consume(ws);
1740
+ if (!allowed) return; // drop the message
1741
+
1742
+ // ... handle message normally
1743
+ }
1744
+ ```
1745
+
1746
+ #### API
1747
+
1748
+ | Method | Description |
1749
+ |---|---|
1750
+ | `limiter.consume(ws, cost?)` | Deduct tokens (cost must be >= 0, defaults to 1), returns `{ allowed, remaining, resetMs }` |
1751
+ | `limiter.reset(key)` | Clear the bucket for a key |
1752
+ | `limiter.ban(key, duration?)` | Manually ban a key |
1753
+ | `limiter.unban(key)` | Remove a ban |
1754
+ | `limiter.clear()` | Reset all state |
1755
+
1756
+ #### Options
1757
+
1758
+ | Option | Default | Description |
1759
+ |---|---|---|
1760
+ | `points` | *required* | Tokens per interval (positive integer) |
1761
+ | `interval` | *required* | Refill interval in ms |
1762
+ | `blockDuration` | `0` | Auto-ban duration in ms when exhausted (0 = no auto-ban) |
1763
+ | `keyBy` | `'ip'` | `'ip'`, `'connection'`, or `(ws) => string` |
1764
+
1765
+ With `keyBy: 'ip'` (default), the limiter reads `userData.remoteAddress`, `.ip`, or `.address`. With `keyBy: 'connection'`, each WebSocket gets its own bucket. Pass a function for custom grouping (e.g. by user ID or room).
1766
+
1767
+ #### Limitations
1768
+
1769
+ - **Server-side only.** No client component needed.
1770
+ - **In-memory.** Buckets live in the process. In cluster mode, each worker has independent rate limits (acceptable for most apps -- abusers hit the same worker via the acceptor).
1771
+ - **Lazy cleanup.** Expired buckets are swept when the internal map exceeds 1000 entries.
1772
+
1773
+ ### Cursor (ephemeral state)
1774
+
1775
+ Lightweight fire-and-forget broadcasting for transient state -- mouse cursors, text selections, drag positions, drawing strokes. Built-in throttle with trailing edge ensures the final position always arrives. Auto-cleanup on disconnect.
1776
+
1777
+ #### Setup
1778
+
1779
+ ```js
1780
+ // src/lib/server/cursors.js
1781
+ import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
1782
+
1783
+ export const cursors = createCursor({
1784
+ throttle: 50, // at most one broadcast per 50ms per user per topic
1785
+ select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
1786
+ });
1787
+ ```
1788
+
1789
+ #### Server usage
1790
+
1791
+ ```js
1792
+ // src/hooks.ws.js
1793
+ import { cursors } from '$lib/server/cursors';
1794
+
1795
+ export function message(ws, data, { platform }) {
1796
+ const msg = JSON.parse(Buffer.from(data).toString());
1797
+ if (msg.type === 'cursor') {
1798
+ cursors.update(ws, msg.topic, { x: msg.x, y: msg.y }, platform);
1799
+ }
1800
+ }
1801
+
1802
+ export function close(ws, { platform }) {
1803
+ cursors.remove(ws, platform);
1804
+ }
1805
+ ```
1806
+
1807
+ #### Client usage
1808
+
1809
+ ```svelte
1810
+ <script>
1811
+ import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
1812
+
1813
+ const positions = cursor('canvas');
1814
+ </script>
1815
+
1816
+ {#each [...$positions] as [key, { user, data }] (key)}
1817
+ <div
1818
+ class="cursor-dot"
1819
+ style="left: {data.x}px; top: {data.y}px; background: {user.color}"
1820
+ >
1821
+ {user.name}
1822
+ </div>
1823
+ {/each}
1824
+ ```
1825
+
1826
+ The client store is a `Readable<Map<string, { user, data }>>`. The Map updates when cursors move or disconnect.
1827
+
1828
+ #### Server API
1829
+
1830
+ | Method | Description |
1831
+ |---|---|
1832
+ | `cursors.update(ws, topic, data, platform)` | Broadcast position (throttled) |
1833
+ | `cursors.remove(ws, platform)` | Remove from all topics, broadcast removal |
1834
+ | `cursors.list(topic)` | Current positions (for SSR) |
1835
+ | `cursors.clear()` | Reset all state and timers |
1836
+
1837
+ #### How throttle works
1838
+
1839
+ The cursor plugin uses leading edge + trailing edge throttle internally:
1840
+
1841
+ ```
1842
+ t=0 update({x:0}) --> broadcasts immediately (leading edge)
1843
+ t=20 update({x:5}) --> stored (within 50ms window)
1844
+ t=40 update({x:9}) --> stored (overwrites x:5)
1845
+ t=50 [timer fires] --> broadcasts {x:9} (trailing edge)
1846
+ ```
1847
+
1848
+ The trailing edge ensures you always see where the cursor stopped, even if the user stops moving mid-window.
1849
+
1850
+ #### Limitations
1851
+
1852
+ - **In-memory.** Cursor positions live in the process. In cluster mode, each worker tracks its own connections.
1853
+ - **No persistence.** Positions are lost on restart. This is intentional -- cursors are ephemeral.
1854
+
1855
+ ### Queue (ordered delivery)
1856
+
1857
+ Per-key async task queue with configurable concurrency and backpressure. Guarantees in-order processing per key -- useful for sequential operations like collaborative editing, turn-based games, or transaction sequences.
1858
+
1859
+ #### Setup
1860
+
1861
+ ```js
1862
+ // src/lib/server/queue.js
1863
+ import { createQueue } from 'svelte-adapter-uws/plugins/queue';
1864
+
1865
+ // Sequential processing per key (default concurrency: 1)
1866
+ export const queue = createQueue({ maxSize: 100 });
1867
+ ```
1868
+
1869
+ #### Usage
1870
+
1871
+ ```js
1872
+ // src/hooks.ws.js
1873
+ import { queue } from '$lib/server/queue';
1874
+
1875
+ export async function message(ws, data, { platform }) {
1876
+ const msg = JSON.parse(Buffer.from(data).toString());
1877
+
1878
+ // Messages for the same topic are processed one at a time
1879
+ const result = await queue.push(msg.topic, async () => {
1880
+ const record = await db.update(msg.data);
1881
+ platform.publish(msg.topic, 'updated', record);
1882
+ return record;
1883
+ });
1884
+ }
1885
+ ```
1886
+
1887
+ #### API
1888
+
1889
+ | Method | Description |
1890
+ |---|---|
1891
+ | `queue.push(key, task)` | Enqueue a task, returns promise with the task's return value |
1892
+ | `queue.size(key?)` | Waiting + running count for a key, or total |
1893
+ | `queue.clear(key?)` | Cancel waiting tasks (running tasks continue) |
1894
+ | `queue.drain(key?)` | Wait for all tasks to complete |
1895
+
1896
+ #### Options
1897
+
1898
+ | Option | Default | Description |
1899
+ |---|---|---|
1900
+ | `concurrency` | `1` | Max concurrent tasks per key |
1901
+ | `maxSize` | `Infinity` | Max waiting tasks per key (rejects when exceeded) |
1902
+ | `onDrop` | `null` | Called with `{ key, task }` when a task is rejected |
1903
+
1904
+ Different keys are independent -- `push('room-a', ...)` and `push('room-b', ...)` run concurrently. Only tasks with the same key are queued.
1905
+
1906
+ #### Limitations
1907
+
1908
+ - **Server-side only.** No client component.
1909
+ - **In-memory.** Queue state lives in the process. Not durable across restarts.
1910
+ - **No cancellation.** Running tasks cannot be aborted. `clear()` only rejects waiting tasks.
1911
+
1912
+ ### Broadcast groups
1913
+
1914
+ Named groups with explicit membership, roles, metadata, and lifecycle hooks. Like topics but with access control -- you decide who can join, what role they have, and what happens when the group fills up or closes.
1915
+
1916
+ #### Setup
1917
+
1918
+ ```js
1919
+ // src/lib/server/lobby.js
1920
+ import { createGroup } from 'svelte-adapter-uws/plugins/groups';
1921
+
1922
+ export const lobby = createGroup('lobby', {
1923
+ maxMembers: 50,
1924
+ meta: { game: 'chess' },
1925
+ onJoin: (ws, role) => console.log('joined as', role),
1926
+ onFull: (ws, role) => {
1927
+ // optionally notify the rejected client
1928
+ }
1929
+ });
1930
+ ```
1931
+
1932
+ #### Server usage
1933
+
1934
+ ```js
1935
+ // src/hooks.ws.js
1936
+ import { lobby } from '$lib/server/lobby';
1937
+
1938
+ export function subscribe(ws, topic, { platform }) {
1939
+ if (topic === 'lobby') {
1940
+ const joined = lobby.join(ws, platform, 'member');
1941
+ if (!joined) {
1942
+ platform.send(ws, 'system', 'error', 'Lobby is full');
1943
+ }
1944
+ }
1945
+ }
1946
+
1947
+ export function close(ws, { platform }) {
1948
+ lobby.leave(ws, platform);
1949
+ }
1950
+ ```
1951
+
1952
+ Publish to group members:
1953
+
1954
+ ```js
1955
+ // Broadcast to everyone
1956
+ lobby.publish(platform, 'chat', { text: 'hello' });
1957
+
1958
+ // Broadcast only to admins
1959
+ lobby.publish(platform, 'admin-alert', { msg: 'new report' }, 'admin');
1960
+ ```
1961
+
1962
+ #### Client usage
1963
+
1964
+ ```svelte
1965
+ <script>
1966
+ import { group } from 'svelte-adapter-uws/plugins/groups/client';
1967
+
1968
+ const lobby = group('lobby');
1969
+ const members = lobby.members;
1970
+ </script>
1971
+
1972
+ <p>{$members.length} members</p>
1973
+ ```
1974
+
1975
+ The client store exposes two reactive values: the main store for events (`$lobby` -- latest message) and `.members` for the live member list. The member list updates automatically on join, leave, and close events -- no polling needed.
1976
+
1977
+ #### Server API
1978
+
1979
+ | Method | Description |
1980
+ |---|---|
1981
+ | `group.join(ws, platform, role?)` | Add member. Returns `true` or `false` if full/closed |
1982
+ | `group.leave(ws, platform)` | Remove member |
1983
+ | `group.publish(platform, event, data, role?)` | Broadcast (optionally filtered by role) |
1984
+ | `group.send(platform, ws, event, data)` | Send to one member (throws if not a member) |
1985
+ | `group.members()` | Array of `{ ws, role }` |
1986
+ | `group.count()` | Member count |
1987
+ | `group.has(ws)` | Check membership |
1988
+ | `group.close(platform)` | Dissolve group, notify everyone |
1989
+ | `group.name` | Group name (read-only) |
1990
+ | `group.meta` | Metadata (get/set) |
1991
+
1992
+ Roles: `'member'` (default), `'admin'`, `'viewer'`.
1993
+
1994
+ #### Options
1995
+
1996
+ | Option | Default | Description |
1997
+ |---|---|---|
1998
+ | `maxMembers` | `Infinity` | Maximum members |
1999
+ | `meta` | `{}` | Initial metadata (shallow-copied) |
2000
+ | `onJoin` | -- | `(ws, role) => void` |
2001
+ | `onLeave` | -- | `(ws, role) => void` |
2002
+ | `onFull` | -- | `(ws, role) => void` |
2003
+ | `onClose` | -- | `() => void` |
2004
+
2005
+ #### Limitations
2006
+
2007
+ - **In-memory.** Group state lives in the process. In cluster mode, each worker manages its own groups independently.
2008
+ - **No persistence.** Groups are lost on restart. If you need durable rooms, store membership in a database and rebuild on start.
2009
+ - **Role-filtered publish uses `send()`.** When filtering by role, the plugin iterates members and sends individually instead of using the topic broadcast. Fine for typical group sizes, but O(n) with member count.
2010
+
1639
2011
  ---
1640
2012
 
1641
2013
  **Deployment & scaling**
package/files/handler.js CHANGED
@@ -831,6 +831,10 @@ if (WS_ENABLED) {
831
831
  if (requestPort) {
832
832
  expectedHost = requestHost.replace(/:\d+$/, '') + ':' + requestPort;
833
833
  }
834
+ // Strip default ports so "example.com" matches "example.com:443"
835
+ // (URL.host omits the port when it is the default for the scheme)
836
+ const defaultPort = requestScheme === 'https' ? '443' : '80';
837
+ expectedHost = expectedHost.replace(':' + defaultPort, '');
834
838
  allowed = parsed.host === expectedHost && parsed.protocol === requestScheme + ':';
835
839
  }
836
840
  } catch {
package/files/index.js CHANGED
@@ -45,8 +45,8 @@ if (is_primary) {
45
45
  // Exponential backoff for crash-looping workers
46
46
  let restart_delay = 0;
47
47
  const RESTART_DELAY_MAX = 5000;
48
- /** @type {ReturnType<typeof setTimeout> | null} */
49
- let backoff_reset_timer = null;
48
+ /** @type {Set<ReturnType<typeof setTimeout>>} */
49
+ const restart_timers = new Set();
50
50
 
51
51
  function spawn_worker() {
52
52
  const worker = new Worker(fileURLToPath(import.meta.url));
@@ -59,7 +59,8 @@ if (is_primary) {
59
59
  console.log(`Worker thread ${worker.threadId} registered`);
60
60
  // Worker started successfully - reset backoff
61
61
  restart_delay = 0;
62
- if (backoff_reset_timer) { clearTimeout(backoff_reset_timer); backoff_reset_timer = null; }
62
+ for (const t of restart_timers) clearTimeout(t);
63
+ restart_timers.clear();
63
64
  // Start (or resume) listening once a worker is ready to handle requests
64
65
  if (!listening) {
65
66
  listening = true;
@@ -100,7 +101,12 @@ if (is_primary) {
100
101
  }
101
102
  restart_delay = restart_delay ? Math.min(restart_delay * 2, RESTART_DELAY_MAX) : 100;
102
103
  console.log(`Worker thread ${worker.threadId} exited with code ${code}, restarting in ${restart_delay}ms...`);
103
- backoff_reset_timer = setTimeout(() => { spawn_worker(); }, restart_delay);
104
+ const timer = setTimeout(() => {
105
+ restart_timers.delete(timer);
106
+ if (shutting_down) return;
107
+ spawn_worker();
108
+ }, restart_delay);
109
+ restart_timers.add(timer);
104
110
  }
105
111
  // If shutting down and all workers have exited, exit immediately
106
112
  if (shutting_down && workers.size === 0) {
@@ -121,11 +127,9 @@ if (is_primary) {
121
127
  shutting_down = true;
122
128
  console.log(`Primary received ${reason}, shutting down ${workers.size} workers...`);
123
129
 
124
- // Cancel any pending worker restart so we don't spawn during shutdown
125
- if (backoff_reset_timer) {
126
- clearTimeout(backoff_reset_timer);
127
- backoff_reset_timer = null;
128
- }
130
+ // Cancel all pending worker restarts so we don't spawn during shutdown
131
+ for (const t of restart_timers) clearTimeout(t);
132
+ restart_timers.clear();
129
133
 
130
134
  // Stop accepting new connections
131
135
  if (listen_socket) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "SvelteKit adapter for uWebSockets.js - high-performance C++ HTTP server with built-in WebSocket support",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
@@ -53,6 +53,34 @@
53
53
  "./plugins/throttle": {
54
54
  "types": "./plugins/throttle/server.d.ts",
55
55
  "default": "./plugins/throttle/server.js"
56
+ },
57
+ "./plugins/ratelimit": {
58
+ "types": "./plugins/ratelimit/server.d.ts",
59
+ "default": "./plugins/ratelimit/server.js"
60
+ },
61
+ "./plugins/cursor": {
62
+ "types": "./plugins/cursor/server.d.ts",
63
+ "default": "./plugins/cursor/server.js"
64
+ },
65
+ "./plugins/cursor/client": {
66
+ "types": "./plugins/cursor/client.d.ts",
67
+ "default": "./plugins/cursor/client.js"
68
+ },
69
+ "./plugins/middleware": {
70
+ "types": "./plugins/middleware/server.d.ts",
71
+ "default": "./plugins/middleware/server.js"
72
+ },
73
+ "./plugins/queue": {
74
+ "types": "./plugins/queue/server.d.ts",
75
+ "default": "./plugins/queue/server.js"
76
+ },
77
+ "./plugins/groups": {
78
+ "types": "./plugins/groups/server.d.ts",
79
+ "default": "./plugins/groups/server.js"
80
+ },
81
+ "./plugins/groups/client": {
82
+ "types": "./plugins/groups/client.d.ts",
83
+ "default": "./plugins/groups/client.js"
56
84
  }
57
85
  },
58
86
  "types": "./index.d.ts",
@@ -0,0 +1,33 @@
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
+ * @example
17
+ * ```svelte
18
+ * <script>
19
+ * import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
20
+ *
21
+ * const cursors = cursor('canvas');
22
+ * </script>
23
+ *
24
+ * {#each [...$cursors] as [key, { user, data }] (key)}
25
+ * <div style="left: {data.x}px; top: {data.y}px">
26
+ * {user.name}
27
+ * </div>
28
+ * {/each}
29
+ * ```
30
+ */
31
+ export function cursor<UserInfo = unknown, Data = unknown>(
32
+ topic: string
33
+ ): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Client-side cursor helper for svelte-adapter-uws.
3
+ *
4
+ * Subscribes to the internal `__cursor:{topic}` channel and maintains
5
+ * a live Map of cursor positions. The server handles throttling and
6
+ * cleanup; this module keeps the client-side state in sync.
7
+ *
8
+ * @module svelte-adapter-uws/plugins/cursor/client
9
+ */
10
+
11
+ import { on } from '../../client.js';
12
+ import { writable } from 'svelte/store';
13
+
14
+ /**
15
+ * Get a reactive store of cursor positions on a topic.
16
+ *
17
+ * Returns a readable Svelte store containing a Map of connection keys
18
+ * to `{ user, data }` objects. The Map updates automatically when
19
+ * cursors move or disconnect.
20
+ *
21
+ * @template UserInfo, Data
22
+ * @param {string} topic - Topic to track cursors on
23
+ * @returns {import('svelte/store').Readable<Map<string, { user: UserInfo, data: Data }>>}
24
+ *
25
+ * @example
26
+ * ```svelte
27
+ * <script>
28
+ * import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
29
+ *
30
+ * const cursors = cursor('canvas');
31
+ * </script>
32
+ *
33
+ * {#each [...$cursors] as [key, { user, data }] (key)}
34
+ * <div style="left: {data.x}px; top: {data.y}px" class="cursor">
35
+ * {user.name}
36
+ * </div>
37
+ * {/each}
38
+ * ```
39
+ */
40
+ export function cursor(topic) {
41
+ const cursorTopic = '__cursor:' + topic;
42
+ const source = on(cursorTopic);
43
+
44
+ /** @type {Map<string, { user: any, data: any }>} */
45
+ let cursorMap = new Map();
46
+ const output = writable(/** @type {Map<string, any>} */ (new Map()));
47
+
48
+ let sourceUnsub = /** @type {(() => void) | null} */ (null);
49
+ let refCount = 0;
50
+
51
+ function startListening() {
52
+ sourceUnsub = source.subscribe((event) => {
53
+ if (event === null) return;
54
+
55
+ if (event.event === 'update' && event.data != null) {
56
+ const { key, user, data } = event.data;
57
+ cursorMap.set(key, { user, data });
58
+ output.set(new Map(cursorMap));
59
+ return;
60
+ }
61
+
62
+ if (event.event === 'remove' && event.data != null) {
63
+ const { key } = event.data;
64
+ if (cursorMap.delete(key)) {
65
+ output.set(new Map(cursorMap));
66
+ }
67
+ }
68
+ });
69
+ }
70
+
71
+ function stopListening() {
72
+ if (sourceUnsub) {
73
+ sourceUnsub();
74
+ sourceUnsub = null;
75
+ }
76
+ cursorMap = new Map();
77
+ }
78
+
79
+ return {
80
+ subscribe(fn) {
81
+ if (refCount++ === 0) startListening();
82
+ const unsub = output.subscribe(fn);
83
+ return () => {
84
+ unsub();
85
+ if (--refCount === 0) stopListening();
86
+ };
87
+ }
88
+ };
89
+ }