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 +70 -0
- package/package.json +3 -3
- package/server.d.ts +115 -5
- package/server.js +325 -48
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
1258
|
-
|
|
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. **
|
|
3049
|
-
*
|
|
3050
|
-
*
|
|
3051
|
-
*
|
|
3052
|
-
* `live.
|
|
3053
|
-
*
|
|
3054
|
-
*
|
|
3055
|
-
*
|
|
3056
|
-
*
|
|
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
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
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
|
|
3217
|
+
return await _remoteRegistry.request(userId, event, data, options || undefined);
|
|
3151
3218
|
} catch (err) {
|
|
3152
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
3280
|
-
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
};
|