svelte-adapter-uws-extensions 0.5.8 → 0.5.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -154,7 +154,7 @@
154
154
  "node": ">=22.0.0"
155
155
  },
156
156
  "peerDependencies": {
157
- "svelte-adapter-uws": "^0.5.7"
157
+ "svelte-adapter-uws": "^0.5.8"
158
158
  },
159
159
  "dependencies": {
160
160
  "ioredis": "^5.0.0"
package/redis/cursor.js CHANGED
@@ -233,6 +233,13 @@ export function createCursor(client, options = {}) {
233
233
  enqueueInbound(parsed.topic, entry.key, entry.data, activePlatform);
234
234
  }
235
235
  }
236
+ } else if (parsed.event === EVENTS.REMOVE && parsed.payload && typeof parsed.payload.key === 'string') {
237
+ // Peer-relayed cursor removes are coalesced through the
238
+ // same tick buffer the local close path uses. Without
239
+ // this, a mass-disconnect on one instance produces an
240
+ // equally-sized O(N) immediate-publish storm on every
241
+ // other instance, propagating the OOM risk cluster-wide.
242
+ queueRemove(parsed.topic, parsed.payload.key, activePlatform);
236
243
  } else {
237
244
  activePlatform.publish(
238
245
  '__cursor:' + parsed.topic,
@@ -443,6 +450,75 @@ export function createCursor(client, options = {}) {
443
450
  let driftMax = 0;
444
451
  let flushCount = 0;
445
452
 
453
+ // Pending REMOVE keys per topic, coalesced into one wire frame per
454
+ // subscriber per event-loop iteration. Mass-disconnect scenarios (e.g.
455
+ // 5K cursors closing in one tick during a stress test or browser-tab
456
+ // teardown of a packed board) used to fire 5K immediate publishes per
457
+ // topic, each fanning out to every remaining subscriber. The resulting
458
+ // O(N^2) uWS send queue allocations OOM-killed workers ~18s into the
459
+ // cleanup cascade. With this buffer, every subscriber sees one frame
460
+ // per topic per tick containing the full leave list, regardless of
461
+ // how many sockets dropped together. Symmetric to the presence
462
+ // `pendingDiffs` buffer.
463
+ //
464
+ // Why `setTimeout(0)` over `queueMicrotask`: uWS dispatches each WS
465
+ // close as its own JS task, and N-API drains microtasks at the C++/JS
466
+ // boundary between tasks. A microtask-deferred flush fires BEFORE the
467
+ // next socket's close handler runs, so cross-socket coalescing is
468
+ // impossible at the microtask level. `setTimeout(0)` lands in libuv's
469
+ // timers phase, which fires only after the poll phase has dispatched
470
+ // every ready socket event in the current iteration.
471
+ /** @type {Map<string, Set<string>>} */
472
+ const pendingRemoves = new Map();
473
+ /** @type {ReturnType<typeof setTimeout> | null} */
474
+ let removeFlushTimer = null;
475
+ /** @type {import('svelte-adapter-uws').Platform | null} */
476
+ let removeFlushPlatform = null;
477
+
478
+ function queueRemove(topic, key, platform) {
479
+ let set = pendingRemoves.get(topic);
480
+ if (!set) {
481
+ set = new Set();
482
+ pendingRemoves.set(topic, set);
483
+ }
484
+ set.add(key);
485
+ removeFlushPlatform = platform;
486
+ if (removeFlushTimer === null) {
487
+ removeFlushTimer = setTimeout(flushPendingRemoves, 0);
488
+ if (removeFlushTimer.unref) removeFlushTimer.unref();
489
+ }
490
+ }
491
+
492
+ function flushPendingRemoves() {
493
+ removeFlushTimer = null;
494
+ const platform = removeFlushPlatform;
495
+ removeFlushPlatform = null;
496
+ if (!platform) {
497
+ pendingRemoves.clear();
498
+ return;
499
+ }
500
+ for (const [topic, keys] of pendingRemoves) {
501
+ if (keys.size === 0) continue;
502
+ // publishBatched (adapter >= 0.5.0-next.5) bundles N events into
503
+ // one wire frame per subscriber. Each subscriber decodes the
504
+ // individual REMOVE events from the frame, so no client change
505
+ // is required. Fall back to per-event publishes when the
506
+ // adapter does not expose publishBatched.
507
+ if (typeof platform.publishBatched === 'function') {
508
+ const messages = [];
509
+ for (const key of keys) {
510
+ messages.push({ topic: '__cursor:' + topic, event: EVENTS.REMOVE, data: { key } });
511
+ }
512
+ try { platform.publishBatched(messages); } catch { /* platform unavailable mid-flight */ }
513
+ } else {
514
+ for (const key of keys) {
515
+ try { platform.publish('__cursor:' + topic, EVENTS.REMOVE, { key }); } catch { /* swallow */ }
516
+ }
517
+ }
518
+ }
519
+ pendingRemoves.clear();
520
+ }
521
+
446
522
  function relay(topic, event, payload) {
447
523
  if (b) { try { b.guard(); } catch { return; } }
448
524
  const msg = JSON.stringify({ instanceId, topic, event, payload });
@@ -694,7 +770,7 @@ export function createCursor(client, options = {}) {
694
770
  if (topicPending.size === 0) redisPending.delete(topic);
695
771
  }
696
772
 
697
- platform.publish('__cursor:' + topic, EVENTS.REMOVE, { key });
773
+ queueRemove(topic, key, platform);
698
774
  relay(topic, EVENTS.REMOVE, { key });
699
775
  return true;
700
776
  }
@@ -870,7 +946,7 @@ export function createCursor(client, options = {}) {
870
946
  }
871
947
 
872
948
  for (const t of removedTopics) {
873
- platform.publish('__cursor:' + t, EVENTS.REMOVE, { key: state.key });
949
+ queueRemove(t, state.key, platform);
874
950
  const topicMap = topics.get(t);
875
951
  if (topicMap) {
876
952
  topicMap.delete(state.key);
@@ -961,6 +1037,9 @@ export function createCursor(client, options = {}) {
961
1037
  }
962
1038
  // Tracker-level scheduler timer + dirty-topic set.
963
1039
  if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
1040
+ if (removeFlushTimer !== null) { clearTimeout(removeFlushTimer); removeFlushTimer = null; }
1041
+ removeFlushPlatform = null;
1042
+ pendingRemoves.clear();
964
1043
  dirtyTopics.clear();
965
1044
  topics.clear();
966
1045
  topicFlush.clear();
@@ -982,6 +1061,9 @@ export function createCursor(client, options = {}) {
982
1061
  }
983
1062
  }
984
1063
  if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
1064
+ if (removeFlushTimer !== null) { clearTimeout(removeFlushTimer); removeFlushTimer = null; }
1065
+ removeFlushPlatform = null;
1066
+ pendingRemoves.clear();
985
1067
  dirtyTopics.clear();
986
1068
  topicFlush.clear();
987
1069
  if (subscriber) {
@@ -1048,6 +1130,16 @@ export function createCursor(client, options = {}) {
1048
1130
  tracker.snapshot(ws, data.topic, platform);
1049
1131
  return;
1050
1132
  }
1133
+ // Silent no-op when the caller dispatches a parsed object whose
1134
+ // `type` is not ours. The dispatch-to-all pattern (e.g. forwarding
1135
+ // every unhandled frame to cursor.hooks.message AND
1136
+ // presence.hooks.message and letting each ignore frames not
1137
+ // addressed to it) is legitimate. Only warn on shapes that
1138
+ // indicate the wrong wiring (raw bytes, non-object) where the
1139
+ // developer almost certainly intended a parsed envelope.
1140
+ if (data && typeof data === 'object' && !Array.isArray(data) && typeof data.type === 'string') {
1141
+ return;
1142
+ }
1051
1143
  _warnCursorHooksMessageShape(data);
1052
1144
  },
1053
1145
  close(ws, { platform }) {
package/redis/presence.js CHANGED
@@ -8,9 +8,10 @@
8
8
  * Wire shape clients see on `__presence:{topic}`:
9
9
  * - `state` (sent once on subscribe to a single connection)
10
10
  * payload: `{[userKey]: data}` flat snapshot of current presence
11
- * - `diff` (broadcast to topic subscribers, microtask-batched)
11
+ * - `diff` (broadcast to topic subscribers, tick-batched)
12
12
  * payload: `{joins: {[key]: data}, leaves: {[key]: data}}`
13
- * Same-tick joins+leaves on the same key collapse: latest op wins.
13
+ * Joins+leaves on the same key in one event-loop iteration
14
+ * collapse: latest op wins.
14
15
  * - `heartbeat` (broadcast to topic subscribers, per heartbeat interval)
15
16
  * payload: array of currently-known user keys
16
17
  *
@@ -317,14 +318,29 @@ export function createPresence(client, options = {}) {
317
318
 
318
319
  /**
319
320
  * Per-topic pending diff buffer: latest op per key wins. Joins and
320
- * leaves on the same key in the same microtask collapse so the wire
321
- * only sees the net change. Flushed once per microtask via
322
- * `scheduleFlush`. Mirrors the buffer model the adapter's bundled
323
- * presence plugin uses, so a single client decoder handles both.
321
+ * leaves on the same key in one event-loop iteration collapse so the
322
+ * wire only sees the net change. Flushed once per iteration via
323
+ * `setTimeout(flushPendingDiffs, 0)` armed when the first dirty entry
324
+ * lands. Mirrors the buffer model the adapter's bundled presence
325
+ * plugin uses, so a single client decoder handles both.
326
+ *
327
+ * Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS
328
+ * message as its own JS task, and N-API drains microtasks at the C++/JS
329
+ * boundary between tasks. A microtask-deferred flush fires BEFORE the
330
+ * next socket's handler runs, so cross-socket coalescing is impossible
331
+ * at the microtask level - a mass-join into a populated topic produces
332
+ * O(N) one-entry publishes instead of one batched diff. `setTimeout(0)`
333
+ * lands in libuv's timers phase, which fires only after the poll phase
334
+ * has dispatched every ready socket message in the current iteration -
335
+ * so all joins arriving together end up in one flush regardless of how
336
+ * many task boundaries separate them. Same structural choice the
337
+ * 0.5.7 cursor always-tick rewrite locked in.
338
+ *
324
339
  * @type {Map<string, Map<string, { op: 'join' | 'leave', data: Record<string, any> }>>}
325
340
  */
326
341
  const pendingDiffs = new Map();
327
- let diffFlushScheduled = false;
342
+ /** @type {ReturnType<typeof setTimeout> | null} */
343
+ let diffFlushTimer = null;
328
344
  /** @type {import('svelte-adapter-uws').Platform | null} */
329
345
  let diffFlushPlatform = null;
330
346
 
@@ -339,14 +355,17 @@ export function createPresence(client, options = {}) {
339
355
  }
340
356
  entries.set(key, { op, data });
341
357
  diffFlushPlatform = platform;
342
- if (!diffFlushScheduled) {
343
- diffFlushScheduled = true;
344
- queueMicrotask(flushPendingDiffs);
358
+ if (diffFlushTimer === null) {
359
+ diffFlushTimer = setTimeout(flushPendingDiffs, 0);
360
+ if (diffFlushTimer.unref) diffFlushTimer.unref();
345
361
  }
346
362
  }
347
363
 
348
364
  function flushPendingDiffs() {
349
- diffFlushScheduled = false;
365
+ if (diffFlushTimer !== null) {
366
+ clearTimeout(diffFlushTimer);
367
+ diffFlushTimer = null;
368
+ }
350
369
  const platform = diffFlushPlatform;
351
370
  diffFlushPlatform = null;
352
371
  if (!platform) {
@@ -1359,7 +1378,10 @@ export function createPresence(client, options = {}) {
1359
1378
  syncObservers.clear();
1360
1379
  syncCounts.clear();
1361
1380
  pendingDiffs.clear();
1362
- diffFlushScheduled = false;
1381
+ if (diffFlushTimer !== null) {
1382
+ clearTimeout(diffFlushTimer);
1383
+ diffFlushTimer = null;
1384
+ }
1363
1385
  diffFlushPlatform = null;
1364
1386
  connCounter = 0;
1365
1387
  },
@@ -1379,7 +1401,10 @@ export function createPresence(client, options = {}) {
1379
1401
  keyspaceSubscribed = false;
1380
1402
  activePlatform = null;
1381
1403
  pendingDiffs.clear();
1382
- diffFlushScheduled = false;
1404
+ if (diffFlushTimer !== null) {
1405
+ clearTimeout(diffFlushTimer);
1406
+ diffFlushTimer = null;
1407
+ }
1383
1408
  diffFlushPlatform = null;
1384
1409
  },
1385
1410
 
package/redis/pubsub.js CHANGED
@@ -120,13 +120,25 @@ export function createPubSubBus(client, options = {}) {
120
120
  /** @type {(() => void) | null} */
121
121
  let unsubscribeBreaker = null;
122
122
 
123
- // Microtask relay batching: coalesce Redis publishes within a single
124
- // event-loop tick into one pipelined round trip. Each envelope tracks
125
- // its underlying message count so the relayed-messages counter stays
126
- // accurate when batch envelopes carry many messages each.
123
+ // Tick relay batching: coalesce Redis publishes within a single
124
+ // event-loop iteration into one pipelined round trip. Each envelope
125
+ // tracks its underlying message count so the relayed-messages counter
126
+ // stays accurate when batch envelopes carry many messages each.
127
+ //
128
+ // Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS
129
+ // message as its own JS task, and N-API drains microtasks at the C++/JS
130
+ // boundary between tasks. A microtask-deferred flush fires BEFORE the
131
+ // next socket's handler runs, so cross-socket coalescing is impossible
132
+ // at the microtask level - N publishes from N socket handlers in the
133
+ // same iteration produce N Redis round-trips instead of one pipelined
134
+ // call. `setTimeout(0)` lands in libuv's timers phase, which fires only
135
+ // after the poll phase has dispatched every ready socket message in the
136
+ // current iteration. Same structural choice the 0.5.7 cursor always-tick
137
+ // rewrite locked in.
127
138
  /** @type {Array<{msg: string, count: number}>} */
128
139
  let relayBatch = [];
129
- let relayScheduled = false;
140
+ /** @type {ReturnType<typeof setTimeout> | null} */
141
+ let relayTimer = null;
130
142
  let relayBatchWarnFired = false;
131
143
 
132
144
  function scheduleRelay(msg, count) {
@@ -135,23 +147,26 @@ export function createPubSubBus(client, options = {}) {
135
147
  if (relayBatch.length >= MAX_PUBSUB_RELAY_BATCH_PER_TICK && !relayBatchWarnFired) {
136
148
  relayBatchWarnFired = true;
137
149
  console.warn(
138
- '[pubsub] microtask relay batch reached ' + relayBatch.length +
139
- ' entries in one tick. The batch is drained every microtask, so a ' +
150
+ '[pubsub] tick relay batch reached ' + relayBatch.length +
151
+ ' entries in one iteration. The batch is drained every tick, so a ' +
140
152
  'caller emitted a million publishes in one synchronous burst - likely ' +
141
153
  'a publish-in-loop without yielding.\n' +
142
154
  ' See: https://svti.me/pubsub-burst'
143
155
  );
144
156
  }
145
- if (!relayScheduled) {
146
- relayScheduled = true;
147
- queueMicrotask(flushRelay);
157
+ if (relayTimer === null) {
158
+ relayTimer = setTimeout(flushRelay, 0);
159
+ if (relayTimer.unref) relayTimer.unref();
148
160
  }
149
161
  }
150
162
 
151
163
  function flushRelay() {
152
164
  const batch = relayBatch;
153
165
  relayBatch = [];
154
- relayScheduled = false;
166
+ if (relayTimer !== null) {
167
+ clearTimeout(relayTimer);
168
+ relayTimer = null;
169
+ }
155
170
  if (b) {
156
171
  try { b.guard(); } catch { return; }
157
172
  }
@@ -380,6 +395,11 @@ export function createPubSubBus(client, options = {}) {
380
395
  unsubscribeBreaker();
381
396
  unsubscribeBreaker = null;
382
397
  }
398
+ if (relayTimer !== null) {
399
+ clearTimeout(relayTimer);
400
+ relayTimer = null;
401
+ }
402
+ relayBatch = [];
383
403
  if (!active || !subscriber) return;
384
404
  active = false;
385
405
  activePlatform = null;
@@ -107,16 +107,26 @@ export function createShardedBus(client, options = {}) {
107
107
  /** @type {WeakMap<any, Set<string>>} ws -> set of followed topics */
108
108
  const wsFollows = new WeakMap();
109
109
 
110
- // One-shot warn flag for the per-tick microtask batch cap.
110
+ // One-shot warn flag for the per-tick batch cap.
111
111
  let batchChannelsWarnFired = false;
112
112
 
113
- // Per-channel microtask batch: coalesce SPUBLISHes for the same
114
- // channel within one tick into a single pipelined call. Each entry
115
- // tracks the underlying topic list so the relayed-messages counter
116
- // stays accurate when batch envelopes carry many messages.
113
+ // Per-channel tick batch: coalesce SPUBLISHes for the same channel
114
+ // within one event-loop iteration into a single pipelined call. Each
115
+ // entry tracks the underlying topic list so the relayed-messages
116
+ // counter stays accurate when batch envelopes carry many messages.
117
+ //
118
+ // Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS
119
+ // message as its own JS task, and N-API drains microtasks at the C++/JS
120
+ // boundary between tasks. A microtask-deferred flush fires BEFORE the
121
+ // next socket's handler runs, so cross-socket coalescing is impossible
122
+ // at the microtask level. `setTimeout(0)` lands in libuv's timers
123
+ // phase, which fires only after the poll phase has dispatched every
124
+ // ready socket message in the current iteration. Same structural
125
+ // choice the 0.5.7 cursor always-tick rewrite locked in.
117
126
  /** @type {Map<string, Array<{msg: string, topics: string[]}>>} */
118
127
  let channelBatches = new Map();
119
- let relayScheduled = false;
128
+ /** @type {ReturnType<typeof setTimeout> | null} */
129
+ let relayTimer = null;
120
130
 
121
131
  function scheduleRelay(channel, msg, topics) {
122
132
  let arr = channelBatches.get(channel);
@@ -128,23 +138,26 @@ export function createShardedBus(client, options = {}) {
128
138
  if (channelBatches.size >= MAX_SHARDED_BUS_BATCH_CHANNELS_PER_TICK && !batchChannelsWarnFired) {
129
139
  batchChannelsWarnFired = true;
130
140
  console.warn(
131
- '[sharded-bus] microtask batch reached ' + channelBatches.size +
132
- ' distinct channels in one tick. The batch is drained every microtask, ' +
141
+ '[sharded-bus] tick batch reached ' + channelBatches.size +
142
+ ' distinct channels in one iteration. The batch is drained every tick, ' +
133
143
  'so reaching this size means a publisher emitted a million distinct ' +
134
144
  'channels in one synchronous burst - likely a topic-cardinality leak.\n' +
135
145
  ' See: https://svti.me/sharded-bus-burst'
136
146
  );
137
147
  }
138
- if (!relayScheduled) {
139
- relayScheduled = true;
140
- queueMicrotask(flushRelay);
148
+ if (relayTimer === null) {
149
+ relayTimer = setTimeout(flushRelay, 0);
150
+ if (relayTimer.unref) relayTimer.unref();
141
151
  }
142
152
  }
143
153
 
144
154
  function flushRelay() {
145
155
  const batches = channelBatches;
146
156
  channelBatches = new Map();
147
- relayScheduled = false;
157
+ if (relayTimer !== null) {
158
+ clearTimeout(relayTimer);
159
+ relayTimer = null;
160
+ }
148
161
  if (b) {
149
162
  try { b.guard(); } catch { return; }
150
163
  }
@@ -268,7 +281,10 @@ export function createShardedBus(client, options = {}) {
268
281
  subscriber = null;
269
282
  }
270
283
  channelBatches = new Map();
271
- relayScheduled = false;
284
+ if (relayTimer !== null) {
285
+ clearTimeout(relayTimer);
286
+ relayTimer = null;
287
+ }
272
288
  followCounts.clear();
273
289
  channelRefcounts.clear();
274
290
  }