svelte-adapter-uws 0.5.6 → 0.5.8

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
@@ -145,9 +145,9 @@ These change observable runtime behavior. Most apps are unaffected; a few will n
145
145
 
146
146
  ### Presence plugin wire format switched to a compact diff protocol
147
147
 
148
- **What changed.** The five-event format (`list` / `join` / `updated` / `leave` / `heartbeat`) collapses to two diff-shaped events plus the existing heartbeat: `presence_state` (full snapshot) and `presence_diff` (joins/leaves). Diffs are microtask-batched. Server and client ship in one bundle, so a single-package upgrade is seamless.
148
+ **What changed.** The five-event format (`list` / `join` / `updated` / `leave` / `heartbeat`) collapses to two diff-shaped events plus the existing heartbeat: `state` (full snapshot) and `diff` (joins/leaves). Diffs are microtask-batched. Server and client ship in one bundle, so a single-package upgrade is seamless.
149
149
 
150
- **How to migrate.** No action needed for users of the bundled `presence()` Svelte store on the client. Hand-rolled clients that consume the wire directly need to switch decoders to handle `presence_state` and `presence_diff` events. Stale browser tabs from a previous deploy will see a blank presence list until refresh.
150
+ **How to migrate.** No action needed for users of the bundled `presence()` Svelte store on the client. Hand-rolled clients that consume the wire directly need to switch decoders to handle `state` and `diff` events. Stale browser tabs from a previous deploy will see a blank presence list until refresh.
151
151
 
152
152
  ### Wire single-subscribe frames consult `subscribeBatch` when only `subscribeBatch` is exported
153
153
 
package/README.md CHANGED
@@ -2476,7 +2476,7 @@ presence.leave(ws, platform) // remove from all topics (call from close
2476
2476
  presence.sync(ws, topic, platform) // send snapshot without joining (for observers)
2477
2477
  presence.list(topic) // current user data array
2478
2478
  presence.count(topic) // unique user count
2479
- presence.flushDiffs() // drain buffered presence_diff publishes synchronously
2479
+ presence.flushDiffs() // drain buffered diff publishes synchronously
2480
2480
  presence.clear() // reset everything (stops heartbeat timer)
2481
2481
  ```
2482
2482
 
@@ -2484,9 +2484,9 @@ presence.clear() // reset everything (stops heartbeat timer)
2484
2484
 
2485
2485
  The plugin emits three frame types on the `__presence:{topic}` channel:
2486
2486
 
2487
- - `{event: 'presence_state', data: {[key]: meta}}` - full snapshot, sent to a single connection on join or sync.
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`.
2487
+ - `{event: 'state', data: {[key]: meta}}` - full snapshot, sent to a single connection on join or sync.
2488
+ - `{event: '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 `diff`.
2490
2490
 
2491
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).
2492
2492
 
@@ -2501,7 +2501,7 @@ const users = presence('room');
2501
2501
  // $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
2502
2502
  ```
