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 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: 60_000 // optional: needed if clients use maxAge
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: full userData)
2454
- heartbeat: 60_000 // broadcast active keys every 60s (default: disabled)
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 two frame types on the `__presence:{topic}` channel:
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
- `heartbeat` events (when configured) are unchanged: they carry an array of currently-active keys.
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 `presence()` function accepts an optional second argument with a `maxAge` option (in milliseconds). When set, entries that haven't been refreshed within that window are automatically removed from the store. This makes clients self-healing when the server fails to broadcast a leaving entry in a `presence_diff` frame under load.
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
- **Important:** `maxAge` requires the server-side `heartbeat` option. Without heartbeat, no events arrive between the initial `presence_state` snapshot and eventual leave, so maxAge would expire every user - including ones who are still connected. The heartbeat periodically tells clients which keys are still active, resetting their maxAge timers.
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 120s without a heartbeat refresh
2496
- const users = presence('room', { maxAge: 120_000 });
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
- const stats = ws.getUserData()[WS_STATS];
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
- const stats = ws.getUserData()[WS_STATS];
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
- const userData = ws.getUserData();
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
- const result = ws.send(payload, false, false);
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
- const result = ws.send(payload, false, false);
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
- const userData = ws.getUserData();
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
- const decision = filter(ws.getUserData());
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
- const subs = ws.getUserData()[WS_SUBSCRIPTIONS];
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(topic);
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
- const subs = ws.getUserData()[WS_SUBSCRIPTIONS];
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
- const userData = ws.getUserData();
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); } catch { return; }
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); } catch { continue; }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -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 throttle state for `topicThrottle` coalescing.
197
- * Dirty entries are keyed by connection key; latest-wins. When the
198
- * coalesce window elapses, `dirty.size === 1` sends a single `update`
199
- * and any other count sends one `bulk` array.
200
- * @type {Map<string, { lastFlush: number, timer: any, dirty: Map<string, { data: any, platform: any }> }>}
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(typeof ws.getUserData === 'function' ? ws.getUserData() : {}),
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 (clears any pending timer first).
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 = { lastFlush: 0, timer: null, dirty: new Map() };
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
- flushDirty(topic, state.dirty);
307
- state.dirty.clear();
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
- if (!state.timer) {
312
- state.timer = setTimeout(() => {
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
- for (const [, state] of topicFlush) {
464
- if (state.timer) clearTimeout(state.timer);
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;
@@ -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
- ws.subscribe(internalTopic);
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
- * When `maxAge` is set, entries that haven't been refreshed (via `list`
9
- * or `join` events) within that window are automatically removed. This
10
- * makes clients self-healing when the server fails to broadcast a `leave`
11
- * event (e.g. mass disconnects overwhelming Redis cleanup).
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
- * // Self-healing: entries expire after 90s without a refresh
66
- * const users = presence('room', { maxAge: 90_000 });
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
- const maxAge = options?.maxAge;
72
- const cacheKey = maxAge > 0 ? topic + '\0' + maxAge : topic;
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
- * When set, the server periodically publishes a `heartbeat` event to all
51
- * presence topics containing the list of active user keys. This resets
52
- * the `maxAge` timer on clients, preventing live users from being expired.
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 0 (disabled)
62
+ * @default 30000
57
63
  *
58
64
  * @example
59
65
  * ```js
60
- * // Server heartbeat every 60s, client maxAge 120s
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=0] - Interval in milliseconds between heartbeat broadcasts.
56
- * When set, the server periodically publishes a `heartbeat` event to all presence topics
57
- * containing the list of active keys. This resets the `maxAge` timer on clients, preventing
58
- * live users from being expired. Set this to a value shorter than the client's `maxAge`.
59
- * Disabled by default (0 or omitted).
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
- const heartbeatMs = options.heartbeat || 0;
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
- _platform.publish(
377
- TOPIC_PREFIX + topic,
378
- 'heartbeat',
379
- [...users.keys()]
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
- const data = select(ws.getUserData());
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.subscribe(presenceTopic);
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
- const stats = ws.getUserData()[WS_STATS];
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
- const stats = ws.getUserData()[WS_STATS];
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); } catch {}
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
- const result = ws.send(payload, false, false);
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
- const decision = filter(ws.getUserData());
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
- const subs = ws.getUserData()[WS_SUBSCRIPTIONS];
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
- const subs = ws.getUserData()[WS_SUBSCRIPTIONS];
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
- const userData = ws.getUserData();
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
- sendOutboundT(ws, payload);
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) {