svelte-adapter-uws 0.5.4 → 0.5.6

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.
@@ -2882,22 +2898,23 @@ const positions = cursor('canvas', { maxAge: 30_000 });
2882
2898
 
2883
2899
  #### How throttle works
2884
2900
 
2885
- The cursor plugin uses two layers of leading-edge + trailing-edge throttle:
2901
+ The cursor plugin uses two layers of throttle:
2886
2902
 
2887
- 1. **`throttle`** caps how often a single user broadcasts on a single topic.
2888
- 2. **`topicThrottle`** caps how often a topic emits a frame at all. Multiple movers in the same window coalesce into one `bulk` array; a single mover in the window emits one `update`.
2903
+ 1. **`throttle`** caps how often a single user broadcasts on a single topic. Leading edge fires the first move immediately; subsequent moves within the window are stored and a trailing timer flushes the latest position at the window boundary.
2904
+ 2. **`topicThrottle`** caps how often a topic emits a frame at all. Every move appends to the topic's dirty set and shares a single tracker-wide timer that fires once per cadence cycle. Multiple movers in the same window coalesce into one `bulk` array; a single mover in the window emits one `update`. There is no synchronous leading-edge fire: every flush goes through the tick, so movers arriving from different sockets (each a separate JS task in production) batch into the same frame regardless of how many task boundaries separate them.
2889
2905
 
2890
2906
  ```
2891
2907
  throttle: 16, topicThrottle: 16
2892
2908
 
2893
- t=0 A.update({x:0}) --> 'join' A, 'update' {x:0} (leading edge of both)
2909
+ t=0 A.update({x:0}) --> 'join' A (catalog channel)
2910
+ position queued in topic dirty set
2894
2911
  t=4 B.update({x:0}) --> 'join' B (catalog channel)
2895
2912
  position queued in topic dirty set
2896
2913
  t=8 A.update({x:5}) --> queued (entry-level throttle says wait until t=16)
2897
- t=16 [trailing timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
2914
+ t=16 [tick timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
2898
2915
  ```
2899
2916
 
2900
- The trailing edges ensure you always see where each cursor stopped, even when the user stops moving mid-window.
2917
+ Latency cost vs. the alternate "fire-the-first-mover-synchronously" design: the first mover on an idle topic waits up to `topicThrottleMs` before its frame leaves. At the default 16 ms (~60 Hz) that's one frame-budget; well below the perceptual floor for cursor. The cost buys cross-socket coalescing - without it, the first mover from each socket fragments out as its own single-cursor `update` because uWS dispatches each WS message as its own JS task and microtasks drain between dispatches.
2901
2918
 
2902
2919
  #### Limitations
2903
2920
 
@@ -3333,6 +3350,8 @@ Per-worker limitations (acceptable for most apps):
3333
3350
  - `platform.connections` - returns the count for the local worker only
3334
3351
  - `platform.subscribers(topic)` - returns the count for the local worker only
3335
3352
  - `platform.sendTo(filter, ...)` - iterates the local worker's connections only, no cross-worker relay
3353
+ - `platform.closedWsAborts` - per-worker counter; sum across workers for cluster total
3354
+ - `platform.assertions` - per-worker counter Map
3336
3355
 
3337
3356
  ### Docker / multi-process deployments (Linux)
3338
3357
 
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.4",
3
+ "version": "0.5.6",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -254,9 +254,17 @@ export function createCursor(options = {}) {
254
254
  const oldest = wsState.keys().next().value;
255
255
  if (oldest !== undefined) wsState.delete(oldest);
256
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
+ }
257
265
  state = {
258
266
  key: String(++connCounter),
259
- user: select(typeof ws.getUserData === 'function' ? ws.getUserData() : {}),
267
+ user: select(userData),
260
268
  topics: new Set()
261
269
  };
262
270
  wsState.set(ws, state);