2503
2503
 
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.
2504
+ The client store defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed by a heartbeat or `diff` / `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.
2505
2505
 
2506
2506
  For admin / audit views that want unbounded retention ("show every user who ever touched this topic"), opt out with `maxAge: 0`:
2507
2507
 
package/files/handler.js CHANGED
@@ -366,12 +366,24 @@ const app = is_tls
366
366
  : uWS.App();
367
367
 
368
368
  // - Cross-worker pub/sub relay (batched) ------------------------------------
369
- // Batch postMessage calls within a single microtask. A SvelteKit action that
370
- // publishes N events sends one structured-clone across the thread boundary
371
- // instead of N. No-op in single-process mode (parentPort is null).
369
+ // Batch postMessage calls within a single event-loop iteration. A SvelteKit
370
+ // action that publishes N events sends one structured-clone across the thread
371
+ // boundary instead of N. No-op in single-process mode (parentPort is null).
372
+ //
373
+ // Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS message
374
+ // as its own JS task, and N-API drains microtasks at the C++/JS boundary
375
+ // between tasks. A microtask-deferred flush fires BEFORE the next socket's
376
+ // handler runs, so cross-socket coalescing is impossible at the microtask
377
+ // level - N publishes from N socket handlers in the same iteration produce N
378
+ // postMessage structured-clones instead of one batched. `setTimeout(0)` lands
379
+ // in libuv's timers phase, which fires only after the poll phase has
380
+ // dispatched every ready socket message in the current iteration. Same
381
+ // structural choice the 0.5.6 cursor always-tick rewrite locked in.
372
382
 
373
383
  /** @type {Array<{topic: string, envelope: string}> | null} */
374
384
  let relayBatch = null;
385
+ /** @type {ReturnType<typeof setTimeout> | null} */
386
+ let relayTimer = null;
375
387
 
376
388
  /**
377
389
  * @param {string} topic
@@ -380,12 +392,14 @@ let relayBatch = null;
380
392
  function batchRelay(topic, envelope) {
381
393
  if (!relayBatch) {
382
394
  relayBatch = [];
383
- queueMicrotask(() => {
395
+ relayTimer = setTimeout(() => {
396
+ relayTimer = null;
384
397
  if (relayBatch) {
385
398
  parentPort.postMessage({ type: 'publish-batch', messages: relayBatch });
386
399
  }
387
400
  relayBatch = null;
388
- });
401
+ }, 0);
402
+ if (relayTimer.unref) relayTimer.unref();
389
403
  }
390
404
  relayBatch.push({ topic, envelope });
391
405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -7,7 +7,7 @@ import type { Readable } from 'svelte/store';
7
7
  * The array updates automatically when users join or leave.
8
8
  *
9
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
10
+ * by a heartbeat or diff/state inside the window are removed
11
11
  * from the local map. The server emits `{userKey: data}` heartbeats
12
12
  * every 30 s by default, so still-present users re-appear on the next
13
13
  * heartbeat (no flicker). Pass `maxAge: 0` to opt out of the sweep for
@@ -6,7 +6,7 @@
6
6
  * this module just keeps the client-side state in sync.
7
7
  *
8
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
9
+ * by a heartbeat or diff/state inside the window are removed
10
10
  * from the local map. The in-memory server (and the Redis-backed variant
11
11
  * in svelte-adapter-uws-extensions) emits `{userKey: data}` heartbeats
12
12
  * every 30 s by default, so a still-present user re-appears on the very
@@ -127,7 +127,7 @@ export function presence(topic, options) {
127
127
  sourceUnsub = source.subscribe((event) => {
128
128
  if (event === null) return;
129
129
 
130
- if (event.event === 'presence_state' && event.data && typeof event.data === 'object') {
130
+ if (event.event === 'state' && event.data && typeof event.data === 'object') {
131
131
  userMap = new Map();
132
132
  timestamps.clear();
133
133
  const now = Date.now();
@@ -139,7 +139,7 @@ export function presence(topic, options) {
139
139
  return;
140
140
  }
141
141
 
142
- if (event.event === 'presence_diff' && event.data && typeof event.data === 'object') {
142
+ if (event.event === 'diff' && event.data && typeof event.data === 'object') {
143
143
  const { joins, leaves } = event.data;
144
144
  const now = Date.now();
145
145
  let changed = false;
@@ -175,7 +175,7 @@ export function presence(topic, options) {
175
175
  // recover entries the local sweep had already removed -
176
176
  // once an entry aged out, the next heartbeat couldn't
177
177
  // bring it back and the user stayed missing until a
178
- // presence_diff or presence_state arrived.
178
+ // diff or state arrived.
179
179
  for (const [key, data] of Object.entries(event.data)) {
180
180
  timestamps.set(key, now);
181
181
  const prev = userMap.get(key);
@@ -187,7 +187,7 @@ export function presence(topic, options) {
187
187
  } else if (Array.isArray(event.data)) {
188
188
  // Back-compat: keys-only heartbeat (older server). Refresh
189
189
  // existing entries; cannot recover aged-out ones from this
190
- // shape. The presence_diff / presence_state reconciliation
190
+ // shape. The diff / state reconciliation
191
191
  // path still corrects missing entries on the next event.
192
192
  for (const key of event.data) {
193
193
  if (timestamps.has(key)) {
@@ -206,7 +206,7 @@ export function presence(topic, options) {
206
206
 
207
207
  // Request a presence snapshot every time the socket opens (initial
208
208
  // connect AND reconnects). Without this, a reconnecting client
209
- // missed any presence_diff frames that fired during the disconnect
209
+ // missed any diff frames that fired during the disconnect
210
210
  // window and its in-memory map stayed at whatever it last knew.
211
211
  // Symmetric to the cursor plugin's `cursor-snapshot` send.
212
212
  statusUnsub = status.subscribe((s) => {
@@ -51,7 +51,7 @@ export interface PresenceOptions<UserData = unknown, Selected extends Record<str
51
51
  * topics carrying a `{userKey: data}` map of every active user. This
52
52
  * refreshes each entry's `maxAge` timer on the client AND re-adds any
53
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
54
+ * users do not flicker out when a `diff` is missed (transient
55
55
  * network blip, JS thread saturation).
56
56
  *
57
57
  * Set this to a value shorter than the client's `maxAge`. The 30 s
@@ -69,7 +69,7 @@ export interface PresenceOptions<UserData = unknown, Selected extends Record<str
69
69
  *
70
70
  * @example
71
71
  * ```js
