svelte-realtime 0.5.4 → 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.
Files changed (4) hide show
  1. package/README.md +21 -0
  2. package/client.d.ts +14 -0
  3. package/client.js +180 -94
  4. package/package.json +1 -1
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
- * Clean up subscriptions.
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 cleanup() {
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
- /** @type {boolean} Whether a deferred cleanup is pending (prevents thrashing on rapid unsub+resub) */
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, subscription is still alive
2712
+ // Rapid sync resub (same microtask) - cancel the pending cleanup,
2713
+ // WS subscription is still attached.
2602
2714
  _pendingCleanup = false;
2603
- } else {
2604
- // First subscriber - start the stream
2605
- fetchAndSubscribe();
2606
- _devtoolsStream(path, topic, subCount, merge);
2607
-
2608
- // Quiescence tracking: register this stream's contribution to
2609
- // the global in-flight counter. Subscriber fires synchronously
2610
- // with the current status so initial 'loading' is captured.
2611
- _quiescenceUnsub = _statusStore.subscribe((s) => {
2612
- const inFlight = s === 'loading' || s === 'reconnecting';
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
- if (subCount > 0) {
2636
- _status = 'reconnecting';
2637
- _statusStore.set('reconnecting');
2638
- if (_reconnectTimer) clearTimeout(_reconnectTimer);
2639
- let delay;
2640
- // Reconnect jitter: spread a fleet's reconnect attempts across the
2641
- // window so a server restart does not get a thundering-herd retry
2642
- // spike. Math.random is the right primitive here - jitter does not
2643
- // need crypto-quality entropy. Not security-relevant.
2644
- if (_reconnectAttempts < 2) {
2645
- delay = 20 + Math.floor(Math.random() * 80);
2646
- } else {
2647
- const base = Math.min(1000 * Math.pow(2.2, _reconnectAttempts - 2), 300000);
2648
- delay = Math.floor(base * (0.75 + Math.random() * 0.5));
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
- cleanup();
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 and offline queue.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },