svelte-adapter-uws 0.5.3 → 0.5.5
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 +35 -12
- package/files/handler.js +119 -18
- package/index.d.ts +48 -0
- package/package.json +1 -1
- package/plugins/cursor/server.js +174 -31
- package/plugins/groups/server.js +9 -1
- package/plugins/presence/client.d.ts +9 -0
- package/plugins/presence/client.js +20 -8
- package/plugins/presence/server.d.ts +18 -6
- package/plugins/presence/server.js +39 -15
- package/testing.js +56 -13
- package/vite.js +7 -0
package/README.md
CHANGED
|
@@ -1274,6 +1274,22 @@ export async function GET({ platform }) {
|
|
|
1274
1274
|
|
|
1275
1275
|
The returned `Map` is the live module-level instance - read-only, do not mutate. In test mode (`process.env.VITEST` set, or `NODE_ENV === 'test'`) the assert helper additionally throws so test runners surface the failure; in production it logs and counts but does not throw, so a violation inside a uWS callback frame cannot crash the worker.
|
|
1276
1276
|
|
|
1277
|
+
### `platform.closedWsAborts`
|
|
1278
|
+
|
|
1279
|
+
Per-worker count of best-effort uWS operations that aborted because the underlying WebSocket had already closed. Bumped every time `platform.subscribe`, `platform.unsubscribe`, `platform.send`, `platform.sendCoalesced`, `platform.sendTo`, or `platform.request` is called on a `ws` whose native handle has been freed - typically because the caller `await`-ed something (auth, loader, subscribe hook) and the client closed during the wait.
|
|
1280
|
+
|
|
1281
|
+
These methods are *closed-WS safe* by contract: they swallow uWS's `Invalid access of closed uWS.WebSocket` exception, return a success-shaped no-op sentinel (`null` for subscribe, `false` for unsubscribe, `2` for send, etc.), and bump this counter. Callers can fire-and-forget without a per-site try/catch.
|
|
1282
|
+
|
|
1283
|
+
```js
|
|
1284
|
+
export async function GET({ platform }) {
|
|
1285
|
+
return json({ closedWsAborts: platform.closedWsAborts });
|
|
1286
|
+
}
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
A non-zero value is normal under client churn (tab close, network blips, mass reconnect waves). A rapidly-growing value under steady load indicates either pathological client behaviour or that the server's async setup path is too long for its connect rate. In clustered mode, sum across workers for cluster-wide visibility.
|
|
1290
|
+
|
|
1291
|
+
Monotonic, per-worker, reset only on process restart.
|
|
1292
|
+
|
|
1277
1293
|
### `platform.pressure` and `platform.onPressure(cb)`
|
|
1278
1294
|
|
|
1279
1295
|
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.
|
|
@@ -2377,8 +2393,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
|
|
|
2377
2393
|
|
|
2378
2394
|
export const presence = createPresence({
|
|
2379
2395
|
key: 'id',
|
|
2380
|
-
select: (userData) => ({ id: userData.id, name: userData.name })
|
|
2381
|
-
heartbeat:
|
|
2396
|
+
select: (userData) => ({ id: userData.id, name: userData.name })
|
|
2397
|
+
// heartbeat: 30_000 (default) - broadcast every 30s; clients refresh maxAge / re-add aged-out entries
|
|
2382
2398
|
// maxConnections: 1_000_000 (default) - hard cap on tracked connections
|
|
2383
2399
|
// maxTopics: 1_000_000 (default) - hard cap on active topic registry
|
|
2384
2400
|
});
|
|
@@ -2450,8 +2466,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
|
|
|
2450
2466
|
|
|
2451
2467
|
const presence = createPresence({
|
|
2452
2468
|
key: 'id', // field for multi-tab dedup (default: 'id')
|
|
2453
|
-
select: (userData) => userData, // extract public fields (default:
|
|
2454
|
-
heartbeat:
|
|
2469
|
+
select: (userData) => userData, // extract public fields (default: recursive denylist)
|
|
2470
|
+
heartbeat: 30_000 // broadcast every 30s (default: 30000; pass 0 to disable)
|
|
2455
2471
|
});
|
|
2456
2472
|
|
|
2457
2473
|
presence.hooks // ready-made { subscribe, unsubscribe, close } hooks
|
|
@@ -2466,14 +2482,15 @@ presence.clear() // reset everything (stops heartbeat timer)
|
|
|
2466
2482
|
|
|
2467
2483
|
#### Wire format
|
|
2468
2484
|
|
|
2469
|
-
The plugin emits
|
|
2485
|
+
The plugin emits three frame types on the `__presence:{topic}` channel:
|
|
2470
2486
|
|
|
2471
2487
|
- `{event: 'presence_state', data: {[key]: meta}}` - full snapshot, sent to a single connection on join or sync.
|
|
2472
2488
|
- `{event: 'presence_diff', data: {joins: {[key]: meta}, leaves: {[key]: meta}}}` - changes, broadcast to all subscribers of the topic.
|
|
2489
|
+
- `{event: 'heartbeat', data: {[key]: meta}}` - periodic full-roster refresh, broadcast every `heartbeat` ms (30 s default). Carries a `{userKey: data}` map so a client whose entry aged out of its local `maxAge` sweep can re-add it from the heartbeat alone, without waiting for the next `presence_diff`.
|
|
2473
2490
|
|
|
2474
2491
|
Diffs are buffered in a microtask queue: multiple joins / leaves in the same tick collapse into one diff frame. Within a diff, `leaves` are applied first then `joins`, so an update (same key in both) ends with the user present using the new data. If a key cycles join then leave in the same tick, the diff carries only the latest op (`leave` wins).
|
|
2475
2492
|
|
|
2476
|
-
|
|
2493
|
+
The Redis-backed variant in the [extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) package emits the same three frame shapes, so the same client bundle works against either backend.
|
|
2477
2494
|
|
|
2478
2495
|
#### Client API
|
|
2479
2496
|
|
|
@@ -2484,20 +2501,24 @@ const users = presence('room');
|
|
|
2484
2501
|
// $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
|
|
2485
2502
|
```
|
|
2486
2503
|
|
|
2487
|
-
The
|
|
2504
|
+
The client store defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed by a heartbeat or `presence_diff` / `presence_state` inside the window are removed from the local map. With the server's 30 s default heartbeat, still-present users are refreshed three times per window and never flicker; ghost entries left over by silent server-side cleanup (cluster mass-disconnect, ungraceful client close) clear within one sweep window.
|
|
2488
2505
|
|
|
2489
|
-
|
|
2506
|
+
For admin / audit views that want unbounded retention ("show every user who ever touched this topic"), opt out with `maxAge: 0`:
|
|
2507
|
+
|
|
2508
|
+
```js
|
|
2509
|
+
const everyoneEver = presence('room', { maxAge: 0 });
|
|
2510
|
+
```
|
|
2511
|
+
|
|
2512
|
+
To customize the window, set `maxAge` and the matching server `heartbeat` together (rule of thumb: heartbeat is one-third of `maxAge` or less, so a still-present user gets at least two refreshes per sweep window):
|
|
2490
2513
|
|
|
2491
2514
|
```js
|
|
2492
2515
|
// Server: heartbeat every 60s
|
|
2493
2516
|
const presence = createPresence({ key: 'id', heartbeat: 60_000 });
|
|
2494
2517
|
|
|
2495
|
-
// Client: entries expire after
|
|
2496
|
-
const users = presence('room', { maxAge:
|
|
2518
|
+
// Client: entries expire after 180s without a heartbeat refresh
|
|
2519
|
+
const users = presence('room', { maxAge: 180_000 });
|
|
2497
2520
|
```
|
|
2498
2521
|
|
|
2499
|
-
Rule of thumb: set `heartbeat` to half (or less) of the client's `maxAge`.
|
|
2500
|
-
|
|
2501
2522
|
#### How multi-tab dedup works
|
|
2502
2523
|
|
|
2503
2524
|
If user "Alice" (key `id: '1'`) has three browser tabs open, `presence.join()` is called three times with the same key. The plugin ref-counts connections per key: Alice appears once in the list. When she closes two tabs, she stays present. Only when the last tab closes does the plugin broadcast a `leave` event.
|
|
@@ -3328,6 +3349,8 @@ Per-worker limitations (acceptable for most apps):
|
|
|
3328
3349
|
- `platform.connections` - returns the count for the local worker only
|
|
3329
3350
|
- `platform.subscribers(topic)` - returns the count for the local worker only
|
|
3330
3351
|
- `platform.sendTo(filter, ...)` - iterates the local worker's connections only, no cross-worker relay
|
|
3352
|
+
- `platform.closedWsAborts` - per-worker counter; sum across workers for cluster total
|
|
3353
|
+
- `platform.assertions` - per-worker counter Map
|
|
3331
3354
|
|
|
3332
3355
|
### Docker / multi-process deployments (Linux)
|
|
3333
3356
|
|
package/files/handler.js
CHANGED
|
@@ -421,7 +421,8 @@ const closeHookRegistered = WS_ENABLED && !!wsModule.close;
|
|
|
421
421
|
*/
|
|
422
422
|
function bumpIn(ws, message) {
|
|
423
423
|
if (!closeHookRegistered) return;
|
|
424
|
-
|
|
424
|
+
let stats;
|
|
425
|
+
try { stats = ws.getUserData()[WS_STATS]; } catch { return; }
|
|
425
426
|
if (!stats) return;
|
|
426
427
|
stats.messagesIn++;
|
|
427
428
|
stats.bytesIn += typeof message === 'string' ? message.length : message.byteLength;
|
|
@@ -439,7 +440,8 @@ function bumpIn(ws, message) {
|
|
|
439
440
|
*/
|
|
440
441
|
function bumpOut(ws, payload) {
|
|
441
442
|
if (!closeHookRegistered) return;
|
|
442
|
-
|
|
443
|
+
let stats;
|
|
444
|
+
try { stats = ws.getUserData()[WS_STATS]; } catch { return; }
|
|
443
445
|
if (!stats) return;
|
|
444
446
|
stats.messagesOut++;
|
|
445
447
|
stats.bytesOut += payload.length;
|
|
@@ -489,6 +491,16 @@ function maybeWarnTopicRegistry() {
|
|
|
489
491
|
|
|
490
492
|
let publishCountWindow = 0;
|
|
491
493
|
let totalSubscriptions = 0;
|
|
494
|
+
// Count of best-effort operations that aborted because the underlying
|
|
495
|
+
// uWS WebSocket had already closed. The platform contract is that
|
|
496
|
+
// ws-targeted public methods (subscribe / unsubscribe / send /
|
|
497
|
+
// sendCoalesced / request) and internal helpers that may run after an
|
|
498
|
+
// `await` never propagate uWS's "Invalid access of closed
|
|
499
|
+
// uWS.WebSocket" exception to user code - they swallow it, return a
|
|
500
|
+
// no-op sentinel, and bump this counter. Operators can read
|
|
501
|
+
// `platform.closedWsAborts` to detect when mass-connect kernel /
|
|
502
|
+
// backpressure churn is closing sockets mid-async-setup at scale.
|
|
503
|
+
let closedWsAborts = 0;
|
|
492
504
|
|
|
493
505
|
/**
|
|
494
506
|
* Per-topic publish counters for runaway-publisher detection. Single
|
|
@@ -824,7 +836,7 @@ async function runUserSubscribeGate(ws, topic) {
|
|
|
824
836
|
function sendSubscribed(ws, topic, ref) {
|
|
825
837
|
if (ref === null) return;
|
|
826
838
|
const payload = JSON.stringify({ type: 'subscribed', topic, ref });
|
|
827
|
-
ws.send(payload, false, false);
|
|
839
|
+
try { ws.send(payload, false, false); } catch { closedWsAborts++; return; }
|
|
828
840
|
bumpOut(ws, payload);
|
|
829
841
|
}
|
|
830
842
|
|
|
@@ -840,7 +852,7 @@ function sendSubscribed(ws, topic, ref) {
|
|
|
840
852
|
function sendSubscribeDenied(ws, topic, ref, reason) {
|
|
841
853
|
if (ref === null) return;
|
|
842
854
|
const payload = JSON.stringify({ type: 'subscribe-denied', topic, ref, reason });
|
|
843
|
-
ws.send(payload, false, false);
|
|
855
|
+
try { ws.send(payload, false, false); } catch { closedWsAborts++; return; }
|
|
844
856
|
bumpOut(ws, payload);
|
|
845
857
|
}
|
|
846
858
|
|
|
@@ -852,15 +864,29 @@ function sendSubscribeDenied(ws, topic, ref, reason) {
|
|
|
852
864
|
* @param {import('uWebSockets.js').WebSocket<any>} ws
|
|
853
865
|
*/
|
|
854
866
|
function flushCoalescedFor(ws) {
|
|
855
|
-
|
|
867
|
+
let userData;
|
|
868
|
+
try { userData = ws.getUserData(); }
|
|
869
|
+
catch { closedWsAborts++; return; }
|
|
856
870
|
const pending = userData[WS_COALESCED];
|
|
857
871
|
if (!pending || pending.size === 0) return;
|
|
858
872
|
assert(pending instanceof Map, 'coalesce.pending-type', null);
|
|
873
|
+
let aborted = false;
|
|
859
874
|
drainCoalesced(pending, (msg) => {
|
|
875
|
+
if (aborted) return 2;
|
|
860
876
|
assert(typeof msg.topic === 'string', 'coalesce.entry-topic-type', null);
|
|
861
877
|
assert(typeof msg.event === 'string', 'coalesce.entry-event-type', null);
|
|
862
878
|
const payload = envelopePrefix(msg.topic, msg.event) + JSON.stringify(msg.data ?? null) + '}';
|
|
863
|
-
|
|
879
|
+
let result;
|
|
880
|
+
try { result = ws.send(payload, false, false); }
|
|
881
|
+
catch {
|
|
882
|
+
// Socket closed mid-drain. There will be no further `drain`
|
|
883
|
+
// event to retry on, so dropping the rest of the buffer is
|
|
884
|
+
// the only correct outcome - returning 1 (BACKPRESSURE) lets
|
|
885
|
+
// drainCoalesced clear the current entry and stop iterating.
|
|
886
|
+
closedWsAborts++;
|
|
887
|
+
aborted = true;
|
|
888
|
+
return 1;
|
|
889
|
+
}
|
|
864
890
|
// `result` MUST propagate to drainCoalesced. 0=SUCCESS removes the
|
|
865
891
|
// entry; 1=BACKPRESSURE removes it and halts the loop; 2=DROPPED
|
|
866
892
|
// retains the entry for retry on next drain. Don't refactor away
|
|
@@ -869,6 +895,7 @@ function flushCoalescedFor(ws) {
|
|
|
869
895
|
if (result !== 2) bumpOut(ws, payload);
|
|
870
896
|
return result;
|
|
871
897
|
});
|
|
898
|
+
if (aborted) pending.clear();
|
|
872
899
|
}
|
|
873
900
|
|
|
874
901
|
/** @type {import('./index.js').Platform} */
|
|
@@ -927,7 +954,13 @@ const platform = {
|
|
|
927
954
|
send(ws, topic, event, data) {
|
|
928
955
|
const payload = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
|
|
929
956
|
assert(payload.length > 0, 'envelope.send-empty', { topic, event });
|
|
930
|
-
|
|
957
|
+
// `ws.send` throws on a freed native handle (callers may reach
|
|
958
|
+
// here after an `await` that outlasted the socket). Return 2
|
|
959
|
+
// (DROPPED, the uWS sentinel) so callers can pattern-match
|
|
960
|
+
// without distinguishing closed from backpressure-dropped.
|
|
961
|
+
let result;
|
|
962
|
+
try { result = ws.send(payload, false, false); }
|
|
963
|
+
catch { closedWsAborts++; return 2; }
|
|
931
964
|
bumpOut(ws, payload);
|
|
932
965
|
return result;
|
|
933
966
|
},
|
|
@@ -954,7 +987,9 @@ const platform = {
|
|
|
954
987
|
* on the next drain.
|
|
955
988
|
*/
|
|
956
989
|
sendCoalesced(ws, { key, topic, event, data }) {
|
|
957
|
-
|
|
990
|
+
let userData;
|
|
991
|
+
try { userData = ws.getUserData(); }
|
|
992
|
+
catch { closedWsAborts++; return; }
|
|
958
993
|
let pending = userData[WS_COALESCED];
|
|
959
994
|
if (!pending) {
|
|
960
995
|
pending = new Map();
|
|
@@ -990,7 +1025,15 @@ const platform = {
|
|
|
990
1025
|
const envelope = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
|
|
991
1026
|
let count = 0;
|
|
992
1027
|
for (const ws of wsConnections) {
|
|
993
|
-
|
|
1028
|
+
// uWS's close event fires synchronously and removes from
|
|
1029
|
+
// wsConnections before any user code runs, so under normal
|
|
1030
|
+
// flow every entry here is open. Defensive try/catch covers
|
|
1031
|
+
// pathological cases (e.g. user filter triggers a close via
|
|
1032
|
+
// side effect, or another worker raced through cleanup).
|
|
1033
|
+
let userData;
|
|
1034
|
+
try { userData = ws.getUserData(); }
|
|
1035
|
+
catch { closedWsAborts++; continue; }
|
|
1036
|
+
const decision = filter(userData);
|
|
994
1037
|
if (decision && typeof decision.then === 'function') {
|
|
995
1038
|
if (!sendToAsyncWarned) {
|
|
996
1039
|
sendToAsyncWarned = true;
|
|
@@ -1005,7 +1048,8 @@ const platform = {
|
|
|
1005
1048
|
continue;
|
|
1006
1049
|
}
|
|
1007
1050
|
if (decision) {
|
|
1008
|
-
ws.send(envelope, false, false);
|
|
1051
|
+
try { ws.send(envelope, false, false); }
|
|
1052
|
+
catch { closedWsAborts++; continue; }
|
|
1009
1053
|
bumpOut(ws, envelope);
|
|
1010
1054
|
count++;
|
|
1011
1055
|
}
|
|
@@ -1040,6 +1084,30 @@ const platform = {
|
|
|
1040
1084
|
return readAssertionCounts();
|
|
1041
1085
|
},
|
|
1042
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Per-worker count of best-effort uWS operations that aborted
|
|
1089
|
+
* because the underlying WebSocket had already closed.
|
|
1090
|
+
*
|
|
1091
|
+
* Ws-targeted platform methods (`subscribe`, `unsubscribe`, `send`,
|
|
1092
|
+
* `sendCoalesced`, `sendTo`, `request`) and the wire-level
|
|
1093
|
+
* subscribe / subscribe-batch handlers all swallow uWS's "Invalid
|
|
1094
|
+
* access of closed uWS.WebSocket" exception so callers never need
|
|
1095
|
+
* a per-site try/catch. Each swallow bumps this counter.
|
|
1096
|
+
*
|
|
1097
|
+
* A non-zero value is normal under churn (clients close mid-async-
|
|
1098
|
+
* setup all the time). A rapidly-growing value under steady load
|
|
1099
|
+
* indicates either pathological client behaviour or that the
|
|
1100
|
+
* server's async setup path is too long for its connect rate -
|
|
1101
|
+
* worth investigating but not, by itself, a bug.
|
|
1102
|
+
*
|
|
1103
|
+
* Monotonic, per-worker, reset only on process restart.
|
|
1104
|
+
*
|
|
1105
|
+
* @returns {number}
|
|
1106
|
+
*/
|
|
1107
|
+
get closedWsAborts() {
|
|
1108
|
+
return closedWsAborts;
|
|
1109
|
+
},
|
|
1110
|
+
|
|
1043
1111
|
/**
|
|
1044
1112
|
* Number of clients subscribed to a specific topic.
|
|
1045
1113
|
*/
|
|
@@ -1138,7 +1206,13 @@ const platform = {
|
|
|
1138
1206
|
// non-ASCII topic names (`__signal:Jose`, presence rooms with
|
|
1139
1207
|
// localized labels) must not be blocked at the platform layer.
|
|
1140
1208
|
if (!isValidWireTopic(topic, true)) return 'INVALID_TOPIC';
|
|
1141
|
-
|
|
1209
|
+
// `ws.getUserData()` throws on a freed native handle. RPC and
|
|
1210
|
+
// plugin code paths routinely `await` something else before
|
|
1211
|
+
// reaching here, so the WS may already be closed by the time
|
|
1212
|
+
// this runs. Treat as a silent no-op.
|
|
1213
|
+
let subs;
|
|
1214
|
+
try { subs = ws.getUserData()[WS_SUBSCRIPTIONS]; }
|
|
1215
|
+
catch { closedWsAborts++; return null; }
|
|
1142
1216
|
assert(subs instanceof Set, 'subs.shape', null);
|
|
1143
1217
|
if (subs.has(topic)) return null;
|
|
1144
1218
|
if (subs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) return 'RATE_LIMITED';
|
|
@@ -1150,7 +1224,13 @@ const platform = {
|
|
|
1150
1224
|
// counter bump in that case.
|
|
1151
1225
|
if (subs.has(topic)) return null;
|
|
1152
1226
|
if (subs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) return 'RATE_LIMITED';
|
|
1153
|
-
ws.subscribe(
|
|
1227
|
+
// `ws.subscribe()` throws if the socket closed during the await
|
|
1228
|
+
// above. Under mass-connect / backpressure churn this is the
|
|
1229
|
+
// dominant abort mode (10-15% of connections close mid-setup).
|
|
1230
|
+
// Swallow, count, and return success-shaped null so callers can
|
|
1231
|
+
// fire-and-forget without per-site try/catch.
|
|
1232
|
+
try { ws.subscribe(topic); }
|
|
1233
|
+
catch { closedWsAborts++; return null; }
|
|
1154
1234
|
subs.add(topic);
|
|
1155
1235
|
totalSubscriptions++;
|
|
1156
1236
|
return null;
|
|
@@ -1227,10 +1307,17 @@ const platform = {
|
|
|
1227
1307
|
* @returns {boolean} `true` if a subscription was removed
|
|
1228
1308
|
*/
|
|
1229
1309
|
unsubscribe(ws, topic) {
|
|
1230
|
-
|
|
1310
|
+
// Closed sockets get an early-out: there is no subscription
|
|
1311
|
+
// state to remove, no uWS bookkeeping to drop, no informational
|
|
1312
|
+
// hook to fire. The platform contract is "best effort; no throw
|
|
1313
|
+
// on closed WS" - mirrors subscribe / send.
|
|
1314
|
+
let subs;
|
|
1315
|
+
try { subs = ws.getUserData()[WS_SUBSCRIPTIONS]; }
|
|
1316
|
+
catch { closedWsAborts++; return false; }
|
|
1231
1317
|
assert(subs instanceof Set, 'subs.shape-unsubscribe', null);
|
|
1232
1318
|
if (!subs.has(topic)) return false;
|
|
1233
|
-
ws.unsubscribe(topic);
|
|
1319
|
+
try { ws.unsubscribe(topic); }
|
|
1320
|
+
catch { closedWsAborts++; return false; }
|
|
1234
1321
|
subs.delete(topic);
|
|
1235
1322
|
totalSubscriptions--;
|
|
1236
1323
|
assert(totalSubscriptions >= 0, 'subs.total-negative', { totalSubscriptions });
|
|
@@ -1493,7 +1580,12 @@ const platform = {
|
|
|
1493
1580
|
* so cleanup is automatic on close - no module-level leak risk.
|
|
1494
1581
|
*/
|
|
1495
1582
|
request(ws, event, data, options) {
|
|
1496
|
-
|
|
1583
|
+
let userData;
|
|
1584
|
+
try { userData = ws.getUserData(); }
|
|
1585
|
+
catch {
|
|
1586
|
+
closedWsAborts++;
|
|
1587
|
+
return Promise.reject(new Error('connection closed'));
|
|
1588
|
+
}
|
|
1497
1589
|
let pending = userData[WS_PENDING_REQUESTS];
|
|
1498
1590
|
if (!pending) {
|
|
1499
1591
|
pending = new Map();
|
|
@@ -1514,7 +1606,14 @@ const platform = {
|
|
|
1514
1606
|
}, timeoutMs);
|
|
1515
1607
|
pending.set(ref, { resolve, reject, timer });
|
|
1516
1608
|
const payload = JSON.stringify({ type: 'request', ref, event, data: data ?? null });
|
|
1517
|
-
ws.send(payload, false, false);
|
|
1609
|
+
try { ws.send(payload, false, false); }
|
|
1610
|
+
catch {
|
|
1611
|
+
closedWsAborts++;
|
|
1612
|
+
clearTimeout(timer);
|
|
1613
|
+
pending.delete(ref);
|
|
1614
|
+
reject(new Error('connection closed'));
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1518
1617
|
bumpOut(ws, payload);
|
|
1519
1618
|
});
|
|
1520
1619
|
},
|
|
@@ -3093,7 +3192,8 @@ if (WS_ENABLED) {
|
|
|
3093
3192
|
sendSubscribeDenied(ws, msg.topic, ref, 'RATE_LIMITED');
|
|
3094
3193
|
return;
|
|
3095
3194
|
}
|
|
3096
|
-
try { ws.subscribe(msg.topic); }
|
|
3195
|
+
try { ws.subscribe(msg.topic); }
|
|
3196
|
+
catch { closedWsAborts++; return; }
|
|
3097
3197
|
subs.add(msg.topic);
|
|
3098
3198
|
totalSubscriptions++;
|
|
3099
3199
|
if (wsDebug) console.log('[ws] subscribe topic=%s', msg.topic);
|
|
@@ -3171,7 +3271,8 @@ if (WS_ENABLED) {
|
|
|
3171
3271
|
sendSubscribeDenied(ws, topic, ref, 'RATE_LIMITED');
|
|
3172
3272
|
continue;
|
|
3173
3273
|
}
|
|
3174
|
-
try { ws.subscribe(topic); }
|
|
3274
|
+
try { ws.subscribe(topic); }
|
|
3275
|
+
catch { closedWsAborts++; continue; }
|
|
3175
3276
|
subs.add(topic);
|
|
3176
3277
|
totalSubscriptions++;
|
|
3177
3278
|
subscribed++;
|
package/index.d.ts
CHANGED
|
@@ -1202,6 +1202,14 @@ export interface Platform {
|
|
|
1202
1202
|
* Send a message to a single WebSocket connection.
|
|
1203
1203
|
* Wraps in the same `{ topic, event, data }` envelope as `publish()`.
|
|
1204
1204
|
*
|
|
1205
|
+
* Returns the uWS send result: `0` = SUCCESS, `1` = BACKPRESSURE
|
|
1206
|
+
* (queued, will flush on drain), `2` = DROPPED (frame discarded
|
|
1207
|
+
* because the socket has closed or its queue is over the limit).
|
|
1208
|
+
*
|
|
1209
|
+
* Closed-WS safe: if the socket has already closed, returns `2`
|
|
1210
|
+
* (DROPPED) and bumps `platform.closedWsAborts` rather than
|
|
1211
|
+
* propagating uWS's "Invalid access" exception.
|
|
1212
|
+
*
|
|
1205
1213
|
* @example
|
|
1206
1214
|
* ```js
|
|
1207
1215
|
* // In hooks.ws.js - reply to sender:
|
|
@@ -1310,6 +1318,36 @@ export interface Platform {
|
|
|
1310
1318
|
*/
|
|
1311
1319
|
readonly connections: number;
|
|
1312
1320
|
|
|
1321
|
+
/**
|
|
1322
|
+
* Per-worker count of best-effort uWS operations that aborted
|
|
1323
|
+
* because the underlying WebSocket had already closed.
|
|
1324
|
+
*
|
|
1325
|
+
* Ws-targeted platform methods (`subscribe`, `unsubscribe`, `send`,
|
|
1326
|
+
* `sendCoalesced`, `sendTo`, `request`) and the wire-level
|
|
1327
|
+
* subscribe / subscribe-batch handlers swallow uWS's "Invalid
|
|
1328
|
+
* access of closed uWS.WebSocket" exception so callers never need
|
|
1329
|
+
* a per-site try/catch when a socket closes mid-async-setup.
|
|
1330
|
+
* Each swallow bumps this counter.
|
|
1331
|
+
*
|
|
1332
|
+
* A non-zero value is normal under client churn (browser tab close,
|
|
1333
|
+
* network blips, mass-reconnect waves). A rapidly-growing value
|
|
1334
|
+
* under steady load indicates either pathological client behaviour
|
|
1335
|
+
* or that the server's async setup path is too long for its
|
|
1336
|
+
* connect rate - worth investigating but not, on its own, a bug.
|
|
1337
|
+
*
|
|
1338
|
+
* Monotonic, per-worker, reset only on process restart. In
|
|
1339
|
+
* clustered mode, sum across workers to get the cluster total.
|
|
1340
|
+
*
|
|
1341
|
+
* @example
|
|
1342
|
+
* ```js
|
|
1343
|
+
* // periodic ops log
|
|
1344
|
+
* setInterval(() => {
|
|
1345
|
+
* console.log('closed-ws aborts:', platform.closedWsAborts);
|
|
1346
|
+
* }, 60_000);
|
|
1347
|
+
* ```
|
|
1348
|
+
*/
|
|
1349
|
+
readonly closedWsAborts: number;
|
|
1350
|
+
|
|
1313
1351
|
/**
|
|
1314
1352
|
* Number of clients subscribed to a specific topic.
|
|
1315
1353
|
*
|
|
@@ -1387,6 +1425,13 @@ export interface Platform {
|
|
|
1387
1425
|
* hook may be async (the framework awaits the hook before inspecting
|
|
1388
1426
|
* its return). Callers must `await` the result.
|
|
1389
1427
|
*
|
|
1428
|
+
* Closed-WS safe: if the socket closes during the awaited hook (or
|
|
1429
|
+
* before the call, e.g. caller `await`-ed something else first) the
|
|
1430
|
+
* method silently returns `null` rather than propagating uWS's
|
|
1431
|
+
* "Invalid access of closed uWS.WebSocket" exception. Each abort
|
|
1432
|
+
* increments `platform.closedWsAborts`. Callers can fire-and-forget
|
|
1433
|
+
* without a per-site try/catch.
|
|
1434
|
+
*
|
|
1390
1435
|
* @example
|
|
1391
1436
|
* ```js
|
|
1392
1437
|
* // In an RPC handler that needs to subscribe the connection
|
|
@@ -1456,6 +1501,9 @@ export interface Platform {
|
|
|
1456
1501
|
* otherwise removes the subscription, decrements `totalSubscriptions`,
|
|
1457
1502
|
* fires `hooks.ws.unsubscribe` (informational, not a gate - mirrors
|
|
1458
1503
|
* the wire-level unsubscribe path), and returns `true`.
|
|
1504
|
+
*
|
|
1505
|
+
* Closed-WS safe: returns `false` and bumps `platform.closedWsAborts`
|
|
1506
|
+
* if the socket has already closed.
|
|
1459
1507
|
*/
|
|
1460
1508
|
unsubscribe(ws: WebSocket<unknown>, topic: string): boolean;
|
|
1461
1509
|
|
package/package.json
CHANGED
package/plugins/cursor/server.js
CHANGED
|
@@ -99,6 +99,13 @@ const EVENTS = Object.freeze({
|
|
|
99
99
|
* subscribe so late joiners see existing cursors immediately.
|
|
100
100
|
* @property {() => void} clear -
|
|
101
101
|
* Clear all cursor tracking state and pending timers.
|
|
102
|
+
* @property {() => { flushes: number, driftMeanMs: number, driftMaxMs: number, dirtyTopicsCurrent: number, activeTopicsTotal: number }} stats -
|
|
103
|
+
* Snapshot of scheduler health. `flushes` is the total tick-driven
|
|
104
|
+
* flushes; `driftMeanMs` / `driftMaxMs` measure the gap between the
|
|
105
|
+
* target deadline and the actual fire time (`> topicThrottle` indicates
|
|
106
|
+
* sustained event-loop saturation); `dirtyTopicsCurrent` is topics with
|
|
107
|
+
* pending coalesced entries (should hover near zero); `activeTopicsTotal`
|
|
108
|
+
* is topics with at least one local cursor.
|
|
102
109
|
*/
|
|
103
110
|
|
|
104
111
|
/**
|
|
@@ -193,14 +200,48 @@ export function createCursor(options = {}) {
|
|
|
193
200
|
const topics = new Map();
|
|
194
201
|
|
|
195
202
|
/**
|
|
196
|
-
* Per-topic aggregate
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
203
|
+
* Per-topic aggregate flush state.
|
|
204
|
+
*
|
|
205
|
+
* - `dirty`: cursors awaiting coalesced flush. Keyed by connection key;
|
|
206
|
+
* latest-wins. When the coalesce window elapses, `dirty.size === 1`
|
|
207
|
+
* sends a single `update`; any other count sends one `bulk` array.
|
|
208
|
+
* - `lastFlush`: target-anchored timestamp of the most recent flush.
|
|
209
|
+
* Advanced by `topicThrottleMs` per cycle (not to actual fire time)
|
|
210
|
+
* so a single late tick does not compound drift on subsequent cycles.
|
|
211
|
+
*
|
|
212
|
+
* @type {Map<string, { dirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
|
|
201
213
|
*/
|
|
202
214
|
const topicFlush = new Map();
|
|
203
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Topics with at least one pending dirty entry. Bounded by mover count,
|
|
218
|
+
* not active-topic count, so the scheduler walks only dirty topics on
|
|
219
|
+
* each tick instead of every active one.
|
|
220
|
+
* @type {Set<string>}
|
|
221
|
+
*/
|
|
222
|
+
const dirtyTopics = new Set();
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Single tracker-wide timer. Always points at the next earliest topic
|
|
226
|
+
* deadline (or null when idle). Replaces the previous per-topic
|
|
227
|
+
* setTimeout pattern: N pending timers -> 1 pending timer regardless
|
|
228
|
+
* of topic count. Scheduling cost is O(dirty topics), not O(active
|
|
229
|
+
* topics).
|
|
230
|
+
* @type {ReturnType<typeof setTimeout> | null}
|
|
231
|
+
*/
|
|
232
|
+
let tickTimer = null;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Drift accounting for `stats()` observability. Mean (target - actual)
|
|
236
|
+
* and max over tick-driven flushes. Leading-edge synchronous flushes
|
|
237
|
+
* are NOT counted (they fire on the caller's thread, not via the
|
|
238
|
+
* scheduler; their drift is structurally zero).
|
|
239
|
+
*/
|
|
240
|
+
let driftSum = 0;
|
|
241
|
+
let driftCount = 0;
|
|
242
|
+
let driftMax = 0;
|
|
243
|
+
let flushCount = 0;
|
|
244
|
+
|
|
204
245
|
/**
|
|
205
246
|
* Get or create ws state and return the connection key + user data.
|
|
206
247
|
* @param {any} ws
|
|
@@ -213,9 +254,17 @@ export function createCursor(options = {}) {
|
|
|
213
254
|
const oldest = wsState.keys().next().value;
|
|
214
255
|
if (oldest !== undefined) wsState.delete(oldest);
|
|
215
256
|
}
|
|
257
|
+
let userData = {};
|
|
258
|
+
if (typeof ws.getUserData === 'function') {
|
|
259
|
+
// Closed-WS race: caller may reach here after an `await`
|
|
260
|
+
// that outlasted the socket; getUserData throws on a
|
|
261
|
+
// freed handle. Fall back to an empty userData rather
|
|
262
|
+
// than crashing the worker.
|
|
263
|
+
try { userData = ws.getUserData(); } catch { userData = {}; }
|
|
264
|
+
}
|
|
216
265
|
state = {
|
|
217
266
|
key: String(++connCounter),
|
|
218
|
-
user: select(
|
|
267
|
+
user: select(userData),
|
|
219
268
|
topics: new Set()
|
|
220
269
|
};
|
|
221
270
|
wsState.set(ws, state);
|
|
@@ -224,14 +273,15 @@ export function createCursor(options = {}) {
|
|
|
224
273
|
}
|
|
225
274
|
|
|
226
275
|
/**
|
|
227
|
-
* Drop the topic's coalesce state
|
|
276
|
+
* Drop the topic's coalesce state. The single tracker-wide tickTimer is
|
|
277
|
+
* left alone (it self-cancels on the next tick when `dirtyTopics` is
|
|
278
|
+
* empty); we just remove this topic from both the flush map and the
|
|
279
|
+
* dirty set so the next tick skips it.
|
|
228
280
|
* @param {string} topic
|
|
229
281
|
*/
|
|
230
282
|
function clearTopicFlush(topic) {
|
|
231
|
-
const flushState = topicFlush.get(topic);
|
|
232
|
-
if (!flushState) return;
|
|
233
|
-
if (flushState.timer) clearTimeout(flushState.timer);
|
|
234
283
|
topicFlush.delete(topic);
|
|
284
|
+
dirtyTopics.delete(topic);
|
|
235
285
|
}
|
|
236
286
|
|
|
237
287
|
/**
|
|
@@ -262,6 +312,7 @@ export function createCursor(options = {}) {
|
|
|
262
312
|
*/
|
|
263
313
|
function flushDirty(topic, dirty) {
|
|
264
314
|
if (dirty.size === 0) return;
|
|
315
|
+
flushCount++;
|
|
265
316
|
if (dirty.size === 1) {
|
|
266
317
|
const [k, v] = dirty.entries().next().value;
|
|
267
318
|
doBroadcast(topic, k, v.data, v.platform);
|
|
@@ -278,9 +329,77 @@ export function createCursor(options = {}) {
|
|
|
278
329
|
}
|
|
279
330
|
}
|
|
280
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Scheduler tick. Walks `dirtyTopics`, flushes any topic whose deadline
|
|
334
|
+
* (`lastFlush + topicThrottleMs`) has passed, and re-arms `tickTimer`
|
|
335
|
+
* for the next earliest pending deadline. Topics whose deadline has
|
|
336
|
+
* not yet passed stay in `dirtyTopics` for the next tick.
|
|
337
|
+
*
|
|
338
|
+
* Target-anchored advance: on flush, `lastFlush` is set to the deadline
|
|
339
|
+
* (not the actual fire time) so a single late tick does not compound
|
|
340
|
+
* drift on subsequent cycles. If we fell behind by more than one cycle
|
|
341
|
+
* (event loop saturation > `topicThrottleMs`), `lastFlush` resets to
|
|
342
|
+
* `now` to avoid queueing phantom catch-up fires.
|
|
343
|
+
*/
|
|
344
|
+
function tick() {
|
|
345
|
+
tickTimer = null;
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
let nextDeadline = Infinity;
|
|
348
|
+
|
|
349
|
+
for (const topic of dirtyTopics) {
|
|
350
|
+
const state = topicFlush.get(topic);
|
|
351
|
+
if (!state) { dirtyTopics.delete(topic); continue; }
|
|
352
|
+
if (state.dirty.size === 0) {
|
|
353
|
+
dirtyTopics.delete(topic);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const deadline = state.lastFlush + topicThrottleMs;
|
|
357
|
+
if (deadline <= now) {
|
|
358
|
+
const drift = now - deadline;
|
|
359
|
+
driftSum += drift;
|
|
360
|
+
driftCount++;
|
|
361
|
+
if (drift > driftMax) driftMax = drift;
|
|
362
|
+
|
|
363
|
+
flushDirty(topic, state.dirty); // increments flushCount internally
|
|
364
|
+
state.dirty.clear();
|
|
365
|
+
dirtyTopics.delete(topic);
|
|
366
|
+
|
|
367
|
+
state.lastFlush = drift < topicThrottleMs ? deadline : now;
|
|
368
|
+
} else if (deadline < nextDeadline) {
|
|
369
|
+
nextDeadline = deadline;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (nextDeadline !== Infinity) {
|
|
374
|
+
tickTimer = setTimeout(tick, Math.max(0, nextDeadline - Date.now()));
|
|
375
|
+
}
|
|
376
|
+
// else: scheduler idle until next `broadcast()` call.
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function armTick(delay) {
|
|
380
|
+
if (tickTimer !== null) return;
|
|
381
|
+
tickTimer = setTimeout(tick, delay);
|
|
382
|
+
}
|
|
383
|
+
|
|
281
384
|
/**
|
|
282
385
|
* Route a broadcast through the per-topic coalesce window when
|
|
283
386
|
* `topicThrottle` is enabled, or directly publish when disabled.
|
|
387
|
+
*
|
|
388
|
+
* Leading-edge claims the cadence slot synchronously (lastFlush =
|
|
389
|
+
* now) but defers the actual flush by one microtask so co-arriving
|
|
390
|
+
* broadcasts in the same JS pass batch into a single bulk frame.
|
|
391
|
+
* Without the microtask defer, an event-loop pause > topicThrottleMs
|
|
392
|
+
* caused the post-pause first cursor to fire alone (single-cursor
|
|
393
|
+
* UPDATE) while every other cursor in the same burst queued to the
|
|
394
|
+
* trailing tick: under sustained pressure (30K RPCs/sec/worker) this
|
|
395
|
+
* fragmented 86% of cursor frames into single-cursor UPDATEs.
|
|
396
|
+
* Microtasks run after the current synchronous code completes but
|
|
397
|
+
* before the next I/O / setTimeout / event-loop tick, so any
|
|
398
|
+
* subsequent broadcast() in the same handler batch adds itself to
|
|
399
|
+
* `dirty` before the flush runs.
|
|
400
|
+
*
|
|
401
|
+
* Trailing-edge fires via the single tracker-wide `tickTimer` for
|
|
402
|
+
* broadcasts that land mid-window.
|
|
284
403
|
*/
|
|
285
404
|
function broadcast(topic, key, data, platform) {
|
|
286
405
|
if (topicThrottleMs <= 0) {
|
|
@@ -290,34 +409,31 @@ export function createCursor(options = {}) {
|
|
|
290
409
|
|
|
291
410
|
let state = topicFlush.get(topic);
|
|
292
411
|
if (!state) {
|
|
293
|
-
state = {
|
|
412
|
+
state = { dirty: new Map(), lastFlush: 0, pendingMicroflush: false };
|
|
294
413
|
topicFlush.set(topic, state);
|
|
295
414
|
}
|
|
296
|
-
|
|
297
415
|
state.dirty.set(key, { data, platform });
|
|
298
416
|
|
|
299
417
|
const now = Date.now();
|
|
300
418
|
if (now - state.lastFlush >= topicThrottleMs) {
|
|
301
|
-
if (state.timer) {
|
|
302
|
-
clearTimeout(state.timer);
|
|
303
|
-
state.timer = null;
|
|
304
|
-
}
|
|
305
419
|
state.lastFlush = now;
|
|
306
|
-
|
|
307
|
-
|
|
420
|
+
dirtyTopics.delete(topic);
|
|
421
|
+
// Schedule once per cycle slot; subsequent broadcasts inside
|
|
422
|
+
// the same microtask boundary just append to `state.dirty`.
|
|
423
|
+
if (!state.pendingMicroflush) {
|
|
424
|
+
state.pendingMicroflush = true;
|
|
425
|
+
queueMicrotask(() => {
|
|
426
|
+
state.pendingMicroflush = false;
|
|
427
|
+
if (state.dirty.size === 0) return;
|
|
428
|
+
flushDirty(topic, state.dirty);
|
|
429
|
+
state.dirty.clear();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
308
432
|
return;
|
|
309
433
|
}
|
|
310
434
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const s = topicFlush.get(topic);
|
|
314
|
-
if (!s) return;
|
|
315
|
-
s.timer = null;
|
|
316
|
-
s.lastFlush = Date.now();
|
|
317
|
-
flushDirty(topic, s.dirty);
|
|
318
|
-
s.dirty.clear();
|
|
319
|
-
}, topicThrottleMs - (now - state.lastFlush));
|
|
320
|
-
}
|
|
435
|
+
dirtyTopics.add(topic);
|
|
436
|
+
armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
|
|
321
437
|
}
|
|
322
438
|
|
|
323
439
|
/** @type {CursorTracker} */
|
|
@@ -460,15 +576,42 @@ export function createCursor(options = {}) {
|
|
|
460
576
|
if (entry.timer) clearTimeout(entry.timer);
|
|
461
577
|
}
|
|
462
578
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
579
|
+
if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
|
|
580
|
+
dirtyTopics.clear();
|
|
466
581
|
topics.clear();
|
|
467
582
|
topicFlush.clear();
|
|
468
583
|
wsState.clear();
|
|
469
584
|
connCounter = 0;
|
|
470
585
|
},
|
|
471
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Snapshot of scheduler health. Always available, near-zero cost.
|
|
589
|
+
*
|
|
590
|
+
* - `flushes`: total tick-driven flushes since tracker creation.
|
|
591
|
+
* - `driftMeanMs`: mean (target_deadline - actual_fire_time) across
|
|
592
|
+
* all tick-driven flushes. 0 means perfect cadence; values >
|
|
593
|
+
* `topicThrottle` indicate sustained event-loop saturation or
|
|
594
|
+
* CPU contention.
|
|
595
|
+
* - `driftMaxMs`: largest single observed late fire. Useful for
|
|
596
|
+
* spotting one-off GC pauses vs. sustained drift.
|
|
597
|
+
* - `dirtyTopicsCurrent`: topics with pending coalesced entries
|
|
598
|
+
* right now. Should hover near zero in healthy operation.
|
|
599
|
+
* - `activeTopicsTotal`: topics with at least one local cursor.
|
|
600
|
+
*
|
|
601
|
+
* Leading-edge synchronous flushes (first call on an idle topic)
|
|
602
|
+
* are not counted in drift stats - they fire on the call thread,
|
|
603
|
+
* not via the scheduler.
|
|
604
|
+
*/
|
|
605
|
+
stats() {
|
|
606
|
+
return {
|
|
607
|
+
flushes: flushCount,
|
|
608
|
+
driftMeanMs: driftCount > 0 ? driftSum / driftCount : 0,
|
|
609
|
+
driftMaxMs: driftMax,
|
|
610
|
+
dirtyTopicsCurrent: dirtyTopics.size,
|
|
611
|
+
activeTopicsTotal: topics.size
|
|
612
|
+
};
|
|
613
|
+
},
|
|
614
|
+
|
|
472
615
|
hooks: {
|
|
473
616
|
message(ws, { data, platform }) {
|
|
474
617
|
let parsed;
|
package/plugins/groups/server.js
CHANGED
|
@@ -168,7 +168,15 @@ export function createGroup(name, options = {}) {
|
|
|
168
168
|
// Publish join BEFORE subscribing so joiner doesn't see own join
|
|
169
169
|
platform.publish(internalTopic, 'join', { role, count: members.size });
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
// Callers reach `join` after their own async auth chain; the
|
|
172
|
+
// socket may have closed in the meantime. Roll back the
|
|
173
|
+
// member entry instead of letting uWS's "Invalid access"
|
|
174
|
+
// crash the worker.
|
|
175
|
+
try { ws.subscribe(internalTopic); }
|
|
176
|
+
catch {
|
|
177
|
+
members.delete(ws);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
172
180
|
|
|
173
181
|
// Send current member list to the joiner
|
|
174
182
|
platform.send(ws, internalTopic, 'members', membersList());
|
|
@@ -6,10 +6,19 @@ import type { Readable } from 'svelte/store';
|
|
|
6
6
|
* Returns a readable Svelte store containing an array of user data objects.
|
|
7
7
|
* The array updates automatically when users join or leave.
|
|
8
8
|
*
|
|
9
|
+
* Defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed
|
|
10
|
+
* by a heartbeat or presence_diff/state inside the window are removed
|
|
11
|
+
* from the local map. The server emits `{userKey: data}` heartbeats
|
|
12
|
+
* every 30 s by default, so still-present users re-appear on the next
|
|
13
|
+
* heartbeat (no flicker). Pass `maxAge: 0` to opt out of the sweep for
|
|
14
|
+
* admin / audit views that want unbounded retention.
|
|
15
|
+
*
|
|
9
16
|
* You must also subscribe to the topic itself (via `on()`, `crud()`, etc.)
|
|
10
17
|
* for the server's `subscribe` hook to fire and register your presence.
|
|
11
18
|
*
|
|
12
19
|
* @param topic - Topic to track presence on
|
|
20
|
+
* @param options - `maxAge` defaults to 90000 (ms). Pass `0` to disable
|
|
21
|
+
* the sweep.
|
|
13
22
|
*
|
|
14
23
|
* @example
|
|
15
24
|
* ```svelte
|
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
* a live list of who's connected. The server handles join/leave tracking;
|
|
6
6
|
* this module just keeps the client-side state in sync.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* or
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed
|
|
9
|
+
* by a heartbeat or presence_diff/state inside the window are removed
|
|
10
|
+
* from the local map. The in-memory server (and the Redis-backed variant
|
|
11
|
+
* in svelte-adapter-uws-extensions) emits `{userKey: data}` heartbeats
|
|
12
|
+
* every 30 s by default, so a still-present user re-appears on the very
|
|
13
|
+
* next heartbeat - no flicker for live users, and ghost entries from
|
|
14
|
+
* silent server-side TTL expiry (cluster mass-disconnect, ungraceful
|
|
15
|
+
* client close) clear within one sweep window.
|
|
16
|
+
*
|
|
17
|
+
* Apps that want unbounded retention ("show every user who ever touched
|
|
18
|
+
* this topic" - admin / audit views) opt out with `maxAge: 0`.
|
|
12
19
|
*
|
|
13
20
|
* @module svelte-adapter-uws/plugins/presence/client
|
|
14
21
|
*/
|
|
@@ -62,14 +69,19 @@ const presenceStores = new Map();
|
|
|
62
69
|
* @example
|
|
63
70
|
* ```svelte
|
|
64
71
|
* <script>
|
|
65
|
-
* //
|
|
66
|
-
* const users = presence('room', { maxAge:
|
|
72
|
+
* // Opt out of the default 90 s sweep for an admin / audit view.
|
|
73
|
+
* const users = presence('room', { maxAge: 0 });
|
|
67
74
|
* </script>
|
|
68
75
|
* ```
|
|
69
76
|
*/
|
|
70
77
|
export function presence(topic, options) {
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
// Default 90 s sweep matches the extensions Redis presence's default
|
|
79
|
+
// `ttl: 90` (server-side per-field TTL) and gives the in-memory
|
|
80
|
+
// server's 30 s default heartbeat a 3x safety margin. Apps that want
|
|
81
|
+
// "show every user who ever touched this topic" (admin/audit views)
|
|
82
|
+
// opt out with `maxAge: 0`.
|
|
83
|
+
const maxAge = options?.maxAge ?? 90000;
|
|
84
|
+
const cacheKey = topic + '\0' + maxAge;
|
|
73
85
|
|
|
74
86
|
const cached = presenceStores.get(cacheKey);
|
|
75
87
|
if (cached) return cached;
|
|
@@ -47,19 +47,31 @@ export interface PresenceOptions<UserData = unknown, Selected extends Record<str
|
|
|
47
47
|
/**
|
|
48
48
|
* Interval in milliseconds between heartbeat broadcasts.
|
|
49
49
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* The server periodically publishes a `heartbeat` event to all presence
|
|
51
|
+
* topics carrying a `{userKey: data}` map of every active user. This
|
|
52
|
+
* refreshes each entry's `maxAge` timer on the client AND re-adds any
|
|
53
|
+
* entry the client swept while the user was still present, so live
|
|
54
|
+
* users do not flicker out when a `presence_diff` is missed (transient
|
|
55
|
+
* network blip, JS thread saturation).
|
|
53
56
|
*
|
|
54
|
-
* Set this to a value shorter than the client's `maxAge`.
|
|
57
|
+
* Set this to a value shorter than the client's `maxAge`. The 30 s
|
|
58
|
+
* default fits the 90 s default client `maxAge` with a 3x safety
|
|
59
|
+
* margin. Pass `0` to disable heartbeats entirely (apps that do not
|
|
60
|
+
* use the `maxAge` self-healing path).
|
|
55
61
|
*
|
|
56
|
-
* @default
|
|
62
|
+
* @default 30000
|
|
57
63
|
*
|
|
58
64
|
* @example
|
|
59
65
|
* ```js
|
|
60
|
-
* //
|
|
66
|
+
* // Slower heartbeat (less wire traffic, larger window for ghost entries)
|
|
61
67
|
* const presence = createPresence({ heartbeat: 60_000 });
|
|
62
68
|
* ```
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```js
|
|
72
|
+
* // Disable heartbeats; client must rely on presence_diff alone
|
|
73
|
+
* const presence = createPresence({ heartbeat: 0 });
|
|
74
|
+
* ```
|
|
63
75
|
*/
|
|
64
76
|
heartbeat?: number;
|
|
65
77
|
|
|
@@ -52,11 +52,14 @@ const TOPIC_PREFIX = '__presence:';
|
|
|
52
52
|
*
|
|
53
53
|
* Should return JSON-serializable data (plain objects, arrays, strings, numbers,
|
|
54
54
|
* booleans, null) since the result is sent over WebSocket.
|
|
55
|
-
* @property {number} [heartbeat=
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
55
|
+
* @property {number} [heartbeat=30000] - Interval in milliseconds between heartbeat broadcasts.
|
|
56
|
+
* The server periodically publishes a `heartbeat` event to all presence topics carrying a
|
|
57
|
+
* `{userKey: data}` map of every active user. This refreshes each entry's `maxAge` timer on
|
|
58
|
+
* the client AND re-adds any entry the client swept while the user was still present, so
|
|
59
|
+
* live users do not flicker out when a `presence_diff` is missed (e.g. transient network
|
|
60
|
+
* blip, JS thread saturation). Set this to a value shorter than the client's `maxAge`
|
|
61
|
+
* (default client `maxAge` is 90 s, so 30 s gives a 3x safety margin). Pass `0` to disable
|
|
62
|
+
* heartbeats entirely (apps that do not use the `maxAge` self-healing path).
|
|
60
63
|
*/
|
|
61
64
|
|
|
62
65
|
/**
|
|
@@ -252,7 +255,15 @@ function defaultPresenceSelect(obj, ancestors) {
|
|
|
252
255
|
export function createPresence(options = {}) {
|
|
253
256
|
const keyField = options.key || 'id';
|
|
254
257
|
const select = options.select || defaultPresenceSelect;
|
|
255
|
-
|
|
258
|
+
// Default 30 s heartbeat keeps the client's `maxAge` sweep self-healing:
|
|
259
|
+
// a still-present user re-appears on the next heartbeat after their
|
|
260
|
+
// entry ages out of the local map. Apps that want zero heartbeat
|
|
261
|
+
// traffic (no `maxAge` consumers, or out-of-band liveness) pass
|
|
262
|
+
// `heartbeat: 0` explicitly to opt out.
|
|
263
|
+
const heartbeatMs = options.heartbeat ?? 30000;
|
|
264
|
+
if (typeof heartbeatMs !== 'number' || !Number.isFinite(heartbeatMs) || heartbeatMs < 0) {
|
|
265
|
+
throw new Error('presence: heartbeat must be a non-negative number');
|
|
266
|
+
}
|
|
256
267
|
const maxConnections = options.maxConnections ?? 1_000_000;
|
|
257
268
|
const maxTopics = options.maxTopics ?? 1_000_000;
|
|
258
269
|
|
|
@@ -373,11 +384,16 @@ export function createPresence(options = {}) {
|
|
|
373
384
|
if (heartbeatMs > 0) {
|
|
374
385
|
heartbeatTimer = setInterval(() => {
|
|
375
386
|
for (const [topic, users] of topicPresence) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
387
|
+
// Publish a `{userKey: data}` map (rather than a keys-only
|
|
388
|
+
// array) so a client whose entry aged out of its local
|
|
389
|
+
// `maxAge` sweep between heartbeats can re-add it from the
|
|
390
|
+
// heartbeat alone, without waiting for a presence_diff /
|
|
391
|
+
// presence_state to reconcile. Matches the Redis-backed
|
|
392
|
+
// variant in svelte-adapter-uws-extensions.
|
|
393
|
+
/** @type {Record<string, any>} */
|
|
394
|
+
const dataMap = {};
|
|
395
|
+
for (const [userKey, entry] of users) dataMap[userKey] = entry.data;
|
|
396
|
+
_platform.publish(TOPIC_PREFIX + topic, 'heartbeat', dataMap);
|
|
381
397
|
}
|
|
382
398
|
}, heartbeatMs);
|
|
383
399
|
}
|
|
@@ -427,7 +443,13 @@ export function createPresence(options = {}) {
|
|
|
427
443
|
let connTopics = wsTopics.get(ws);
|
|
428
444
|
if (connTopics && connTopics.has(topic)) return;
|
|
429
445
|
|
|
430
|
-
|
|
446
|
+
// Callers typically reach here after an `await` in their own
|
|
447
|
+
// join flow (auth, loader, RPC handshake). If the socket
|
|
448
|
+
// closed mid-await `getUserData()` throws; presence is a
|
|
449
|
+
// best-effort layer, so silently no-op rather than crash.
|
|
450
|
+
let userData;
|
|
451
|
+
try { userData = ws.getUserData(); } catch { return; }
|
|
452
|
+
const data = select(userData);
|
|
431
453
|
if (!data || typeof data !== 'object') {
|
|
432
454
|
throw new TypeError(
|
|
433
455
|
`presence select() must return a plain object, got ${data === null ? 'null' : typeof data}`
|
|
@@ -476,8 +498,10 @@ export function createPresence(options = {}) {
|
|
|
476
498
|
bufferDiff(topic, 'join', key, data, platform);
|
|
477
499
|
}
|
|
478
500
|
|
|
479
|
-
// Subscribe this ws to the presence channel (server-side, idempotent)
|
|
480
|
-
ws
|
|
501
|
+
// Subscribe this ws to the presence channel (server-side, idempotent).
|
|
502
|
+
// `platform.send` is closed-ws-safe on the adapter side; the
|
|
503
|
+
// direct `ws.subscribe` is not - guard locally.
|
|
504
|
+
try { ws.subscribe(presenceTopic); } catch { return; }
|
|
481
505
|
|
|
482
506
|
// Send the full current snapshot to this connection. The joining
|
|
483
507
|
// user sees the complete state (including themselves) immediately;
|
|
@@ -502,7 +526,7 @@ export function createPresence(options = {}) {
|
|
|
502
526
|
capturePlatform(platform);
|
|
503
527
|
const users = topicPresence.get(topic);
|
|
504
528
|
const presenceTopic = TOPIC_PREFIX + topic;
|
|
505
|
-
ws.subscribe(presenceTopic);
|
|
529
|
+
try { ws.subscribe(presenceTopic); } catch { return; }
|
|
506
530
|
platform.send(ws, presenceTopic, 'presence_state', snapshotState(users));
|
|
507
531
|
},
|
|
508
532
|
|
package/testing.js
CHANGED
|
@@ -150,16 +150,25 @@ export async function createTestServer(options = {}) {
|
|
|
150
150
|
|
|
151
151
|
const closeHookRegisteredT = !!handler.close;
|
|
152
152
|
let sendToAsyncWarnedT = false;
|
|
153
|
+
// Mirrors prod's `closedWsAborts`. createTestServer uses real uWS,
|
|
154
|
+
// so a closed-WS race (subscribe gate awaits something, client
|
|
155
|
+
// closes during the await, post-await ws.subscribe throws) is
|
|
156
|
+
// exercisable here exactly like in production. Hardening below
|
|
157
|
+
// catches the uWS exception, bumps this counter, and returns the
|
|
158
|
+
// platform's success-shaped no-op sentinel.
|
|
159
|
+
let closedWsAbortsT = 0;
|
|
153
160
|
function bumpInT(ws, message) {
|
|
154
161
|
if (!closeHookRegisteredT) return;
|
|
155
|
-
|
|
162
|
+
let stats;
|
|
163
|
+
try { stats = ws.getUserData()[WS_STATS]; } catch { return; }
|
|
156
164
|
if (!stats) return;
|
|
157
165
|
stats.messagesIn++;
|
|
158
166
|
stats.bytesIn += typeof message === 'string' ? message.length : message.byteLength;
|
|
159
167
|
}
|
|
160
168
|
function bumpOutT(ws, payload) {
|
|
161
169
|
if (!closeHookRegisteredT) return;
|
|
162
|
-
|
|
170
|
+
let stats;
|
|
171
|
+
try { stats = ws.getUserData()[WS_STATS]; } catch { return; }
|
|
163
172
|
if (!stats) return;
|
|
164
173
|
stats.messagesOut++;
|
|
165
174
|
stats.bytesOut += payload.length;
|
|
@@ -186,12 +195,15 @@ export async function createTestServer(options = {}) {
|
|
|
186
195
|
const delay = chaos.getDelayMs();
|
|
187
196
|
if (delay > 0) {
|
|
188
197
|
setTimeout(() => {
|
|
189
|
-
try { ws.send(payload, false, false); }
|
|
198
|
+
try { ws.send(payload, false, false); }
|
|
199
|
+
catch { closedWsAbortsT++; return; }
|
|
190
200
|
bumpOutT(ws, payload);
|
|
191
201
|
}, delay);
|
|
192
202
|
return 1;
|
|
193
203
|
}
|
|
194
|
-
|
|
204
|
+
let result;
|
|
205
|
+
try { result = ws.send(payload, false, false); }
|
|
206
|
+
catch { closedWsAbortsT++; return 2; }
|
|
195
207
|
bumpOutT(ws, payload);
|
|
196
208
|
return result;
|
|
197
209
|
}
|
|
@@ -225,7 +237,10 @@ export async function createTestServer(options = {}) {
|
|
|
225
237
|
const msg = envelope(topic, event, data);
|
|
226
238
|
let count = 0;
|
|
227
239
|
for (const ws of wsConnections) {
|
|
228
|
-
|
|
240
|
+
let userData;
|
|
241
|
+
try { userData = ws.getUserData(); }
|
|
242
|
+
catch { closedWsAbortsT++; continue; }
|
|
243
|
+
const decision = filter(userData);
|
|
229
244
|
if (decision && typeof decision.then === 'function') {
|
|
230
245
|
if (!sendToAsyncWarnedT) {
|
|
231
246
|
sendToAsyncWarnedT = true;
|
|
@@ -247,6 +262,7 @@ export async function createTestServer(options = {}) {
|
|
|
247
262
|
},
|
|
248
263
|
get connections() { return wsConnections.size; },
|
|
249
264
|
get assertions() { return readAssertionCounts(); },
|
|
265
|
+
get closedWsAborts() { return closedWsAbortsT; },
|
|
250
266
|
subscribers(topic) { return app.numSubscribers(topic); },
|
|
251
267
|
// Mirror production: report a numeric cap and a constant-time
|
|
252
268
|
// bufferedAmount so test code can exercise the same backpressure-
|
|
@@ -264,7 +280,9 @@ export async function createTestServer(options = {}) {
|
|
|
264
280
|
// user hook so async hooks gate correctly.
|
|
265
281
|
// Server-side caller: trust non-ASCII topics (matches platform.subscribe in production).
|
|
266
282
|
if (!isValidWireTopic(topic, true)) return 'INVALID_TOPIC';
|
|
267
|
-
|
|
283
|
+
let subs;
|
|
284
|
+
try { subs = ws.getUserData()[WS_SUBSCRIPTIONS]; }
|
|
285
|
+
catch { closedWsAbortsT++; return null; }
|
|
268
286
|
if (!(subs instanceof Set)) return 'INVALID_TOPIC';
|
|
269
287
|
if (subs.has(topic)) return null;
|
|
270
288
|
if (subs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) return 'RATE_LIMITED';
|
|
@@ -272,7 +290,8 @@ export async function createTestServer(options = {}) {
|
|
|
272
290
|
if (denial !== null) return denial;
|
|
273
291
|
if (subs.has(topic)) return null;
|
|
274
292
|
if (subs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) return 'RATE_LIMITED';
|
|
275
|
-
ws.subscribe(topic);
|
|
293
|
+
try { ws.subscribe(topic); }
|
|
294
|
+
catch { closedWsAbortsT++; return null; }
|
|
276
295
|
subs.add(topic);
|
|
277
296
|
return null;
|
|
278
297
|
},
|
|
@@ -282,9 +301,12 @@ export async function createTestServer(options = {}) {
|
|
|
282
301
|
return await runUserSubscribeGateT(ws, topic);
|
|
283
302
|
},
|
|
284
303
|
unsubscribe(ws, topic) {
|
|
285
|
-
|
|
304
|
+
let subs;
|
|
305
|
+
try { subs = ws.getUserData()[WS_SUBSCRIPTIONS]; }
|
|
306
|
+
catch { closedWsAbortsT++; return false; }
|
|
286
307
|
if (!(subs instanceof Set) || !subs.has(topic)) return false;
|
|
287
|
-
ws.unsubscribe(topic);
|
|
308
|
+
try { ws.unsubscribe(topic); }
|
|
309
|
+
catch { closedWsAbortsT++; return false; }
|
|
288
310
|
subs.delete(topic);
|
|
289
311
|
handler.unsubscribe?.(ws, topic, { platform: ws.getUserData()[WS_PLATFORM] });
|
|
290
312
|
return true;
|
|
@@ -370,7 +392,12 @@ export async function createTestServer(options = {}) {
|
|
|
370
392
|
app.publish(fanoutTopic, sharedBatchEnv, false, false);
|
|
371
393
|
},
|
|
372
394
|
request(ws, event, data, options) {
|
|
373
|
-
|
|
395
|
+
let userData;
|
|
396
|
+
try { userData = ws.getUserData(); }
|
|
397
|
+
catch {
|
|
398
|
+
closedWsAbortsT++;
|
|
399
|
+
return Promise.reject(new Error('connection closed'));
|
|
400
|
+
}
|
|
374
401
|
let pending = userData[WS_PENDING_REQUESTS];
|
|
375
402
|
if (!pending) {
|
|
376
403
|
pending = new Map();
|
|
@@ -390,7 +417,21 @@ export async function createTestServer(options = {}) {
|
|
|
390
417
|
}, timeoutMs);
|
|
391
418
|
pending.set(ref, { resolve, reject, timer });
|
|
392
419
|
const payload = JSON.stringify({ type: 'request', ref, event, data: data ?? null });
|
|
393
|
-
|
|
420
|
+
// Direct ws.send so we can distinguish "closed WS"
|
|
421
|
+
// (throws -> reject now) from "backpressure DROPPED"
|
|
422
|
+
// (returns 2 -> let it time out, matches production
|
|
423
|
+
// semantics where uWS will not retry on its own).
|
|
424
|
+
// sendOutboundT exists for chaos-injection; the request
|
|
425
|
+
// flow takes the bare path and re-uses bumpOutT.
|
|
426
|
+
try { ws.send(payload, false, false); }
|
|
427
|
+
catch {
|
|
428
|
+
closedWsAbortsT++;
|
|
429
|
+
clearTimeout(timer);
|
|
430
|
+
pending.delete(ref);
|
|
431
|
+
reject(new Error('connection closed'));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
bumpOutT(ws, payload);
|
|
394
435
|
});
|
|
395
436
|
},
|
|
396
437
|
topic(name) {
|
|
@@ -623,7 +664,8 @@ export async function createTestServer(options = {}) {
|
|
|
623
664
|
sendDeniedT(ws, msg.topic, ref, 'RATE_LIMITED');
|
|
624
665
|
return;
|
|
625
666
|
}
|
|
626
|
-
ws.subscribe(msg.topic);
|
|
667
|
+
try { ws.subscribe(msg.topic); }
|
|
668
|
+
catch { closedWsAbortsT++; return; }
|
|
627
669
|
subs.add(msg.topic);
|
|
628
670
|
sendSubscribedT(ws, msg.topic, ref);
|
|
629
671
|
return;
|
|
@@ -680,7 +722,8 @@ export async function createTestServer(options = {}) {
|
|
|
680
722
|
sendDeniedT(ws, topic, ref, 'RATE_LIMITED');
|
|
681
723
|
continue;
|
|
682
724
|
}
|
|
683
|
-
ws.subscribe(topic);
|
|
725
|
+
try { ws.subscribe(topic); }
|
|
726
|
+
catch { closedWsAbortsT++; continue; }
|
|
684
727
|
udSubs.add(topic);
|
|
685
728
|
sendSubscribedT(ws, topic, ref);
|
|
686
729
|
}
|
package/vite.js
CHANGED
|
@@ -398,6 +398,13 @@ export default function uws(options = {}) {
|
|
|
398
398
|
// see the documented "no violations" state.
|
|
399
399
|
return new Map();
|
|
400
400
|
},
|
|
401
|
+
get closedWsAborts() {
|
|
402
|
+
// Dev uses Node's `ws` library, not uWS - sockets do not
|
|
403
|
+
// throw "Invalid access" when written to after close, so
|
|
404
|
+
// the closed-WS abort path doesn't exist here. Mirror the
|
|
405
|
+
// prod surface as a constant zero.
|
|
406
|
+
return 0;
|
|
407
|
+
},
|
|
401
408
|
subscribers(topic) {
|
|
402
409
|
let count = 0;
|
|
403
410
|
for (const [, topics] of subscriptions) {
|