@@ -375,11 +383,30 @@ export function createCursor(options = {}) {
375
383
 
376
384
  /**
377
385
  * Route a broadcast through the per-topic coalesce window when
378
- * `topicThrottle` is enabled, or directly publish when disabled.
386
+ * `topicThrottle` is enabled, or publish immediately when disabled.
387
+ *
388
+ * Every broadcast appends to `dirty` and arms (or shares) the
389
+ * tracker-wide tick timer. When the cadence window has already
390
+ * elapsed since the last flush, the tick is armed at delay 0 so it
391
+ * fires on the next event-loop iteration; otherwise it is armed at
392
+ * the remaining window time. Either way, the actual fanout happens
393
+ * inside `tick()`, never synchronously.
394
+ *
395
+ * Why no synchronous leading-edge fire: uWS dispatches each WS
396
+ * message as its own JS task. Microtasks drain at the C++ <-> JS
397
+ * boundary between tasks, so a `queueMicrotask`-deferred flush
398
+ * (previous design) runs BEFORE the next socket's message handler -
399
+ * cross-socket coalescing window is zero, and every "first message
400
+ * of a new cadence slot" from any socket fires alone as a single-
401
+ * cursor UPDATE. Going through the tick timer instead schedules a
402
+ * macrotask, which is dequeued only after the poll phase processes
403
+ * every ready message on every socket. All messages dispatched in
404
+ * the same loop iteration end up in one flush.
379
405
  *
380
- * Leading-edge synchronous flush preserves the contract that the first
381
- * call on an idle topic publishes immediately (no setTimeout(0) detour).
382
- * Trailing-edge fires via the single tracker-wide `tickTimer`.
406
+ * Latency cost: the first cursor on an idle topic waits up to
407
+ * `topicThrottleMs` (one cycle) before its frame leaves. At the
408
+ * default 16 ms / 60 Hz this is one frame-budget; at 8 ms / 125 Hz
409
+ * it is half a frame. Below the perceptual floor for cursor.
383
410
  */
384
411
  function broadcast(topic, key, data, platform) {
385
412
  if (topicThrottleMs <= 0) {
@@ -389,22 +416,19 @@ export function createCursor(options = {}) {
389
416
 
390
417
  let state = topicFlush.get(topic);
391
418
  if (!state) {
392
- state = { dirty: new Map(), lastFlush: 0 };
419
+ // Anchor lastFlush one full cycle in the past so the first
420
+ // broadcast on a fresh topic is treated as "cycle ready" and
421
+ // schedules the tick at delay 0 with zero drift, rather than
422
+ // inflating drift stats by Date.now() worth of "lateness".
423
+ state = { dirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
393
424
  topicFlush.set(topic, state);
394
425
  }
395
426
  state.dirty.set(key, { data, platform });
396
-
397
- const now = Date.now();
398
- if (now - state.lastFlush >= topicThrottleMs) {
399
- state.lastFlush = now;
400
- flushDirty(topic, state.dirty);
401
- state.dirty.clear();
402
- dirtyTopics.delete(topic);
403
- return;
404
- }
405
-
406
427
  dirtyTopics.add(topic);
407
- armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
428
+
429
+ const elapsed = Date.now() - state.lastFlush;
430
+ const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
431
+ armTick(delay);
408
432
  }
409
433
 
410
434
  /** @type {CursorTracker} */
@@ -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());
@@ -443,7 +443,13 @@ export function createPresence(options = {}) {
443
443
  let connTopics = wsTopics.get(ws);
444
444
  if (connTopics && connTopics.has(topic)) return;
445
445
 
446
- 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);
447
453
  if (!data || typeof data !== 'object') {
448
454
  throw new TypeError(
449
455
  `presence select() must return a plain object, got ${data === null ? 'null' : typeof data}`
@@ -492,8 +498,10 @@ export function createPresence(options = {}) {
492
498
  bufferDiff(topic, 'join', key, data, platform);
493
499
  }
494
500
 
495
- // Subscribe this ws to the presence channel (server-side, idempotent)
496
- 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; }
497
505
 
498
506
  // Send the full current snapshot to this connection. The joining
499
507
  // user sees the complete state (including themselves) immediately;
@@ -518,7 +526,7 @@ export function createPresence(options = {}) {
518
526
  capturePlatform(platform);
519
527
  const users = topicPresence.get(topic);
520
528
  const presenceTopic = TOPIC_PREFIX + topic;
521
- ws.subscribe(presenceTopic);
529
+ try { ws.subscribe(presenceTopic); } catch { return; }
522
530
  platform.send(ws, presenceTopic, 'presence_state', snapshotState(users));
523
531
  },
524
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) {