svelte-realtime 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/MIGRATION.md CHANGED
@@ -287,6 +287,56 @@ Saturation behavior: entries with a pending leave timer are evicted first; if st
287
287
 
288
288
  Not required. Adopting these gets you the full 0.5 experience.
289
289
 
290
+ ### `ctx.skip(key, ms)` for per-key handler gating
291
+
292
+ **What's new.** A per-key gate primitive on `LiveContext`. Returns `true` to skip the call (key is within its cooldown window), `false` to run it. Pairs with `ctx.shed` semantically so call sites read uniformly with an early `return`:
293
+
294
+ ```js
295
+ export const moveNote = live(async (ctx, noteId, x, y) => {
296
+ if (ctx.shed('background')) return; // pressure shed
297
+ if (ctx.skip(`move:${noteId}`, 16)) return; // per-key handler gate
298
+ await dbUpdateNote(noteId, x, y);
299
+ ctx.publish(TOPICS.notes, 'updated', { noteId, x, y });
300
+ });
301
+ ```
302
+
303
+ State is per-replica (CPU/DB shed, not cluster-wide rate limit; for cross-replica gating use `live.rateLimit({ store: 'redis' })` or the `redis/ratelimit` extension). Capped at 5000 active entries with fail-open semantics on overflow (returns `false`, dev-warns once). Throws `LiveError('INVALID_ARG', ...)` on `key` not a string or `ms` not a positive finite number.
304
+
305
+ This is the primitive developers were reaching for when they wrote `ctx.throttle('move:id', 50)` thinking it gated handler execution. The old `ctx.throttle` / `ctx.debounce` are outbound publish helpers (renamed to `publishThrottled` / `publishDebounced` - see [Cosmetic](#cosmetic)); the new `ctx.skip` is the actual handler gate.
306
+
307
+ ### `createMessage({ onJsonMessage(ws, msg, platform) })` for plugin-layer JSON dispatch
308
+
309
+ **What's new.** A callback on `createMessage` that receives the parsed envelope when a non-RPC text frame parses as a JSON object. Replaces the manual `TextDecoder + JSON.parse + dispatch` pattern that plugins like `cursor.hooks.message` previously required in user `hooks.ws.js`.
310
+
311
+ ```js
312
+ // Before
313
+ import { createMessage } from 'svelte-realtime/server';
314
+ import { cursor } from '$lib/server/redis';
315
+
316
+ export const message = createMessage({
317
+ onUnhandled(ws, data, platform) {
318
+ if (!(data instanceof ArrayBuffer) || data.byteLength < 2) return;
319
+ let msg;
320
+ try { msg = JSON.parse(new TextDecoder().decode(data)); } catch { return; }
321
+ if (!msg || typeof msg !== 'object') return;
322
+ if (msg.type === 'cursor') {
323
+ cursor.hooks.message(ws, { data: msg, platform });
324
+ }
325
+ }
326
+ });
327
+
328
+ // After
329
+ export const message = createMessage({
330
+ onJsonMessage(ws, msg, platform) {
331
+ if (msg.type === 'cursor') cursor.hooks.message(ws, { data: msg, platform });
332
+ }
333
+ });
334
+ ```
335
+
336
+ Two-tier lookup: (1) fast path uses the `msg` field forwarded by `svelte-adapter-uws@^0.5.3` (one parse total); (2) fallback parses locally for frames the adapter didn't fast-path (older adapter, > 8 KiB frame, or non-`{"ty` prefix). Frames that aren't JSON, can't parse, parse to a non-object, or exceed the depth cap (`maxJsonDepth`, default 64) fall through to `onUnhandled` with the original raw bytes. The adapter's `maxPayloadLength` (default 1 MB) is the structural size ceiling.
337
+
338
+ Both `onJsonMessage` and `onUnhandled` can be set together for mixed JSON / binary frame handling.
339
+
290
340
  ### Move `setCronPlatform` and `live.configurePush({ remoteRegistry })` to `init({ platform })`
291
341
 
292
342
  **What changed.** Both functions used to be wired from `open(ws, platform)`. The recommended call site is now the adapter's `init({ platform })` lifecycle hook, which fires once per worker after the listen socket is bound and before any upgrade / open / message hook runs. This eliminates the boot-to-first-connect window where cron ticks were no-ops and `live.push` could not reach cross-instance users.
@@ -376,6 +426,26 @@ export const transport = realtimeTransport();
376
426
 
377
427
  Type-only changes, deprecations, dead code removed. No action required for most apps.
378
428
 
429
+ ### `ctx.throttle` / `ctx.debounce` renamed to `ctx.publishThrottled` / `ctx.publishDebounced`; old names accepted as soft-deprecated aliases
430
+
431
+ **What changed.** The names `throttle` / `debounce` in JS-land (lodash, RxJS, Underscore) typically mean "gate a function's execution." The realtime helpers actually scheduled outbound publishes - misreading the name as a gate led to calls like `ctx.throttle('move:noteId', 50)` (developer intent: "gate this handler") which silently published junk frames to a topic nobody subscribed to at the full client rate (`event=50` (number), `data=undefined`, `ms=undefined` -> `setTimeout(_, 0)` -> zero-ms window -> next call publishes again). The new names `publishThrottled` / `publishDebounced` put "publish" central so the misread becomes structurally impossible.
432
+
433
+ For the gate-handler use case the developer was actually after, `ctx.skip(key, ms)` is the new primitive (see [Recommended new patterns](#recommended-new-patterns)).
434
+
435
+ **How to migrate.** Optional rename for new code:
436
+
437
+ ```diff
438
+ - ctx.throttle(topic, event, data, ms)
439
+ + ctx.publishThrottled(topic, event, data, ms)
440
+
441
+ - ctx.debounce(topic, event, data, ms)
442
+ + ctx.publishDebounced(topic, event, data, ms)
443
+ ```
444
+
445
+ The old names keep working as aliases indefinitely. A one-time dev warning per process per name fires on first call to the old name; production behaviour is unchanged. To silence the dev warning, rename. `live.cron()` and `live()` contexts both gained the new names; both keep the old aliases.
446
+
447
+ If you wrote `ctx.throttle('move:id', 50)` thinking it would gate handler execution, the fix is `if (ctx.skip('move:id', 50)) return` at the top of the handler body. See the `ctx.skip` migration entry in [Recommended new patterns](#recommended-new-patterns).
448
+
379
449
  ### `pushHooks.close` now drains stream-subscription bookkeeping when called with `ctx`
380
450
 
381
451
  **What changed.** Pre-0.5, `pushHooks.close` was push-only. Apps following the JSDoc-ordained `export const close = pushHooks.close;` left stream-subscription bookkeeping (`_topicWsCounts`, silent-topic watchdogs, `__onUnsubscribe` callbacks) un-drained. A 30s flurry of `silent topic` warnings fired after every page closed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -89,12 +89,12 @@
89
89
  "peerDependencies": {
90
90
  "@sveltejs/kit": "^2.0.0",
91
91
  "svelte": "^4.0.0 || ^5.0.0",
92
- "svelte-adapter-uws": "^0.5.2"
92
+ "svelte-adapter-uws": "^0.5.3"
93
93
  },
94
94
  "devDependencies": {
95
95
  "@playwright/test": "^1.59.1",
96
96
  "fast-check": "^4.7.0",
97
- "svelte-adapter-uws-extensions": "^0.5.2",
97
+ "svelte-adapter-uws-extensions": "^0.5.3",
98
98
  "vitest": "^4.0.18"
99
99
  },
100
100
  "keywords": [
package/server.d.ts CHANGED
@@ -9,9 +9,32 @@ export interface CronContext {
9
9
  platform: Platform;
10
10
  /** Shorthand for `platform.publish` - delegates to whatever platform was passed in. */
11
11
  publish: Platform['publish'];
12
- /** Throttled publish - sends at most once per `ms` milliseconds. */
12
+ /**
13
+ * Publish a value to a topic at most once per `ms` milliseconds.
14
+ * The latest value always arrives (trailing edge). Outbound publish
15
+ * helper - does NOT gate handler execution. For per-key handler gating
16
+ * use `ctx.skip(key, ms)`.
17
+ */
18
+ publishThrottled(topic: string, event: string, data: any, ms: number): void;
19
+ /**
20
+ * Publish a value to a topic after `ms` milliseconds of silence. Outbound
21
+ * publish helper - does NOT gate handler execution. For per-key handler
22
+ * gating use `ctx.skip(key, ms)`.
23
+ */
24
+ publishDebounced(topic: string, event: string, data: any, ms: number): void;
25
+ /**
26
+ * @deprecated Renamed to `publishThrottled`. The old name reads like a
27
+ * handler gate, but it is a publish helper. For per-key handler gating
28
+ * use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
29
+ * a one-time dev warning fires on first call.
30
+ */
13
31
  throttle(topic: string, event: string, data: any, ms: number): void;
14
- /** Debounced publish - sends after `ms` milliseconds of silence. */
32
+ /**
33
+ * @deprecated Renamed to `publishDebounced`. The old name reads like a
34
+ * handler gate, but it is a publish helper. For per-key handler gating
35
+ * use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
36
+ * a one-time dev warning fires on first call.
37
+ */
15
38
  debounce(topic: string, event: string, data: any, ms: number): void;
16
39
  /** Send a point-to-point signal to a specific user. */
17
40
  signal(userId: string, event: string, data: any): void;
@@ -37,12 +60,58 @@ export interface LiveContext<UserData = unknown> {
37
60
  publish: Platform['publish'];
38
61
  /** Cursor value sent by the client for paginated stream requests. `null` if not paginated. */
39
62
  cursor: any;
40
- /** Throttled publish - sends at most once per `ms` milliseconds. */
63
+ /**
64
+ * Publish a value to a topic at most once per `ms` milliseconds.
65
+ * The latest value always arrives (trailing edge). Outbound publish
66
+ * helper - does NOT gate handler execution. For per-key handler gating
67
+ * use `ctx.skip(key, ms)`.
68
+ */
69
+ publishThrottled(topic: string, event: string, data: any, ms: number): void;
70
+ /**
71
+ * Publish a value to a topic after `ms` milliseconds of silence. Outbound
72
+ * publish helper - does NOT gate handler execution. For per-key handler
73
+ * gating use `ctx.skip(key, ms)`.
74
+ */
75
+ publishDebounced(topic: string, event: string, data: any, ms: number): void;
76
+ /**
77
+ * @deprecated Renamed to `publishThrottled`. The old name reads like a
78
+ * handler gate, but it is a publish helper. For per-key handler gating
79
+ * use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
80
+ * a one-time dev warning fires on first call.
81
+ */
41
82
  throttle(topic: string, event: string, data: any, ms: number): void;
42
- /** Debounced publish - sends after `ms` milliseconds of silence. */
83
+ /**
84
+ * @deprecated Renamed to `publishDebounced`. The old name reads like a
85
+ * handler gate, but it is a publish helper. For per-key handler gating
86
+ * use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
87
+ * a one-time dev warning fires on first call.
88
+ */
43
89
  debounce(topic: string, event: string, data: any, ms: number): void;
44
90
  /** Send a point-to-point signal to a specific user. */
45
91
  signal(userId: string, event: string, data: any): void;
92
+ /**
93
+ * Per-key handler gate. Returns `true` to skip the call (key is within
94
+ * its cooldown window), `false` to run it (no entry, or window elapsed).
95
+ * Pair with an early `return` inside the handler body.
96
+ *
97
+ * State is per-replica - this is a CPU/DB shed, not a cluster-wide rate
98
+ * limit. For cross-replica gating use `live.rateLimit({ store: 'redis' })`
99
+ * or `redis/ratelimit` from svelte-adapter-uws-extensions.
100
+ *
101
+ * Throws `LiveError('INVALID_ARG', ...)` if `key` isn't a string or `ms`
102
+ * isn't a positive finite number. Capped at 5000 active entries with
103
+ * fail-open semantics on overflow (returns `false`, dev-warns once).
104
+ *
105
+ * @example
106
+ * ```js
107
+ * export const moveNote = live(async (ctx, noteId, x, y) => {
108
+ * if (ctx.skip(`move:${noteId}`, 16)) return; // drop calls within 16ms
109
+ * await dbUpdateNote(noteId, x, y);
110
+ * ctx.publish(TOPICS.notes, 'updated', { noteId, x, y });
111
+ * });
112
+ * ```
113
+ */
114
+ skip(key: string, ms: number): boolean;
46
115
  /**
47
116
  * Pressure-aware shed check. Returns `true` if a request of the given
48
117
  * class of service should be shed under current `platform.pressure`.
@@ -500,7 +569,48 @@ export interface CreateMessageOptions {
500
569
  onError?(path: string, error: unknown, ctx: LiveContext<any>): void;
501
570
 
502
571
  /**
503
- * Called when a message is not an RPC request.
572
+ * Called when a non-RPC text frame parses as a JSON object envelope.
573
+ * The framework runs `TextDecoder + JSON.parse` once (or, when the
574
+ * adapter already parsed the frame for its own control-message routing,
575
+ * uses the adapter-forwarded value directly - one parse total), and
576
+ * hands the parsed value to this callback.
577
+ *
578
+ * Dispatch by `msg.type` inside the callback. Plugin-layer hooks
579
+ * (`cursor.hooks.message`, future presence/typing) consume the parsed
580
+ * value so user wiring doesn't re-parse on every frame.
581
+ *
582
+ * Frames that don't look like a JSON object (first byte not `{`),
583
+ * fail to parse, or sit at nesting depth greater than `maxJsonDepth`,
584
+ * fall through to `onUnhandled` with the original raw bytes.
585
+ *
586
+ * The adapter's `websocket.maxPayloadLength` already bounds the bytes
587
+ * `JSON.parse` ever sees, so there's no separate size cap here.
588
+ *
589
+ * @example
590
+ * ```js
591
+ * createMessage({
592
+ * onJsonMessage(ws, msg, platform) {
593
+ * if (msg.type === 'cursor') cursor.hooks.message(ws, { data: msg, platform });
594
+ * else if (msg.type === 'presence-snapshot') presence.hooks.message(ws, { data: msg, platform });
595
+ * }
596
+ * })
597
+ * ```
598
+ */
599
+ onJsonMessage?(ws: WebSocket<any>, msg: any, platform: Platform): void;
600
+
601
+ /**
602
+ * Maximum nesting depth allowed in a parsed `onJsonMessage` envelope.
603
+ * Frames deeper than this fall through to `onUnhandled` unparsed.
604
+ * Mirrors `handleRpc`'s `maxEnvelopeDepth` semantics; same default.
605
+ *
606
+ * @default 64
607
+ */
608
+ maxJsonDepth?: number;
609
+
610
+ /**
611
+ * Called when a message is not an RPC request and either no
612
+ * `onJsonMessage` is set, or the frame is binary / non-JSON / parses
613
+ * to a non-object / exceeds `maxJsonDepth`.
504
614
  * Use for mixing RPC with custom message handling.
505
615
  */
506
616
  onUnhandled?(ws: WebSocket<any>, data: ArrayBuffer, platform: Platform): void;
package/server.js CHANGED
@@ -823,12 +823,22 @@ function _applyInitTransform(transform, data) {
823
823
  return transform(data);
824
824
  }
825
825
 
826
- /** @type {WeakMap<any, { publish: Function, throttle: Function, debounce: Function, signal: Function, batch: Function, shed: Function }>} */
826
+ /** @type {WeakMap<any, { publish: Function, publishThrottled: Function, publishDebounced: Function, throttle: Function, debounce: Function, signal: Function, batch: Function, shed: Function, skip: Function }>} */
827
827
  const _ctxHelpersCache = new WeakMap();
828
828
 
829
829
  /** @type {boolean} */
830
830
  const _IS_DEV = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production';
831
831
 
832
+ /** Dev-warn dedup: one-time "ctx.throttle is deprecated" warning. */
833
+ let _throttleDeprecatedWarned = false;
834
+ /** Dev-warn dedup: one-time "ctx.debounce is deprecated" warning. */
835
+ let _debounceDeprecatedWarned = false;
836
+ /** Dev-warn dedup: per-helper bad-args warning. Keys: 'publishThrottled', 'publishDebounced', 'throttle', 'debounce'. */
837
+ /** @type {Record<string, boolean>} */
838
+ const _publishHelperBadArgsWarned = Object.create(null);
839
+ /** Dev-warn dedup: one-time "ctx.skip gate map at capacity" warning. */
840
+ let _skipGateCapWarned = false;
841
+
832
842
  /**
833
843
  * Topics declared with `live.stream(..., { replay: true })`. Populated at
834
844
  * declaration time for static topics and at first-subscribe time for
@@ -1254,15 +1264,50 @@ function _getCtxHelpers(platform) {
1254
1264
  };
1255
1265
  helpers = {
1256
1266
  publish,
1257
- throttle: (topic, event, data, ms) => _throttlePublish(platform, topic, event, data, ms),
1258
- debounce: (topic, event, data, ms) => _debouncePublish(platform, topic, event, data, ms),
1267
+ publishThrottled: (...args) => {
1268
+ _checkPublishHelperArgs('publishThrottled', args);
1269
+ return _throttlePublish(platform, /** @type {string} */ (args[0]), /** @type {string} */ (args[1]), args[2], /** @type {number} */ (args[3]));
1270
+ },
1271
+ publishDebounced: (...args) => {
1272
+ _checkPublishHelperArgs('publishDebounced', args);
1273
+ return _debouncePublish(platform, /** @type {string} */ (args[0]), /** @type {string} */ (args[1]), args[2], /** @type {number} */ (args[3]));
1274
+ },
1275
+ throttle: (...args) => {
1276
+ if (_IS_DEV && !_throttleDeprecatedWarned) {
1277
+ _throttleDeprecatedWarned = true;
1278
+ console.warn(
1279
+ '[svelte-realtime] ctx.throttle is deprecated -- rename to ctx.publishThrottled. ' +
1280
+ 'The old name reads like a handler gate, but it is a 4-arg publish helper. ' +
1281
+ 'For per-key handler gating use ctx.skip(key, ms); the publish-helper behaviour ' +
1282
+ 'is unchanged. This warning fires once per process.\n' +
1283
+ ' See: https://svti.me/publish-throttled'
1284
+ );
1285
+ }
1286
+ _checkPublishHelperArgs('throttle', args);
1287
+ return _throttlePublish(platform, /** @type {string} */ (args[0]), /** @type {string} */ (args[1]), args[2], /** @type {number} */ (args[3]));
1288
+ },
1289
+ debounce: (...args) => {
1290
+ if (_IS_DEV && !_debounceDeprecatedWarned) {
1291
+ _debounceDeprecatedWarned = true;
1292
+ console.warn(
1293
+ '[svelte-realtime] ctx.debounce is deprecated -- rename to ctx.publishDebounced. ' +
1294
+ 'The old name reads like a handler gate, but it is a 4-arg publish helper. ' +
1295
+ 'For per-key handler gating use ctx.skip(key, ms); the publish-helper behaviour ' +
1296
+ 'is unchanged. This warning fires once per process.\n' +
1297
+ ' See: https://svti.me/publish-debounced'
1298
+ );
1299
+ }
1300
+ _checkPublishHelperArgs('debounce', args);
1301
+ return _debouncePublish(platform, /** @type {string} */ (args[0]), /** @type {string} */ (args[1]), args[2], /** @type {number} */ (args[3]));
1302
+ },
1259
1303
  signal: (userId, event, data) => {
1260
1304
  const reason = _validUserIdReason(userId);
1261
1305
  if (reason !== null) throw new LiveError('INVALID_USER_ID', 'ctx.signal: ' + reason);
1262
1306
  return platform.publish('__signal:' + userId, event, data);
1263
1307
  },
1264
1308
  batch: (messages) => platform.batch ? platform.batch(messages) : messages.forEach((m) => publish(m.topic, m.event, m.data, m.options)),
1265
- shed: (className) => _shouldShed(platform, className)
1309
+ shed: (className) => _shouldShed(platform, className),
1310
+ skip: (key, ms) => _skipGate(key, ms)
1266
1311
  };
1267
1312
  _ctxHelpersCache.set(platform, helpers);
1268
1313
  }
@@ -1400,7 +1445,7 @@ function _rollbackStreamSubscribe(ws, topic, fn, ctx) {
1400
1445
  * @param {any} user
1401
1446
  * @param {any} ws
1402
1447
  * @param {import('svelte-adapter-uws').Platform} platform
1403
- * @param {{ publish: Function, throttle: Function, debounce: Function, signal: Function, batch: Function, shed: Function }} helpers
1448
+ * @param {{ publish: Function, publishThrottled: Function, publishDebounced: Function, throttle: Function, debounce: Function, signal: Function, batch: Function, shed: Function, skip: Function }} helpers
1404
1449
  * @param {any} cursor
1405
1450
  * @param {string | null} [idempotencyKey] Envelope-supplied idempotency key, or null. Internal use only.
1406
1451
  * @returns {any}
@@ -1412,11 +1457,14 @@ function _buildCtx(user, ws, platform, helpers, cursor, idempotencyKey) {
1412
1457
  platform,
1413
1458
  publish: helpers.publish,
1414
1459
  cursor,
1460
+ publishThrottled: helpers.publishThrottled,
1461
+ publishDebounced: helpers.publishDebounced,
1415
1462
  throttle: helpers.throttle,
1416
1463
  debounce: helpers.debounce,
1417
1464
  signal: helpers.signal,
1418
1465
  batch: helpers.batch,
1419
1466
  shed: helpers.shed,
1467
+ skip: helpers.skip,
1420
1468
  requestId: platform.requestId,
1421
1469
  _idempotencyKey: idempotencyKey || null
1422
1470
  };
@@ -3045,15 +3093,23 @@ export const pushHooks = {
3045
3093
  * Send a server-initiated request to a connected user and await the reply.
3046
3094
  *
3047
3095
  * Lookup order:
3048
- * 1. **Local registry** - the per-userId Map populated by `pushHooks.open` /
3049
- * `pushHooks.close`. Resolves directly via `platform.request(ws, ...)` --
3050
- * no I/O.
3051
- * 2. **Remote registry** - the optional `remoteRegistry` configured via
3052
- * `live.configurePush({ remoteRegistry })`. When the userId is not
3053
- * registered on this instance and a registry is configured,
3054
- * `live.push` falls through to `remoteRegistry.request(userId, ...)`
3055
- * so a request from any instance reaches the user's owning instance
3056
- * over the registry's transport.
3096
+ * 1. **Remote registry (when configured)** - the optional `remoteRegistry`
3097
+ * set via `live.configurePush({ remoteRegistry })` is the cluster-wide
3098
+ * source of truth for "which instance currently owns this userId" (most-
3099
+ * recently-opened wins via the registry's last-write-wins userToInstance
3100
+ * map). `live.push` delegates to `remoteRegistry.request(userId, ...)`
3101
+ * so the recipient is deterministic regardless of which instance the
3102
+ * caller runs on. The registry's own self-targeting short-circuit
3103
+ * (`registry.js: ownerInstanceId === instanceId`) means no extra Redis
3104
+ * hop when the canonical owner IS this instance -- single-tab
3105
+ * performance is unchanged.
3106
+ * 2. **Local registry (fallback)** - the per-userId Map populated by
3107
+ * `pushHooks.open` / `pushHooks.close`. When no `remoteRegistry` is
3108
+ * configured (single-instance dev), this is the only path. When a
3109
+ * `remoteRegistry` IS configured, the local entry is used only as a
3110
+ * best-effort fallback on the brief race window where `pushHooks.open`
3111
+ * has populated the local map but the cluster pub/sub event has not
3112
+ * yet propagated to this instance's index.
3057
3113
  *
3058
3114
  * Returns whatever the client's `onPush(event, handler)` returns.
3059
3115
  *
@@ -3141,20 +3197,38 @@ live.push = async function push(target, event, data, options) {
3141
3197
  throw new LiveError('VALIDATION', '[svelte-realtime] live.push: target.userId must be a non-empty string');
3142
3198
  }
3143
3199
 
3144
- const entry = _pushRegistry.get(userId);
3145
- if (entry) {
3146
- if (typeof entry.platform.request !== 'function') {
3147
- throw new Error('[svelte-realtime] live.push: platform.request is not available; requires svelte-adapter-uws >= 0.5.0-next.4');
3148
- }
3200
+ // Cluster-first when a remoteRegistry is configured: the registry's
3201
+ // userToInstance map is the cluster-wide canonical-owner truth (most-
3202
+ // recent open wins per its last-write-wins applyOpenEvent). The
3203
+ // registry's own self-targeting short-circuit means no extra Redis
3204
+ // hop when the canonical owner is THIS instance -- single-tab perf
3205
+ // is unchanged. Multi-tab same-user across instances now routes
3206
+ // deterministically to the cluster-canonical recipient regardless of
3207
+ // which instance the caller runs on, matching the documented
3208
+ // "cluster-wide most-recent-wins" contract above.
3209
+ //
3210
+ // On a brief registry-offline race (fresh local open whose cluster
3211
+ // pub/sub event has not yet propagated to this instance's index),
3212
+ // fall back to the local entry so the just-opened user does not see
3213
+ // a NOT_FOUND for their own push.
3214
+ const localEntry = _pushRegistry.get(userId);
3215
+ if (_remoteRegistry) {
3149
3216
  try {
3150
- return await entry.platform.request(entry.ws, event, data, options || undefined);
3217
+ return await _remoteRegistry.request(userId, event, data, options || undefined);
3151
3218
  } catch (err) {
3152
- throw _translatePushError(err);
3219
+ if (localEntry && _isRegistryOfflineError(err)) {
3220
+ // fall through to local fast path below
3221
+ } else {
3222
+ throw _translatePushError(err);
3223
+ }
3153
3224
  }
3154
3225
  }
3155
- if (_remoteRegistry) {
3226
+ if (localEntry) {
3227
+ if (typeof localEntry.platform.request !== 'function') {
3228
+ throw new Error('[svelte-realtime] live.push: platform.request is not available; requires svelte-adapter-uws >= 0.5.0-next.4');
3229
+ }
3156
3230
  try {
3157
- return await _remoteRegistry.request(userId, event, data, options || undefined);
3231
+ return await localEntry.platform.request(localEntry.ws, event, data, options || undefined);
3158
3232
  } catch (err) {
3159
3233
  throw _translatePushError(err);
3160
3234
  }
@@ -3195,6 +3269,31 @@ function _translatePushError(err) {
3195
3269
  return err;
3196
3270
  }
3197
3271
 
3272
+ /**
3273
+ * Detect the "cluster has no entry for this userId" rejection from a
3274
+ * configured remoteRegistry. Used by live.push / live.notify to decide
3275
+ * whether to fall back to a (potentially fresher) local registry entry
3276
+ * during the brief propagation race after `pushHooks.open` writes to
3277
+ * Redis + publishes the event but the subscriber index hasn't yet
3278
+ * applied it on the current instance.
3279
+ *
3280
+ * Conservatively substring-matches "offline" -- the extensions
3281
+ * registry's exact wording is `registry.request: target user "..." is
3282
+ * offline`, and the project's existing test fixtures throw bare
3283
+ * `Error('offline')`. Other cluster errors (recipient handler throw,
3284
+ * timeout) are real signals and NOT eligible for local fallback so
3285
+ * caller-defined error shapes propagate intact.
3286
+ *
3287
+ * @param {unknown} err
3288
+ * @returns {boolean}
3289
+ */
3290
+ function _isRegistryOfflineError(err) {
3291
+ const msg = err && typeof (/** @type {any} */ (err).message) === 'string'
3292
+ ? /** @type {any} */ (err).message
3293
+ : '';
3294
+ return /offline/i.test(msg);
3295
+ }
3296
+
3198
3297
  /**
3199
3298
  * Bounded internal timeout for the wire-level request that backs
3200
3299
  * `live.notify`. The caller doesn't await the reply - this only
@@ -3276,8 +3375,46 @@ live.notify = function notify(target, event, data) {
3276
3375
  throw new LiveError('VALIDATION', '[svelte-realtime] live.notify: target.userId must be a non-empty string');
3277
3376
  }
3278
3377
 
3279
- const entry = _pushRegistry.get(userId);
3280
- if (entry) {
3378
+ // Cluster-first when a remoteRegistry is configured -- same rationale
3379
+ // as live.push above: the cluster registry's canonical-owner truth
3380
+ // routes deterministically to the most-recently-opened ws regardless
3381
+ // of caller instance. Self-target short-circuit keeps single-tab perf
3382
+ // unchanged. Multi-tab same-user across instances correctly reaches
3383
+ // the cluster-canonical recipient instead of the caller-local one.
3384
+ //
3385
+ // Fire-and-forget contract is preserved: any delivery failure
3386
+ // (offline, timeout, client handler throw, remote registry error)
3387
+ // is silent. The local fallback on registry-offline is a UX-only
3388
+ // optimization for the brief propagation race after a fresh open.
3389
+ const localEntry = _pushRegistry.get(userId);
3390
+ if (_remoteRegistry) {
3391
+ try {
3392
+ const p = _remoteRegistry.request(userId, event, data, { timeoutMs: _NOTIFY_INTERNAL_TIMEOUT_MS });
3393
+ if (localEntry) {
3394
+ // Brief registry-offline race after a fresh local open:
3395
+ // the cluster pub/sub event has not yet propagated, the
3396
+ // cluster rejects with "is offline", but the local
3397
+ // registry already has a valid entry. Fall back so the
3398
+ // user does not silently drop their own first notify.
3399
+ p.catch((err) => {
3400
+ if (_isRegistryOfflineError(err)) _deliverLocalNotify(localEntry);
3401
+ });
3402
+ } else {
3403
+ p.catch(() => { /* silent: fire-and-forget contract */ });
3404
+ }
3405
+ } catch {
3406
+ // Sync throw from registry shape; try local as best-effort.
3407
+ if (localEntry) _deliverLocalNotify(localEntry);
3408
+ }
3409
+ return Promise.resolve();
3410
+ }
3411
+ if (localEntry) _deliverLocalNotify(localEntry);
3412
+ // Offline + no cluster routing: silent no-op. The caller chose
3413
+ // notify; "we couldn't reach the user" isn't an error in this
3414
+ // contract - they'll see the result next time they load.
3415
+ return Promise.resolve();
3416
+
3417
+ function _deliverLocalNotify(entry) {
3281
3418
  if (typeof entry.platform.request !== 'function') {
3282
3419
  // Same versioning constraint as live.push - platform.request
3283
3420
  // requires svelte-adapter-uws >= 0.5.0-next.4. Stay silent in
@@ -3286,7 +3423,7 @@ live.notify = function notify(target, event, data) {
3286
3423
  if (_IS_DEV) {
3287
3424
  console.warn('[svelte-realtime] live.notify: platform.request is not available; requires svelte-adapter-uws >= 0.5.0-next.4. Notify dispatch silently no-op.\n See: https://svti.me/migration');
3288
3425
  }
3289
- return Promise.resolve();
3426
+ return;
3290
3427
  }
3291
3428
  try {
3292
3429
  entry.platform.request(entry.ws, event, data, { timeoutMs: _NOTIFY_INTERNAL_TIMEOUT_MS })
@@ -3299,24 +3436,7 @@ live.notify = function notify(target, event, data) {
3299
3436
  // platform.request can throw synchronously on a torn-down ws.
3300
3437
  // Same fire-and-forget contract: silent.
3301
3438
  }
3302
- return Promise.resolve();
3303
3439
  }
3304
- if (_remoteRegistry) {
3305
- try {
3306
- _remoteRegistry.request(userId, event, data, { timeoutMs: _NOTIFY_INTERNAL_TIMEOUT_MS })
3307
- .catch(() => {
3308
- // Cluster-route error (offline cluster-wide, transport
3309
- // failure, remote handler throw) - silent.
3310
- });
3311
- } catch {
3312
- // Sync throw from registry shape - silent.
3313
- }
3314
- return Promise.resolve();
3315
- }
3316
- // Offline + no cluster routing: silent no-op. The caller chose
3317
- // notify; "we couldn't reach the user" isn't an error in this
3318
- // contract - they'll see the result next time they load.
3319
- return Promise.resolve();
3320
3440
  };
3321
3441
 
3322
3442
  /**
@@ -7942,6 +8062,111 @@ function _debouncePublish(platform, topic, event, data, ms) {
7942
8062
  }, ms));
7943
8063
  }
7944
8064
 
8065
+ /**
8066
+ * Per-key gate state. Each entry is a setTimeout handle that self-deletes
8067
+ * the key when its cooldown window elapses. Shape mirrors `_throttles` /
8068
+ * `_debounces` so memory accounting and cap semantics are uniform.
8069
+ *
8070
+ * @type {Map<string, ReturnType<typeof setTimeout>>}
8071
+ */
8072
+ const _skipGates = new Map();
8073
+
8074
+ /**
8075
+ * Per-key rate gate. Returns `true` to skip the call (key is within its
8076
+ * cooldown window), `false` to run it (no entry, or window elapsed). The
8077
+ * caller pairs this with an early `return` inside an RPC handler:
8078
+ *
8079
+ * export const moveNote = live(async (ctx, noteId, x, y) => {
8080
+ * if (ctx.skip(`move:${noteId}`, 16)) return; // drop calls within 16ms
8081
+ * await dbUpdateNote(noteId, x, y);
8082
+ * ctx.publish(TOPICS.notes, 'updated', { noteId, x, y });
8083
+ * });
8084
+ *
8085
+ * Pairs with `ctx.shed` semantically (both return `true` to early-return),
8086
+ * so call sites read uniformly. Different from `ctx.publishThrottled` /
8087
+ * `ctx.publishDebounced` which schedule outbound publishes - `ctx.skip`
8088
+ * gates the inbound handler body.
8089
+ *
8090
+ * **Memory:** capped at `_THROTTLE_DEBOUNCE_MAX` (5000) entries. When the
8091
+ * cap is hit, the gate fails open (returns `false`, does NOT stamp a new
8092
+ * entry) so a runaway dynamic-key generator (e.g. spraying unique keys to
8093
+ * exhaust the map) cannot silently start blocking legitimate calls. The
8094
+ * first cap-hit fires a one-shot dev warning so operators see the issue.
8095
+ *
8096
+ * **Cluster:** state is per-replica. Each replica that received an RPC
8097
+ * call evaluates `ctx.skip` against its local map; the gate is a CPU/DB
8098
+ * shed, not a cluster-wide ratelimit. For cross-replica gating use
8099
+ * `live.rateLimit({ store: 'redis' })` or `redis/ratelimit`.
8100
+ *
8101
+ * @param {string} key
8102
+ * @param {number} ms
8103
+ * @returns {boolean} `true` to skip the call; `false` to run it
8104
+ */
8105
+ function _skipGate(key, ms) {
8106
+ if (typeof key !== 'string') {
8107
+ throw new LiveError('INVALID_ARG', 'ctx.skip: key must be a string (got ' + (typeof key) + ')');
8108
+ }
8109
+ if (typeof ms !== 'number' || !(ms > 0) || !Number.isFinite(ms)) {
8110
+ throw new LiveError('INVALID_ARG', 'ctx.skip: ms must be a positive finite number (got ' + String(ms) + ')');
8111
+ }
8112
+ if (_skipGates.has(key)) return true;
8113
+ if (_skipGates.size >= _THROTTLE_DEBOUNCE_MAX) {
8114
+ if (_IS_DEV && !_skipGateCapWarned) {
8115
+ _skipGateCapWarned = true;
8116
+ console.warn(
8117
+ '[svelte-realtime] ctx.skip: gate map at capacity (' + _THROTTLE_DEBOUNCE_MAX + ' entries). ' +
8118
+ 'Falling open - calls are no longer being gated. Check for runaway dynamic-key generation ' +
8119
+ '(e.g. unique-per-request keys).\n' +
8120
+ ' See: https://svti.me/skip-gate'
8121
+ );
8122
+ }
8123
+ return false;
8124
+ }
8125
+ _skipGates.set(key, setTimeout(() => { _skipGates.delete(key); }, ms));
8126
+ return false;
8127
+ }
8128
+
8129
+ /**
8130
+ * Dev-only sanity check for `ctx.publishThrottled` / `ctx.publishDebounced`
8131
+ * (and the deprecated `ctx.throttle` / `ctx.debounce` aliases). Logs a
8132
+ * one-time warning per helper name when args don't match the publish-
8133
+ * helper shape `(topic: string, event: string, data: any, ms: number > 0)`.
8134
+ *
8135
+ * The misuse pattern is calling `ctx.throttle('move:id', 50)` thinking it
8136
+ * gates a handler - it doesn't, it's a 4-arg publish helper. The warning
8137
+ * points at `ctx.skip(key, ms)` as the actual gate primitive.
8138
+ *
8139
+ * Production silently continues (no throw) so existing buggy deployments
8140
+ * don't crash on adapter upgrade; the dev warning surfaces the issue at
8141
+ * code-change time, not at runtime.
8142
+ *
8143
+ * @param {string} name - bare helper name (e.g. `'publishThrottled'`)
8144
+ * @param {ReadonlyArray<unknown>} args - the call's argument list
8145
+ */
8146
+ function _checkPublishHelperArgs(name, args) {
8147
+ if (!_IS_DEV) return;
8148
+ if (_publishHelperBadArgsWarned[name]) return;
8149
+ const ok = args.length >= 4
8150
+ && typeof args[0] === 'string'
8151
+ && typeof args[1] === 'string'
8152
+ && typeof args[3] === 'number'
8153
+ && /** @type {number} */ (args[3]) > 0
8154
+ && Number.isFinite(/** @type {number} */ (args[3]));
8155
+ if (ok) return;
8156
+ _publishHelperBadArgsWarned[name] = true;
8157
+ console.warn(
8158
+ '[svelte-realtime] ctx.' + name + ' called with bad args -- expected ' +
8159
+ '(topic: string, event: string, data: any, ms: number > 0). Got ' +
8160
+ 'argc=' + args.length + ', topic=' + (typeof args[0]) +
8161
+ ', event=' + (typeof args[1]) +
8162
+ ', ms=' + (typeof args[3] === 'number' ? String(args[3]) : typeof args[3]) + '. ' +
8163
+ 'ctx.' + name + ' is a publish helper, not a handler gate. ' +
8164
+ 'For per-key handler gating use ctx.skip(key, ms); for handler-wide rate ' +
8165
+ 'limiting use live.rateLimit().\n' +
8166
+ ' See: https://svti.me/publish-helper-args'
8167
+ );
8168
+ }
8169
+
7945
8170
  /**
7946
8171
  * Send an RPC response to a single client.
7947
8172
  * @param {any} ws
@@ -8371,13 +8596,14 @@ export function message(ws, { data, platform }) {
8371
8596
  /**
8372
8597
  * Create a custom message hook with options baked in.
8373
8598
  *
8374
- * @param {{ platform?: (p: import('svelte-adapter-uws').Platform) => import('svelte-adapter-uws').Platform, beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void, onUnhandled?: (ws: any, data: ArrayBuffer, platform: import('svelte-adapter-uws').Platform) => void }} [options]
8375
- * @returns {(ws: any, ctx: { data: ArrayBuffer, platform: import('svelte-adapter-uws').Platform }) => void}
8599
+ * @param {{ platform?: (p: import('svelte-adapter-uws').Platform) => import('svelte-adapter-uws').Platform, beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void, onJsonMessage?: (ws: any, msg: any, platform: import('svelte-adapter-uws').Platform) => void, maxJsonDepth?: number, onUnhandled?: (ws: any, data: ArrayBuffer, platform: import('svelte-adapter-uws').Platform) => void }} [options]
8600
+ * @returns {(ws: any, ctx: { data: ArrayBuffer, msg?: any, platform: import('svelte-adapter-uws').Platform }) => void}
8376
8601
  */
8377
8602
  export function createMessage(options) {
8378
8603
  if (!options) return message;
8379
8604
 
8380
- const { platform: transformPlatform, beforeExecute, onError, onUnhandled } = options;
8605
+ const { platform: transformPlatform, beforeExecute, onError, onJsonMessage, onUnhandled } = options;
8606
+ const maxJsonDepth = (options && /** @type {any} */ (options).maxJsonDepth) || _DEFAULT_MAX_ENVELOPE_DEPTH;
8381
8607
 
8382
8608
  /** @type {any} */
8383
8609
  const rpcOpts = {};
@@ -8385,7 +8611,13 @@ export function createMessage(options) {
8385
8611
  if (onError) rpcOpts.onError = onError;
8386
8612
  const hasRpcOpts = beforeExecute || onError;
8387
8613
 
8388
- return function customMessage(ws, { data, platform }) {
8614
+ return function customMessage(ws, ctx) {
8615
+ const { data, platform } = ctx;
8616
+ // `msg` is forwarded by svelte-adapter-uws when it JSON-parsed the
8617
+ // frame for control-message routing but no control type matched.
8618
+ // Undefined on older adapter versions / binary / prefix-miss / parse-
8619
+ // fail / non-object. See svelte-adapter-uws MessageContext docs.
8620
+ const forwardedMsg = /** @type {any} */ (ctx).msg;
8389
8621
  // Install the framework's publish wrap on the platform (idempotent
8390
8622
  // per platform). After this returns, `platform.publish` is
8391
8623
  // `derivedPublish`, which is the single bus-routing site for the
@@ -8420,7 +8652,52 @@ export function createMessage(options) {
8420
8652
  p = platform;
8421
8653
  }
8422
8654
  const handled = handleRpc(ws, data, p, hasRpcOpts ? rpcOpts : undefined);
8423
- if (!handled && onUnhandled) {
8655
+ if (handled) return;
8656
+
8657
+ // JSON-envelope dispatch. Plugin-layer frames (cursor `{type:'cursor',...}`,
8658
+ // future presence-snapshot, typing indicators, etc.) reach a single
8659
+ // callback with the parsed value, so user wiring doesn't re-parse on
8660
+ // every frame.
8661
+ //
8662
+ // Two-tier lookup:
8663
+ // 1) Fast path - if the adapter already parsed for control routing,
8664
+ // use the forwarded `msg` directly (one parse total).
8665
+ // 2) Fallback - if the adapter didn't forward (older adapter version,
8666
+ // frame > 8 KiB, or first byte not `{"ty`), parse here.
8667
+ //
8668
+ // `exceedsEnvelopeDepth` mirrors `handleRpc`'s defense-in-depth against
8669
+ // host walkers; deeper-than-cap envelopes fall through to `onUnhandled`
8670
+ // with raw bytes so callers can log without crashing.
8671
+ //
8672
+ // The adapter's `maxPayloadLength` (default 1 MB) already bounds the
8673
+ // bytes `JSON.parse` ever sees - no separate size cap needed here.
8674
+ if (onJsonMessage) {
8675
+ /** @type {any} */
8676
+ let dispatchMsg;
8677
+ if (forwardedMsg !== undefined && forwardedMsg !== null && typeof forwardedMsg === 'object') {
8678
+ if (!exceedsEnvelopeDepth(forwardedMsg, maxJsonDepth)) {
8679
+ dispatchMsg = forwardedMsg;
8680
+ }
8681
+ // Adapter forwarded an envelope but depth busted -> skip fallback
8682
+ // (re-parsing the same bytes would produce the same too-deep object).
8683
+ } else if (data instanceof ArrayBuffer && data.byteLength >= 2) {
8684
+ const bytes = new Uint8Array(data);
8685
+ if (bytes[0] === 0x7B /* '{' */) {
8686
+ try {
8687
+ const parsed = JSON.parse(textDecoder.decode(data));
8688
+ if (parsed !== null && typeof parsed === 'object' && !exceedsEnvelopeDepth(parsed, maxJsonDepth)) {
8689
+ dispatchMsg = parsed;
8690
+ }
8691
+ } catch { /* fall through to onUnhandled */ }
8692
+ }
8693
+ }
8694
+ if (dispatchMsg !== undefined) {
8695
+ onJsonMessage(ws, dispatchMsg, p);
8696
+ return;
8697
+ }
8698
+ }
8699
+
8700
+ if (onUnhandled) {
8424
8701
  onUnhandled(ws, data, p);
8425
8702
  }
8426
8703
  };