72
- * // Disable heartbeats; client must rely on presence_diff alone
72
+ * // Disable heartbeats; client must rely on diff alone
73
73
  * const presence = createPresence({ heartbeat: 0 });
74
74
  * ```
75
75
  */
@@ -105,9 +105,9 @@ export interface PresenceTracker<Selected extends Record<string, any> = Record<s
105
105
  *
106
106
  * What happens:
107
107
  * 1. Adds the user to the topic's presence map
108
- * 2. Buffers a `join` entry into the next presence_diff broadcast (microtask-flushed)
108
+ * 2. Buffers a `join` entry into the next diff broadcast (microtask-flushed)
109
109
  * 3. Subscribes this ws to the presence channel
110
- * 4. Sends the full current snapshot (`presence_state`) to this ws
110
+ * 4. Sends the full current snapshot (`state`) to this ws
111
111
  *
112
112
  * @example
113
113
  * ```js
@@ -123,7 +123,7 @@ export interface PresenceTracker<Selected extends Record<string, any> = Record<s
123
123
  *
124
124
  * Call this from your `close` hook. Handles multi-tab correctly:
125
125
  * if the user has other connections still open, they stay present.
126
- * Only buffers a `leave` entry into the next presence_diff when the
126
+ * Only buffers a `leave` entry into the next diff when the
127
127
  * last connection closes.
128
128
  *
129
129
  * @example
@@ -136,7 +136,7 @@ export interface PresenceTracker<Selected extends Record<string, any> = Record<s
136
136
  leave(ws: WebSocket<any>, platform: Platform): void;
137
137
 
