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 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
- One-liner for real-time collections. Handles `created`, `updated`, and `deleted` events automatically:
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, { platform }) {
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, oldest evicted (default: 100)
1534
+ maxTopics: 100 // max tracked topics, LRU evicted (default: 100)
1449
1535
  });
1450
1536
 
1451
- replay.publish(platform, topic, event, data) // publish + buffer
1452
- replay.seq(topic) // current sequence number
1453
- replay.since(topic, seq) // buffered messages after seq
1454
- replay.replay(ws, topic, sinceSeq, platform) // send missed messages to one client
1455
- replay.clear() // reset everything
1456
- replay.clearTopic(topic) // reset one 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.close;
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, { platform }) {
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, data, { platform }) {
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 -- the `bulk` event applies multiple cursor updates in a single store emission, which is used by the [extensions repo](https://github.com/lanteanio/svelte-adapter-uws-extensions) topicThrottle feature when flushing coalesced updates.
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. Guarantees in-order processing per key -- useful for sequential operations like collaborative editing, turn-based games, or transaction sequences.
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, { platform }) {
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 function subscribe(ws, topic, { platform }) {
2010
- if (topic === 'lobby') {
2011
- const joined = lobby.join(ws, platform, 'member');
2012
- if (!joined) {
2013
- platform.send(ws, 'system', 'error', 'Lobby is full');
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 function close(ws, { platform }) {
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** | 135,300 req/s | 20,100 req/s | **6.7x faster** |
2285
- | **SSR** | 125,100 req/s | 53,900 req/s | **2.3x faster** |
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,642,000 | baseline |
2298
- | **adapter-uws** (full handler) | 3,625,000 | 1.0x |
2299
- | **ws** library | 177,200 | **20.5x slower** |
2300
- | **socket.io** | 164,500 | **22.1x slower** |
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.6M messages/s with perfect 50x fan-out. After optimization, 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).
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) - 23% total overhead vs barebones uWS:**
2460
+ **HTTP (SSR path) - ~32% total overhead vs barebones uWS:**
2307
2461
 
2308
2462
  | Layer | Cost | Notes |
2309
2463
  |---|---|---|
2310
- | `res.cork()` + status + headers | 11.4% | Writing a proper HTTP response - unavoidable |
2311
- | `new Request()` construction | 9.7% | Required by SvelteKit's `server.respond()` contract |
2312
- | Response body reader loop | ~2% | `getReader()` + `read()` + async scheduling |
2313
- | Header collection, AbortController | ~0% | Measured at 0.08us and 0.004us per request |
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
- The two largest WebSocket costs were `JSON.parse()` on every message for the subscribe/unsubscribe check (15%) and `JSON.stringify()` for envelope wrapping (8%). Both have been optimized:
2469
+ **WebSocket - at parity with barebones uWS pub/sub:**
2318
2470
 
2319
- | Layer | Before | After | How |
2320
- |---|---|---|---|
2321
- | Subscribe/unsubscribe check | ~15% | ~0% | Byte-prefix discriminator: control messages start with `{"ty` (byte[3]=`y`), user envelopes start with `{"to` (byte[3]=`o`). A single byte comparison skips `JSON.parse` for all regular messages -- from 0.39us to 0.001us per message. |
2322
- | Envelope wrapping | ~8% | ~4.5% | String template with `esc()` validation instead of `JSON.stringify` on a wrapper object. Topic and event names are validated with a fast char scan (~10ns) that throws on quotes, backslashes, or control characters - only `data` is stringified. From 0.135us to ~0.085us per publish. |
2323
- | Connection tracking | ~2% | ~2% | Unchanged |
2324
- | Origin validation, upgrade headers | ~2% | ~2% | Unchanged |
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 77% of raw uWS HTTP throughput and ~96% of raw uWS 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.
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';