svelte-adapter-uws-extensions 0.5.7 → 0.5.9

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/MIGRATION.md CHANGED
@@ -36,7 +36,7 @@ Both hashes have per-field TTLs via `HPEXPIRE`. The pre-fix whole-key EXPIRE is
36
36
 
37
37
  3. **`metrics().staleCleanedTotal` is now always 0.** Per-field staleness is enforced atomically by Redis HPEXPIRE rather than by an application-side cleanup script, so there is nothing to count. The field stays in the return shape for backward-compatibility. The corresponding Prometheus counter `presence_stale_cleaned_total` is no longer registered.
38
38
 
39
- 4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `presence_state` when one fired. Same shape in 0.5 (the per-topic hash whole-key expiry only fires when every field has expired, i.e. no live instances). Per-field expiry (a single crashed instance) does NOT trigger this notification; users disappear from `list()` / `count()` results lazily. If you need explicit per-field-expiry events, subscribe to `__keyevent@*__:hexpired` separately (separate Redis CONFIG flag).
39
+ 4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `state` when one fired. Same shape in 0.5 (the per-topic hash whole-key expiry only fires when every field has expired, i.e. no live instances). Per-field expiry (a single crashed instance) does NOT trigger this notification; users disappear from `list()` / `count()` results lazily. If you need explicit per-field-expiry events, subscribe to `__keyevent@*__:hexpired` separately (separate Redis CONFIG flag).
40
40
 
41
41
  5. **Falling back to single-instance.** If you can't upgrade Redis, switch to the in-memory `createPresence` plugin from `svelte-adapter-uws/plugins/presence`. Public API is the same; you lose cross-instance presence which is the whole point of the Redis variant, but for single-instance deployments it's a clean drop-in.
42
42
 
