svelte-realtime 0.5.3 → 0.5.5
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/README.md +21 -0
- package/client.d.ts +14 -0
- package/client.js +180 -94
- package/package.json +1 -1
- package/server.d.ts +17 -0
- package/server.js +54 -23
package/README.md
CHANGED
|
@@ -1271,6 +1271,27 @@ Call `configure()` once at app startup. The hooks fire on state transitions only
|
|
|
1271
1271
|
| `onConnect()` | Called when the WebSocket connection opens after a reconnect |
|
|
1272
1272
|
| `onDisconnect()` | Called when the WebSocket connection closes |
|
|
1273
1273
|
| `beforeReconnect()` | Called before each reconnection attempt (can be async) |
|
|
1274
|
+
| `timeout` | Default RPC timeout in ms (default `30000`). Per-call `.with({ timeout })` overrides. |
|
|
1275
|
+
| `resumeGraceMs` | Stream resume-grace window in ms (default `60000`). See [Pause and resume without re-rehydrating](#pause-and-resume-without-re-rehydrating) below. Set to `0` to disable. |
|
|
1276
|
+
|
|
1277
|
+
### Pause and resume without re-rehydrating
|
|
1278
|
+
|
|
1279
|
+
When the last subscriber of a stream unsubs, the stream releases its WebSocket subscription immediately (giving the server back its slot, dropping the in-flight counter) but keeps the in-memory data model -- `currentValue`, the last seen `seq` / `version`, the pagination `cursor`, and any history -- for `resumeGraceMs` (default 60 seconds). If a new `subscribe()` lands inside that window, the stream re-attaches its listeners and sends the retained cursor on the resume envelope, so the server can fill the gap from its bounded replay buffer (or `delta.fromSeq`, or a truncated-cache fall-through to a full rehydrate) instead of cold-starting.
|
|
1280
|
+
|
|
1281
|
+
This is the default for two reasons:
|
|
1282
|
+
|
|
1283
|
+
1. **Pause/resume UIs work for free.** A `{#if active} <SubscribedComponent /> {/if}` toggle, or an `$effect` whose subscribe-arm flips on user action, can pause and resume the subscription without re-loading from scratch. The events that arrived during the pause stream in via the replay buffer.
|
|
1284
|
+
2. **Browser back/forward feels instant.** Navigating away and back within the grace window restores the previous data immediately, and any events the user missed are gap-filled by the server.
|
|
1285
|
+
|
|
1286
|
+
If the grace expires without a new subscriber, the data model resets and the next subscribe is a true cold start. Apps that prefer aggressive memory reclamation can shorten or disable the grace:
|
|
1287
|
+
|
|
1288
|
+
```js
|
|
1289
|
+
configure({ resumeGraceMs: 0 }); // every unsub is a full reset (pre-grace behavior)
|
|
1290
|
+
configure({ resumeGraceMs: 5_000 }); // 5s grace covers brief toggles
|
|
1291
|
+
configure({ resumeGraceMs: 300_000 }); // 5min grace for navigation-heavy apps
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
The grace only affects local data retention. The server's replay buffer and `delta.fromSeq` window are independent and govern how far back the gap-fill can reach.
|
|
1274
1295
|
|
|
1275
1296
|
### Cross-origin and native app usage
|
|
1276
1297
|
|
package/client.d.ts
CHANGED
|
@@ -534,6 +534,20 @@ export function configure(config: {
|
|
|
534
534
|
onConnect?(): void;
|
|
535
535
|
/** Called when the WebSocket connection closes. */
|
|
536
536
|
onDisconnect?(): void;
|
|
537
|
+
/** Default RPC timeout in ms; per-call `.with({ timeout })` overrides. @default 30000 */
|
|
538
|
+
timeout?: number;
|
|
539
|
+
/**
|
|
540
|
+
* Stream resume-grace window in ms. When the last subscriber of a stream
|
|
541
|
+
* unsubs, the WS subscription is released immediately but the data model
|
|
542
|
+
* (currentValue, seq, version, cursor) is kept for this long. A new
|
|
543
|
+
* subscribe within the window resumes from the retained cursor so the
|
|
544
|
+
* server can fill the gap from its replay buffer (or fromSeq, or
|
|
545
|
+
* truncated -> full rehydrate) instead of cold-rehydrating. Covers
|
|
546
|
+
* pause/resume UIs and browser back/forward navigation. Set to 0 to
|
|
547
|
+
* disable the grace window.
|
|
548
|
+
* @default 60000
|
|
549
|
+
*/
|
|
550
|
+
resumeGraceMs?: number;
|
|
537
551
|
/** Offline mutation queue configuration. */
|
|
538
552
|
offline?: {
|
|
539
553
|
/** Enable queuing RPCs when disconnected. */
|
package/client.js
CHANGED
|
@@ -314,6 +314,25 @@ function _getTimeout() {
|
|
|
314
314
|
return _clientConfig.timeout || _DEFAULT_TIMEOUT;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
const _DEFAULT_RESUME_GRACE_MS = 60000;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Stream resume-grace window in ms. When the last subscriber unsubs, the
|
|
321
|
+
* stream releases its WS subscription immediately but keeps the in-memory
|
|
322
|
+
* data model (currentValue, _lastSeq, _lastVersion, _cursor) for this
|
|
323
|
+
* long. A new subscribe() within the window resumes from the retained
|
|
324
|
+
* cursor so the server can fill the gap from its replay buffer instead
|
|
325
|
+
* of cold-rehydrating. Set to 0 to disable the grace window (every
|
|
326
|
+
* cleanup is a full reset).
|
|
327
|
+
*
|
|
328
|
+
* @returns {number}
|
|
329
|
+
*/
|
|
330
|
+
function _getResumeGraceMs() {
|
|
331
|
+
const v = _clientConfig.resumeGraceMs;
|
|
332
|
+
if (typeof v === 'number' && v >= 0) return v;
|
|
333
|
+
return _DEFAULT_RESUME_GRACE_MS;
|
|
334
|
+
}
|
|
335
|
+
|
|
317
336
|
/** @type {boolean} Whether the connection is permanently dead (terminal close code, exhausted retries, or explicit close) */
|
|
318
337
|
let _terminated = false;
|
|
319
338
|
|
|
@@ -2509,9 +2528,16 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2509
2528
|
}
|
|
2510
2529
|
|
|
2511
2530
|
/**
|
|
2512
|
-
*
|
|
2531
|
+
* Tear down WS-level subscription handles, transient flags, and any
|
|
2532
|
+
* in-flight subscribe request. Leaves the in-memory data model
|
|
2533
|
+
* (currentValue, _index, _history, _lastSeq, _lastVersion, _cursor,
|
|
2534
|
+
* _hasMore, _schemaVersion, topic) intact so that a subscribe() call
|
|
2535
|
+
* landing during the resume-grace window can reattach listeners,
|
|
2536
|
+
* call fetchAndSubscribe() with the retained seq/version/cursor, and
|
|
2537
|
+
* let the server fill the gap from its replay buffer (or fromSeq, or
|
|
2538
|
+
* a truncated -> full rehydrate fallback) instead of cold-starting.
|
|
2513
2539
|
*/
|
|
2514
|
-
function
|
|
2540
|
+
function _releaseSubscription() {
|
|
2515
2541
|
if (pendingId) {
|
|
2516
2542
|
const entry = pending.get(pendingId);
|
|
2517
2543
|
if (entry) {
|
|
@@ -2547,10 +2573,19 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2547
2573
|
_bufA.length = 0;
|
|
2548
2574
|
_bufB.length = 0;
|
|
2549
2575
|
_activeBuf = _bufA;
|
|
2576
|
+
fetching = false;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/**
|
|
2580
|
+
* Reset the in-memory data model and error state. Runs when the
|
|
2581
|
+
* resume-grace window expires with no new subscriber, or immediately
|
|
2582
|
+
* on cleanup when resumeGraceMs is 0. After this runs, the next
|
|
2583
|
+
* subscribe() is a true cold start.
|
|
2584
|
+
*/
|
|
2585
|
+
function _resetSession() {
|
|
2550
2586
|
if (topic) _unregisterTopicErrorSetter(topic, _setError);
|
|
2551
2587
|
topic = null;
|
|
2552
2588
|
initialLoaded = false;
|
|
2553
|
-
fetching = false;
|
|
2554
2589
|
buffer = [];
|
|
2555
2590
|
currentValue = undefined;
|
|
2556
2591
|
store.set(undefined);
|
|
@@ -2562,17 +2597,6 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2562
2597
|
_history = [];
|
|
2563
2598
|
_historyIndex = -1;
|
|
2564
2599
|
_reconnectAttempts = 0;
|
|
2565
|
-
// Reset session-resume cursors. Cleanup means the stream is being
|
|
2566
|
-
// abandoned (last subscriber gone, deferred-cleanup microtask fired);
|
|
2567
|
-
// the next subscribe must start fresh, not falsely resume from
|
|
2568
|
-
// whatever seq / version / cursor the prior session left behind.
|
|
2569
|
-
// Without these resets, an unmount/remount cycle (e.g. browser back
|
|
2570
|
-
// then forward) sends a stale `seq` to the server, the server
|
|
2571
|
-
// responds with a since-seq delta (often empty), and the client's
|
|
2572
|
-
// reset `currentValue = undefined` never gets repopulated -- the
|
|
2573
|
-
// store stays undefined and any `{#if $store === undefined}` spinner
|
|
2574
|
-
// hangs forever. In-session WS reconnects do NOT go through cleanup,
|
|
2575
|
-
// so the replay-buffer gap-fill optimization is preserved for those.
|
|
2576
2600
|
_lastSeq = null;
|
|
2577
2601
|
_lastVersion = undefined;
|
|
2578
2602
|
_schemaVersion = initialSchemaVersion;
|
|
@@ -2582,8 +2606,95 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2582
2606
|
_devtoolsStream(path, null, 0, merge);
|
|
2583
2607
|
}
|
|
2584
2608
|
|
|
2585
|
-
/**
|
|
2609
|
+
/**
|
|
2610
|
+
* Full cleanup: release WS handles AND reset session state. Equivalent
|
|
2611
|
+
* to the pre-grace cleanup; used when resumeGraceMs is 0 (opt-out) or
|
|
2612
|
+
* when grace expires.
|
|
2613
|
+
*/
|
|
2614
|
+
function cleanup() {
|
|
2615
|
+
_releaseSubscription();
|
|
2616
|
+
_resetSession();
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
/** @type {boolean} Whether a microtask-deferred cleanup is pending (handles rapid sync unsub+resub) */
|
|
2586
2620
|
let _pendingCleanup = false;
|
|
2621
|
+
/** @type {ReturnType<typeof setTimeout> | null} Resume-grace expiry timer; non-null while state is being retained for a possible resume */
|
|
2622
|
+
let _resumeGraceTimer = null;
|
|
2623
|
+
/** @type {boolean} Whether the stream is in the resume-grace window (released WS, retained data) */
|
|
2624
|
+
let _inGracePeriod = false;
|
|
2625
|
+
|
|
2626
|
+
/**
|
|
2627
|
+
* Wire up the per-subscribe lifecycle listeners: quiescence tracking
|
|
2628
|
+
* (registers the stream with the global in-flight counter) and the
|
|
2629
|
+
* reconnect-on-open watcher. Shared between first-subscribe and
|
|
2630
|
+
* resume-from-grace so both paths get the same listener setup.
|
|
2631
|
+
*/
|
|
2632
|
+
function _attachLifecycleListeners() {
|
|
2633
|
+
_quiescenceUnsub = _statusStore.subscribe((s) => {
|
|
2634
|
+
const inFlight = s === 'loading' || s === 'reconnecting';
|
|
2635
|
+
if (inFlight && !_countedInFlight) {
|
|
2636
|
+
_countedInFlight = true;
|
|
2637
|
+
_addInFlight();
|
|
2638
|
+
} else if (!inFlight && _countedInFlight) {
|
|
2639
|
+
_countedInFlight = false;
|
|
2640
|
+
_removeInFlight();
|
|
2641
|
+
}
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2644
|
+
// `status.subscribe` fires synchronously with the current value, which
|
|
2645
|
+
// for a stream subscribing during page hydration is usually 'connecting'
|
|
2646
|
+
// (since `_connect()` is lazy on the first subscriber). Filter on 'open'
|
|
2647
|
+
// first and track whether we've ever seen one, so the FIRST 'open' is
|
|
2648
|
+
// the lifetime baseline rather than treating it as a reconnect bounce.
|
|
2649
|
+
let hasOpenedOnce = false;
|
|
2650
|
+
statusUnsub = status.subscribe((s) => {
|
|
2651
|
+
if (s !== 'open') return;
|
|
2652
|
+
if (!hasOpenedOnce) {
|
|
2653
|
+
hasOpenedOnce = true;
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
if (subCount > 0) {
|
|
2657
|
+
_status = 'reconnecting';
|
|
2658
|
+
_statusStore.set('reconnecting');
|
|
2659
|
+
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
|
2660
|
+
let delay;
|
|
2661
|
+
// Reconnect jitter: spread a fleet's reconnect attempts across the
|
|
2662
|
+
// window so a server restart does not get a thundering-herd retry
|
|
2663
|
+
// spike. Math.random is the right primitive here - jitter does not
|
|
2664
|
+
// need crypto-quality entropy. Not security-relevant.
|
|
2665
|
+
if (_reconnectAttempts < 2) {
|
|
2666
|
+
delay = 20 + Math.floor(Math.random() * 80);
|
|
2667
|
+
} else {
|
|
2668
|
+
const base = Math.min(1000 * Math.pow(2.2, _reconnectAttempts - 2), 300000);
|
|
2669
|
+
delay = Math.floor(base * (0.75 + Math.random() * 0.5));
|
|
2670
|
+
}
|
|
2671
|
+
_reconnectAttempts++;
|
|
2672
|
+
_reconnectTimer = setTimeout(() => {
|
|
2673
|
+
_reconnectTimer = null;
|
|
2674
|
+
if (topicUnsub) {
|
|
2675
|
+
topicUnsub();
|
|
2676
|
+
topicUnsub = null;
|
|
2677
|
+
}
|
|
2678
|
+
initialLoaded = false;
|
|
2679
|
+
fetching = false;
|
|
2680
|
+
buffer = [];
|
|
2681
|
+
fetchAndSubscribe();
|
|
2682
|
+
}, delay);
|
|
2683
|
+
}
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
// Surface terminal close as an error on the stream (adapter 0.4.0)
|
|
2687
|
+
try {
|
|
2688
|
+
const conn = _connect();
|
|
2689
|
+
if (conn && typeof conn.ready === 'function') {
|
|
2690
|
+
conn.ready().catch((/** @type {any} */ err) => {
|
|
2691
|
+
if (subCount > 0) {
|
|
2692
|
+
_setError(new RpcError(err?.code || 'CONNECTION_CLOSED', err?.message || 'Connection permanently closed'));
|
|
2693
|
+
}
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
} catch {}
|
|
2697
|
+
}
|
|
2587
2698
|
|
|
2588
2699
|
return {
|
|
2589
2700
|
// Stamped metadata so test-affordances like `subscribeAt`
|
|
@@ -2598,83 +2709,35 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2598
2709
|
subscribe(fn) {
|
|
2599
2710
|
if (subCount++ === 0) {
|
|
2600
2711
|
if (_pendingCleanup) {
|
|
2601
|
-
// Rapid resub - cancel the pending cleanup,
|
|
2712
|
+
// Rapid sync resub (same microtask) - cancel the pending cleanup,
|
|
2713
|
+
// WS subscription is still attached.
|
|
2602
2714
|
_pendingCleanup = false;
|
|
2603
|
-
} else {
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
if (inFlight && !_countedInFlight) {
|
|
2614
|
-
_countedInFlight = true;
|
|
2615
|
-
_addInFlight();
|
|
2616
|
-
} else if (!inFlight && _countedInFlight) {
|
|
2617
|
-
_countedInFlight = false;
|
|
2618
|
-
_removeInFlight();
|
|
2619
|
-
}
|
|
2620
|
-
});
|
|
2621
|
-
|
|
2622
|
-
// Listen for reconnects to refetch (debounced to avoid thundering herd).
|
|
2623
|
-
// `status.subscribe` fires synchronously with the current value, which
|
|
2624
|
-
// for a stream subscribing during page hydration is usually 'connecting'
|
|
2625
|
-
// (since `_connect()` is lazy on the first subscriber). Filter on 'open'
|
|
2626
|
-
// first and track whether we've ever seen one, so the FIRST 'open' is
|
|
2627
|
-
// the lifetime baseline rather than treating it as a reconnect bounce.
|
|
2628
|
-
let hasOpenedOnce = false;
|
|
2629
|
-
statusUnsub = status.subscribe((s) => {
|
|
2630
|
-
if (s !== 'open') return;
|
|
2631
|
-
if (!hasOpenedOnce) {
|
|
2632
|
-
hasOpenedOnce = true;
|
|
2633
|
-
return;
|
|
2715
|
+
} else if (_inGracePeriod) {
|
|
2716
|
+
// Resume during the grace window: WS handles were released but
|
|
2717
|
+
// session state (currentValue, _lastSeq, _lastVersion, _cursor)
|
|
2718
|
+
// is intact. Re-attach lifecycle listeners and call
|
|
2719
|
+
// fetchAndSubscribe(); the retained cursors ride along on the
|
|
2720
|
+
// subscribe envelope so the server can fill the gap from its
|
|
2721
|
+
// replay buffer (or fromSeq, or truncated -> full rehydrate).
|
|
2722
|
+
if (_resumeGraceTimer) {
|
|
2723
|
+
clearTimeout(_resumeGraceTimer);
|
|
2724
|
+
_resumeGraceTimer = null;
|
|
2634
2725
|
}
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
_reconnectAttempts++;
|
|
2651
|
-
_reconnectTimer = setTimeout(() => {
|
|
2652
|
-
_reconnectTimer = null;
|
|
2653
|
-
if (topicUnsub) {
|
|
2654
|
-
topicUnsub();
|
|
2655
|
-
topicUnsub = null;
|
|
2656
|
-
}
|
|
2657
|
-
initialLoaded = false;
|
|
2658
|
-
fetching = false;
|
|
2659
|
-
buffer = [];
|
|
2660
|
-
fetchAndSubscribe();
|
|
2661
|
-
}, delay);
|
|
2662
|
-
}
|
|
2663
|
-
});
|
|
2664
|
-
|
|
2665
|
-
// Surface terminal close as an error on the stream (adapter 0.4.0)
|
|
2666
|
-
try {
|
|
2667
|
-
const conn = _connect();
|
|
2668
|
-
if (conn && typeof conn.ready === 'function') {
|
|
2669
|
-
conn.ready().catch((/** @type {any} */ err) => {
|
|
2670
|
-
if (subCount > 0) {
|
|
2671
|
-
_setError(new RpcError(err?.code || 'CONNECTION_CLOSED', err?.message || 'Connection permanently closed'));
|
|
2672
|
-
}
|
|
2673
|
-
});
|
|
2674
|
-
}
|
|
2675
|
-
} catch {}
|
|
2676
|
-
|
|
2677
|
-
} // end else (not _pendingCleanup)
|
|
2726
|
+
_inGracePeriod = false;
|
|
2727
|
+
// Flip status back to 'loading' so the newly-attached quiescence
|
|
2728
|
+
// subscriber sees us as in-flight while the resume envelope is
|
|
2729
|
+
// outstanding (it fires synchronously with the current value).
|
|
2730
|
+
_status = 'loading';
|
|
2731
|
+
_statusStore.set('loading');
|
|
2732
|
+
_attachLifecycleListeners();
|
|
2733
|
+
fetchAndSubscribe();
|
|
2734
|
+
_devtoolsStream(path, topic, subCount, merge);
|
|
2735
|
+
} else {
|
|
2736
|
+
// First subscriber - start the stream
|
|
2737
|
+
fetchAndSubscribe();
|
|
2738
|
+
_devtoolsStream(path, topic, subCount, merge);
|
|
2739
|
+
_attachLifecycleListeners();
|
|
2740
|
+
}
|
|
2678
2741
|
}
|
|
2679
2742
|
|
|
2680
2743
|
const unsub = store.subscribe(fn);
|
|
@@ -2686,7 +2749,24 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2686
2749
|
queueMicrotask(() => {
|
|
2687
2750
|
if (_pendingCleanup && subCount === 0) {
|
|
2688
2751
|
_pendingCleanup = false;
|
|
2689
|
-
|
|
2752
|
+
const graceMs = _getResumeGraceMs();
|
|
2753
|
+
if (graceMs > 0) {
|
|
2754
|
+
// Release WS handles immediately (give server back the
|
|
2755
|
+
// subscription, stop counting toward quiescence) but
|
|
2756
|
+
// retain session state for graceMs to support pause/resume
|
|
2757
|
+
// and back/forward navigation patterns. If a new
|
|
2758
|
+
// subscribe() lands before the timer fires, it resumes
|
|
2759
|
+
// from the retained seq via fetchAndSubscribe.
|
|
2760
|
+
_releaseSubscription();
|
|
2761
|
+
_inGracePeriod = true;
|
|
2762
|
+
_resumeGraceTimer = setTimeout(() => {
|
|
2763
|
+
_resumeGraceTimer = null;
|
|
2764
|
+
_inGracePeriod = false;
|
|
2765
|
+
_resetSession();
|
|
2766
|
+
}, graceMs);
|
|
2767
|
+
} else {
|
|
2768
|
+
cleanup();
|
|
2769
|
+
}
|
|
2690
2770
|
}
|
|
2691
2771
|
});
|
|
2692
2772
|
}
|
|
@@ -3336,7 +3416,7 @@ function _checkArgs(path, args) {
|
|
|
3336
3416
|
* @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function, idempotencyKey?: string, timeout?: number }} OfflineEntry
|
|
3337
3417
|
*/
|
|
3338
3418
|
|
|
3339
|
-
/** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, upload?: { frameSize?: number, chunkSize?: number, highWaterMark?: number, lowWaterMark?: number }, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
|
|
3419
|
+
/** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, upload?: { frameSize?: number, chunkSize?: number, highWaterMark?: number, lowWaterMark?: number }, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
|
|
3340
3420
|
let _clientConfig = {};
|
|
3341
3421
|
|
|
3342
3422
|
/** @type {boolean} */
|
|
@@ -3352,9 +3432,15 @@ let _isOffline = false;
|
|
|
3352
3432
|
let _replayingQueue = false;
|
|
3353
3433
|
|
|
3354
3434
|
/**
|
|
3355
|
-
* Configure client-side connection hooks
|
|
3435
|
+
* Configure client-side connection hooks, RPC timeout, stream resume
|
|
3436
|
+
* grace window, and offline queue.
|
|
3437
|
+
*
|
|
3438
|
+
* `resumeGraceMs` (default 60000) controls how long a stream retains its
|
|
3439
|
+
* data model after the last subscriber unsubs. A new subscribe within the
|
|
3440
|
+
* window resumes from the retained seq/version/cursor so the server can
|
|
3441
|
+
* gap-fill instead of cold-rehydrating. Set to 0 to disable.
|
|
3356
3442
|
*
|
|
3357
|
-
* @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
|
|
3443
|
+
* @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
|
|
3358
3444
|
*/
|
|
3359
3445
|
export function configure(config) {
|
|
3360
3446
|
_clientConfig = config;
|
package/package.json
CHANGED
package/server.d.ts
CHANGED
|
@@ -619,6 +619,23 @@ export const combineMerge: <T extends object>(...buckets: Array<T | undefined>)
|
|
|
619
619
|
*/
|
|
620
620
|
export const MAX_AGGREGATE_BUCKETS: number;
|
|
621
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Maximum entries in the per-userId connection registry that backs
|
|
624
|
+
* `live.push({ userId })` / `live.notify`. Saturation behaviour is
|
|
625
|
+
* WARN-once and skip new registrations; existing entries continue to
|
|
626
|
+
* route. Default 10,000,000.
|
|
627
|
+
*/
|
|
628
|
+
export const MAX_PUSH_REGISTRY: number;
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Maximum entries in the in-memory presence-ref map that backs
|
|
632
|
+
* `live.room({ presence })` on the single-instance / dev path. When
|
|
633
|
+
* `platform.presence.list` is wired (e.g. via the Redis presence
|
|
634
|
+
* extension), this cap is bypassed. Saturation behaviour is WARN-once
|
|
635
|
+
* and skip new entries. Default 1,000,000.
|
|
636
|
+
*/
|
|
637
|
+
export const MAX_PRESENCE_REF: number;
|
|
638
|
+
|
|
622
639
|
export type TopicEntry = string | ((...args: any[]) => string);
|
|
623
640
|
|
|
624
641
|
/**
|
package/server.js
CHANGED
|
@@ -982,8 +982,16 @@ const _publishRateConfig = {
|
|
|
982
982
|
const _publishRateWarned = new Set();
|
|
983
983
|
/** @type {WeakMap<any, ReturnType<typeof setInterval>>} */
|
|
984
984
|
const _publishRateSamplers = new WeakMap();
|
|
985
|
-
/**
|
|
986
|
-
|
|
985
|
+
/**
|
|
986
|
+
* Bumped by `_resetPublishRateWarning` and by `live.publishRateWarning(false)`.
|
|
987
|
+
* Each sampler captures its activation-time epoch and self-clears on the next
|
|
988
|
+
* fire when the epoch no longer matches. Pattern used in place of the prior
|
|
989
|
+
* strong-reference `Set<platform>` because that Set held every dev-mode
|
|
990
|
+
* platform alive across the process lifetime, defeating the WeakMap above and
|
|
991
|
+
* leaking the platform + all captured helpers/closures on every per-call
|
|
992
|
+
* wrap pattern (e.g. cron tick wrapping a fresh `bus.wrap(platform)` per fire).
|
|
993
|
+
*/
|
|
994
|
+
let _publishRateEpoch = 0;
|
|
987
995
|
|
|
988
996
|
/**
|
|
989
997
|
* Activate the dev-mode publish-rate warning sampler for one platform.
|
|
@@ -993,6 +1001,15 @@ const _publishRateActivePlatforms = new Set();
|
|
|
993
1001
|
* underscore prefix so tests can drive activation deterministically
|
|
994
1002
|
* without going through the async RPC path.
|
|
995
1003
|
*
|
|
1004
|
+
* The sampler closure must NOT strongly capture `platform`. Node's timer
|
|
1005
|
+
* queue holds the `setInterval` Timer alive until clearInterval fires; if
|
|
1006
|
+
* the closure captured `platform` directly, every platform ever passed in
|
|
1007
|
+
* would stay reachable forever, leaking the entire helpers+closures graph.
|
|
1008
|
+
* The `WeakRef` wrapper here breaks that retention: on each tick the
|
|
1009
|
+
* sampler derefs, and a null deref (platform GC'd elsewhere) self-clears
|
|
1010
|
+
* the timer. Net effect: at most one stale tick after platform GC, then
|
|
1011
|
+
* the entry vanishes.
|
|
1012
|
+
*
|
|
996
1013
|
* @param {any} platform
|
|
997
1014
|
*/
|
|
998
1015
|
export function _activatePublishRateWarning(platform) {
|
|
@@ -1000,8 +1017,22 @@ export function _activatePublishRateWarning(platform) {
|
|
|
1000
1017
|
if (!_publishRateConfig.enabled) return;
|
|
1001
1018
|
if (_publishRateSamplers.has(platform)) return;
|
|
1002
1019
|
if (typeof platform?.pressure !== 'object' || platform.pressure === null) return;
|
|
1020
|
+
const platformRef = new WeakRef(platform);
|
|
1021
|
+
const epoch = _publishRateEpoch;
|
|
1003
1022
|
const sampler = setInterval(() => {
|
|
1004
|
-
|
|
1023
|
+
// Self-clear on disable / reset / platform-GC. Any of the three
|
|
1024
|
+
// makes the sampler stale; clearInterval here lets Node drop the
|
|
1025
|
+
// Timer from the queue on the next event-loop turn.
|
|
1026
|
+
if (!_publishRateConfig.enabled || epoch !== _publishRateEpoch) {
|
|
1027
|
+
clearInterval(sampler);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const p = platformRef.deref();
|
|
1031
|
+
if (!p) {
|
|
1032
|
+
clearInterval(sampler);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const top = p.pressure?.topPublishers;
|
|
1005
1036
|
if (!Array.isArray(top)) return;
|
|
1006
1037
|
for (const entry of top) {
|
|
1007
1038
|
if (!entry || typeof entry.topic !== 'string') continue;
|
|
@@ -1029,24 +1060,24 @@ export function _activatePublishRateWarning(platform) {
|
|
|
1029
1060
|
}, _publishRateConfig.intervalMs);
|
|
1030
1061
|
if (typeof sampler.unref === 'function') sampler.unref();
|
|
1031
1062
|
_publishRateSamplers.set(platform, sampler);
|
|
1032
|
-
_publishRateActivePlatforms.add(platform);
|
|
1033
1063
|
}
|
|
1034
1064
|
|
|
1035
1065
|
/**
|
|
1036
1066
|
* Reset the dev-mode publish-rate warning state. Tests only. Clears the
|
|
1037
|
-
* one-shot warned set
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1067
|
+
* one-shot warned set and bumps the per-process epoch so every existing
|
|
1068
|
+
* sampler self-clears on its next fire. Stale samplers stop within one
|
|
1069
|
+
* `intervalMs` of the reset (default 5s); a same-platform re-activation
|
|
1070
|
+
* after reset gets a fresh sampler because the old WeakMap entry's
|
|
1071
|
+
* sampler will self-clear on its next tick and never write state again.
|
|
1072
|
+
*
|
|
1073
|
+
* If a test needs synchronous teardown (e.g. to assert no extra warns
|
|
1074
|
+
* fire after reset within the same tick), call this AND assert that
|
|
1075
|
+
* `_publishRateConfig.enabled` is false; samplers short-circuit on the
|
|
1076
|
+
* disabled flag without doing any work.
|
|
1041
1077
|
*/
|
|
1042
1078
|
export function _resetPublishRateWarning() {
|
|
1043
1079
|
_publishRateWarned.clear();
|
|
1044
|
-
|
|
1045
|
-
const sampler = _publishRateSamplers.get(platform);
|
|
1046
|
-
if (sampler) clearInterval(sampler);
|
|
1047
|
-
_publishRateSamplers.delete(platform);
|
|
1048
|
-
}
|
|
1049
|
-
_publishRateActivePlatforms.clear();
|
|
1080
|
+
_publishRateEpoch++;
|
|
1050
1081
|
}
|
|
1051
1082
|
|
|
1052
1083
|
/**
|
|
@@ -2183,13 +2214,13 @@ export function _resetRateLimits() {
|
|
|
2183
2214
|
live.publishRateWarning = function publishRateWarning(config) {
|
|
2184
2215
|
if (config === false) {
|
|
2185
2216
|
_publishRateConfig.enabled = false;
|
|
2186
|
-
//
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2217
|
+
// Existing samplers self-clear on their next fire via the
|
|
2218
|
+
// `_publishRateConfig.enabled` check at the top of the callback.
|
|
2219
|
+
// Bumping the epoch is belt-and-suspenders: a sampler whose
|
|
2220
|
+
// callback is in flight when the flag flips still sees the
|
|
2221
|
+
// epoch mismatch on its NEXT scheduled fire. Worst case is one
|
|
2222
|
+
// stale interval (default 5s) before the timer goes idle.
|
|
2223
|
+
_publishRateEpoch++;
|
|
2193
2224
|
return;
|
|
2194
2225
|
}
|
|
2195
2226
|
if (config === undefined || config === true) {
|
|
@@ -7696,10 +7727,10 @@ function _respond(ws, platform, correlationId, payload) {
|
|
|
7696
7727
|
if (_IS_DEV) {
|
|
7697
7728
|
// Estimate size without double-serialization.
|
|
7698
7729
|
const data = payload.data;
|
|
7699
|
-
if ((Array.isArray(data) && data.length >
|
|
7730
|
+
if ((Array.isArray(data) && data.length > 5000) || (typeof data === 'string' && data.length > 800_000)) {
|
|
7700
7731
|
console.warn(
|
|
7701
7732
|
`[svelte-realtime] RPC response for '${correlationId}' contains ${data.length} items - ` +
|
|
7702
|
-
|
|
7733
|
+
"large responses may exceed maxPayloadLength (default 1 MB; raise `websocket.maxPayloadLength` in svelte.config.js if needed).\n See: https://svti.me/adapter-config"
|
|
7703
7734
|
);
|
|
7704
7735
|
}
|
|
7705
7736
|
}
|