svelte-adapter-uws 0.4.13 → 0.5.0-next.1

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
@@ -887,6 +887,13 @@ sub.on('message', (channel, payload) => {
887
887
  });
888
888
  ```
889
889
 
890
+ Every published frame is also stamped with a monotonic per-topic `seq` field in the envelope (first publish to a topic is `seq: 1`, then 2, 3, ...). Reconnecting clients can use this to detect dropped frames and resume from where they left off. Pass `{ seq: false }` to skip stamping for ephemeral or high-cardinality topics where the counter map would grow unbounded:
891
+
892
+ ```js
893
+ // Skip seq for per-user cursor topics: counter map would grow with users
894
+ platform.publish(`cursor:${userId}`, 'move', pos, { seq: false });
895
+ ```
896
+
890
897
  ```js
891
898
  // src/routes/todos/+page.server.js
892
899
  export const actions = {
@@ -953,6 +960,40 @@ export function message(ws, { data, platform }) {
953
960
  }
954
961
  ```
955
962
 
963
+ ### `platform.sendCoalesced(ws, { key, topic, event, data })`
964
+
965
+ Send a message to a single connection with **coalesce-by-key** semantics. Each `(connection, key)` pair holds at most one pending message; if a newer call for the same `key` arrives before the previous frame drains to the wire, the older value is replaced in place.
966
+
967
+ Use this for latest-value streams where intermediate values are noise -- price ticks, cursor positions, presence state, typing indicators, scroll position. Under load, this is the difference between the client lagging by a thousand stale frames and the client always seeing the most recent value.
968
+
969
+ For at-least-once delivery use `platform.send()` or `platform.publish()` instead. `sendCoalesced` is explicitly drop-the-middle, keep-the-latest.
970
+
971
+ ```js
972
+ // src/hooks.ws.js - cursor positions during a collaborative edit
973
+ export function message(ws, { data, platform }) {
974
+ const msg = JSON.parse(Buffer.from(data).toString());
975
+ if (msg.event === 'cursor') {
976
+ const { docId, userId } = ws.getUserData();
977
+ // Coalesce per (connection, user) - one pending cursor frame per peer.
978
+ // High-frequency mousemove updates collapse cleanly under backpressure.
979
+ for (const peer of getPeersOf(docId)) {
980
+ platform.sendCoalesced(peer, {
981
+ key: 'cursor:' + userId,
982
+ topic: 'doc:' + docId,
983
+ event: 'cursor',
984
+ data: { userId, x: msg.data.x, y: msg.data.y }
985
+ });
986
+ }
987
+ }
988
+ }
989
+ ```
990
+
991
+ Three properties worth knowing:
992
+
993
+ - **Latest value wins.** `set` on an existing key replaces the value but keeps the original slot, so coalescing one key never reorders the rest of the queue.
994
+ - **Lazy serialization.** `data` is held as-is in the per-connection buffer and only `JSON.stringify`'d at flush time. A stream that overwrites the same key 1000 times before a single drain pays one serialization, not 1000.
995
+ - **Auto-resume on drain.** When `maxBackpressure` is hit, pumping stops and resumes on the next uWS drain event automatically. No manual flow control.
996
+
956
997
  ### `platform.sendTo(filter, topic, event, data)`
957
998
 
958
999
  Send a message to all connections whose `userData` matches a filter function. Returns the number of connections the message was sent to.
@@ -1006,6 +1047,77 @@ export async function GET({ platform, params }) {
1006
1047
  }
1007
1048
  ```
1008
1049
 
1050
+ ### `platform.pressure` and `platform.onPressure(cb)`
1051
+
1052
+ Worker-local backpressure signal. The adapter samples once per second (configurable) and reports the most urgent active stress as a single `reason` enum, so user code can degrade with intent instead of generic panic.
1053
+
1054
+ ```js
1055
+ platform.pressure
1056
+ // {
1057
+ // active: false,
1058
+ // subscriberRatio: 12.4, // total subscriptions / connections, on this worker
1059
+ // publishRate: 240, // platform.publish() calls/sec, last sample
1060
+ // memoryMB: 128, // process.memoryUsage().rss in MB
1061
+ // reason: 'NONE' // 'NONE' | 'PUBLISH_RATE' | 'SUBSCRIBERS' | 'MEMORY'
1062
+ // }
1063
+ ```
1064
+
1065
+ Reading `platform.pressure` is a property access -- safe in hot paths, no I/O. Use it for synchronous shed decisions in request handlers:
1066
+
1067
+ ```js
1068
+ // src/routes/api/heavy-write/+server.js
1069
+ export async function POST({ platform, request }) {
1070
+ if (platform.pressure.reason === 'MEMORY') {
1071
+ return new Response('Try again shortly', { status: 503 });
1072
+ }
1073
+ // ... normal write path
1074
+ }
1075
+ ```
1076
+
1077
+ `platform.onPressure(cb)` fires only on **transitions** (when `reason` changes between samples), not on every tick. Returns an unsubscribe function:
1078
+
1079
+ ```js
1080
+ // src/hooks.ws.js - notify the connected client when pressure state changes
1081
+ export function open(ws, { platform }) {
1082
+ const off = platform.onPressure(({ reason, active }) => {
1083
+ platform.send(ws, '__pressure', reason, { active });
1084
+ });
1085
+ ws.getUserData().__offPressure = off;
1086
+ }
1087
+
1088
+ export function close(ws) {
1089
+ ws.getUserData().__offPressure?.();
1090
+ }
1091
+ ```
1092
+
1093
+ **Reason precedence is fixed:** `MEMORY > PUBLISH_RATE > SUBSCRIBERS`. A worker under multiple stresses reports the most urgent one. Memory wins because the worker is approaching OOM and nothing else matters; publish rate is next because CPU saturation cascades fastest; subscriber ratio is last because heavy fan-out degrades gracefully.
1094
+
1095
+ **Thresholds are configurable per-deployment.** Defaults are conservative -- a healthy small app should never trip them in steady state. Override via `WebSocketOptions.pressure`:
1096
+
1097
+ ```js
1098
+ // svelte.config.js
1099
+ import adapter from 'svelte-adapter-uws';
1100
+
1101
+ export default {
1102
+ kit: {
1103
+ adapter: adapter({
1104
+ websocket: {
1105
+ pressure: {
1106
+ memoryHeapUsedRatio: 0.9, // default 0.85
1107
+ publishRatePerSec: 50000, // default 10000
1108
+ subscriberRatio: false, // disable this signal
1109
+ sampleIntervalMs: 500 // default 1000; clamped to >=100
1110
+ }
1111
+ }
1112
+ })
1113
+ }
1114
+ };
1115
+ ```
1116
+
1117
+ Set any individual threshold to `false` to disable that signal. `sampleIntervalMs` is clamped to a minimum of 100 ms.
1118
+
1119
+ > **Clustering:** `platform.pressure` is per-worker. Each worker samples its own counters and reports its own snapshot. There is no aggregate "cluster pressure" -- a hot worker should shed its own load without waiting for the rest of the cluster.
1120
+
1009
1121
  ### `platform.topic(name)` - scoped helper
1010
1122
 
1011
1123
  Reduces repetition when publishing multiple events to the same topic:
@@ -2900,13 +3012,26 @@ Every message sent through `platform.publish()` or `platform.topic().created()`
2900
3012
  {
2901
3013
  "topic": "todos",
2902
3014
  "event": "created",
2903
- "data": { "id": 1, "text": "Buy milk", "done": false }
3015
+ "data": { "id": 1, "text": "Buy milk", "done": false },
3016
+ "seq": 42
2904
3017
  }
2905
3018
  ```
2906
3019
 
3020
+ The `seq` field is a monotonic per-topic sequence number stamped automatically on every `platform.publish()`. The first publish to a topic sends `seq: 1`, the next `seq: 2`, and so on; each topic has its own counter. Reconnecting clients can use the seq to detect dropped frames and resume from where they left off. Pass `{ seq: false }` to skip stamping when you don't care about gap detection or when topic cardinality is unbounded:
3021
+
3022
+ ```js
3023
+ // Standard publish - seq stamped automatically
3024
+ platform.publish('chat', 'message', msg);
3025
+
3026
+ // Opt out for ephemeral or high-cardinality topics
3027
+ platform.publish(`cursor:${userId}`, 'move', pos, { seq: false });
3028
+ ```
3029
+
3030
+ > **Clustering:** the per-topic counter is worker-local. Each worker stamps its own publishes; relayed messages from other workers pass through with the originating worker's seq. For cluster-wide monotonic seq across all workers, wire up the Redis Lua INCR variant from the extensions package.
3031
+
2907
3032
  The client store parses this automatically. When you use `on('todos')`, the store value is:
2908
3033
  ```js
2909
- { topic: 'todos', event: 'created', data: { id: 1, text: 'Buy milk', done: false } }
3034
+ { topic: 'todos', event: 'created', data: { id: 1, text: 'Buy milk', done: false }, seq: 42 }
2910
3035
  ```
2911
3036
 
2912
3037
  When you use `on('todos', 'created')`, you get the payload wrapped in `{ data }`:
package/client.d.ts CHANGED
@@ -17,14 +17,20 @@ export interface ConnectOptions {
17
17
 
18
18
  /**
19
19
  * Base delay in ms before reconnecting after a disconnect.
20
- * Uses exponential backoff with jitter.
20
+ * The actual delay grows as `base * 2.2^attempt` with a +/- 25%
21
+ * jitter, capped at `maxReconnectInterval`.
21
22
  * @default 3000
22
23
  */
23
24
  reconnectInterval?: number;
24
25
 
25
26
  /**
26
- * Maximum delay in ms between reconnection attempts.
27
- * @default 30000
27
+ * Maximum delay in ms between reconnection attempts. Once the
28
+ * exponential curve hits this cap it stays there until the
29
+ * connection succeeds. The default 5 minute cap is long enough
30
+ * that 10K clients hammering a recovering server don't sustain the
31
+ * outage, short enough that a recovered server picks up its
32
+ * clients within a coffee break.
33
+ * @default 300000
28
34
  */
29
35
  maxReconnectInterval?: number;
30
36
 
@@ -92,6 +98,16 @@ export interface WSEvent<T = unknown> {
92
98
  event: string;
93
99
  /** The event payload. */
94
100
  data: T;
101
+ /**
102
+ * Monotonic per-topic sequence number stamped by the server on every
103
+ * `platform.publish()` (omitted when the publisher opts out via
104
+ * `{ seq: false }`). Each topic has an independent counter starting
105
+ * at 1.
106
+ *
107
+ * Worker-local in clustered mode unless an extension provides a
108
+ * cluster-wide source of truth (e.g. Redis Lua INCR).
109
+ */
110
+ seq?: number;
95
111
  }
96
112
 
97
113
  // -- Scannable store ----------------------------------------------------------
@@ -335,7 +351,7 @@ export function once<T = unknown>(topic: string, event: string, options?: { time
335
351
  * the new topic and the old one is released.
336
352
  *
337
353
  * Useful when the topic depends on runtime state like a user ID, selected item,
338
- * or route parameter no manual subscribe/unsubscribe lifecycle to manage.
354
+ * or route parameter - no manual subscribe/unsubscribe lifecycle to manage.
339
355
  *
340
356
  * @example
341
357
  * ```svelte
package/client.js CHANGED
@@ -498,6 +498,62 @@ const THROTTLE_CLOSE_CODES = new Set([
498
498
  4429, // Rate limited (custom)
499
499
  ]);
500
500
 
501
+ /**
502
+ * Classify a WebSocket close code into one of three reconnect behaviors.
503
+ *
504
+ * - `'TERMINAL'`: the server has permanently rejected this client.
505
+ * Reconnecting would be pointless. The client store transitions to a
506
+ * permanently-closed state and stops trying. Codes: 1008 (policy
507
+ * violation), 4401 (unauthorized), 4403 (forbidden).
508
+ * - `'THROTTLE'`: the server is rate-limiting. Reconnect is still
509
+ * attempted but the client jumps ahead in the backoff curve to avoid
510
+ * hammering a busy server. Code: 4429 (too many requests).
511
+ * - `'RETRY'`: every other code, including normal closes (1000/1001) and
512
+ * abnormal ones (1006/1011/1012). The client reconnects with the
513
+ * standard backoff curve.
514
+ *
515
+ * Pure: no I/O, no globals. Suitable for unit tests.
516
+ *
517
+ * @param {number | undefined} code
518
+ * @returns {'TERMINAL' | 'THROTTLE' | 'RETRY'}
519
+ */
520
+ export function classifyCloseCode(code) {
521
+ if (TERMINAL_CLOSE_CODES.has(code)) return 'TERMINAL';
522
+ if (THROTTLE_CLOSE_CODES.has(code)) return 'THROTTLE';
523
+ return 'RETRY';
524
+ }
525
+
526
+ /**
527
+ * Compute the next reconnect delay using exponential backoff with
528
+ * proportional jitter.
529
+ *
530
+ * The capped delay is `min(base * 2.2^attempt, maxDelay)`. A random factor
531
+ * in `[0.75, 1.25]` is then applied multiplicatively, so the final delay
532
+ * spans +/- 25% of the capped value. Multiplicative jitter keeps spread
533
+ * meaningful at high attempt counts: with 10K clients all reconnecting
534
+ * after a server restart, additive +/- 500ms jitter clusters reconnects
535
+ * inside a 1 second window; proportional jitter spreads them across
536
+ * a window proportional to the current backoff.
537
+ *
538
+ * The 2.2 exponent with a 5 minute cap is aggressive enough to back off
539
+ * fast under sustained server pain (the default 3 second base hits the
540
+ * cap by attempt 6) and gentle enough that a brief restart resolves
541
+ * before the user notices.
542
+ *
543
+ * Pure: no I/O, no globals. Pass a deterministic `randFactor` for
544
+ * reproducible assertions in tests.
545
+ *
546
+ * @param {number} base base interval in ms (e.g. 3000)
547
+ * @param {number} maxDelay cap in ms (e.g. 300000)
548
+ * @param {number} attempt zero-based attempt counter
549
+ * @param {number} [randFactor] random factor in [0, 1); defaults to Math.random()
550
+ * @returns {number}
551
+ */
552
+ export function nextReconnectDelay(base, maxDelay, attempt, randFactor = Math.random()) {
553
+ const capped = Math.min(base * Math.pow(2.2, attempt), maxDelay);
554
+ return capped * (0.75 + randFactor * 0.5);
555
+ }
556
+
501
557
  /**
502
558
  * @param {import('./client.js').ConnectOptions} options
503
559
  * @returns {import('./client.js').WSConnection & { _onEvent: (topic: string, event: string) => import('svelte/store').Readable<unknown> }}
@@ -507,7 +563,7 @@ function createConnection(options) {
507
563
  url,
508
564
  path = '/ws',
509
565
  reconnectInterval = 3000,
510
- maxReconnectInterval = 30000,
566
+ maxReconnectInterval = 300000,
511
567
  maxReconnectAttempts = Infinity,
512
568
  debug = false,
513
569
  auth = false
@@ -757,19 +813,19 @@ function createConnection(options) {
757
813
  if (debug) console.log('[ws] disconnected');
758
814
  if (intentionallyClosed) return;
759
815
 
760
- if (TERMINAL_CLOSE_CODES.has(event?.code)) {
816
+ const cls = classifyCloseCode(event?.code);
817
+ if (cls === 'TERMINAL') {
761
818
  // Server has permanently rejected this client - do not retry.
762
819
  // Use ws.close(4401) or ws.close(1008) on the server when credentials
763
820
  // are invalid or the connection is forbidden, to stop the retry loop.
764
- if (debug) console.warn('[ws] connection permanently closed by server (code ' + event.code + ')');
821
+ if (debug) console.warn('[ws] connection permanently closed by server (code ' + event?.code + ')');
765
822
  terminalClosed = true;
766
823
  permaClosedStore.set(true);
767
824
  return;
768
825
  }
769
826
 
770
- if (THROTTLE_CLOSE_CODES.has(event?.code)) {
771
- // Server is rate-limiting us - jump ahead in the backoff curve
772
- // to avoid hammering it with immediate reconnect attempts.
827
+ if (cls === 'THROTTLE') {
828
+ // Jump ahead in the backoff curve to avoid hammering a rate-limited server.
773
829
  attempt = Math.max(attempt, 5);
774
830
  }
775
831
 
@@ -789,13 +845,7 @@ function createConnection(options) {
789
845
  permaClosedStore.set(true);
790
846
  return;
791
847
  }
792
- // Proportional jitter (±25% of the base delay) prevents thundering herd
793
- // on server restarts. With 10K clients and additive ±500ms jitter all
794
- // reconnections cluster in a 1s window; proportional jitter spreads them
795
- // over ~15s at higher attempt counts where the base delay is large.
796
- const base = Math.min(reconnectInterval * Math.pow(1.5, attempt), maxReconnectInterval);
797
- const jitter = base * 0.25 * (Math.random() * 2 - 1);
798
- const delay = Math.max(0, base + jitter);
848
+ const delay = nextReconnectDelay(reconnectInterval, maxReconnectInterval, attempt);
799
849
  attempt++;
800
850
  reconnectTimer = setTimeout(() => {
801
851
  reconnectTimer = null;
package/files/handler.js CHANGED
@@ -12,7 +12,7 @@ import { manifest, prerendered, base } from 'MANIFEST';
12
12
  import { env } from 'ENV';
13
13
  import * as wsModule from 'WS_HANDLER';
14
14
  import { parseCookies, createCookies } from './cookies.js';
15
- import { mimeLookup, parse_as_bytes, parse_origin, writeChunkWithBackpressure } from './utils.js';
15
+ import { mimeLookup, parse_as_bytes, parse_origin, writeChunkWithBackpressure, drainCoalesced, computePressureReason, nextTopicSeq, completeEnvelope } from './utils.js';
16
16
 
17
17
  /* global ENV_PREFIX */
18
18
  /* global PRECOMPRESS */
@@ -408,6 +408,160 @@ const wsConnections = new Set();
408
408
  // Read once at module load so it is never sampled inside a hot callback.
409
409
  const wsDebug = WS_ENABLED && env('WS_DEBUG', '') === '1';
410
410
 
411
+ // -- Per-topic broadcast sequence numbers ------------------------------------
412
+ // Each platform.publish() stamps a monotonic per-topic seq into the envelope
413
+ // so reconnecting clients can detect gaps and resume from where they left
414
+ // off. Worker-local in clustered mode: cross-worker authority requires the
415
+ // extensions package's Lua INCR variant. See README "Sequence numbers" for
416
+ // the cluster caveat. The map persists for process lifetime; one entry per
417
+ // topic ever published. High-cardinality producers can opt out per-call
418
+ // via { seq: false }.
419
+ /** @type {Map<string, number>} */
420
+ const topicSeqs = new Map();
421
+
422
+ // -- Pressure tracking -------------------------------------------------------
423
+ // Coarse 1 Hz sampler exposed as `platform.pressure` (snapshot) and
424
+ // `platform.onPressure(cb)` (transition callback). State lives at module
425
+ // scope so platform.publish() and the subscribe/unsubscribe handlers can
426
+ // bump counters with one integer add - no allocations on the hot path.
427
+
428
+ let publishCountWindow = 0;
429
+ let totalSubscriptions = 0;
430
+
431
+ /**
432
+ * @typedef {{
433
+ * active: boolean,
434
+ * subscriberRatio: number,
435
+ * publishRate: number,
436
+ * memoryMB: number,
437
+ * reason: 'NONE' | 'PUBLISH_RATE' | 'SUBSCRIBERS' | 'MEMORY'
438
+ * }} PressureSnapshot
439
+ */
440
+
441
+ /** @type {PressureSnapshot} */
442
+ const pressureSnapshot = {
443
+ active: false,
444
+ subscriberRatio: 0,
445
+ publishRate: 0,
446
+ memoryMB: 0,
447
+ reason: 'NONE'
448
+ };
449
+
450
+ /** @type {Set<(snapshot: PressureSnapshot) => void>} */
451
+ const pressureListeners = new Set();
452
+
453
+ /** @type {ReturnType<typeof setInterval> | null} */
454
+ let pressureTimer = null;
455
+
456
+ /**
457
+ * Default pressure thresholds. Designed to be safe rather than tight: the
458
+ * goal is "no false positives in the steady state of a healthy small app,"
459
+ * not "perfectly tuned for sustained five-figure publish rates." Override
460
+ * per-deployment via the `pressure` field on the WebSocket options.
461
+ */
462
+ const DEFAULT_PRESSURE_THRESHOLDS = {
463
+ memoryHeapUsedRatio: 0.85,
464
+ publishRatePerSec: 10000,
465
+ subscriberRatio: 50,
466
+ sampleIntervalMs: 1000
467
+ };
468
+
469
+ /**
470
+ * Sample once: read counters, fold them into the snapshot, fire listeners
471
+ * iff `reason` changed. Called by the 1 Hz timer; also extracted so a test
472
+ * harness can drive samples directly without spinning real timers.
473
+ *
474
+ * @param {{ memoryHeapUsedRatio: number | false, publishRatePerSec: number | false, subscriberRatio: number | false, sampleIntervalMs: number }} thresholds
475
+ */
476
+ function samplePressure(thresholds) {
477
+ const interval = thresholds.sampleIntervalMs / 1000;
478
+ const publishRate = interval > 0 ? publishCountWindow / interval : 0;
479
+ publishCountWindow = 0;
480
+
481
+ const connections = wsConnections.size;
482
+ const subscriberRatio = connections > 0 ? totalSubscriptions / connections : 0;
483
+
484
+ const mem = process.memoryUsage();
485
+ const heapUsedRatio = mem.heapTotal > 0 ? mem.heapUsed / mem.heapTotal : 0;
486
+ const memoryMB = mem.rss / (1024 * 1024);
487
+
488
+ const reason = computePressureReason(
489
+ { heapUsedRatio, publishRate, subscriberRatio },
490
+ thresholds
491
+ );
492
+
493
+ const transitioned = reason !== pressureSnapshot.reason;
494
+ pressureSnapshot.subscriberRatio = subscriberRatio;
495
+ pressureSnapshot.publishRate = publishRate;
496
+ pressureSnapshot.memoryMB = memoryMB;
497
+ pressureSnapshot.reason = reason;
498
+ pressureSnapshot.active = reason !== 'NONE';
499
+
500
+ if (transitioned) {
501
+ for (const cb of pressureListeners) {
502
+ try {
503
+ cb(pressureSnapshot);
504
+ } catch (err) {
505
+ console.error('[pressure] listener threw:', err);
506
+ }
507
+ }
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Merge user-supplied pressure options on top of the safe defaults. Each
513
+ * threshold accepts `false` to disable that signal. `sampleIntervalMs` is
514
+ * clamped to a sane minimum to avoid pathological tight-loop sampling if
515
+ * a user passes 0 or a negative number.
516
+ *
517
+ * @param {{ memoryHeapUsedRatio?: number | false, publishRatePerSec?: number | false, subscriberRatio?: number | false, sampleIntervalMs?: number } | undefined} opts
518
+ */
519
+ function resolvePressureThresholds(opts) {
520
+ const merged = { ...DEFAULT_PRESSURE_THRESHOLDS, ...(opts || {}) };
521
+ if (typeof merged.sampleIntervalMs !== 'number' || merged.sampleIntervalMs < 100) {
522
+ merged.sampleIntervalMs = DEFAULT_PRESSURE_THRESHOLDS.sampleIntervalMs;
523
+ }
524
+ return merged;
525
+ }
526
+
527
+ /**
528
+ * Start the 1 Hz pressure sampler. Idempotent: a second call replaces the
529
+ * existing timer with a new one using the supplied thresholds.
530
+ *
531
+ * @param {Parameters<typeof resolvePressureThresholds>[0]} opts
532
+ */
533
+ function startPressureSampling(opts) {
534
+ const thresholds = resolvePressureThresholds(opts);
535
+ if (pressureTimer) clearInterval(pressureTimer);
536
+ pressureTimer = setInterval(() => samplePressure(thresholds), thresholds.sampleIntervalMs);
537
+ if (typeof pressureTimer.unref === 'function') pressureTimer.unref();
538
+ }
539
+
540
+ function stopPressureSampling() {
541
+ if (pressureTimer) {
542
+ clearInterval(pressureTimer);
543
+ pressureTimer = null;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Drain any pending coalesce-by-key messages on a single connection.
549
+ * Serializes lazily: only the surviving (latest) value per key pays
550
+ * JSON.stringify cost.
551
+ *
552
+ * @param {import('uWebSockets.js').WebSocket<any>} ws
553
+ */
554
+ function flushCoalescedFor(ws) {
555
+ const userData = ws.getUserData();
556
+ const pending = userData.__coalesced;
557
+ if (!pending || pending.size === 0) return;
558
+ drainCoalesced(pending, (msg) => ws.send(
559
+ envelopePrefix(msg.topic, msg.event) + JSON.stringify(msg.data ?? null) + '}',
560
+ false,
561
+ false
562
+ ));
563
+ }
564
+
411
565
  /** @type {import('./index.js').Platform} */
412
566
  const platform = {
413
567
  /**
@@ -416,7 +570,11 @@ const platform = {
416
570
  * No-op if no clients are subscribed - safe to call unconditionally.
417
571
  */
418
572
  publish(topic, event, data, options) {
419
- const envelope = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
573
+ publishCountWindow++;
574
+ const seq = (options && options.seq === false)
575
+ ? null
576
+ : nextTopicSeq(topicSeqs, topic);
577
+ const envelope = completeEnvelope(envelopePrefix(topic, event), data, seq);
420
578
  const result = app.publish(topic, envelope, false, false);
421
579
  // Relay to other workers via main thread (no-op in single-process mode).
422
580
  // Pass { relay: false } when the message originates from an external
@@ -444,6 +602,38 @@ const platform = {
444
602
  return ws.send(envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}', false, false);
445
603
  },
446
604
 
605
+ /**
606
+ * Send a message to a single connection with coalesce-by-key semantics.
607
+ *
608
+ * Each (ws, key) pair holds at most one pending message. If a newer
609
+ * sendCoalesced for the same key arrives before the previous one drains
610
+ * out to the wire, the older message is dropped in place: latest value
611
+ * wins, original insertion order is preserved.
612
+ *
613
+ * Use for latest-value streams where intermediate values are noise:
614
+ * price ticks, cursor positions, presence state, typing indicators,
615
+ * scroll/scrub positions. For at-least-once delivery use send() or
616
+ * publish() instead.
617
+ *
618
+ * Serialization is deferred to the actual flush, so a stream that
619
+ * overwrites the same key 1000 times before a single drain pays only
620
+ * one JSON.stringify, not 1000.
621
+ *
622
+ * The flush attempts immediately and again on every uWS drain event.
623
+ * On BACKPRESSURE or DROPPED from ws.send, pumping stops and resumes
624
+ * on the next drain.
625
+ */
626
+ sendCoalesced(ws, { key, topic, event, data }) {
627
+ const userData = ws.getUserData();
628
+ let pending = userData.__coalesced;
629
+ if (!pending) {
630
+ pending = new Map();
631
+ userData.__coalesced = pending;
632
+ }
633
+ pending.set(key, { topic, event, data });
634
+ flushCoalescedFor(ws);
635
+ },
636
+
447
637
  /**
448
638
  * Send a message to connections matching a filter.
449
639
  * The filter receives each connection's userData (from the upgrade handler).
@@ -493,6 +683,35 @@ const platform = {
493
683
  return results;
494
684
  },
495
685
 
686
+ /**
687
+ * Live snapshot of worker-local backpressure signals.
688
+ *
689
+ * `reason` is one of `'NONE'`, `'PUBLISH_RATE'`, `'SUBSCRIBERS'`,
690
+ * `'MEMORY'`. Precedence is fixed (MEMORY > PUBLISH_RATE > SUBSCRIBERS),
691
+ * so a worker under multiple stresses reports the most urgent one.
692
+ *
693
+ * Sampled by a coarse 1 Hz timer. Reading the snapshot is a property
694
+ * access; no I/O or computation per read. Use `onPressure` for
695
+ * push-style reaction on transitions.
696
+ */
697
+ get pressure() {
698
+ return pressureSnapshot;
699
+ },
700
+
701
+ /**
702
+ * Register a callback fired on each pressure-state transition (when
703
+ * `reason` changes between samples). Fired at most once per sample
704
+ * tick. Returns an unsubscribe function.
705
+ *
706
+ * Callbacks are invoked synchronously inside the sampler. A throwing
707
+ * listener does not break the sampler or other listeners; the error
708
+ * is logged and the next listener still runs.
709
+ */
710
+ onPressure(cb) {
711
+ pressureListeners.add(cb);
712
+ return () => pressureListeners.delete(cb);
713
+ },
714
+
496
715
  /**
497
716
  * Get a scoped helper for a topic - less repetition when publishing
498
717
  * multiple events to the same topic.
@@ -1653,7 +1872,9 @@ if (WS_ENABLED) {
1653
1872
  // no cookie parsing). Inject remoteAddress so plugins/ratelimit can
1654
1873
  // key on the real client IP via ws.getUserData().remoteAddress.
1655
1874
  if (!wsModule.upgrade) {
1656
- res.upgrade({ remoteAddress: clientIp }, secKey, secProtocol, secExtensions, context);
1875
+ res.cork(() => {
1876
+ res.upgrade({ remoteAddress: clientIp }, secKey, secProtocol, secExtensions, context);
1877
+ });
1657
1878
  return;
1658
1879
  }
1659
1880
 
@@ -1723,23 +1944,25 @@ if (WS_ENABLED) {
1723
1944
  }
1724
1945
  const ud = userData || {};
1725
1946
  if (!ud.remoteAddress) ud.remoteAddress = clientIp;
1726
- if (responseHeaders) {
1727
- maybeWarnSetCookieOnUpgrade(responseHeaders);
1728
- for (const [hk, hv] of Object.entries(responseHeaders)) {
1729
- if (Array.isArray(hv)) {
1730
- for (const v of hv) res.writeHeader(hk, v);
1731
- } else {
1732
- res.writeHeader(hk, hv);
1947
+ if (responseHeaders) maybeWarnSetCookieOnUpgrade(responseHeaders);
1948
+ res.cork(() => {
1949
+ if (responseHeaders) {
1950
+ for (const [hk, hv] of Object.entries(responseHeaders)) {
1951
+ if (Array.isArray(hv)) {
1952
+ for (const v of hv) res.writeHeader(hk, v);
1953
+ } else {
1954
+ res.writeHeader(hk, hv);
1955
+ }
1733
1956
  }
1734
1957
  }
1735
- }
1736
- res.upgrade(
1737
- ud,
1738
- secKey,
1739
- secProtocol,
1740
- secExtensions,
1741
- context
1742
- );
1958
+ res.upgrade(
1959
+ ud,
1960
+ secKey,
1961
+ secProtocol,
1962
+ secExtensions,
1963
+ context
1964
+ );
1965
+ });
1743
1966
  })
1744
1967
  .catch((err) => {
1745
1968
  clearTimeout(timer);
@@ -1787,14 +2010,19 @@ if (WS_ENABLED) {
1787
2010
  if (wsModule.subscribe && wsModule.subscribe(ws, msg.topic, { platform }) === false) {
1788
2011
  return;
1789
2012
  }
2013
+ const subs = ws.getUserData().__subscriptions;
2014
+ const isNew = !subs.has(msg.topic);
1790
2015
  ws.subscribe(msg.topic);
1791
- ws.getUserData().__subscriptions.add(msg.topic);
2016
+ subs.add(msg.topic);
2017
+ if (isNew) totalSubscriptions++;
1792
2018
  if (wsDebug) console.log('[ws] subscribe topic=%s', msg.topic);
1793
2019
  return;
1794
2020
  }
1795
2021
  if (msg.type === 'unsubscribe' && typeof msg.topic === 'string') {
1796
2022
  ws.unsubscribe(msg.topic);
1797
- ws.getUserData().__subscriptions.delete(msg.topic);
2023
+ if (ws.getUserData().__subscriptions.delete(msg.topic)) {
2024
+ totalSubscriptions--;
2025
+ }
1798
2026
  if (wsDebug) console.log('[ws] unsubscribe topic=%s', msg.topic);
1799
2027
  wsModule.unsubscribe?.(ws, msg.topic, { platform });
1800
2028
  return;
@@ -1814,8 +2042,10 @@ if (WS_ENABLED) {
1814
2042
  }
1815
2043
  if (!valid) continue;
1816
2044
  if (wsModule.subscribe && wsModule.subscribe(ws, topic, { platform }) === false) continue;
2045
+ const isNew = !userData.__subscriptions.has(topic);
1817
2046
  ws.subscribe(topic);
1818
2047
  userData.__subscriptions.add(topic);
2048
+ if (isNew) totalSubscriptions++;
1819
2049
  subscribed++;
1820
2050
  }
1821
2051
  if (wsDebug) console.log('[ws] subscribe-batch count=%d', subscribed);
@@ -1829,13 +2059,19 @@ if (WS_ENABLED) {
1829
2059
  wsModule.message?.(ws, { data: message, isBinary, platform });
1830
2060
  },
1831
2061
 
1832
- drain: wsModule.drain ? (ws) => wsModule.drain(ws, { platform }) : undefined,
2062
+ drain: (ws) => {
2063
+ // Resume any sendCoalesced traffic held back by backpressure
2064
+ // before delegating to the user's drain hook.
2065
+ flushCoalescedFor(ws);
2066
+ wsModule.drain?.(ws, { platform });
2067
+ },
1833
2068
 
1834
2069
  close: (ws, code, message) => {
1835
2070
  const subscriptions = ws.getUserData().__subscriptions || new Set();
1836
2071
  try {
1837
2072
  wsModule.close?.(ws, { code, message, platform, subscriptions });
1838
2073
  } finally {
2074
+ totalSubscriptions -= subscriptions.size;
1839
2075
  wsConnections.delete(ws);
1840
2076
  if (wsDebug) console.log('[ws] close code=%d connections=%d', code, wsConnections.size);
1841
2077
  }
@@ -1856,6 +2092,8 @@ if (WS_ENABLED) {
1856
2092
  if (WS_PATH !== '/ws') {
1857
2093
  console.log(`Client must match: connect({ path: '${WS_PATH}' })`);
1858
2094
  }
2095
+
2096
+ startPressureSampling(wsOptions.pressure);
1859
2097
  }
1860
2098
 
1861
2099
  // Health check endpoint (before catch-all so it never hits SSR)
@@ -1927,6 +2165,7 @@ export function shutdown() {
1927
2165
  uWS.us_listen_socket_close(listenSocket);
1928
2166
  listenSocket = null;
1929
2167
  }
2168
+ stopPressureSampling();
1930
2169
  for (const ws of wsConnections) {
1931
2170
  ws.close(1001, 'Server shutting down');
1932
2171
  }
package/files/utils.js CHANGED
@@ -145,6 +145,118 @@ export function writeChunkWithBackpressure(res, value, timeoutMs = 30000) {
145
145
  return ok ? true : /** @type {Promise<boolean>} */ (drainPromise);
146
146
  }
147
147
 
148
+ /**
149
+ * Drain a coalesce-by-key buffer.
150
+ *
151
+ * Iterates entries in insertion order and calls `send` for each. Entries
152
+ * whose send result is SUCCESS (0) are removed from the map. The function
153
+ * stops on the first BACKPRESSURE (1) or DROPPED (2) result, leaving the
154
+ * remaining entries (and the one that just hit pressure, in the DROPPED
155
+ * case) for a later flush.
156
+ *
157
+ * Pure: no I/O of its own, no timers, no globals. The caller supplies
158
+ * `send`, which is the only side-effecting boundary, so this is unit-
159
+ * testable with a mock send fn.
160
+ *
161
+ * Map insertion order is preserved across overwrites: setting an existing
162
+ * key replaces the value but keeps the original slot. Latest value wins,
163
+ * order is stable.
164
+ *
165
+ * @template T
166
+ * @param {Map<string, T>} pending
167
+ * @param {(value: T) => number} send 0 SUCCESS, 1 BACKPRESSURE, 2 DROPPED
168
+ */
169
+ export function drainCoalesced(pending, send) {
170
+ for (const [key, value] of pending) {
171
+ const result = send(value);
172
+ if (result === 2) return;
173
+ pending.delete(key);
174
+ if (result === 1) return;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Allocate the next monotonic sequence number for a topic, mutating
180
+ * `seqMap` in place. The first call for a topic returns 1; subsequent
181
+ * calls return the previous value plus one. Each topic has an
182
+ * independent counter.
183
+ *
184
+ * Pure with respect to inputs other than the supplied map. Suitable
185
+ * for unit tests that pass a fresh map per case.
186
+ *
187
+ * @param {Map<string, number>} seqMap
188
+ * @param {string} topic
189
+ * @returns {number}
190
+ */
191
+ export function nextTopicSeq(seqMap, topic) {
192
+ const next = (seqMap.get(topic) ?? 0) + 1;
193
+ seqMap.set(topic, next);
194
+ return next;
195
+ }
196
+
197
+ /**
198
+ * Complete a JSON envelope started by an `envelopePrefix` builder.
199
+ *
200
+ * Appends the JSON-encoded data and an optional `seq` field, plus the
201
+ * closing brace. When `seq` is `null` or `undefined` the field is
202
+ * omitted entirely so the wire shape matches the legacy
203
+ * `{topic,event,data}` envelope verbatim. When `seq` is a number the
204
+ * resulting envelope is `{topic,event,data,seq}`.
205
+ *
206
+ * No JSON.stringify on the seq itself: numbers serialize identically
207
+ * via plain string concatenation, saving a stringify call on the
208
+ * publish hot path.
209
+ *
210
+ * @param {string} prefix output of envelopePrefix(topic, event)
211
+ * @param {unknown} data
212
+ * @param {number | null | undefined} seq
213
+ * @returns {string}
214
+ */
215
+ export function completeEnvelope(prefix, data, seq) {
216
+ const body = prefix + JSON.stringify(data ?? null);
217
+ return seq == null ? body + '}' : body + ',"seq":' + seq + '}';
218
+ }
219
+
220
+ /**
221
+ * Resolve which pressure signal (if any) is firing for a given sample.
222
+ *
223
+ * Precedence is fixed: MEMORY beats PUBLISH_RATE beats SUBSCRIBERS. Memory
224
+ * is the most urgent signal because the worker is approaching OOM; publish
225
+ * rate is next because CPU saturation cascades fastest; subscriber ratio
226
+ * comes last because heavy fan-out degrades gracefully.
227
+ *
228
+ * Any threshold may be `false` to disable that signal entirely. A signal
229
+ * fires when the corresponding sample value is greater than or equal to
230
+ * its threshold.
231
+ *
232
+ * Pure: no I/O, no globals. Suitable for unit tests.
233
+ *
234
+ * @param {{ heapUsedRatio: number, publishRate: number, subscriberRatio: number }} sample
235
+ * @param {{ memoryHeapUsedRatio: number | false, publishRatePerSec: number | false, subscriberRatio: number | false }} thresholds
236
+ * @returns {'NONE' | 'PUBLISH_RATE' | 'SUBSCRIBERS' | 'MEMORY'}
237
+ */
238
+ export function computePressureReason(sample, thresholds) {
239
+ if (
240
+ thresholds.memoryHeapUsedRatio !== false &&
241
+ sample.heapUsedRatio >= thresholds.memoryHeapUsedRatio
242
+ ) {
243
+ return 'MEMORY';
244
+ }
245
+ if (
246
+ thresholds.publishRatePerSec !== false &&
247
+ sample.publishRate >= thresholds.publishRatePerSec
248
+ ) {
249
+ return 'PUBLISH_RATE';
250
+ }
251
+ if (
252
+ thresholds.subscriberRatio !== false &&
253
+ sample.subscriberRatio >= thresholds.subscriberRatio
254
+ ) {
255
+ return 'SUBSCRIBERS';
256
+ }
257
+ return 'NONE';
258
+ }
259
+
148
260
  /**
149
261
  * @param {string | undefined} value
150
262
  * @returns {string | undefined}
package/index.d.ts CHANGED
@@ -211,6 +211,77 @@ export interface WebSocketOptions {
211
211
  * @default 10
212
212
  */
213
213
  upgradeRateLimitWindow?: number;
214
+
215
+ /**
216
+ * Backpressure-signal thresholds for `platform.pressure` and
217
+ * `platform.onPressure(cb)`. The adapter samples the worker once per
218
+ * `sampleIntervalMs` and reports the most urgent active signal.
219
+ *
220
+ * Any individual threshold may be set to `false` to disable that
221
+ * signal entirely. The defaults are conservative: a small healthy app
222
+ * should never trip them in steady state.
223
+ *
224
+ * @example
225
+ * ```js
226
+ * adapter({
227
+ * websocket: {
228
+ * pressure: {
229
+ * memoryHeapUsedRatio: 0.9,
230
+ * publishRatePerSec: 50000,
231
+ * subscriberRatio: false // disable this signal
232
+ * }
233
+ * }
234
+ * });
235
+ * ```
236
+ */
237
+ pressure?: {
238
+ /**
239
+ * Trigger `'MEMORY'` pressure when `process.memoryUsage().heapUsed
240
+ * / heapTotal` is greater than or equal to this ratio (0 to 1).
241
+ *
242
+ * Memory has the highest precedence: a worker approaching OOM
243
+ * reports `'MEMORY'` even if publish rate or fan-out are also
244
+ * elevated.
245
+ *
246
+ * Set to `false` to disable.
247
+ *
248
+ * @default 0.85
249
+ */
250
+ memoryHeapUsedRatio?: number | false;
251
+
252
+ /**
253
+ * Trigger `'PUBLISH_RATE'` pressure when `platform.publish()`
254
+ * calls per second on this worker reach this value.
255
+ *
256
+ * Set to `false` to disable.
257
+ *
258
+ * @default 10000
259
+ */
260
+ publishRatePerSec?: number | false;
261
+
262
+ /**
263
+ * Trigger `'SUBSCRIBERS'` pressure when the average number of
264
+ * subscriptions per active connection (total subscriptions /
265
+ * connections, on the local worker) reaches this value.
266
+ *
267
+ * High fan-out per connection means each `publish()` does heavy
268
+ * work; this signal lets a multi-tenant deployment shed
269
+ * background streams before broadcast latency climbs.
270
+ *
271
+ * Set to `false` to disable.
272
+ *
273
+ * @default 50
274
+ */
275
+ subscriberRatio?: number | false;
276
+
277
+ /**
278
+ * Sample interval in milliseconds. Clamped to a minimum of 100 ms
279
+ * to prevent pathological tight-loop sampling.
280
+ *
281
+ * @default 1000
282
+ */
283
+ sampleIntervalMs?: number;
284
+ };
214
285
  }
215
286
 
216
287
  // -- User's WebSocket handler module exports ---------------------------------
@@ -462,6 +533,30 @@ export interface WebSocketHandler<UserData = unknown> {
462
533
 
463
534
  // -- Platform type for event.platform ----------------------------------------
464
535
 
536
+ /**
537
+ * Snapshot returned by `platform.pressure` and supplied to
538
+ * `platform.onPressure(cb)` callbacks. All numbers are worker-local.
539
+ */
540
+ export interface PressureSnapshot {
541
+ /** `true` when `reason !== 'NONE'`. Convenience flag for boolean checks. */
542
+ readonly active: boolean;
543
+ /**
544
+ * Average subscriptions per connection on this worker
545
+ * (`totalSubscriptions / connections`). `0` when the worker has no
546
+ * connections.
547
+ */
548
+ readonly subscriberRatio: number;
549
+ /** `platform.publish()` calls per second on this worker, last sample window. */
550
+ readonly publishRate: number;
551
+ /** Resident-set size in megabytes (`process.memoryUsage().rss`). */
552
+ readonly memoryMB: number;
553
+ /**
554
+ * Most urgent active signal, by fixed precedence:
555
+ * `MEMORY > PUBLISH_RATE > SUBSCRIBERS > NONE`.
556
+ */
557
+ readonly reason: 'NONE' | 'PUBLISH_RATE' | 'SUBSCRIBERS' | 'MEMORY';
558
+ }
559
+
465
560
  /**
466
561
  * Available on `event.platform` in server hooks, load functions, and actions.
467
562
  *
@@ -484,12 +579,29 @@ export interface Platform {
484
579
  * The message is automatically wrapped in a `{ topic, event, data }` envelope
485
580
  * that the client store (`svelte-adapter-uws/client`) understands.
486
581
  *
582
+ * Every published frame is automatically stamped with a monotonic
583
+ * per-topic `seq` field in the envelope. The first publish to a topic
584
+ * sends `seq: 1`, the next `seq: 2`, and so on; each topic has an
585
+ * independent counter. Reconnecting clients can use the seq to detect
586
+ * gaps and resume from where they left off. Pass `{ seq: false }` to
587
+ * skip stamping for high-cardinality or perf-sensitive topics where
588
+ * the counter map would grow unbounded.
589
+ *
590
+ * In clustered mode the seq is worker-local (each worker stamps its
591
+ * own publishes; relayed messages pass through with the originating
592
+ * worker's seq). For cluster-wide monotonic seq, wire up the Redis
593
+ * Lua INCR variant from the extensions package.
594
+ *
487
595
  * @param topic - Topic string (e.g. `'todos'`, `'user:123'`, `'org:456'`)
488
596
  * @param event - Event name (e.g. `'created'`, `'updated'`, `'deleted'`)
489
597
  * @param data - Payload (will be JSON-serialized)
490
- * @param options - Optional. Pass `{ relay: false }` to skip cross-worker relay
491
- * (use this when the message comes from an external pub/sub source like Redis
492
- * or Postgres that already delivers to every process).
598
+ * @param options - Optional.
599
+ * - `relay: false` skips cross-worker relay (use when the message
600
+ * comes from an external pub/sub source like Redis or Postgres
601
+ * that already delivers to every process).
602
+ * - `seq: false` skips the per-topic monotonic seq stamp (use for
603
+ * ephemeral or high-cardinality topics where the counter map
604
+ * would grow unbounded).
493
605
  *
494
606
  * @example
495
607
  * ```js
@@ -500,7 +612,7 @@ export interface Platform {
500
612
  * }
501
613
  * ```
502
614
  */
503
- publish(topic: string, event: string, data?: unknown, options?: { relay?: boolean }): boolean;
615
+ publish(topic: string, event: string, data?: unknown, options?: { relay?: boolean; seq?: boolean }): boolean;
504
616
 
505
617
  /**
506
618
  * Publish multiple messages in one call.
@@ -531,6 +643,57 @@ export interface Platform {
531
643
  */
532
644
  send(ws: WebSocket<any>, topic: string, event: string, data?: unknown): number;
533
645
 
646
+ /**
647
+ * Send a message to a single connection with coalesce-by-key semantics.
648
+ *
649
+ * Each `(ws, key)` pair holds at most one pending message. If a newer
650
+ * `sendCoalesced` for the same `key` arrives before the previous frame
651
+ * drains to the wire, the older one is dropped in place: latest value
652
+ * wins. Insertion order is preserved across overwrites.
653
+ *
654
+ * Use for latest-value streams where intermediate values are noise -
655
+ * price ticks, cursor positions, presence state, typing indicators,
656
+ * scroll/scrub positions. For at-least-once delivery, use `send()` or
657
+ * `publish()` instead.
658
+ *
659
+ * Serialization is deferred to the actual flush, so a stream that
660
+ * overwrites the same `key` 1000 times before a single drain pays one
661
+ * `JSON.stringify`, not 1000.
662
+ *
663
+ * The flush attempts immediately and again on every uWS drain event.
664
+ * On backpressure or drop from the underlying socket, pumping stops
665
+ * and resumes when the connection drains.
666
+ *
667
+ * @example
668
+ * ```js
669
+ * // In hooks.ws.js - cursor positions during a collaborative edit.
670
+ * // Each peer sees only the latest cursor for every other user;
671
+ * // intermediate positions are dropped under load.
672
+ * export function message(ws, { data, platform }) {
673
+ * const msg = JSON.parse(Buffer.from(data).toString());
674
+ * if (msg.event !== 'cursor') return;
675
+ * const { docId, userId } = ws.getUserData();
676
+ * for (const peer of getPeersOf(docId)) {
677
+ * platform.sendCoalesced(peer, {
678
+ * key: 'cursor:' + userId,
679
+ * topic: 'doc:' + docId,
680
+ * event: 'cursor',
681
+ * data: { userId, x: msg.data.x, y: msg.data.y }
682
+ * });
683
+ * }
684
+ * }
685
+ * ```
686
+ *
687
+ * @param ws - The WebSocket connection.
688
+ * @param message - `{ key, topic, event, data }`. `key` identifies the
689
+ * coalesce slot per connection; `topic`, `event`, `data` are the
690
+ * envelope fields the client store understands.
691
+ */
692
+ sendCoalesced(
693
+ ws: WebSocket<any>,
694
+ message: { key: string; topic: string; event: string; data?: unknown }
695
+ ): void;
696
+
534
697
  /**
535
698
  * Send a message to all connections whose userData matches a filter.
536
699
  * Returns the number of connections the message was sent to.
@@ -539,7 +702,7 @@ export interface Platform {
539
702
  *
540
703
  * **Performance note:** `sendTo()` iterates every open connection on the local
541
704
  * worker to evaluate the filter. For broadcasting to large groups, prefer
542
- * `publish()` with a topic topics are dispatched by uWS's C++ TopicTree
705
+ * `publish()` with a topic - topics are dispatched by uWS's C++ TopicTree
543
706
  * with O(subscribers) fan-out and no JS loop. Use `sendTo()` when you need
544
707
  * to target connections by arbitrary runtime properties that can't be mapped
545
708
  * to a static topic name (e.g., filtering by session data set at upgrade time).
@@ -589,6 +752,59 @@ export interface Platform {
589
752
  */
590
753
  subscribers(topic: string): number;
591
754
 
755
+ /**
756
+ * Live snapshot of worker-local backpressure signals.
757
+ *
758
+ * Sampled by a coarse 1 Hz timer (configurable via
759
+ * `WebSocketOptions.pressure.sampleIntervalMs`). Reading the snapshot
760
+ * is a property access; no I/O or computation per read.
761
+ *
762
+ * `reason` is the most urgent active signal. Precedence is fixed:
763
+ * `MEMORY > PUBLISH_RATE > SUBSCRIBERS`. A worker under multiple
764
+ * stresses reports the highest-priority one.
765
+ *
766
+ * @example
767
+ * ```js
768
+ * export async function POST({ platform, request }) {
769
+ * if (platform.pressure.reason === 'MEMORY') {
770
+ * return new Response('Try again shortly', { status: 503 });
771
+ * }
772
+ * const todo = await db.create(await request.formData());
773
+ * platform.publish('todos', 'created', todo);
774
+ * return new Response('OK');
775
+ * }
776
+ * ```
777
+ */
778
+ readonly pressure: PressureSnapshot;
779
+
780
+ /**
781
+ * Register a callback fired on each pressure-state transition (when
782
+ * `pressure.reason` changes between samples). Fired at most once per
783
+ * sample tick. Returns an unsubscribe function.
784
+ *
785
+ * Use this for push-style reaction: pause background streams when the
786
+ * worker is under load, resume them when it recovers.
787
+ *
788
+ * Callbacks run synchronously inside the sampler. A throwing listener
789
+ * does not break the sampler or other listeners; the error is logged
790
+ * and the next listener still runs.
791
+ *
792
+ * @example
793
+ * ```js
794
+ * export function open(ws, { platform }) {
795
+ * const off = platform.onPressure(({ reason, active }) => {
796
+ * ws.send(JSON.stringify({ topic: '__pressure', event: reason, data: { active } }));
797
+ * });
798
+ * ws.getUserData().__offPressure = off;
799
+ * }
800
+ *
801
+ * export function close(ws) {
802
+ * ws.getUserData().__offPressure?.();
803
+ * }
804
+ * ```
805
+ */
806
+ onPressure(cb: (snapshot: PressureSnapshot) => void): () => void;
807
+
592
808
  /**
593
809
  * Get a scoped helper for a topic. Reduces repetition when publishing
594
810
  * multiple events to the same topic, and provides CRUD shorthand methods
package/index.js CHANGED
@@ -289,7 +289,8 @@ export default function (opts = {}) {
289
289
  allowedOrigins: websocket?.allowedOrigins ?? 'same-origin',
290
290
  upgradeTimeout: websocket?.upgradeTimeout ?? 10,
291
291
  upgradeRateLimit: websocket?.upgradeRateLimit ?? 10,
292
- upgradeRateLimitWindow: websocket?.upgradeRateLimitWindow ?? 10
292
+ upgradeRateLimitWindow: websocket?.upgradeRateLimitWindow ?? 10,
293
+ pressure: websocket?.pressure
293
294
  };
294
295
 
295
296
  // Scan the bundled WS handler for `upgradeResponse(..., { 'set-cookie': ... })`
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.4.13",
3
+ "version": "0.5.0-next.1",
4
+ "publishConfig": {
5
+ "tag": "next"
6
+ },
4
7
  "description": "SvelteKit adapter for uWebSockets.js - high-performance C++ HTTP server with built-in WebSocket support",
5
8
  "author": "Kevin Radziszewski",
6
9
  "license": "MIT",
package/testing.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseCookies } from './files/cookies.js';
2
+ import { nextTopicSeq, completeEnvelope } from './files/utils.js';
2
3
 
3
4
  /**
4
5
  * Safely quote a string for JSON embedding. Throws on invalid characters.
@@ -23,10 +24,12 @@ function esc(s) {
23
24
  * @param {string} topic
24
25
  * @param {string} event
25
26
  * @param {unknown} [data]
27
+ * @param {number | null} [seq]
26
28
  * @returns {string}
27
29
  */
28
- function envelope(topic, event, data) {
29
- return '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data ?? null) + '}';
30
+ function envelope(topic, event, data, seq) {
31
+ const prefix = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":';
32
+ return completeEnvelope(prefix, data, seq);
30
33
  }
31
34
 
32
35
  /**
@@ -56,6 +59,9 @@ export async function createTestServer(options = {}) {
56
59
  /** @type {Set<import('uWebSockets.js').WebSocket<any>>} */
57
60
  const wsConnections = new Set();
58
61
 
62
+ /** @type {Map<string, number>} */
63
+ const topicSeqs = new Map();
64
+
59
65
  /** @type {Array<(value: any) => void>} */
60
66
  let connectionWaiters = [];
61
67
 
@@ -63,8 +69,11 @@ export async function createTestServer(options = {}) {
63
69
  let messageWaiters = [];
64
70
 
65
71
  const platform = {
66
- publish(topic, event, data) {
67
- const msg = envelope(topic, event, data);
72
+ publish(topic, event, data, options) {
73
+ const seq = (options && options.seq === false)
74
+ ? null
75
+ : nextTopicSeq(topicSeqs, topic);
76
+ const msg = envelope(topic, event, data, seq);
68
77
  return app.publish(topic, msg, false, false);
69
78
  },
70
79
  send(ws, topic, event, data) {
@@ -115,7 +124,9 @@ export async function createTestServer(options = {}) {
115
124
  const rawIp = new TextDecoder().decode(res.getRemoteAddressAsText());
116
125
 
117
126
  if (!handler.upgrade) {
118
- res.upgrade({ remoteAddress: rawIp }, secKey, secProtocol, secExtensions, context);
127
+ res.cork(() => {
128
+ res.upgrade({ remoteAddress: rawIp }, secKey, secProtocol, secExtensions, context);
129
+ });
119
130
  return;
120
131
  }
121
132
 
@@ -143,16 +154,18 @@ export async function createTestServer(options = {}) {
143
154
  userData = result || {};
144
155
  }
145
156
  if (!userData.remoteAddress) userData.remoteAddress = rawIp;
146
- if (responseHeaders) {
147
- for (const [hk, hv] of Object.entries(responseHeaders)) {
148
- if (Array.isArray(hv)) {
149
- for (const v of hv) res.writeHeader(hk, v);
150
- } else {
151
- res.writeHeader(hk, hv);
157
+ res.cork(() => {
158
+ if (responseHeaders) {
159
+ for (const [hk, hv] of Object.entries(responseHeaders)) {
160
+ if (Array.isArray(hv)) {
161
+ for (const v of hv) res.writeHeader(hk, v);
162
+ } else {
163
+ res.writeHeader(hk, hv);
164
+ }
152
165
  }
153
166
  }
154
- }
155
- res.upgrade(userData, secKey, secProtocol, secExtensions, context);
167
+ res.upgrade(userData, secKey, secProtocol, secExtensions, context);
168
+ });
156
169
  })
157
170
  .catch((err) => {
158
171
  if (!aborted) {
package/vite.js CHANGED
@@ -275,7 +275,7 @@ export default function uws(options = {}) {
275
275
  return;
276
276
  }
277
277
 
278
- // E7: warn if our WS path collides with the Vite HMR WebSocket path.
278
+ // Warn if our WS path collides with the Vite HMR WebSocket path.
279
279
  const hmrConfig = server.config.server?.hmr;
280
280
  if (hmrConfig && typeof hmrConfig === 'object' && hmrConfig.path === wsPath) {
281
281
  server.config.logger.warn(