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 +127 -2
- package/client.d.ts +20 -4
- package/client.js +63 -13
- package/files/handler.js +260 -21
- package/files/utils.js +112 -0
- package/index.d.ts +221 -5
- package/index.js +2 -1
- package/package.json +4 -1
- package/testing.js +26 -13
- package/vite.js +1 -1
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
771
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|