138
138
  /**
139
- * Send the current presence snapshot (`presence_state`) to a connection without joining.
139
+ * Send the current presence snapshot (`state`) to a connection without joining.
140
140
  *
141
141
  * Use this for observers (admin dashboards, spectators) who want to
142
142
  * see who's present without being counted as present themselves.
@@ -187,7 +187,7 @@ export interface PresenceTracker<Selected extends Record<string, any> = Record<s
187
187
  clear(): void;
188
188
 
189
189
  /**
190
- * Drain any buffered `presence_diff` publishes synchronously.
190
+ * Drain any buffered `diff` publishes synchronously.
191
191
  *
192
192
  * Diffs are normally microtask-batched: multiple joins / leaves in the
193
193
  * same tick collapse into one broadcast frame. Tests use this to
@@ -56,7 +56,7 @@ const TOPIC_PREFIX = '__presence:';
56
56
  * The server periodically publishes a `heartbeat` event to all presence topics carrying a
57
57
  * `{userKey: data}` map of every active user. This refreshes each entry's `maxAge` timer on
58
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
59
+ * live users do not flicker out when a `diff` is missed (e.g. transient network
60
60
  * blip, JS thread saturation). Set this to a value shorter than the client's `maxAge`
61
61
  * (default client `maxAge` is 90 s, so 30 s gives a 3x safety margin). Pass `0` to disable
62
62
  * heartbeats entirely (apps that do not use the `maxAge` self-healing path).
@@ -302,12 +302,28 @@ export function createPresence(options = {}) {
302
302
 
303
303
  /**
304
304
  * Per-topic pending diff buffer: latest op per key wins. Joins and leaves
305
- * happening on the same key in a tick collapse so the wire only sees the
306
- * net change. Flushed once per microtask via `scheduleDiffFlush`.
305
+ * happening on the same key in one event-loop iteration collapse so the
306
+ * wire only sees the net change. Flushed once per iteration via
307
+ * `setTimeout(() => flushDiffs(platform), 0)` armed when the first dirty
308
+ * entry lands.
309
+ *
310
+ * Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS
311
+ * message as its own JS task, and N-API drains microtasks at the C++/JS
312
+ * boundary between tasks. A microtask-deferred flush fires BEFORE the
313
+ * next socket's handler runs, so cross-socket coalescing is impossible
314
+ * at the microtask level - a mass-join into a populated topic produces
315
+ * O(N) one-entry diffs instead of one batched diff. `setTimeout(0)`
316
+ * lands in libuv's timers phase, which fires only after the poll phase
317
+ * has dispatched every ready socket message in the current iteration -
318
+ * so all joins arriving together end up in one flush regardless of how
319
+ * many task boundaries separate them. Same structural choice the
320
+ * 0.5.6 cursor always-tick rewrite locked in.
321
+ *
307
322
  * @type {Map<string, Map<string, { op: 'join' | 'leave', data: Record<string, any> }>>}
308
323
  */
309
324
  const pendingDiffs = new Map();
310
- let diffFlushScheduled = false;
325
+ /** @type {ReturnType<typeof setTimeout> | null} */
326
+ let diffFlushTimer = null;
311
327
 
312
328
  /**
313
329
  * @param {string} topic
@@ -323,15 +339,18 @@ export function createPresence(options = {}) {
323
339
  pendingDiffs.set(topic, entries);
324
340
  }
325
341
  entries.set(key, { op, data });
326
- if (!diffFlushScheduled) {
327
- diffFlushScheduled = true;
328
- queueMicrotask(() => flushDiffs(platform));
342
+ if (diffFlushTimer === null) {
343
+ diffFlushTimer = setTimeout(() => flushDiffs(platform), 0);
344
+ if (diffFlushTimer.unref) diffFlushTimer.unref();
329
345
  }
330
346
  }
331
347
 
332
348
  /** @param {import('../../index.js').Platform} platform */
333
349
  function flushDiffs(platform) {
334
- diffFlushScheduled = false;
350
+ if (diffFlushTimer !== null) {
351
+ clearTimeout(diffFlushTimer);
352
+ diffFlushTimer = null;
353
+ }
335
354
  for (const [topic, entries] of pendingDiffs) {
336
355
  /** @type {Record<string, Record<string, any>>} */
337
356
  const joins = {};
@@ -341,13 +360,13 @@ export function createPresence(options = {}) {
341
360
  if (op === 'join') joins[key] = data;
342
361
  else leaves[key] = data;
343
362
  }
344
- platform.publish(TOPIC_PREFIX + topic, 'presence_diff', { joins, leaves });
363
+ platform.publish(TOPIC_PREFIX + topic, 'diff', { joins, leaves });
345
364
  }
346
365
  pendingDiffs.clear();
347
366
  }
