svelte-adapter-uws 0.3.9 → 0.4.0
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 +247 -50
- package/client.d.ts +32 -0
- package/client.js +274 -27
- package/files/env.js +12 -2
- package/files/handler.js +756 -135
- package/files/index.js +318 -254
- package/index.d.ts +41 -5
- package/package.json +1 -1
- package/plugins/cursor/client.js +56 -3
- package/plugins/cursor/server.d.ts +52 -0
- package/plugins/cursor/server.js +46 -11
- package/plugins/groups/client.js +36 -5
- package/plugins/groups/server.d.ts +19 -0
- package/plugins/groups/server.js +28 -11
- package/plugins/middleware/server.js +1 -1
- package/plugins/presence/client.js +20 -1
- package/plugins/presence/server.d.ts +11 -2
- package/plugins/presence/server.js +118 -29
- package/plugins/queue/server.d.ts +3 -1
- package/plugins/queue/server.js +9 -12
- package/plugins/ratelimit/server.js +1 -1
- package/plugins/replay/client.d.ts +76 -57
- package/plugins/replay/client.js +38 -7
- package/plugins/replay/server.d.ts +8 -3
- package/plugins/replay/server.js +51 -19
- package/vite.js +54 -4
package/README.md
CHANGED
|
@@ -386,6 +386,13 @@ adapter({
|
|
|
386
386
|
// Seconds before an async upgrade handler is rejected with 504 (0 to disable)
|
|
387
387
|
upgradeTimeout: 10, // default: 10
|
|
388
388
|
|
|
389
|
+
// Sliding-window rate limit: max WebSocket upgrade requests per IP per window.
|
|
390
|
+
// Prevents connection flood attacks. Uses a sliding window so a client cannot
|
|
391
|
+
// double the effective rate by placing requests at a fixed-window boundary.
|
|
392
|
+
// Set to 0 to disable.
|
|
393
|
+
upgradeRateLimit: 10, // default: 10
|
|
394
|
+
upgradeRateLimitWindow: 10, // window size in seconds, default: 10
|
|
395
|
+
|
|
389
396
|
// Allowed origins for WebSocket connections
|
|
390
397
|
// 'same-origin' - only accept where Origin matches Host and scheme (default)
|
|
391
398
|
// '*' - accept from any origin
|
|
@@ -397,6 +404,24 @@ adapter({
|
|
|
397
404
|
})
|
|
398
405
|
```
|
|
399
406
|
|
|
407
|
+
### Static file behavior
|
|
408
|
+
|
|
409
|
+
All static assets (from the `client/` and `prerendered/` output directories) are loaded once at startup and served directly from RAM. Each response automatically includes:
|
|
410
|
+
|
|
411
|
+
- `Content-Type`: detected from the file extension
|
|
412
|
+
- `Vary: Accept-Encoding`: required for correct CDN/proxy caching when serving precompressed variants
|
|
413
|
+
- `Accept-Ranges: bytes`: enables partial content requests (e.g. for download resume)
|
|
414
|
+
- `X-Content-Type-Options: nosniff`: prevents MIME-type sniffing in browsers
|
|
415
|
+
- `ETag`: derived from the file's modification time and size; enables `304 Not Modified` responses
|
|
416
|
+
- `Cache-Control: public, max-age=31536000, immutable`: for versioned assets under `/_app/immutable/`
|
|
417
|
+
- `Cache-Control: no-cache`: for all other assets (forces ETag revalidation)
|
|
418
|
+
|
|
419
|
+
**Range requests (HTTP 206):** The server handles `Range: bytes=start-end` requests for static files. Single byte ranges are supported (`bytes=0-499`, `bytes=-500`, `bytes=500-`). Multi-range requests (comma-separated) are served as full `200` responses. An unsatisfiable range returns `416 Range Not Satisfiable`. When a `Range` header is present, the response is always served uncompressed so byte offsets are correct. The `If-Range` header is respected: if it doesn't match the file's ETag, the full file is returned.
|
|
420
|
+
|
|
421
|
+
Files with extensions that browsers cannot render inline (`.zip`, `.tar`, `.tgz`, `.exe`, `.dmg`, `.pkg`, `.deb`, `.apk`, `.iso`, `.img`, `.bin`, etc.) automatically receive `Content-Disposition: attachment` so browsers prompt a download dialog instead of attempting to display them.
|
|
422
|
+
|
|
423
|
+
If `precompress: true` is set in the adapter options, brotli (`.br`) and gzip (`.gz`) precompressed variants are loaded at startup and served when the client's `Accept-Encoding` header includes `br` or `gzip`. Precompressed variants are only used when they are smaller than the original file.
|
|
424
|
+
|
|
400
425
|
---
|
|
401
426
|
|
|
402
427
|
## Environment variables
|
|
@@ -421,6 +446,7 @@ If you set `envPrefix: 'MY_APP_'` in the adapter config, all variables are prefi
|
|
|
421
446
|
| `SHUTDOWN_TIMEOUT` | `30` | Seconds to wait during graceful shutdown |
|
|
422
447
|
| `CLUSTER_WORKERS` | - | Number of worker threads (or `auto` for CPU count) |
|
|
423
448
|
| `CLUSTER_MODE` | *(auto)* | `reuseport` (Linux default) or `acceptor` (other platforms) |
|
|
449
|
+
| `WS_DEBUG` | - | Set to `1` to enable structured WebSocket debug logging (open, close, subscribe, publish) |
|
|
424
450
|
|
|
425
451
|
### Graceful shutdown
|
|
426
452
|
|
|
@@ -566,6 +592,12 @@ export function subscribe(ws, topic, { platform }) {
|
|
|
566
592
|
if (topic.startsWith('admin') && role !== 'admin') return false;
|
|
567
593
|
}
|
|
568
594
|
|
|
595
|
+
// Called when a client unsubscribes from a topic (optional)
|
|
596
|
+
// Use this to clean up per-topic state (presence, groups, etc.)
|
|
597
|
+
export function unsubscribe(ws, topic, { platform }) {
|
|
598
|
+
console.log(`Unsubscribed from ${topic}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
569
601
|
// Called when the connection closes
|
|
570
602
|
export function close(ws, { code, message, platform }) {
|
|
571
603
|
const { userId } = ws.getUserData();
|
|
@@ -849,6 +881,8 @@ platform.sendTo(
|
|
|
849
881
|
);
|
|
850
882
|
```
|
|
851
883
|
|
|
884
|
+
> **Performance:** `sendTo` iterates every open connection and runs your filter function against each one. It's fine for low-frequency operations like sending a DM or notifying admins, but don't use it in a hot loop. If you're broadcasting to a known group of users, subscribe them to a shared topic and use `platform.publish()` instead -- topic-based pub/sub is handled natively by uWS in C++ and doesn't touch the JS event loop.
|
|
885
|
+
|
|
852
886
|
### `platform.connections`
|
|
853
887
|
|
|
854
888
|
Number of active WebSocket connections:
|
|
@@ -912,6 +946,20 @@ online.increment(5); // -> { event: 'increment', data: 5 }
|
|
|
912
946
|
online.decrement(); // -> { event: 'decrement', data: 1 }
|
|
913
947
|
```
|
|
914
948
|
|
|
949
|
+
### `platform.batch(messages)`
|
|
950
|
+
|
|
951
|
+
Publish multiple messages in a single call. Useful when an action updates several topics at once:
|
|
952
|
+
|
|
953
|
+
```js
|
|
954
|
+
platform.batch([
|
|
955
|
+
{ topic: 'todos', event: 'created', data: todo },
|
|
956
|
+
{ topic: `user:${userId}`, event: 'activity', data: { action: 'create' } },
|
|
957
|
+
{ topic: 'stats', event: 'increment', data: { key: 'todos_created' } }
|
|
958
|
+
]);
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
Each entry is published with `platform.publish()`. Cross-worker relay is batched automatically, so this is more efficient than three separate `publish()` calls from a relay overhead perspective.
|
|
962
|
+
|
|
915
963
|
---
|
|
916
964
|
|
|
917
965
|
## Client store API
|
|
@@ -979,9 +1027,31 @@ Like `Array.reduce` but reactive. Each new event feeds through the reducer:
|
|
|
979
1027
|
{/each}
|
|
980
1028
|
```
|
|
981
1029
|
|
|
1030
|
+
### `onDerived(topicFn, store)` - reactive topic subscription
|
|
1031
|
+
|
|
1032
|
+
Subscribes to a topic derived from a reactive value. When the source store changes, the old topic is released and the new one is subscribed automatically.
|
|
1033
|
+
|
|
1034
|
+
```svelte
|
|
1035
|
+
<script>
|
|
1036
|
+
import { page } from '$app/stores';
|
|
1037
|
+
import { onDerived } from 'svelte-adapter-uws/client';
|
|
1038
|
+
import { derived } from 'svelte/store';
|
|
1039
|
+
|
|
1040
|
+
// Subscribe to a different topic based on the current route
|
|
1041
|
+
const roomId = derived(page, ($page) => $page.params.id);
|
|
1042
|
+
const messages = onDerived((id) => `room:${id}`, roomId);
|
|
1043
|
+
</script>
|
|
1044
|
+
|
|
1045
|
+
{#if $messages}
|
|
1046
|
+
<p>{$messages.event}: {JSON.stringify($messages.data)}</p>
|
|
1047
|
+
{/if}
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
Without `onDerived`, you'd need to manually watch the source store and call `connect().subscribe()` / `connect().unsubscribe()` yourself when it changes. `onDerived` handles the full lifecycle: subscribes when the first Svelte subscriber arrives, switches topics when the source changes, and unsubscribes from the server when the last Svelte subscriber leaves.
|
|
1051
|
+
|
|
982
1052
|
### `crud(topic, initial?, options?)` - live CRUD list
|
|
983
1053
|
|
|
984
|
-
|
|
1054
|
+
Subscribes to a topic and handles `created`, `updated`, and `deleted` events automatically:
|
|
985
1055
|
|
|
986
1056
|
```svelte
|
|
987
1057
|
<script>
|
|
@@ -1167,6 +1237,10 @@ await ready();
|
|
|
1167
1237
|
// connection is now open, safe to send messages
|
|
1168
1238
|
```
|
|
1169
1239
|
|
|
1240
|
+
In SSR (no browser WebSocket), `ready()` resolves immediately and is a no-op.
|
|
1241
|
+
|
|
1242
|
+
`ready()` rejects if the connection is permanently closed before it opens. This happens when the server sends a terminal close code (1008/4401/4403), retries are exhausted, or `close()` is called explicitly. If you call `ready()` in a context where permanent closure is possible, add a `.catch()` handler or use `try/await/catch`.
|
|
1243
|
+
|
|
1170
1244
|
### `connect(options?)` - power-user API
|
|
1171
1245
|
|
|
1172
1246
|
Most users don't need this - `on()` and `status` auto-connect. Use `connect()` when you need `close()`, `send()`, or custom options.
|
|
@@ -1191,7 +1265,7 @@ const ws = connect({
|
|
|
1191
1265
|
// [ws] send -> { type: "ping" }
|
|
1192
1266
|
// [ws] disconnected
|
|
1193
1267
|
// [ws] queued -> { type: "important" }
|
|
1194
|
-
// [ws] resubscribe -> todos
|
|
1268
|
+
// [ws] resubscribe-batch -> ['todos', 'chat']
|
|
1195
1269
|
// [ws] flush -> { type: "important" }
|
|
1196
1270
|
|
|
1197
1271
|
// Manual topic management
|
|
@@ -1208,6 +1282,18 @@ ws.sendQueued({ type: 'important', data: '...' });
|
|
|
1208
1282
|
ws.close();
|
|
1209
1283
|
```
|
|
1210
1284
|
|
|
1285
|
+
### Automatic connection behaviors
|
|
1286
|
+
|
|
1287
|
+
The client handles several edge cases automatically, with no configuration required:
|
|
1288
|
+
|
|
1289
|
+
**Exponential backoff with proportional jitter**: each reconnect attempt waits longer than the previous one. The jitter is +-25% of the base delay (not a fixed +-500ms), so at high attempt counts thousands of clients are spread over a wide window rather than clustering.
|
|
1290
|
+
|
|
1291
|
+
**Page visibility reconnect**: when a browser tab resumes from background or a phone is unlocked, the client reconnects immediately instead of waiting for the backoff timer. Browsers often close WebSocket connections silently when a tab is hidden.
|
|
1292
|
+
|
|
1293
|
+
**Batch resubscription**: on reconnect, all topics are resubscribed in batched `subscribe-batch` messages. Each batch stays under the server's 8 KB control-message ceiling and 256-topic-per-batch cap. For typical apps (under 200 topics with short names) this is a single frame; larger sets are automatically chunked.
|
|
1294
|
+
|
|
1295
|
+
**Zombie detection**: the client checks every 30 seconds whether the server has been completely silent for more than 150 seconds (2.5x the server's idle timeout). If so, it forces a close and reconnects. This catches connections that appear open but were silently dropped by the server, which is common on mobile after wake from sleep.
|
|
1296
|
+
|
|
1211
1297
|
---
|
|
1212
1298
|
|
|
1213
1299
|
## Seeding initial state
|
|
@@ -1323,7 +1409,7 @@ export const pipeline = createMiddleware(
|
|
|
1323
1409
|
// src/hooks.ws.js
|
|
1324
1410
|
import { pipeline } from '$lib/server/pipeline';
|
|
1325
1411
|
|
|
1326
|
-
export async function message(ws, data,
|
|
1412
|
+
export async function message(ws, { data, platform }) {
|
|
1327
1413
|
const msg = JSON.parse(Buffer.from(data).toString());
|
|
1328
1414
|
const ctx = await pipeline.run(ws, msg, platform);
|
|
1329
1415
|
if (!ctx) return; // chain was stopped (e.g. auth failed)
|
|
@@ -1410,7 +1496,7 @@ import { replay } from '$lib/server/replay';
|
|
|
1410
1496
|
export function message(ws, { data, platform }) {
|
|
1411
1497
|
const msg = JSON.parse(Buffer.from(data).toString());
|
|
1412
1498
|
if (msg.type === 'replay') {
|
|
1413
|
-
replay.replay(ws, msg.topic, msg.since, platform);
|
|
1499
|
+
replay.replay(ws, msg.topic, msg.since, platform, msg.reqId);
|
|
1414
1500
|
return;
|
|
1415
1501
|
}
|
|
1416
1502
|
}
|
|
@@ -1445,15 +1531,15 @@ import { createReplay } from 'svelte-adapter-uws/plugins/replay';
|
|
|
1445
1531
|
|
|
1446
1532
|
const replay = createReplay({
|
|
1447
1533
|
size: 1000, // max messages per topic (default: 1000)
|
|
1448
|
-
maxTopics: 100 // max tracked topics,
|
|
1534
|
+
maxTopics: 100 // max tracked topics, LRU evicted (default: 100)
|
|
1449
1535
|
});
|
|
1450
1536
|
|
|
1451
|
-
replay.publish(platform, topic, event, data)
|
|
1452
|
-
replay.seq(topic)
|
|
1453
|
-
replay.since(topic, seq)
|
|
1454
|
-
replay.replay(ws, topic, sinceSeq, platform)
|
|
1455
|
-
replay.clear()
|
|
1456
|
-
replay.clearTopic(topic)
|
|
1537
|
+
replay.publish(platform, topic, event, data) // publish + buffer
|
|
1538
|
+
replay.seq(topic) // current sequence number
|
|
1539
|
+
replay.since(topic, seq) // buffered messages after seq
|
|
1540
|
+
replay.replay(ws, topic, sinceSeq, platform, reqId) // send missed messages to one client
|
|
1541
|
+
replay.clear() // reset everything
|
|
1542
|
+
replay.clearTopic(topic) // reset one topic
|
|
1457
1543
|
```
|
|
1458
1544
|
|
|
1459
1545
|
#### Client API
|
|
@@ -1468,6 +1554,18 @@ const store = onReplay('chat', { since: data.seq });
|
|
|
1468
1554
|
const messages = onReplay('chat', { since: data.seq }).scan([], reducer);
|
|
1469
1555
|
```
|
|
1470
1556
|
|
|
1557
|
+
Each `onReplay()` call generates a unique request ID that is sent with the replay request and matched against the server's responses. This means multiple `onReplay('chat', ...)` instances on the same page (e.g. two components subscribing to the same topic) each receive only their own replay stream and don't see each other's events. The server must pass `msg.reqId` to `replay.replay()` as shown above for this to work.
|
|
1558
|
+
|
|
1559
|
+
**Buffer overflow:** If more than `size` messages were published before the client connected and the ring buffer wrapped around, the store emits a synthetic `{ event: 'truncated', data: null }` event after the replayed messages. Check for it in your reducer or subscriber to decide whether to reload all data from the server:
|
|
1560
|
+
|
|
1561
|
+
```js
|
|
1562
|
+
const messages = onReplay('chat', { since: data.seq }).scan(data.messages, (list, { event, data }) => {
|
|
1563
|
+
if (event === 'truncated') return []; // buffer overflow - reload from server
|
|
1564
|
+
if (event === 'created') return [...list, data];
|
|
1565
|
+
return list;
|
|
1566
|
+
});
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1471
1569
|
#### Limitations
|
|
1472
1570
|
|
|
1473
1571
|
- **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.
|
|
@@ -1507,7 +1605,7 @@ export function upgrade({ cookies }) {
|
|
|
1507
1605
|
return { id: user.id, name: user.name };
|
|
1508
1606
|
}
|
|
1509
1607
|
|
|
1510
|
-
export const { subscribe, close } = presence.hooks;
|
|
1608
|
+
export const { subscribe, unsubscribe, close } = presence.hooks;
|
|
1511
1609
|
```
|
|
1512
1610
|
|
|
1513
1611
|
The `hooks` object handles everything: `subscribe` calls `join()` for regular topics and sends the current presence list for `__presence:*` topics, `close` calls `leave()`. If you need custom logic (auth gating, topic filtering), wrap the hook:
|
|
@@ -1518,7 +1616,7 @@ export function subscribe(ws, topic, ctx) {
|
|
|
1518
1616
|
presence.hooks.subscribe(ws, topic, ctx);
|
|
1519
1617
|
}
|
|
1520
1618
|
|
|
1521
|
-
export const close = presence.hooks
|
|
1619
|
+
export const { unsubscribe, close } = presence.hooks;
|
|
1522
1620
|
```
|
|
1523
1621
|
|
|
1524
1622
|
Use it on the client:
|
|
@@ -1563,7 +1661,7 @@ const presence = createPresence({
|
|
|
1563
1661
|
heartbeat: 60_000 // broadcast active keys every 60s (default: disabled)
|
|
1564
1662
|
});
|
|
1565
1663
|
|
|
1566
|
-
presence.hooks // ready-made { subscribe, close } hooks
|
|
1664
|
+
presence.hooks // ready-made { subscribe, unsubscribe, close } hooks
|
|
1567
1665
|
presence.join(ws, topic, platform) // add user to topic (call from subscribe hook)
|
|
1568
1666
|
presence.leave(ws, platform) // remove from all topics (call from close hook)
|
|
1569
1667
|
presence.sync(ws, topic, platform) // send list without joining (for observers)
|
|
@@ -1599,6 +1697,8 @@ Rule of thumb: set `heartbeat` to half (or less) of the client's `maxAge`.
|
|
|
1599
1697
|
|
|
1600
1698
|
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.
|
|
1601
1699
|
|
|
1700
|
+
If Alice's data changes between connections (for example she updates her avatar in one session and opens a fresh tab), `join()` detects the difference and broadcasts an `updated` event so other clients immediately see the new data. The `updated` event has the same shape as `join`: `{ key, data }`.
|
|
1701
|
+
|
|
1602
1702
|
If no `key` field is found in the selected data (e.g. no auth), each connection is tracked separately.
|
|
1603
1703
|
|
|
1604
1704
|
#### Limitations
|
|
@@ -1800,7 +1900,7 @@ export const limiter = createRateLimit({
|
|
|
1800
1900
|
// src/hooks.ws.js
|
|
1801
1901
|
import { limiter } from '$lib/server/ratelimit';
|
|
1802
1902
|
|
|
1803
|
-
export function message(ws, data,
|
|
1903
|
+
export function message(ws, { data, platform }) {
|
|
1804
1904
|
const { allowed, remaining, resetMs } = limiter.consume(ws);
|
|
1805
1905
|
if (!allowed) return; // drop the message
|
|
1806
1906
|
|
|
@@ -1853,15 +1953,31 @@ export const cursors = createCursor({
|
|
|
1853
1953
|
|
|
1854
1954
|
#### Server usage
|
|
1855
1955
|
|
|
1956
|
+
Use the `hooks` helper for zero-config cursor handling. The `message` hook handles `cursor` and `cursor-snapshot` messages automatically, and `close` calls `remove()`. The hooks verify that the sender is subscribed to the `__cursor:{topic}` channel before processing -- clients that haven't passed the `subscribe` hook for that topic are silently rejected.
|
|
1957
|
+
|
|
1856
1958
|
```js
|
|
1857
1959
|
// src/hooks.ws.js
|
|
1858
1960
|
import { cursors } from '$lib/server/cursors';
|
|
1859
1961
|
|
|
1860
|
-
export function message(ws,
|
|
1962
|
+
export function message(ws, ctx) {
|
|
1963
|
+
if (cursors.hooks.message(ws, ctx)) return;
|
|
1964
|
+
// handle other messages...
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
export const close = cursors.hooks.close;
|
|
1968
|
+
```
|
|
1969
|
+
|
|
1970
|
+
For custom auth or topic filtering, handle the messages manually:
|
|
1971
|
+
|
|
1972
|
+
```js
|
|
1973
|
+
export function message(ws, { data, platform }) {
|
|
1861
1974
|
const msg = JSON.parse(Buffer.from(data).toString());
|
|
1862
1975
|
if (msg.type === 'cursor') {
|
|
1863
1976
|
cursors.update(ws, msg.topic, { x: msg.x, y: msg.y }, platform);
|
|
1864
1977
|
}
|
|
1978
|
+
if (msg.type === 'cursor-snapshot') {
|
|
1979
|
+
cursors.snapshot(ws, msg.topic, platform);
|
|
1980
|
+
}
|
|
1865
1981
|
}
|
|
1866
1982
|
|
|
1867
1983
|
export function close(ws, { platform }) {
|
|
@@ -1888,7 +2004,9 @@ export function close(ws, { platform }) {
|
|
|
1888
2004
|
{/each}
|
|
1889
2005
|
```
|
|
1890
2006
|
|
|
1891
|
-
The client store is a `Readable<Map<string, { user, data }>>`. The Map updates when cursors move or disconnect. The store handles `update`, `remove`, and `bulk` events
|
|
2007
|
+
The client store is a `Readable<Map<string, { user, data }>>`. The Map updates when cursors move or disconnect. The store handles `update`, `remove`, `snapshot`, and `bulk` events. The `snapshot` event is authoritative -- it replaces all client-side state (used for initial sync and reconnect). The `bulk` event merges entries additively (used by the [extensions repo](https://github.com/lanteanio/svelte-adapter-uws-extensions) topicThrottle feature when flushing coalesced updates).
|
|
2008
|
+
|
|
2009
|
+
**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 `snapshot` event back with the current cursor state (or an empty array if nobody is active). The client replaces its entire cursor map with the snapshot contents, clearing any stale entries from before the disconnect. Wire `cursors.snapshot()` in your message handler as shown in the server example above.
|
|
1892
2010
|
|
|
1893
2011
|
The `cursor()` function accepts an optional second argument with a `maxAge` option (in milliseconds). When set, cursor entries that haven't received an update within that window are automatically removed. This makes clients self-healing when the server fails to broadcast `remove` events under load:
|
|
1894
2012
|
|
|
@@ -1902,6 +2020,7 @@ const positions = cursor('canvas', { maxAge: 30_000 });
|
|
|
1902
2020
|
|---|---|
|
|
1903
2021
|
| `cursors.update(ws, topic, data, platform)` | Broadcast position (throttled) |
|
|
1904
2022
|
| `cursors.remove(ws, platform)` | Remove from all topics, broadcast removal |
|
|
2023
|
+
| `cursors.snapshot(ws, topic, platform)` | Send current positions to one connection (initial sync) |
|
|
1905
2024
|
| `cursors.list(topic)` | Current positions (for SSR) |
|
|
1906
2025
|
| `cursors.clear()` | Reset all state and timers |
|
|
1907
2026
|
|
|
@@ -1925,7 +2044,7 @@ The trailing edge ensures you always see where the cursor stopped, even if the u
|
|
|
1925
2044
|
|
|
1926
2045
|
### Queue (ordered delivery)
|
|
1927
2046
|
|
|
1928
|
-
Per-key async task queue with configurable concurrency and backpressure.
|
|
2047
|
+
Per-key async task queue with configurable concurrency and backpressure. With the default `concurrency: 1`, tasks are processed strictly in order per key -- useful for sequential operations like collaborative editing, turn-based games, or transaction sequences. With `concurrency > 1`, dequeue order is preserved but tasks run in parallel, so completion order is not guaranteed.
|
|
1929
2048
|
|
|
1930
2049
|
#### Setup
|
|
1931
2050
|
|
|
@@ -1943,7 +2062,7 @@ export const queue = createQueue({ maxSize: 100 });
|
|
|
1943
2062
|
// src/hooks.ws.js
|
|
1944
2063
|
import { queue } from '$lib/server/queue';
|
|
1945
2064
|
|
|
1946
|
-
export async function message(ws, data,
|
|
2065
|
+
export async function message(ws, { data, platform }) {
|
|
1947
2066
|
const msg = JSON.parse(Buffer.from(data).toString());
|
|
1948
2067
|
|
|
1949
2068
|
// Messages for the same topic are processed one at a time
|
|
@@ -2002,22 +2121,30 @@ export const lobby = createGroup('lobby', {
|
|
|
2002
2121
|
|
|
2003
2122
|
#### Server usage
|
|
2004
2123
|
|
|
2124
|
+
Use the `hooks` helper for zero-config access control. The `subscribe` hook intercepts the internal `__group:lobby` topic, calls `join()`, and blocks the subscription if the group is full or closed. The `close` hook calls `leave()`.
|
|
2125
|
+
|
|
2005
2126
|
```js
|
|
2006
2127
|
// src/hooks.ws.js
|
|
2007
2128
|
import { lobby } from '$lib/server/lobby';
|
|
2008
2129
|
|
|
2009
|
-
export
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2130
|
+
export const { subscribe, unsubscribe, close } = lobby.hooks;
|
|
2131
|
+
```
|
|
2132
|
+
|
|
2133
|
+
If you need custom logic (role selection, auth gating), wrap the hook:
|
|
2134
|
+
|
|
2135
|
+
```js
|
|
2136
|
+
// src/hooks.ws.js
|
|
2137
|
+
import { lobby } from '$lib/server/lobby';
|
|
2138
|
+
|
|
2139
|
+
export function subscribe(ws, topic, ctx) {
|
|
2140
|
+
if (topic === '__group:lobby') {
|
|
2141
|
+
const role = ws.getUserData().isAdmin ? 'admin' : 'member';
|
|
2142
|
+
return lobby.join(ws, ctx.platform, role) ? undefined : false;
|
|
2015
2143
|
}
|
|
2144
|
+
lobby.hooks.subscribe(ws, topic, ctx);
|
|
2016
2145
|
}
|
|
2017
2146
|
|
|
2018
|
-
export
|
|
2019
|
-
lobby.leave(ws, platform);
|
|
2020
|
-
}
|
|
2147
|
+
export const { unsubscribe, close } = lobby.hooks;
|
|
2021
2148
|
```
|
|
2022
2149
|
|
|
2023
2150
|
Publish to group members:
|
|
@@ -2059,6 +2186,7 @@ The client store exposes two reactive values: the main store for events (`$lobby
|
|
|
2059
2186
|
| `group.close(platform)` | Dissolve group, notify everyone |
|
|
2060
2187
|
| `group.name` | Group name (read-only) |
|
|
2061
2188
|
| `group.meta` | Metadata (get/set) |
|
|
2189
|
+
| `group.hooks` | Ready-made `{ subscribe, unsubscribe, close }` hooks with access control |
|
|
2062
2190
|
|
|
2063
2191
|
Roles: `'member'` (default), `'admin'`, `'viewer'`.
|
|
2064
2192
|
|
|
@@ -2220,8 +2348,14 @@ net.ipv4.tcp_tw_reuse = 1 # reuse TIME_WAIT sockets faster
|
|
|
2220
2348
|
net.core.somaxconn = 4096 # listen() backlog limit
|
|
2221
2349
|
fs.file-max = 1024000 # system-wide file descriptor limit
|
|
2222
2350
|
net.netfilter.nf_conntrack_max = 262144 # connection tracking table size (default 65536 fills up fast under load, drops ALL new TCP including SSH)
|
|
2351
|
+
net.ipv4.tcp_fastopen = 3 # TCP Fast Open for both client and server (saves 1 RTT on reconnecting clients)
|
|
2352
|
+
net.ipv4.tcp_defer_accept = 5 # don't wake the app until data arrives (ignores port scanners and half-open probes)
|
|
2223
2353
|
```
|
|
2224
2354
|
|
|
2355
|
+
**TCP Fast Open** (`tcp_fastopen = 3`) lets a returning client send data in the SYN packet, eliminating one round-trip for the first request after a short idle. Browsers and HTTP clients that support TFO will use it automatically. The value `3` enables it for both incoming (server) and outgoing (client) connections.
|
|
2356
|
+
|
|
2357
|
+
**TCP Defer Accept** (`tcp_defer_accept = 5`) keeps the kernel from delivering the accepted socket to the application until data arrives. Port scanners, SYN probes, and clients that open a TCP connection but send nothing are handled at the kernel level rather than consuming event loop time. The value is the timeout in seconds before a data-less connection is dropped.
|
|
2358
|
+
|
|
2225
2359
|
### File descriptor limits
|
|
2226
2360
|
|
|
2227
2361
|
Add to `/etc/security/limits.conf` (takes effect on next login):
|
|
@@ -2265,6 +2399,26 @@ Symptoms of NAT table exhaustion:
|
|
|
2265
2399
|
|
|
2266
2400
|
The fix: run the stress test from the server itself (localhost to localhost) or from a machine on the same network as the server. This bypasses NAT entirely and lets you hit the actual server limits.
|
|
2267
2401
|
|
|
2402
|
+
### Connection management (uWS defaults)
|
|
2403
|
+
|
|
2404
|
+
uWebSockets.js manages connection lifecycle at the C++ level. These are its built-in behaviors:
|
|
2405
|
+
|
|
2406
|
+
**HTTP keepalive:** uWS closes idle HTTP connections after 10 seconds of inactivity. This is compiled into the C++ layer and is not configurable from JavaScript. Behind a reverse proxy (nginx, Caddy, Cloudflare), the proxy manages keepalive for external clients; uWS handles only the proxy-to-app leg.
|
|
2407
|
+
|
|
2408
|
+
**Slow-loris protection:** uWS requires at least 16 KB/second of throughput from each HTTP client. Connections that send data slower than this (a common DoS technique) are dropped by the C++ layer before they reach your application code.
|
|
2409
|
+
|
|
2410
|
+
**WebSocket ping/pong:** Set `idleTimeout` in the adapter's `websocket` option (in seconds) to have uWS send automatic WebSocket ping frames and close connections that don't respond. The default is 120 seconds. The client store handles pong automatically.
|
|
2411
|
+
|
|
2412
|
+
```js
|
|
2413
|
+
// svelte.config.js
|
|
2414
|
+
adapter({
|
|
2415
|
+
websocket: {
|
|
2416
|
+
idleTimeout: 120, // close WS connections silent for 120s
|
|
2417
|
+
maxPayloadLength: 16 * 1024 * 1024 // max incoming WS message size
|
|
2418
|
+
}
|
|
2419
|
+
})
|
|
2420
|
+
```
|
|
2421
|
+
|
|
2268
2422
|
---
|
|
2269
2423
|
|
|
2270
2424
|
## Performance
|
|
@@ -2281,8 +2435,8 @@ Tested with a trivial SvelteKit handler (isolates adapter overhead from your app
|
|
|
2281
2435
|
|
|
2282
2436
|
| | adapter-uws | adapter-node | Multiplier |
|
|
2283
2437
|
|---|---|---|---|
|
|
2284
|
-
| **Static files** |
|
|
2285
|
-
| **SSR** |
|
|
2438
|
+
| **Static files** | 165,700 req/s | 24,500 req/s | **6.8x faster** |
|
|
2439
|
+
| **SSR** | 150,500 req/s | 58,300 req/s | **2.6x faster** |
|
|
2286
2440
|
|
|
2287
2441
|
<sup>100 connections, 10 pipelining, 10s, 2 runs averaged. Node v24, Windows 11.</sup>
|
|
2288
2442
|
|
|
@@ -2294,34 +2448,32 @@ The static file gap is the largest because `adapter-node` uses sirv which calls
|
|
|
2294
2448
|
|
|
2295
2449
|
| Server | Messages delivered/s | vs adapter-uws |
|
|
2296
2450
|
|---|---|---|
|
|
2297
|
-
| **uWS native** (barebones) | 3,
|
|
2298
|
-
| **adapter-uws** (full handler) | 3,
|
|
2299
|
-
| **ws** library |
|
|
2300
|
-
| **socket.io** |
|
|
2451
|
+
| **uWS native** (barebones) | 3,583,000 | baseline |
|
|
2452
|
+
| **adapter-uws** (full handler) | 3,583,000 | 1.0x |
|
|
2453
|
+
| **ws** library | 232,200 | **15.4x slower** |
|
|
2454
|
+
| **socket.io** | 226,700 | **15.8x slower** |
|
|
2301
2455
|
|
|
2302
|
-
uWS native pub/sub delivered 3.
|
|
2456
|
+
uWS native pub/sub delivered 3.5M messages/s with exact 50x fan-out. The adapter matches it -- the byte-prefix check and string template envelope add near-zero overhead to the hot path. `socket.io` and `ws` both collapsed under the same load, delivering less than 1x fan-out (massive message loss/queueing).
|
|
2303
2457
|
|
|
2304
2458
|
### Where the overhead goes
|
|
2305
2459
|
|
|
2306
|
-
**HTTP (SSR path) -
|
|
2460
|
+
**HTTP (SSR path) - ~32% total overhead vs barebones uWS:**
|
|
2307
2461
|
|
|
2308
2462
|
| Layer | Cost | Notes |
|
|
2309
2463
|
|---|---|---|
|
|
2310
|
-
| `res.cork()` + status + headers |
|
|
2311
|
-
| `new Request()` construction | 9
|
|
2312
|
-
|
|
|
2313
|
-
| Header collection,
|
|
2314
|
-
|
|
2315
|
-
**WebSocket - optimized down from 27% to ~4% overhead vs barebones uWS pub/sub:**
|
|
2464
|
+
| `res.cork()` + status + headers | ~12.6% | Writing a proper HTTP response - unavoidable |
|
|
2465
|
+
| `new Request()` construction | ~9% | Required by SvelteKit's `server.respond()` contract |
|
|
2466
|
+
| async/Promise scheduling | ~3% | `getReader()` + `read()` + event loop yield |
|
|
2467
|
+
| Header collection, remoteAddress | ~1% | `req.forEach` + TextDecoder |
|
|
2316
2468
|
|
|
2317
|
-
|
|
2469
|
+
**WebSocket - at parity with barebones uWS pub/sub:**
|
|
2318
2470
|
|
|
2319
|
-
| Layer |
|
|
2320
|
-
|
|
2321
|
-
| Subscribe/unsubscribe check | ~
|
|
2322
|
-
| Envelope wrapping | ~
|
|
2323
|
-
| Connection tracking | ~2% |
|
|
2324
|
-
| Origin validation, upgrade headers | ~2% |
|
|
2471
|
+
| Layer | Cost | How |
|
|
2472
|
+
|---|---|---|
|
|
2473
|
+
| Subscribe/unsubscribe check | ~0% | Byte-prefix discriminator: byte[3] is `y` for `{"ty` (control) and `o` for `{"to` (user envelope). One comparison skips `JSON.parse` for all user messages (0.001us per message). |
|
|
2474
|
+
| Envelope wrapping | ~0% | String template + `esc()` char scan instead of `JSON.stringify` on a wrapper object. Only `data` is stringified. ~0.085us per publish. |
|
|
2475
|
+
| Connection tracking | ~2% | `Set` add/delete on open/close. |
|
|
2476
|
+
| Origin validation, upgrade headers | ~2% | Four `req.getHeader` calls on upgrade. |
|
|
2325
2477
|
|
|
2326
2478
|
**What we don't add:**
|
|
2327
2479
|
- No middleware chain (no Polka, no Express)
|
|
@@ -2329,9 +2481,31 @@ The two largest WebSocket costs were `JSON.parse()` on every message for the sub
|
|
|
2329
2481
|
- No per-request stream allocation for static files (in-memory Buffer, not `fs.createReadStream`)
|
|
2330
2482
|
- No Node.js `http.IncomingMessage` shim (we construct `Request` directly from uWS)
|
|
2331
2483
|
|
|
2484
|
+
### SSR request deduplication
|
|
2485
|
+
|
|
2486
|
+
When multiple concurrent requests arrive for the same anonymous (no cookie/auth) GET or HEAD URL, only one is dispatched to SvelteKit. The others wait for the result and reconstruct their own response from the shared buffer. This prevents redundant rendering work during traffic spikes, a common pattern when a post goes viral or a cron job hits a popular page at the same time as real users.
|
|
2487
|
+
|
|
2488
|
+
Dedup is automatically skipped for:
|
|
2489
|
+
- Any request with a `Cookie` or `Authorization` header (personalized responses must not be shared)
|
|
2490
|
+
- POST, PUT, PATCH, DELETE (mutations must always execute)
|
|
2491
|
+
- Responses with a `Set-Cookie` header (personalized)
|
|
2492
|
+
- Response bodies larger than 512 KB (too large to buffer and share)
|
|
2493
|
+
- Requests with an `X-No-Dedup: 1` header (opt-out escape hatch)
|
|
2494
|
+
|
|
2495
|
+
No configuration is needed. The dedup map holds at most 500 in-flight keys simultaneously as a safety valve against memory pressure from unique URLs.
|
|
2496
|
+
|
|
2497
|
+
**Vary and personalization contract:** The adapter deduplicates by method + URL only. It cannot inspect every possible input that might affect your response (user-agent quirks, custom headers, etc.). The contract is:
|
|
2498
|
+
|
|
2499
|
+
- If your route handler produces different output based on a request header or other input, emit a `Vary` header listing those headers. The adapter checks the `Vary` header after rendering and discards the dedup entry if `Vary` is present, preventing that response from being shared.
|
|
2500
|
+
- If you have a route that varies by something the adapter cannot detect (e.g. server-side A/B test state), add `X-No-Dedup: 1` to opt out entirely.
|
|
2501
|
+
|
|
2502
|
+
Anonymous GET/HEAD routes that produce the same output for all users (landing pages, docs, prerendered pages) benefit most from dedup and require no action.
|
|
2503
|
+
|
|
2504
|
+
**Measured benefit:** 200 concurrent requests to the same anonymous URL with a 5ms render delay: without dedup, 200 render calls; with dedup, 1 render call. 200x reduction in CPU and memory pressure.
|
|
2505
|
+
|
|
2332
2506
|
### The bottom line
|
|
2333
2507
|
|
|
2334
|
-
The adapter retains
|
|
2508
|
+
The adapter retains ~68% of raw uWS HTTP throughput and matches uWS native WebSocket throughput. The HTTP overhead is dominated by things SvelteKit requires (`new Request()`, proper HTTP headers). The WebSocket overhead is now almost entirely the `JSON.stringify` of your `data` payload -- the adapter's own machinery costs near zero. In a real app, your load functions and component rendering will dwarf all of this -- the adapter's job is to get out of the way, and it does.
|
|
2335
2509
|
|
|
2336
2510
|
To run the benchmarks yourself:
|
|
2337
2511
|
|
|
@@ -2339,6 +2513,7 @@ To run the benchmarks yourself:
|
|
|
2339
2513
|
npm install # installs uWebSockets.js, autocannon, etc.
|
|
2340
2514
|
node bench/run.mjs # adapter overhead breakdown
|
|
2341
2515
|
node bench/run-compare.mjs # full comparison vs adapter-node + socket.io
|
|
2516
|
+
node bench/run-dedup.mjs # SSR dedup render-call reduction
|
|
2342
2517
|
```
|
|
2343
2518
|
|
|
2344
2519
|
---
|
|
@@ -2505,6 +2680,27 @@ Then check the browser's Network tab -> WS tab. You'll see the upgrade request a
|
|
|
2505
2680
|
- The session expired or is invalid
|
|
2506
2681
|
- `sameSite: 'strict'` can block cookies on cross-origin navigations - try `'lax'` if you're redirecting from an external site
|
|
2507
2682
|
|
|
2683
|
+
**To stop the retry loop when credentials are permanently invalid**, close the WebSocket with a terminal close code from inside your `open` or `message` handler. The client will not reconnect on these codes:
|
|
2684
|
+
|
|
2685
|
+
| Code | Meaning |
|
|
2686
|
+
|---|---|
|
|
2687
|
+
| `1008` | Policy Violation (standard) |
|
|
2688
|
+
| `4401` | Unauthorized (custom) |
|
|
2689
|
+
| `4403` | Forbidden (custom) |
|
|
2690
|
+
|
|
2691
|
+
```js
|
|
2692
|
+
// src/hooks.ws.js
|
|
2693
|
+
export async function open(ws, { platform }) {
|
|
2694
|
+
const userData = ws.getUserData();
|
|
2695
|
+
if (!userData.userId) {
|
|
2696
|
+
ws.close(4401, 'Unauthorized'); // client will not retry
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
```
|
|
2701
|
+
|
|
2702
|
+
When the server closes with code `4429`, the client treats it as a rate limit signal and backs off more aggressively before retrying.
|
|
2703
|
+
|
|
2508
2704
|
### "WebSocket doesn't work with `npm run preview`"
|
|
2509
2705
|
|
|
2510
2706
|
This is expected. SvelteKit's preview server is Vite's built-in HTTP server - it doesn't know about WebSocket upgrades. Use `node build` instead:
|
|
@@ -2678,6 +2874,7 @@ Or if you're using `on()` directly (which auto-connects), call `connect()` first
|
|
|
2678
2874
|
|
|
2679
2875
|
- [svelte-adapter-uws-extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) -- Redis-backed extensions for multi-server deployments: persistent presence, distributed pub/sub, session storage, and more.
|
|
2680
2876
|
- [svelte-realtime](https://github.com/lanteanio/svelte-realtime) -- Opinionated full-stack starter built on this adapter. Auth, database, real-time CRUD, and deployment config out of the box.
|
|
2877
|
+
- [svelte-realtime-demo](https://github.com/lanteanio/svelte-realtime-demo) -- Live demo of svelte-realtime. [Try it here.](https://svelte-realtime-demo.lantean.io/)
|
|
2681
2878
|
|
|
2682
2879
|
## License
|
|
2683
2880
|
|
package/client.d.ts
CHANGED
|
@@ -281,10 +281,42 @@ export function count(topic: string, initial?: number): Readable<number>;
|
|
|
281
281
|
export function once<T = unknown>(topic: string, options?: { timeout?: number }): Promise<WSEvent<T>>;
|
|
282
282
|
export function once<T = unknown>(topic: string, event: string, options?: { timeout?: number }): Promise<{ data: T }>;
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Create a store that subscribes to a topic derived from a reactive value.
|
|
286
|
+
* When the source store changes, the subscription automatically switches to
|
|
287
|
+
* the new topic and the old one is released.
|
|
288
|
+
*
|
|
289
|
+
* Useful when the topic depends on runtime state like a user ID, selected item,
|
|
290
|
+
* or route parameter — no manual subscribe/unsubscribe lifecycle to manage.
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```svelte
|
|
294
|
+
* <script>
|
|
295
|
+
* import { page } from '$app/stores';
|
|
296
|
+
* import { onDerived } from 'svelte-adapter-uws/client';
|
|
297
|
+
* import { derived } from 'svelte/store';
|
|
298
|
+
*
|
|
299
|
+
* // Subscribe to a topic based on the current page's item ID
|
|
300
|
+
* const roomId = derived(page, ($page) => $page.params.id);
|
|
301
|
+
* const messages = onDerived((id) => `room:${id}`, roomId);
|
|
302
|
+
* </script>
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
export function onDerived<T = unknown>(
|
|
306
|
+
topicFn: (value: T) => string,
|
|
307
|
+
store: import('svelte/store').Readable<T>
|
|
308
|
+
): import('svelte/store').Readable<WSEvent | null>;
|
|
309
|
+
|
|
284
310
|
/**
|
|
285
311
|
* Returns a promise that resolves when the WebSocket connection is open.
|
|
286
312
|
* Auto-connects if not already connected.
|
|
287
313
|
*
|
|
314
|
+
* Resolves immediately in SSR (no WebSocket available).
|
|
315
|
+
*
|
|
316
|
+
* Rejects with an error if the connection is permanently closed before it
|
|
317
|
+
* opens - for example, when the server sends a terminal close code (1008,
|
|
318
|
+
* 4401, 4403), retries are exhausted, or `close()` is called explicitly.
|
|
319
|
+
*
|
|
288
320
|
* @example
|
|
289
321
|
* ```js
|
|
290
322
|
* import { ready } from 'svelte-adapter-uws/client';
|