@@ -219,19 +219,19 @@ export function leaveBoard(ws, { topic, platform }) {
219
219
 
220
220
  No `close`-hook change required: uWS releases all per-`ws` subscriptions on disconnect, so `cursors.remove(ws, platform)` in your `close` hook still handles cleanup. The legacy `cursors.hooks.subscribe` slot is now a no-op (the adapter's wire gate prevents it from ever firing); it stays exported for source-compat but new code should not rely on it.
221
221
 
222
- ### Presence wire shape on `__presence:{topic}` migrated to `presence_state` / `presence_diff`
222
+ ### Presence wire shape on `__presence:{topic}` migrated to `state` / `diff`
223
223
 
224
224
  **Only impacts apps with hand-rolled WebSocket clients consuming the wire directly.**
225
225
 
226
226
  **What changed.** The `redis/presence` channel previously emitted `list` / `join` / `leave` / `updated` / `heartbeat` events. It now emits:
227
227
 
228
- - `presence_state` (sent once on subscribe to a single connection): payload is `{[userKey]: data}` - a flat snapshot.
229
- - `presence_diff` (broadcast to topic subscribers, microtask-batched): payload is `{joins: {[key]: data}, leaves: {[key]: data}}`. Same-tick joins+leaves on the same key collapse to the latest op; updates appear as a `joins` entry with the new data.
228
+ - `state` (sent once on subscribe to a single connection): payload is `{[userKey]: data}` - a flat snapshot.
229
+ - `diff` (broadcast to topic subscribers, microtask-batched): payload is `{joins: {[key]: data}, leaves: {[key]: data}}`. Same-tick joins+leaves on the same key collapse to the latest op; updates appear as a `joins` entry with the new data.
230
230
  - `heartbeat` is unchanged.
231
231
 
232
- The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `presence_state` event (was an empty `list` event) on hash expiry.
232
+ The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `state` event (was an empty `list` event) on hash expiry.
233
233
 
234
- **How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `presence_state` / `presence_diff`:
234
+ **How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `state` / `diff`:
235
235
 
236
236
  ```js
237
237
  // before
@@ -241,11 +241,11 @@ case 'leave': remove(payload.key); break;
241
241
  case 'updated': set(payload.key, payload.data); break;
242
242
 
243
243
  // after
244
- case 'presence_state':
244
+ case 'state':
245
245
  state.clear();
246
246
  for (const k of Object.keys(payload)) state.set(k, payload[k]);
247
247
  break;
248
- case 'presence_diff':
248
+ case 'diff':
249
249
  for (const k of Object.keys(payload.joins)) state.set(k, payload.joins[k]);
250
250
  for (const k of Object.keys(payload.leaves)) state.delete(k);
251
251
  break;
package/README.md CHANGED
@@ -608,13 +608,13 @@ Clients see three event types on `__presence:{topic}`. Mirrors the adapter's bun
608
608
 
609
609
  | Event | When | Payload | Direction |
610
610
  |---|---|---|---|
611
- | `presence_state` | Once on subscribe | `{[userKey]: data}` - flat snapshot of current presence | Server -> single connection |
612
- | `presence_diff` | Microtask-batched after joins / leaves / updates | `{joins: {[key]: data}, leaves: {[key]: data}}` | Server -> topic subscribers |
611
+ | `state` | Once on subscribe | `{[userKey]: data}` - flat snapshot of current presence | Server -> single connection |
612
+ | `diff` | Microtask-batched after joins / leaves / updates | `{joins: {[key]: data}, leaves: {[key]: data}}` | Server -> topic subscribers |
613
613
  | `heartbeat` | Per heartbeat interval | `string[]` - array of currently-known user keys | Server -> topic subscribers |
614
614
 
615
- `presence_diff` collapses by key per-tick: if the same user joins and leaves in the same microtask, only the latest op survives on the wire. An update (same user re-joins with different data) appears as a `joins` entry carrying the new data, since clients overwrite their `Map.set` on the same key.
615
+ `diff` collapses by key per-tick: if the same user joins and leaves in the same microtask, only the latest op survives on the wire. An update (same user re-joins with different data) appears as a `joins` entry carrying the new data, since clients overwrite their `Map.set` on the same key.
616
616
 
617
- Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `presence_state` / `presence_diff` shape regardless of which instance the change originated on.
617
+ Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `state` / `diff` shape regardless of which instance the change originated on.
618
618
 
619
619
  Joins are staged with full rollback on failure: local state is set up first, then the Redis hashes are written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone - local maps, the Redis state, and any buffered diff entry are reversed. Compensating join+leave ops on the same key in the same tick collapse to nothing on the wire.
620
620
 
@@ -662,7 +662,7 @@ export async function close(ws, { platform }) {
662
662
  | `select` | strips `__`-prefixed keys | Extract public fields from userData |
663
663
  | `heartbeat` | `30000` | TTL refresh interval in ms |
664
664
  | `ttl` | `90` | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
665
- | `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `presence_state` event. See [Keyspace cleanup mode](#keyspace-cleanup-mode). |
665
+ | `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `state` event. See [Keyspace cleanup mode](#keyspace-cleanup-mode). |
666
666
 
667
667
  #### API
668
668
 
@@ -674,7 +674,7 @@ export async function close(ws, { platform }) {
674
674
  | `list(topic)` | Get current users |
675
675
  | `count(topic)` | Count unique users |
676
676
  | `metrics()` | Synchronous snapshot: `{ totalOnline, heartbeatLatencyMs, staleCleanedTotal }`. See [Metrics snapshot](#metrics-snapshot). |
677
- | `flushDiffs()` | Drain the pending `presence_diff` buffer synchronously. Use in graceful-shutdown paths or tests that need the diff to land before the await chain continues. |
677
+ | `flushDiffs()` | Drain the pending `diff` buffer synchronously. Use in graceful-shutdown paths or tests that need the diff to land before the await chain continues. |
678
678
  | `clear()` | Reset all presence state |
679
679
  | `destroy()` | Stop heartbeat and subscriber |
680
680
  | `hooks` | `{ subscribe, close }` - ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
@@ -695,14 +695,14 @@ Two additional counters track the diff-protocol behavior:
695
695
 
696
696
  | Metric | Description |
697
697
  |---|---|
698
- | `presence_diff_frames_total{topic="..."}` | `presence_diff` frames published to topic subscribers. Compared against `presence_joins_total` + `presence_leaves_total` it tells you how much per-tick coalescing the buffer is doing - the bigger the gap, the more bandwidth saved versus per-event broadcast. |
698
+ | `presence_diff_frames_total{topic="..."}` | `diff` frames published to topic subscribers. Compared against `presence_joins_total` + `presence_leaves_total` it tells you how much per-tick coalescing the buffer is doing - the bigger the gap, the more bandwidth saved versus per-event broadcast. |
699
699
  | `presence_diff_coalesced_total{topic="..."}` | Buffered diff entries overwritten by a later op in the same tick. A non-zero rate confirms the same-key collapse is working (e.g. a user reconnecting fast enough to leave-then-join in one tick). Zero is also a valid state under steady traffic. |
700
700
 
701
701
  #### Keyspace cleanup mode
702
702
 
703
- By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `presence_diff` with the user in `leaves`. If the tracking instance crashes, the broadcast never fires and the observer's UI shows stale data until the page is reloaded.
703
+ By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `diff` with the user in `leaves`. If the tracking instance crashes, the broadcast never fires and the observer's UI shows stale data until the page is reloaded.
704
704
 
705
- `keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore - typically because the only tracker crashed), this instance emits an empty `presence_state` event on `__presence:<topic>` so local subscribers can replace their entire local map with "no one here."
705
+ `keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore - typically because the only tracker crashed), this instance emits an empty `state` event on `__presence:<topic>` so local subscribers can replace their entire local map with "no one here."
706
706
 
707
707
  ```js
708
708
  const presence = createPresence(redis, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
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.6"
157
+ "svelte-adapter-uws": "^0.5.8"
158
158
  },
159
159
  "dependencies": {
160
160
  "ioredis": "^5.0.0"
@@ -38,8 +38,8 @@ export interface RedisPresenceOptions {
38
38
  * both single-instance and cluster deployments.
39
39
  */
40
40
  export type PresenceWireEvent =
41
- | { event: 'presence_state'; data: Record<string, Record<string, any>> }
42
- | { event: 'presence_diff'; data: { joins: Record<string, Record<string, any>>; leaves: Record<string, Record<string, any>> } }
41
+ | { event: 'state'; data: Record<string, Record<string, any>> }
42
+ | { event: 'diff'; data: { joins: Record<string, Record<string, any>>; leaves: Record<string, Record<string, any>> } }
43
43
  | { event: 'heartbeat'; data: string[] };
44
44
 
45
45
  export interface PresenceMetricsSnapshot {
@@ -101,7 +101,7 @@ export interface RedisPresenceTracker {
101
101
  * Drain the pending diff buffer synchronously. The diff buffer
102
102
  * normally flushes on the next microtask after a join / leave /
103
103
  * update; call this when a test or graceful-shutdown path needs
104
- * the `presence_diff` to land before the await chain continues.
104
+ * the `diff` to land before the await chain continues.
105
105
  */
106
106
  flushDiffs(): void;
107
107
 
package/redis/presence.js CHANGED
@@ -6,11 +6,12 @@
6
6
  * for cross-instance join/leave notifications.
7
7
  *
8
8
  * Wire shape clients see on `__presence:{topic}`:
9
- * - `presence_state` (sent once on subscribe to a single connection)
9
+ * - `state` (sent once on subscribe to a single connection)
10
10
  * payload: `{[userKey]: data}` flat snapshot of current presence
11
- * - `presence_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
  *
@@ -148,7 +149,7 @@ return 0
148
149
 
149
150
  /**
150
151
  * Internal cross-instance Redis pub/sub envelope event names. NOT the
151
- * client wire shape - clients see `presence_state` / `presence_diff` /
152
+ * client wire shape - clients see `state` / `diff` /
152
153
  * `heartbeat`. These names live on the `presence:events:{topic}` channel
153
154
  * between instances and are routed into the local diff buffer on receive.
154
155
  */
@@ -256,7 +257,7 @@ export function createPresence(client, options = {}) {
256
257
  const mTotalOnline = m?.gauge('presence_total_online', 'Unique users present per topic on this instance', ['topic']);
257
258
  const mHeartbeatLatency = m?.gauge('presence_heartbeat_latency_ms', 'Duration of the most recent heartbeat tick in milliseconds');
258
259
  const mKeyspaceCleanups = m?.counter('presence_keyspace_cleanups_total', 'Topics whose hash expiry triggered a local empty-list emit');
259
- const mDiffFrames = m?.counter('presence_diff_frames_total', 'presence_diff frames published to topic subscribers', ['topic']);
260
+ const mDiffFrames = m?.counter('presence_diff_frames_total', 'diff frames published to topic subscribers', ['topic']);
260
261
  const mDiffCoalesced = m?.counter('presence_diff_coalesced_total', 'Buffered diff entries overwritten by a later op in the same tick', ['topic']);
261
262
 
262
263
  let lastHeartbeatLatency = 0;
@@ -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) {
@@ -363,7 +382,7 @@ export function createPresence(client, options = {}) {
363
382
  else leaves[key] = data;
364
383
  }
365
384
  try {
366
- platform.publish('__presence:' + topic, 'presence_diff', { joins, leaves }, { relay: false });
385
+ platform.publish('__presence:' + topic, 'diff', { joins, leaves }, { relay: false });
367
386
  mDiffFrames?.inc({ topic: mt(topic) });
368
387
  } catch { /* platform unavailable mid-flight */ }
369
388
  }
@@ -533,11 +552,11 @@ export function createPresence(client, options = {}) {
533
552
  // handler could only refresh `existing` entries; an
534
553
  // entry the client swept (cross-replica relay latency,
535
554
  // brief backpressure, JS thread saturation) could never
536
- // be recovered without a presence_diff for that user.
555
+ // be recovered without a diff for that user.
537
556
  // Older clients fall back gracefully: they see an
538
557
  // object instead of an array and skip the legacy
539
- // "refresh-existing" branch, but the next presence_diff
540
- // or presence_state still reconciles them.
558
+ // "refresh-existing" branch, but the next diff
559
+ // or state still reconciles them.
541
560
  /** @type {Record<string, any>} */
542
561
  const dataMap = {};
543
562
  for (const [userKey, entry] of data) dataMap[userKey] = entry.data;
@@ -590,7 +609,7 @@ export function createPresence(client, options = {}) {
590
609
  // The per-topic hash key expires only when every field has
591
610
  // expired (no live instances presenting any user on this
592
611
  // topic). That is the "whole topic empty" signal we forward
593
- // as an empty presence_state to local subscribers. Per-user
612
+ // as an empty state to local subscribers. Per-user
594
613
  // hash keys (presence:user:{topic}:{userKey}) and the events
595
614
  // channel are filtered out.
596
615
  const topicPrefix = client.key('presence:topic:');
@@ -599,7 +618,7 @@ export function createPresence(client, options = {}) {
599
618
  if (!expiredKey.startsWith(topicPrefix)) return;
600
619
  const topic = expiredKey.slice(topicPrefix.length);
601
620
  if (activePlatform) {
602
- activePlatform.publish('__presence:' + topic, 'presence_state', {}, { relay: false });
621
+ activePlatform.publish('__presence:' + topic, 'state', {}, { relay: false });
603
622
  mKeyspaceCleanups?.inc();
604
623
  }
605
624
  });
@@ -1223,7 +1242,7 @@ export function createPresence(client, options = {}) {
1223
1242
  state[userKey] = entry.data;
1224
1243
  }
1225
1244
  try {
1226
- platform.send(ws, '__presence:' + topic, 'presence_state', state);
1245
+ platform.send(ws, '__presence:' + topic, 'state', state);
1227
1246
  } catch {
1228
1247
  // WebSocket closed before send
1229
1248
  }
@@ -1270,7 +1289,7 @@ export function createPresence(client, options = {}) {
1270
1289
 
1271
1290
  try {
1272
1291
  ws.subscribe(presenceTopic);
1273
- platform.send(ws, presenceTopic, 'presence_state', state);
1292
+ platform.send(ws, presenceTopic, 'state', state);
1274
1293
  } catch {
1275
1294
  const topics = syncObservers.get(ws);
1276
1295
  if (topics && topics.has(topic)) {
@@ -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
 
@@ -1396,13 +1421,13 @@ export function createPresence(client, options = {}) {
1396
1421
  // Client-initiated reconnect-snapshot. The presence plugin
1397
1422
  // client sends `{type:'presence-snapshot', topic}` on every
1398
1423
  // status==='open' (initial connect + reconnect). Re-emits
1399
- // `presence_state` to the requesting ws via `tracker.sync`,
1424
+ // `state` to the requesting ws via `tracker.sync`,
1400
1425
  // which is the same path that fires on a fresh subscribe.
1401
1426
  // Symmetric to cursor's `cursor-snapshot` text frame.
1402
1427
  //
1403
1428
  // Without this, board-scoped presence stayed stale across
1404
1429
  // reconnects: a tab that had joined via an RPC saw no
1405
- // presence_diff during the disconnect window, and on
1430
+ // diff during the disconnect window, and on
1406
1431
  // reconnect its in-memory map was whatever it last knew.
1407
1432
  // Global presence accidentally self-healed because most
1408
1433
  // apps call `presence.join('global')` from the `open` hook
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
  }