348
367
 
349
368
  /**
350
- * Build a presence_state snapshot for a topic: {[key]: data}.
369
+ * Build a state snapshot for a topic: {[key]: data}.
351
370
  * @param {Map<string, { data: Record<string, any>, count: number }> | undefined} users
352
371
  * @returns {Record<string, Record<string, any>>}
353
372
  */
@@ -387,8 +406,8 @@ export function createPresence(options = {}) {
387
406
  // Publish a `{userKey: data}` map (rather than a keys-only
388
407
  // array) so a client whose entry aged out of its local
389
408
  // `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
409
+ // heartbeat alone, without waiting for a diff /
410
+ // state to reconcile. Matches the Redis-backed
392
411
  // variant in svelte-adapter-uws-extensions.
393
412
  /** @type {Record<string, any>} */
394
413
  const dataMap = {};
@@ -484,7 +503,7 @@ export function createPresence(options = {}) {
484
503
  if (existing) {
485
504
  // Same user, additional connection (another tab) - bump count.
486
505
  // A data change (e.g. avatar updated in another session) becomes
487
- // a `join` entry in the next presence_diff: client overwrites
506
+ // a `join` entry in the next diff: client overwrites
488
507
  // the existing key with the new data.
489
508
  existing.count++;
490
509
  if (!deepEqual(existing.data, data)) {
@@ -507,7 +526,7 @@ export function createPresence(options = {}) {
507
526
  // user sees the complete state (including themselves) immediately;
508
527
  // any pending diff fan-out reaches them too but is idempotent on
509
528
  // the client (joins[key] = data is a no-op if already set).
510
- platform.send(ws, presenceTopic, 'presence_state', snapshotState(users));
529
+ platform.send(ws, presenceTopic, 'state', snapshotState(users));
511
530
  },
512
531
 
513
532
  leave(ws, platform) {
@@ -527,7 +546,7 @@ export function createPresence(options = {}) {
527
546
  const users = topicPresence.get(topic);
528
547
  const presenceTopic = TOPIC_PREFIX + topic;
529
548
  try { ws.subscribe(presenceTopic); } catch { return; }
530
- platform.send(ws, presenceTopic, 'presence_state', snapshotState(users));
549
+ platform.send(ws, presenceTopic, 'state', snapshotState(users));
531
550
  },
532
551
 
533
552
  list(topic) {
@@ -554,21 +573,24 @@ export function createPresence(options = {}) {
554
573
  wsTopics.clear();
555
574
  topicPresence.clear();
556
575
  pendingDiffs.clear();
557
- diffFlushScheduled = false;
576
+ if (diffFlushTimer !== null) {
577
+ clearTimeout(diffFlushTimer);
578
+ diffFlushTimer = null;
579
+ }
558
580
  connCounter = 0;
559
581
  },
560
582
 
561
583
  /**
562
584
  * Drain any buffered diff publishes synchronously. Tests use this
563
- * to assert on the wire output without awaiting the microtask
564
- * queue. Production code generally does not need to call it - the
565
- * microtask flush happens automatically. Useful when a caller
585
+ * to assert on the wire output without awaiting the next-tick
586
+ * setTimeout flush. Production code generally does not need to call
587
+ * it - the tick flush happens automatically. Useful when a caller
566
588
  * needs presence state visible to other workers before its own
567
589
  * synchronous block returns (e.g. before responding to an HTTP
568
590
  * request that just triggered a leave).
569
591
  */
570
592
  flushDiffs() {
571
- if (!diffFlushScheduled || !_platform) return;
593
+ if (diffFlushTimer === null || !_platform) return;
572
594
  flushDiffs(_platform);
573
595
  },
574
596