svelte-adapter-uws 0.2.16 → 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 +418 -33
- package/files/handler.js +4 -0
- package/files/index.js +13 -9
- package/package.json +29 -1
- package/plugins/cursor/client.d.ts +33 -0
- package/plugins/cursor/client.js +89 -0
- package/plugins/cursor/server.d.ts +84 -0
- package/plugins/cursor/server.js +229 -0
- package/plugins/groups/client.d.ts +31 -0
- package/plugins/groups/client.js +116 -0
- package/plugins/groups/server.d.ts +108 -0
- package/plugins/groups/server.js +235 -0
- package/plugins/middleware/server.d.ts +68 -0
- package/plugins/middleware/server.js +132 -0
- package/plugins/queue/server.d.ts +74 -0
- package/plugins/queue/server.js +208 -0
- package/plugins/ratelimit/server.d.ts +94 -0
- package/plugins/ratelimit/server.js +215 -0
- package/vite.js +2 -0
package/README.md
CHANGED
|
@@ -40,22 +40,27 @@ I've been loving Svelte and SvelteKit for a long time. I always wanted to expand
|
|
|
40
40
|
- [Seeding initial state](#seeding-initial-state)
|
|
41
41
|
|
|
42
42
|
**Plugins**
|
|
43
|
-
- [Replay (SSR gap)](#replay-
|
|
44
|
-
- [Presence](#presence
|
|
45
|
-
- [Typed channels](#typed-channels
|
|
46
|
-
- [Throttle/debounce](#throttledebounce
|
|
43
|
+
- [Replay (SSR gap)](#replay-ssr-gap)
|
|
44
|
+
- [Presence](#presence)
|
|
45
|
+
- [Typed channels](#typed-channels)
|
|
46
|
+
- [Throttle/debounce](#throttledebounce)
|
|
47
47
|
|
|
48
48
|
**Deployment & scaling**
|
|
49
49
|
- [Deploying with Docker](#deploying-with-docker)
|
|
50
50
|
- [Clustering](#clustering)
|
|
51
51
|
- [Performance](#performance)
|
|
52
52
|
|
|
53
|
+
**Examples**
|
|
54
|
+
- [Full example: real-time todo list](#full-example-real-time-todo-list)
|
|
55
|
+
|
|
53
56
|
**Help**
|
|
54
57
|
- [Troubleshooting](#troubleshooting)
|
|
55
58
|
- [License](#license)
|
|
56
59
|
|
|
57
60
|
---
|
|
58
61
|
|
|
62
|
+
**Getting started**
|
|
63
|
+
|
|
59
64
|
## Installation
|
|
60
65
|
|
|
61
66
|
### Starting from scratch
|
|
@@ -281,6 +286,8 @@ The Vite plugin is required for WebSocket support in both dev and production (se
|
|
|
281
286
|
|
|
282
287
|
Changes to your `hooks.ws` file are picked up automatically — the plugin reloads the handler on save, no dev server restart needed.
|
|
283
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
|
+
|
|
284
291
|
**vite.config.js**
|
|
285
292
|
```js
|
|
286
293
|
import { sveltekit } from '@sveltejs/kit/vite';
|
|
@@ -318,6 +325,8 @@ SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 node build
|
|
|
318
325
|
|
|
319
326
|
---
|
|
320
327
|
|
|
328
|
+
**Configuration**
|
|
329
|
+
|
|
321
330
|
## Adapter options
|
|
322
331
|
|
|
323
332
|
```js
|
|
@@ -443,6 +452,56 @@ SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 HOST=0.0.0.0 BODY_SIZE_LIMIT=10M
|
|
|
443
452
|
|
|
444
453
|
---
|
|
445
454
|
|
|
455
|
+
## TypeScript setup
|
|
456
|
+
|
|
457
|
+
Add the platform type to your `src/app.d.ts`:
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
import type { Platform as AdapterPlatform } from 'svelte-adapter-uws';
|
|
461
|
+
|
|
462
|
+
declare global {
|
|
463
|
+
namespace App {
|
|
464
|
+
interface Platform extends AdapterPlatform {}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export {};
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Now `event.platform.publish()`, `event.platform.topic()`, etc. are fully typed.
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## Svelte 4 support
|
|
476
|
+
|
|
477
|
+
This adapter supports both Svelte 4 and Svelte 5. All examples in this README use Svelte 5 syntax (`$props()`, runes). If you're on Svelte 4, here's how to translate:
|
|
478
|
+
|
|
479
|
+
**Svelte 5 (used in examples)**
|
|
480
|
+
```svelte
|
|
481
|
+
<script>
|
|
482
|
+
import { crud } from 'svelte-adapter-uws/client';
|
|
483
|
+
|
|
484
|
+
let { data } = $props();
|
|
485
|
+
const todos = crud('todos', data.todos);
|
|
486
|
+
</script>
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
**Svelte 4 equivalent**
|
|
490
|
+
```svelte
|
|
491
|
+
<script>
|
|
492
|
+
import { crud } from 'svelte-adapter-uws/client';
|
|
493
|
+
|
|
494
|
+
export let data;
|
|
495
|
+
const todos = crud('todos', data.todos);
|
|
496
|
+
</script>
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
The only difference is how you receive props. The client store API (`on`, `crud`, `lookup`, `latest`, `count`, `once`, `status`, `connect`) works identically in both versions - it uses `svelte/store` which hasn't changed.
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
**WebSocket deep dive**
|
|
504
|
+
|
|
446
505
|
## WebSocket handler (`hooks.ws`)
|
|
447
506
|
|
|
448
507
|
### No handler needed (simplest)
|
|
@@ -1184,7 +1243,79 @@ export async function subscribe(ws, topic, { platform }) {
|
|
|
1184
1243
|
|
|
1185
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.
|
|
1186
1245
|
|
|
1187
|
-
###
|
|
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
|
+
|
|
1318
|
+
### Replay (SSR gap)
|
|
1188
1319
|
|
|
1189
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.
|
|
1190
1321
|
|
|
@@ -1302,12 +1433,10 @@ const messages = onReplay('chat', { since: data.seq }).scan([], reducer);
|
|
|
1302
1433
|
|
|
1303
1434
|
---
|
|
1304
1435
|
|
|
1305
|
-
### Presence
|
|
1436
|
+
### Presence
|
|
1306
1437
|
|
|
1307
1438
|
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
1439
|
|
|
1309
|
-
Like the replay plugin, this is opt-in and has zero impact on the adapter core.
|
|
1310
|
-
|
|
1311
1440
|
#### Setup
|
|
1312
1441
|
|
|
1313
1442
|
Create a shared presence instance:
|
|
@@ -1413,7 +1542,7 @@ If no `key` field is found in the selected data (e.g. no auth), each connection
|
|
|
1413
1542
|
- **Single-worker only.** Each worker tracks its own presence. In clustered mode, the list reflects only the local worker's connections.
|
|
1414
1543
|
- **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
1544
|
|
|
1416
|
-
### Typed channels
|
|
1545
|
+
### Typed channels
|
|
1417
1546
|
|
|
1418
1547
|
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
1548
|
|
|
@@ -1497,7 +1626,7 @@ You can still use `crud()`, `lookup()`, `latest()`, etc. directly with the topic
|
|
|
1497
1626
|
- **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
1627
|
- **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
1628
|
|
|
1500
|
-
### Throttle/debounce
|
|
1629
|
+
### Throttle/debounce
|
|
1501
1630
|
|
|
1502
1631
|
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
1632
|
|
|
@@ -1581,56 +1710,308 @@ t=260 [timer fires, 100ms] --> sends {q:"hel"}
|
|
|
1581
1710
|
- **Latest value only.** Intermediate values within an interval are discarded, not queued. If you need every message delivered, don't throttle.
|
|
1582
1711
|
- **Timer-based.** Uses `setTimeout` internally. Precision depends on Node.js event loop load (typically < 1ms drift).
|
|
1583
1712
|
|
|
1584
|
-
|
|
1713
|
+
### Rate limiting
|
|
1585
1714
|
|
|
1586
|
-
|
|
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.
|
|
1587
1716
|
|
|
1588
|
-
|
|
1717
|
+
Different from throttle -- throttle shapes **outbound** publish rate, rate limiting protects **inbound** against abuse.
|
|
1589
1718
|
|
|
1590
|
-
|
|
1591
|
-
import type { Platform as AdapterPlatform } from 'svelte-adapter-uws';
|
|
1719
|
+
#### Setup
|
|
1592
1720
|
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
|
1597
1743
|
}
|
|
1744
|
+
```
|
|
1598
1745
|
|
|
1599
|
-
|
|
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
|
+
});
|
|
1600
1787
|
```
|
|
1601
1788
|
|
|
1602
|
-
|
|
1789
|
+
#### Server usage
|
|
1603
1790
|
|
|
1604
|
-
|
|
1791
|
+
```js
|
|
1792
|
+
// src/hooks.ws.js
|
|
1793
|
+
import { cursors } from '$lib/server/cursors';
|
|
1605
1794
|
|
|
1606
|
-
|
|
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
|
+
}
|
|
1607
1801
|
|
|
1608
|
-
|
|
1802
|
+
export function close(ws, { platform }) {
|
|
1803
|
+
cursors.remove(ws, platform);
|
|
1804
|
+
}
|
|
1805
|
+
```
|
|
1806
|
+
|
|
1807
|
+
#### Client usage
|
|
1609
1808
|
|
|
1610
|
-
**Svelte 5 (used in examples)**
|
|
1611
1809
|
```svelte
|
|
1612
1810
|
<script>
|
|
1613
|
-
import {
|
|
1811
|
+
import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
1614
1812
|
|
|
1615
|
-
|
|
1616
|
-
const todos = crud('todos', data.todos);
|
|
1813
|
+
const positions = cursor('canvas');
|
|
1617
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}
|
|
1618
1824
|
```
|
|
1619
1825
|
|
|
1620
|
-
|
|
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
|
+
|
|
1621
1964
|
```svelte
|
|
1622
1965
|
<script>
|
|
1623
|
-
import {
|
|
1966
|
+
import { group } from 'svelte-adapter-uws/plugins/groups/client';
|
|
1624
1967
|
|
|
1625
|
-
|
|
1626
|
-
const
|
|
1968
|
+
const lobby = group('lobby');
|
|
1969
|
+
const members = lobby.members;
|
|
1627
1970
|
</script>
|
|
1971
|
+
|
|
1972
|
+
<p>{$members.length} members</p>
|
|
1628
1973
|
```
|
|
1629
1974
|
|
|
1630
|
-
The
|
|
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.
|
|
1631
2010
|
|
|
1632
2011
|
---
|
|
1633
2012
|
|
|
2013
|
+
**Deployment & scaling**
|
|
2014
|
+
|
|
1634
2015
|
## Deploying with Docker
|
|
1635
2016
|
|
|
1636
2017
|
uWebSockets.js is a native C++ addon, so your Docker image needs to match the platform it was compiled for. Build inside the container to be safe.
|
|
@@ -1784,6 +2165,8 @@ node bench/run-compare.mjs # full comparison vs adapter-node + socket.io
|
|
|
1784
2165
|
|
|
1785
2166
|
---
|
|
1786
2167
|
|
|
2168
|
+
**Examples**
|
|
2169
|
+
|
|
1787
2170
|
## Full example: real-time todo list
|
|
1788
2171
|
|
|
1789
2172
|
Here's a complete example tying everything together.
|
|
@@ -1879,6 +2262,8 @@ Open the page in two browser tabs. Create, toggle, or delete a todo in one tab -
|
|
|
1879
2262
|
|
|
1880
2263
|
---
|
|
1881
2264
|
|
|
2265
|
+
**Help**
|
|
2266
|
+
|
|
1882
2267
|
## Troubleshooting
|
|
1883
2268
|
|
|
1884
2269
|
### "WebSocket works in production but not in dev"
|
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
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
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",
|