svelte-adapter-uws-extensions 0.5.7 → 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
@@ -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.8",
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.7"
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,9 +6,9 @@
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, microtask-batched)
12
12
  * payload: `{joins: {[key]: data}, leaves: {[key]: data}}`
13
13
  * Same-tick joins+leaves on the same key collapse: latest op wins.
14
14
  * - `heartbeat` (broadcast to topic subscribers, per heartbeat interval)
@@ -148,7 +148,7 @@ return 0
148
148
 
149
149
  /**
150
150
  * Internal cross-instance Redis pub/sub envelope event names. NOT the
151
- * client wire shape - clients see `presence_state` / `presence_diff` /
151
+ * client wire shape - clients see `state` / `diff` /
152
152
  * `heartbeat`. These names live on the `presence:events:{topic}` channel
153
153
  * between instances and are routed into the local diff buffer on receive.
154
154
  */
@@ -256,7 +256,7 @@ export function createPresence(client, options = {}) {
256
256
  const mTotalOnline = m?.gauge('presence_total_online', 'Unique users present per topic on this instance', ['topic']);
257
257
  const mHeartbeatLatency = m?.gauge('presence_heartbeat_latency_ms', 'Duration of the most recent heartbeat tick in milliseconds');
258
258
  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']);
259
+ const mDiffFrames = m?.counter('presence_diff_frames_total', 'diff frames published to topic subscribers', ['topic']);
260
260
  const mDiffCoalesced = m?.counter('presence_diff_coalesced_total', 'Buffered diff entries overwritten by a later op in the same tick', ['topic']);
261
261
 
262
262
  let lastHeartbeatLatency = 0;
@@ -363,7 +363,7 @@ export function createPresence(client, options = {}) {
363
363
  else leaves[key] = data;
364
364
  }
365
365
  try {
366
- platform.publish('__presence:' + topic, 'presence_diff', { joins, leaves }, { relay: false });
366
+ platform.publish('__presence:' + topic, 'diff', { joins, leaves }, { relay: false });
367
367
  mDiffFrames?.inc({ topic: mt(topic) });
368
368
  } catch { /* platform unavailable mid-flight */ }
369
369
  }
@@ -533,11 +533,11 @@ export function createPresence(client, options = {}) {
533
533
  // handler could only refresh `existing` entries; an
534
534
  // entry the client swept (cross-replica relay latency,
535
535
  // brief backpressure, JS thread saturation) could never
536
- // be recovered without a presence_diff for that user.
536
+ // be recovered without a diff for that user.
537
537
  // Older clients fall back gracefully: they see an
538
538
  // 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.
539
+ // "refresh-existing" branch, but the next diff
540
+ // or state still reconciles them.
541
541
  /** @type {Record<string, any>} */
542
542
  const dataMap = {};
543
543
  for (const [userKey, entry] of data) dataMap[userKey] = entry.data;
@@ -590,7 +590,7 @@ export function createPresence(client, options = {}) {
590
590
  // The per-topic hash key expires only when every field has
591
591
  // expired (no live instances presenting any user on this
592
592
  // topic). That is the "whole topic empty" signal we forward
593
- // as an empty presence_state to local subscribers. Per-user
593
+ // as an empty state to local subscribers. Per-user
594
594
  // hash keys (presence:user:{topic}:{userKey}) and the events
595
595
  // channel are filtered out.
596
596
  const topicPrefix = client.key('presence:topic:');
@@ -599,7 +599,7 @@ export function createPresence(client, options = {}) {
599
599
  if (!expiredKey.startsWith(topicPrefix)) return;
600
600
  const topic = expiredKey.slice(topicPrefix.length);
601
601
  if (activePlatform) {
602
- activePlatform.publish('__presence:' + topic, 'presence_state', {}, { relay: false });
602
+ activePlatform.publish('__presence:' + topic, 'state', {}, { relay: false });
603
603
  mKeyspaceCleanups?.inc();
604
604
  }
605
605
  });
@@ -1223,7 +1223,7 @@ export function createPresence(client, options = {}) {
1223
1223
  state[userKey] = entry.data;
1224
1224
  }
1225
1225
  try {
1226
- platform.send(ws, '__presence:' + topic, 'presence_state', state);
1226
+ platform.send(ws, '__presence:' + topic, 'state', state);
1227
1227
  } catch {
1228
1228
  // WebSocket closed before send
1229
1229
  }
@@ -1270,7 +1270,7 @@ export function createPresence(client, options = {}) {
1270
1270
 
1271
1271
  try {
1272
1272
  ws.subscribe(presenceTopic);
1273
- platform.send(ws, presenceTopic, 'presence_state', state);
1273
+ platform.send(ws, presenceTopic, 'state', state);
1274
1274
  } catch {
1275
1275
  const topics = syncObservers.get(ws);
1276
1276
  if (topics && topics.has(topic)) {
@@ -1396,13 +1396,13 @@ export function createPresence(client, options = {}) {
1396
1396
  // Client-initiated reconnect-snapshot. The presence plugin
1397
1397
  // client sends `{type:'presence-snapshot', topic}` on every
1398
1398
  // status==='open' (initial connect + reconnect). Re-emits
1399
- // `presence_state` to the requesting ws via `tracker.sync`,
1399
+ // `state` to the requesting ws via `tracker.sync`,
1400
1400
  // which is the same path that fires on a fresh subscribe.
1401
1401
  // Symmetric to cursor's `cursor-snapshot` text frame.
1402
1402
  //
1403
1403
  // Without this, board-scoped presence stayed stale across
1404
1404
  // reconnects: a tab that had joined via an RPC saw no
1405
- // presence_diff during the disconnect window, and on
1405
+ // diff during the disconnect window, and on
1406
1406
  // reconnect its in-memory map was whatever it last knew.
1407
1407
  // Global presence accidentally self-healed because most
1408
1408
  // apps call `presence.join('global')` from the `open` hook