svelte-adapter-uws 0.2.15 → 0.2.16

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
@@ -18,24 +18,39 @@ I've been loving Svelte and SvelteKit for a long time. I always wanted to expand
18
18
 
19
19
  ## Table of contents
20
20
 
21
+ **Getting started**
21
22
  - [Installation](#installation)
22
23
  - [Quick start: HTTP](#quick-start-http)
23
24
  - [Quick start: HTTPS](#quick-start-https)
24
25
  - [Quick start: WebSocket](#quick-start-websocket)
25
26
  - [Quick start: WSS (secure WebSocket)](#quick-start-wss-secure-websocket)
26
27
  - [Development, Preview & Production](#development-preview--production)
28
+
29
+ **Configuration**
27
30
  - [Adapter options](#adapter-options)
28
31
  - [Environment variables](#environment-variables)
32
+ - [TypeScript setup](#typescript-setup)
33
+ - [Svelte 4 support](#svelte-4-support)
34
+
35
+ **WebSocket deep dive**
29
36
  - [WebSocket handler (`hooks.ws`)](#websocket-handler-hooksws)
30
37
  - [Authentication](#authentication)
31
38
  - [Platform API (`event.platform`)](#platform-api-eventplatform)
32
39
  - [Client store API](#client-store-api)
33
40
  - [Seeding initial state](#seeding-initial-state)
34
- - [TypeScript setup](#typescript-setup)
35
- - [Svelte 4 support](#svelte-4-support)
41
+
42
+ **Plugins**
43
+ - [Replay (SSR gap)](#replay-plugin-ssr-gap)
44
+ - [Presence](#presence-plugin)
45
+ - [Typed channels](#typed-channels-plugin)
46
+ - [Throttle/debounce](#throttledebounce-plugin)
47
+
48
+ **Deployment & scaling**
36
49
  - [Deploying with Docker](#deploying-with-docker)
37
50
  - [Clustering](#clustering)
38
51
  - [Performance](#performance)
52
+
53
+ **Help**
39
54
  - [Troubleshooting](#troubleshooting)
40
55
  - [License](#license)
41
56
 
@@ -1165,6 +1180,409 @@ export async function subscribe(ws, topic, { platform }) {
1165
1180
 
1166
1181
  ---
1167
1182
 
1183
+ ## Plugins
1184
+
1185
+ 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.
1186
+
1187
+ ### Replay plugin (SSR gap)
1188
+
1189
+ 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.
1190
+
1191
+ The replay plugin solves this without touching the adapter core. It's opt-in -- if you don't import it, it doesn't exist.
1192
+
1193
+ #### How it works
1194
+
1195
+ 1. **Server:** publish through a replay buffer instead of `platform.publish()` directly -- messages get a sequence number and are stored in a ring buffer
1196
+ 2. **SSR:** pass the current sequence number to the client via your `load()` function
1197
+ 3. **Client:** `onReplay()` connects, requests missed messages, and switches to live mode once caught up
1198
+
1199
+ #### Setup
1200
+
1201
+ Create a shared replay instance:
1202
+
1203
+ ```js
1204
+ // src/lib/server/replay.js
1205
+ import { createReplay } from 'svelte-adapter-uws/plugins/replay';
1206
+
1207
+ export const replay = createReplay({ size: 500 });
1208
+ ```
1209
+
1210
+ Use it when publishing:
1211
+
1212
+ ```js
1213
+ // src/routes/chat/+page.server.js
1214
+ import { replay } from '$lib/server/replay';
1215
+
1216
+ export async function load() {
1217
+ const messages = await db.getRecentMessages();
1218
+ return { messages, seq: replay.seq('chat') };
1219
+ }
1220
+
1221
+ export const actions = {
1222
+ send: async ({ request, platform }) => {
1223
+ const form = await request.formData();
1224
+ const msg = await db.createMessage(Object.fromEntries(form));
1225
+ replay.publish(platform, 'chat', 'created', msg);
1226
+ }
1227
+ };
1228
+ ```
1229
+
1230
+ Handle replay requests in your WebSocket handler:
1231
+
1232
+ ```js
1233
+ // src/hooks.ws.js
1234
+ import { replay } from '$lib/server/replay';
1235
+
1236
+ export function message(ws, { data, platform }) {
1237
+ const msg = JSON.parse(Buffer.from(data).toString());
1238
+ if (msg.type === 'replay') {
1239
+ replay.replay(ws, msg.topic, msg.since, platform);
1240
+ return;
1241
+ }
1242
+ }
1243
+ ```
1244
+
1245
+ Subscribe on the client with gap-free delivery:
1246
+
1247
+ ```svelte
1248
+ <!-- src/routes/chat/+page.svelte -->
1249
+ <script>
1250
+ import { onReplay } from 'svelte-adapter-uws/plugins/replay/client';
1251
+ let { data } = $props();
1252
+
1253
+ const messages = onReplay('chat', { since: data.seq }).scan(
1254
+ data.messages,
1255
+ (list, { event, data }) => {
1256
+ if (event === 'created') return [...list, data];
1257
+ return list;
1258
+ }
1259
+ );
1260
+ </script>
1261
+
1262
+ {#each $messages as msg}
1263
+ <p>{msg.text}</p>
1264
+ {/each}
1265
+ ```
1266
+
1267
+ #### Server API
1268
+
1269
+ ```js
1270
+ import { createReplay } from 'svelte-adapter-uws/plugins/replay';
1271
+
1272
+ const replay = createReplay({
1273
+ size: 1000, // max messages per topic (default: 1000)
1274
+ maxTopics: 100 // max tracked topics, oldest evicted (default: 100)
1275
+ });
1276
+
1277
+ replay.publish(platform, topic, event, data) // publish + buffer
1278
+ replay.seq(topic) // current sequence number
1279
+ replay.since(topic, seq) // buffered messages after seq
1280
+ replay.replay(ws, topic, sinceSeq, platform) // send missed messages to one client
1281
+ replay.clear() // reset everything
1282
+ replay.clearTopic(topic) // reset one topic
1283
+ ```
1284
+
1285
+ #### Client API
1286
+
1287
+ ```js
1288
+ import { onReplay } from 'svelte-adapter-uws/plugins/replay/client';
1289
+
1290
+ // Works exactly like on() but bridges the SSR gap
1291
+ const store = onReplay('chat', { since: data.seq });
1292
+
1293
+ // .scan() works the same as on().scan()
1294
+ const messages = onReplay('chat', { since: data.seq }).scan([], reducer);
1295
+ ```
1296
+
1297
+ #### Limitations
1298
+
1299
+ - **In-memory only.** The ring buffer lives in the server process. A restart loses the buffer. For most apps this is fine -- the gap is typically under a second, and a page reload after a server restart gives fresh SSR data anyway.
1300
+ - **Single-worker only.** In clustered mode, each worker has its own buffer. If the SSR load runs on worker A and the WebSocket connects to worker B, the replay won't have the right messages. If you need replay with clustering, stick to a single worker or use an external store.
1301
+ - **Buffer overflow.** If more than `size` messages are published to a topic before a client requests replay, the oldest are gone. Size the buffer for your expected throughput during the SSR-to-connect window (usually well under 100 messages).
1302
+
1303
+ ---
1304
+
1305
+ ### Presence plugin
1306
+
1307
+ Track who's connected to a topic in real time. Handles multi-tab dedup (same user with two tabs open = one presence entry), broadcasts join/leave events, and provides a live store on the client.
1308
+
1309
+ Like the replay plugin, this is opt-in and has zero impact on the adapter core.
1310
+
1311
+ #### Setup
1312
+
1313
+ Create a shared presence instance:
1314
+
1315
+ ```js
1316
+ // src/lib/server/presence.js
1317
+ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
1318
+
1319
+ export const presence = createPresence({
1320
+ key: 'id',
1321
+ select: (userData) => ({ id: userData.id, name: userData.name })
1322
+ });
1323
+ ```
1324
+
1325
+ Wire it into your WebSocket hooks:
1326
+
1327
+ ```js
1328
+ // src/hooks.ws.js
1329
+ import { presence } from '$lib/server/presence';
1330
+
1331
+ export function upgrade({ cookies }) {
1332
+ const user = validateSession(cookies.session_id);
1333
+ if (!user) return false;
1334
+ return { id: user.id, name: user.name };
1335
+ }
1336
+
1337
+ export function subscribe(ws, topic, { platform }) {
1338
+ presence.join(ws, topic, platform);
1339
+ }
1340
+
1341
+ export function close(ws, { platform }) {
1342
+ presence.leave(ws, platform);
1343
+ }
1344
+ ```
1345
+
1346
+ Use it on the client:
1347
+
1348
+ ```svelte
1349
+ <!-- src/routes/room/+page.svelte -->
1350
+ <script>
1351
+ import { on } from 'svelte-adapter-uws/client';
1352
+ import { presence } from 'svelte-adapter-uws/plugins/presence/client';
1353
+
1354
+ const messages = on('room');
1355
+ const users = presence('room');
1356
+ </script>
1357
+
1358
+ <aside>
1359
+ <h3>{$users.length} online</h3>
1360
+ {#each $users as user (user.id)}
1361
+ <span>{user.name}</span>
1362
+ {/each}
1363
+ </aside>
1364
+ ```
1365
+
1366
+ Use `presence.list()` in load functions for SSR:
1367
+
1368
+ ```js
1369
+ // +page.server.js
1370
+ import { presence } from '$lib/server/presence';
1371
+
1372
+ export async function load() {
1373
+ return { users: presence.list('room'), online: presence.count('room') };
1374
+ }
1375
+ ```
1376
+
1377
+ #### Server API
1378
+
1379
+ ```js
1380
+ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
1381
+
1382
+ const presence = createPresence({
1383
+ key: 'id', // field for multi-tab dedup (default: 'id')
1384
+ select: (userData) => userData // extract public fields (default: full userData)
1385
+ });
1386
+
1387
+ presence.join(ws, topic, platform) // add user to topic (call from subscribe hook)
1388
+ presence.leave(ws, platform) // remove from all topics (call from close hook)
1389
+ presence.sync(ws, topic, platform) // send list without joining (for observers)
1390
+ presence.list(topic) // current user data array
1391
+ presence.count(topic) // unique user count
1392
+ presence.clear() // reset everything
1393
+ ```
1394
+
1395
+ #### Client API
1396
+
1397
+ ```js
1398
+ import { presence } from 'svelte-adapter-uws/plugins/presence/client';
1399
+
1400
+ const users = presence('room');
1401
+ // $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
1402
+ ```
1403
+
1404
+ #### How multi-tab dedup works
1405
+
1406
+ If user "Alice" (key `id: '1'`) has three browser tabs open, `presence.join()` is called three times with the same key. The plugin ref-counts connections per key: Alice appears once in the list. When she closes two tabs, she stays present. Only when the last tab closes does the plugin broadcast a `leave` event.
1407
+
1408
+ If no `key` field is found in the selected data (e.g. no auth), each connection is tracked separately.
1409
+
1410
+ #### Limitations
1411
+
1412
+ - **In-memory only.** Same as replay -- server restart clears presence. On restart, clients reconnect and re-subscribe, so the list rebuilds within seconds.
1413
+ - **Single-worker only.** Each worker tracks its own presence. In clustered mode, the list reflects only the local worker's connections.
1414
+ - **Requires subscription.** The client must subscribe to the topic (via `on()`, `crud()`, etc.) for the server's `subscribe` hook to fire. `presence('room')` alone shows you the list but doesn't register you as present unless you're also subscribed to `room`.
1415
+
1416
+ ### Typed channels plugin
1417
+
1418
+ Define message schemas per topic so event names and data shapes are validated at publish time. Catches typos and shape mismatches before they reach the wire -- instead of silently sending garbage that the client ignores.
1419
+
1420
+ #### Setup
1421
+
1422
+ ```js
1423
+ // src/lib/server/channels.js
1424
+ import { createChannel } from 'svelte-adapter-uws/plugins/channels';
1425
+
1426
+ export const todos = createChannel('todos', {
1427
+ created: (d) => ({ id: d.id, text: d.text, done: d.done }),
1428
+ updated: (d) => ({ id: d.id, text: d.text, done: d.done }),
1429
+ deleted: (d) => ({ id: d.id })
1430
+ });
1431
+ ```
1432
+
1433
+ Each event maps to a validator function. The function receives the raw data and returns the validated (and optionally transformed) output. Throw to reject.
1434
+
1435
+ With Zod (or any library that exposes `.parse()`):
1436
+
1437
+ ```js
1438
+ import { z } from 'zod';
1439
+ import { createChannel } from 'svelte-adapter-uws/plugins/channels';
1440
+
1441
+ const Todo = z.object({ id: z.string(), text: z.string(), done: z.boolean() });
1442
+
1443
+ export const todos = createChannel('todos', {
1444
+ created: Todo,
1445
+ updated: Todo,
1446
+ deleted: z.object({ id: z.string() })
1447
+ });
1448
+ ```
1449
+
1450
+ #### Server API
1451
+
1452
+ ```js
1453
+ import { todos } from '$lib/server/channels';
1454
+
1455
+ // In a form action or API route:
1456
+ export async function POST({ request, platform }) {
1457
+ const data = await request.json();
1458
+ const todo = await db.save(data);
1459
+
1460
+ todos.publish(platform, 'created', todo); // validates, then publishes
1461
+ todos.publish(platform, 'typo', todo); // throws: unknown event "typo"
1462
+ todos.publish(platform, 'created', {}); // throws: validation failed (if validator rejects)
1463
+ }
1464
+ ```
1465
+
1466
+ | Method | Description |
1467
+ |---|---|
1468
+ | `channel.publish(platform, event, data)` | Validate and broadcast to all subscribers |
1469
+ | `channel.send(platform, ws, event, data)` | Validate and send to a single connection |
1470
+ | `channel.topic` | The topic string |
1471
+ | `channel.events` | Array of valid event names |
1472
+
1473
+ Validators can strip private fields before publishing. If your validator returns `{ id, text }` but the input had `{ id, text, secret }`, only `id` and `text` reach clients.
1474
+
1475
+ #### Client API
1476
+
1477
+ The client wrapper is optional -- it catches event name typos on the receiving side too.
1478
+
1479
+ ```svelte
1480
+ <script>
1481
+ import { channel } from 'svelte-adapter-uws/plugins/channels/client';
1482
+
1483
+ const todos = channel('todos', ['created', 'updated', 'deleted']);
1484
+
1485
+ const all = todos.on(); // all events (same as on('todos'))
1486
+ const created = todos.on('created'); // filtered (same as on('todos', 'created'))
1487
+ const typo = todos.on('craeted'); // throws Error immediately
1488
+ </script>
1489
+ ```
1490
+
1491
+ The `events` array is optional. Without it, `.on()` works exactly like the regular `on()` with the topic pre-filled -- no validation, just convenience.
1492
+
1493
+ You can still use `crud()`, `lookup()`, `latest()`, etc. directly with the topic string. The client channel is purely additive.
1494
+
1495
+ #### Limitations
1496
+
1497
+ - **Runtime only.** The validation happens at publish/send time, not at compile time. TypeScript generics give you autocomplete for event names, but data shape checking is runtime.
1498
+ - **No dependency on Zod.** The plugin accepts any validator function or any object with a `.parse()` method. You bring your own validation library (or use plain functions).
1499
+
1500
+ ### Throttle/debounce plugin
1501
+
1502
+ Per-topic publish rate limiting. Wraps `platform.publish()` to coalesce rapid-fire updates (mouse position, typing indicators, live metrics). Sends the latest value at most once per interval. No timers to manage yourself.
1503
+
1504
+ Two modes:
1505
+
1506
+ - **`throttle(ms)`** -- sends immediately on first call (leading edge), then at most once per interval (trailing edge). Latest value wins within each interval.
1507
+ - **`debounce(ms)`** -- waits until no calls for the full interval, then sends the latest value. Each new call resets the timer.
1508
+
1509
+ #### Setup
1510
+
1511
+ ```js
1512
+ import { throttle, debounce } from 'svelte-adapter-uws/plugins/throttle';
1513
+
1514
+ const mouse = throttle(50); // at most once per 50ms per topic
1515
+ const search = debounce(300); // wait for 300ms of silence
1516
+ ```
1517
+
1518
+ #### Usage
1519
+
1520
+ ```js
1521
+ // In hooks.ws.js
1522
+ import { mouse, search } from '$lib/server/rate-limiters';
1523
+
1524
+ export function message(ws, { data, platform }) {
1525
+ const msg = JSON.parse(Buffer.from(data).toString());
1526
+
1527
+ if (msg.type === 'cursor') {
1528
+ // 60 mouse moves/sec from 20 users = 1200 publishes/sec
1529
+ // With throttle(50), each topic publishes at most 20/sec
1530
+ mouse.publish(platform, 'cursors', 'move', {
1531
+ userId: ws.getUserData().id,
1532
+ x: msg.x, y: msg.y
1533
+ });
1534
+ }
1535
+
1536
+ if (msg.type === 'search') {
1537
+ // User types fast -- only publish when they pause
1538
+ search.publish(platform, 'search-results', 'query', { q: msg.q });
1539
+ }
1540
+ }
1541
+ ```
1542
+
1543
+ Rate limiting is per-topic. If you call `mouse.publish()` for topics `'room-a'` and `'room-b'`, each topic has its own independent timer.
1544
+
1545
+ #### API
1546
+
1547
+ | Method | Description |
1548
+ |---|---|
1549
+ | `limiter.publish(platform, topic, event, data)` | Publish with rate limiting |
1550
+ | `limiter.flush()` | Send all pending immediately, clear all timers |
1551
+ | `limiter.flush(topic)` | Send pending for one topic |
1552
+ | `limiter.cancel()` | Discard all pending, clear all timers |
1553
+ | `limiter.cancel(topic)` | Discard pending for one topic |
1554
+ | `limiter.interval` | The configured interval in ms |
1555
+
1556
+ #### How throttle works
1557
+
1558
+ ```
1559
+ t=0 publish({x:0}) --> sends immediately (leading edge)
1560
+ t=10 publish({x:1}) --> stored (latest)
1561
+ t=30 publish({x:2}) --> stored (overwrites x:1)
1562
+ t=50 [timer fires] --> sends {x:2} (trailing edge)
1563
+ t=60 publish({x:3}) --> stored
1564
+ t=100 [timer fires] --> sends {x:3}
1565
+ t=150 [timer fires] --> nothing pending, goes idle
1566
+ t=200 publish({x:4}) --> sends immediately (new leading edge)
1567
+ ```
1568
+
1569
+ #### How debounce works
1570
+
1571
+ ```
1572
+ t=0 publish({q:"h"}) --> stored, timer starts
1573
+ t=80 publish({q:"he"}) --> stored, timer resets
1574
+ t=160 publish({q:"hel"}) --> stored, timer resets
1575
+ t=260 [timer fires, 100ms] --> sends {q:"hel"}
1576
+ ```
1577
+
1578
+ #### Limitations
1579
+
1580
+ - **Server-side only.** No client component -- the client receives messages at the throttled rate naturally.
1581
+ - **Latest value only.** Intermediate values within an interval are discarded, not queued. If you need every message delivered, don't throttle.
1582
+ - **Timer-based.** Uses `setTimeout` internally. Precision depends on Node.js event loop load (typically < 1ms drift).
1583
+
1584
+ ---
1585
+
1168
1586
  ## TypeScript setup
1169
1587
 
1170
1588
  Add the platform type to your `src/app.d.ts`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
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",
@@ -25,6 +25,34 @@
25
25
  "./vite": {
26
26
  "types": "./vite.d.ts",
27
27
  "default": "./vite.js"
28
+ },
29
+ "./plugins/replay": {
30
+ "types": "./plugins/replay/server.d.ts",
31
+ "default": "./plugins/replay/server.js"
32
+ },
33
+ "./plugins/replay/client": {
34
+ "types": "./plugins/replay/client.d.ts",
35
+ "default": "./plugins/replay/client.js"
36
+ },
37
+ "./plugins/presence": {
38
+ "types": "./plugins/presence/server.d.ts",
39
+ "default": "./plugins/presence/server.js"
40
+ },
41
+ "./plugins/presence/client": {
42
+ "types": "./plugins/presence/client.d.ts",
43
+ "default": "./plugins/presence/client.js"
44
+ },
45
+ "./plugins/channels": {
46
+ "types": "./plugins/channels/server.d.ts",
47
+ "default": "./plugins/channels/server.js"
48
+ },
49
+ "./plugins/channels/client": {
50
+ "types": "./plugins/channels/client.d.ts",
51
+ "default": "./plugins/channels/client.js"
52
+ },
53
+ "./plugins/throttle": {
54
+ "types": "./plugins/throttle/server.d.ts",
55
+ "default": "./plugins/throttle/server.js"
28
56
  }
29
57
  },
30
58
  "types": "./index.d.ts",
@@ -36,6 +64,7 @@
36
64
  "vite.js",
37
65
  "vite.d.ts",
38
66
  "files",
67
+ "plugins",
39
68
  "LICENSE",
40
69
  "README.md"
41
70
  ],
@@ -0,0 +1,58 @@
1
+ import type { Readable } from 'svelte/store';
2
+ import type { WSEvent, TopicStore } from '../../client.js';
3
+
4
+ export interface ClientChannel<T = unknown> {
5
+ /** The topic this channel subscribes to. */
6
+ readonly topic: string;
7
+
8
+ /** The valid event names (if provided at creation), or null. */
9
+ readonly events: string[] | null;
10
+
11
+ /**
12
+ * Get a reactive store for all events on this channel's topic.
13
+ *
14
+ * Same as `on(topic)` from the client library.
15
+ */
16
+ on(): TopicStore<WSEvent<T>>;
17
+
18
+ /**
19
+ * Get a reactive store filtered to a specific event.
20
+ *
21
+ * Same as `on(topic, event)` from the client library.
22
+ * Throws if the event name was not in the `events` array
23
+ * passed to `channel()`.
24
+ */
25
+ on<E extends string>(event: E): TopicStore<{ data: T }>;
26
+ }
27
+
28
+ /**
29
+ * Create a client-side typed channel for a topic.
30
+ *
31
+ * Scopes event subscriptions into a single object and validates
32
+ * event names at call time. If you pass an `events` array, calling
33
+ * `.on(event)` with an unknown name throws immediately instead of
34
+ * silently subscribing to a misspelled event that never fires.
35
+ *
36
+ * @param topic - Topic to subscribe to
37
+ * @param events - Allowed event names (omit to skip validation)
38
+ *
39
+ * @example
40
+ * ```svelte
41
+ * <script>
42
+ * import { channel } from 'svelte-adapter-uws/plugins/channels/client';
43
+ *
44
+ * const todos = channel('todos', ['created', 'updated', 'deleted']);
45
+ *
46
+ * const all = todos.on(); // all events
47
+ * const created = todos.on('created'); // just 'created' events
48
+ * </script>
49
+ *
50
+ * {#each $created as todo}
51
+ * <p>New: {todo.data.text}</p>
52
+ * {/each}
53
+ * ```
54
+ */
55
+ export function channel<T = unknown>(
56
+ topic: string,
57
+ events?: string[]
58
+ ): ClientChannel<T>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Client-side typed channels for svelte-adapter-uws.
3
+ *
4
+ * Scopes a topic's event subscriptions into a single object and validates
5
+ * event names at call time. The runtime behavior is identical to calling
6
+ * `on()` directly -- this is a convenience wrapper that catches typos
7
+ * early and keeps topic strings DRY.
8
+ *
9
+ * @module svelte-adapter-uws/plugins/channels/client
10
+ */
11
+
12
+ import { on } from '../../client.js';
13
+
14
+ /**
15
+ * Create a client-side typed channel for a topic.
16
+ *
17
+ * If you pass an `events` array, calling `.on(event)` with an unknown
18
+ * event name throws immediately instead of silently subscribing to
19
+ * a misspelled event that never fires.
20
+ *
21
+ * @template T
22
+ * @param {string} topic - Topic to subscribe to
23
+ * @param {string[]} [events] - Allowed event names (omit to skip validation)
24
+ * @returns {import('./client.js').ClientChannel<T>}
25
+ *
26
+ * @example
27
+ * ```svelte
28
+ * <script>
29
+ * import { channel } from 'svelte-adapter-uws/plugins/channels/client';
30
+ *
31
+ * const todos = channel('todos', ['created', 'updated', 'deleted']);
32
+ *
33
+ * const all = todos.on(); // all events (same as on('todos'))
34
+ * const created = todos.on('created'); // filtered (same as on('todos', 'created'))
35
+ * const typo = todos.on('craeted'); // throws Error immediately
36
+ * </script>
37
+ * ```
38
+ */
39
+ export function channel(topic, events) {
40
+ const eventSet = events ? new Set(events) : null;
41
+
42
+ return {
43
+ /** The topic this channel subscribes to. */
44
+ topic,
45
+
46
+ /** The valid event names (if provided at creation). */
47
+ events: events || null,
48
+
49
+ /**
50
+ * Get a reactive store for this channel's topic.
51
+ *
52
+ * @param {string} [event] - Filter to a specific event name
53
+ */
54
+ on(event) {
55
+ if (event != null && eventSet && !eventSet.has(event)) {
56
+ throw new Error(
57
+ `channel "${topic}": unknown event "${event}". Valid events: ${[...eventSet].join(', ')}`
58
+ );
59
+ }
60
+ return event != null ? on(topic, event) : on(topic);
61
+ }
62
+ };
63
+ }