svelte-realtime 0.4.19 → 0.4.21

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 CHANGED
@@ -147,8 +147,6 @@ export const messages = live.stream('messages', async (ctx) => {
147
147
 
148
148
  {#if $messages === undefined}
149
149
  <p>Loading...</p>
150
- {:else if $messages?.error}
151
- <p>Failed to load: {$messages.error.message}</p>
152
150
  {:else}
153
151
  {#each $messages as msg (msg.id)}
154
152
  <p><b>{msg.userId}:</b> {msg.text}</p>
@@ -378,21 +376,19 @@ When the WebSocket reconnects, streams automatically refetch initial data and re
378
376
 
379
377
  ## Error handling
380
378
 
381
- Stream stores have three states:
379
+ The data store value never changes shape. It is always your data type or `undefined` while loading. Errors and connection status live on separate reactive stores so a network failure can never crash your UI:
382
380
 
383
- | Value | Meaning |
384
- |---|---|
385
- | `undefined` | Loading (initial fetch in progress) |
386
- | `Array` / `any` | Data loaded and receiving live updates |
387
- | `{ error: RpcError }` | Initial fetch failed |
381
+ | Property | Type | Description |
382
+ |---|---|---|
383
+ | `$store` | `T \| undefined` | Your data. Never replaced by an error object. On failure, the last loaded value is preserved. |
384
+ | `store.error` | `Readable<RpcError \| null>` | Current error, or `null` when healthy. |
385
+ | `store.status` | `Readable<'loading' \| 'connected' \| 'reconnecting' \| 'error'>` | Connection status. |
388
386
 
389
- Handle all three in your template:
387
+ Handle loading in your template:
390
388
 
391
389
  ```svelte
392
390
  {#if $messages === undefined}
393
391
  <p>Loading...</p>
394
- {:else if $messages?.error}
395
- <p>Failed: {$messages.error.message}</p>
396
392
  {:else}
397
393
  {#each $messages as msg (msg.id)}
398
394
  <p>{msg.text}</p>
@@ -400,6 +396,27 @@ Handle all three in your template:
400
396
  {/if}
401
397
  ```
402
398
 
399
+ To show errors, subscribe to the `.error` store:
400
+
401
+ ```svelte
402
+ <script>
403
+ import { messages } from '$live/chat';
404
+
405
+ const err = messages.error;
406
+ const status = messages.status;
407
+ </script>
408
+
409
+ {#if $err}
410
+ <p>Error: {$err.message} ({$err.code})</p>
411
+ {/if}
412
+
413
+ {#if $status === 'reconnecting'}
414
+ <p>Reconnecting...</p>
415
+ {/if}
416
+ ```
417
+
418
+ Defensive patterns like `($store ?? []).filter(...)` work correctly because `$store` is always an array or `undefined`.
419
+
403
420
  For RPC calls, errors are thrown as `RpcError` with a `code` field:
404
421
 
405
422
  ```js
@@ -421,22 +438,23 @@ try {
421
438
  When the adapter's `ready()` promise rejects (terminal close codes 1008, 4401, 4403, exhausted retries, or explicit `close()`), svelte-realtime:
422
439
 
423
440
  - Rejects all pending RPCs immediately with `RpcError('CONNECTION_CLOSED', ...)`
424
- - Sets an `{ error }` state on all active stream stores
441
+ - Sets `.error` on all active stream stores (the data value is preserved)
425
442
  - Drains the offline queue with errors
426
443
 
427
444
  RPCs called after a terminal close reject immediately without sending.
428
445
 
429
446
  ### Reusable error boundary component
430
447
 
431
- For Svelte 5, you can build a reusable boundary that handles all three stream states:
448
+ For Svelte 5, you can build a reusable boundary that handles loading and error states:
432
449
 
433
450
  ```svelte
434
451
  <!-- src/lib/StreamView.svelte -->
435
452
  <script>
436
- /** @type {{ store: import('svelte/store').Readable, children: import('svelte').Snippet, loading?: import('svelte').Snippet, error?: import('svelte').Snippet<[any]> }} */
453
+ /** @type {{ store: any, children: import('svelte').Snippet, loading?: import('svelte').Snippet, error?: import('svelte').Snippet<[any]> }} */
437
454
  let { store, children, loading, error } = $props();
438
455
 
439
456
  let value = $derived($store);
457
+ const err = store.error;
440
458
  </script>
441
459
 
442
460
  {#if value === undefined}
@@ -445,11 +463,11 @@ For Svelte 5, you can build a reusable boundary that handles all three stream st
445
463
  {:else}
446
464
  <p>Loading...</p>
447
465
  {/if}
448
- {:else if value?.error}
466
+ {:else if $err}
449
467
  {#if error}
450
- {@render error(value.error)}
468
+ {@render error($err)}
451
469
  {:else}
452
- <p>Error: {value.error.message}</p>
470
+ <p>Error: {$err.message}</p>
453
471
  {/if}
454
472
  {:else}
455
473
  {@render children()}
@@ -492,7 +510,7 @@ With default slots, the minimal version is just:
492
510
  </StreamView>
493
511
  ```
494
512
 
495
- This removes the `{#if}/{:else if}/{:else}` boilerplate from every page that uses a stream.
513
+ This removes the `{#if}/{:else}` boilerplate from every page that uses a stream.
496
514
 
497
515
  ---
498
516
 
@@ -673,6 +691,32 @@ export async function load({ platform, locals }) {
673
691
 
674
692
  The hydrated store still subscribes for live updates on first render. It keeps the SSR data visible instead of showing `undefined` during the initial fetch. Guards still run during `.load()` calls. Pass `{ user }` as the second argument if your guard or init function needs user data.
675
693
 
694
+ For dynamic streams (streams with a topic function), call the stream first to get the store, then hydrate:
695
+
696
+ ```js
697
+ // src/routes/team/[id]/+page.server.js
698
+ export async function load({ platform, locals, params }) {
699
+ const { invitations } = await import('$live/invitation');
700
+ const data = await invitations.load(platform, { args: [params.id], user: locals.user });
701
+ return { invitations: data };
702
+ }
703
+ ```
704
+
705
+ ```svelte
706
+ <!-- src/routes/team/[id]/+page.svelte -->
707
+ <script>
708
+ import { invitations } from '$live/invitation';
709
+ import { page } from '$app/state';
710
+ let { data } = $props();
711
+
712
+ const invites = invitations(page.params.id).hydrate(data.invitations);
713
+ </script>
714
+
715
+ {#each $invites as invite (invite.id)}
716
+ <p>{invite.email}</p>
717
+ {/each}
718
+ ```
719
+
676
720
  ---
677
721
 
678
722
  ## Batching
@@ -1281,6 +1325,10 @@ export function open(ws, { platform }) {
1281
1325
  }
1282
1326
  ```
1283
1327
 
1328
+ Without this call, derived streams will still serve their initial SSR data but will never receive live updates. In dev mode, a console warning is emitted when a client subscribes to a derived stream and `_activateDerived` has not been called.
1329
+
1330
+ Dynamic derived compute functions receive `ctx.user` from the subscribing client, so auth checks like `if (orgId !== ctx.user.organization_id) throw new LiveError("FORBIDDEN")` work the same as they do in regular stream handlers.
1331
+
1284
1332
  | Option | Default | Description |
1285
1333
  |---|---|---|
1286
1334
  | `merge` | `'set'` | Merge strategy for the derived topic |
@@ -2009,6 +2057,8 @@ Import from `svelte-realtime/client`.
2009
2057
 
2010
2058
  | Method/Property | Description |
2011
2059
  |---|---|
2060
+ | `error` | `Readable<RpcError \| null>` -- current error, or `null` when healthy |
2061
+ | `status` | `Readable<'loading' \| 'connected' \| 'reconnecting' \| 'error'>` -- connection status |
2012
2062
  | `optimistic(event, data)` | Apply instant UI update, returns rollback function |
2013
2063
  | `hydrate(initialData)` | Pre-populate with SSR data |
2014
2064
  | `loadMore(...extraArgs)` | Load next page (cursor-based) |
package/client.d.ts CHANGED
@@ -45,7 +45,9 @@ export function __rpc(path: string): ((...args: any[]) => Promise<any>) & {
45
45
  * - `undefined` while loading
46
46
  * - The initial data once loaded
47
47
  * - Automatically updated with live pub/sub events
48
- * - `{ error: RpcError }` if the initial fetch fails
48
+ *
49
+ * Errors never replace the store value. On failure, the last known data
50
+ * is preserved and the error is available via the `.error` store.
49
51
  *
50
52
  * @param path - Stream path (e.g. `'chat/messages'`)
51
53
  * @param options - Merge strategy and options
@@ -57,6 +59,10 @@ export function __rpc(path: string): ((...args: any[]) => Promise<any>) & {
57
59
  * SSR hydration, and cursor-based pagination.
58
60
  */
59
61
  export interface StreamStore<T = any> extends Readable<T> {
62
+ /** Reactive store holding the current error, or null when healthy. Errors never replace the data store value. */
63
+ readonly error: Readable<RpcError | null>;
64
+ /** Reactive store holding the connection status: 'loading', 'connected', 'reconnecting', or 'error'. */
65
+ readonly status: Readable<'loading' | 'connected' | 'reconnecting' | 'error'>;
60
66
  /** Apply an instant UI update. Returns a rollback function. */
61
67
  optimistic(event: string, data: any): () => void;
62
68
  /** Pre-populate with SSR data to avoid loading spinners. */
package/client.js CHANGED
@@ -508,6 +508,28 @@ function _createStream(path, options, dynamicArgs) {
508
508
  let currentValue;
509
509
  const store = writable(undefined);
510
510
 
511
+ /** @type {RpcError | null} */
512
+ let _error = null;
513
+ const _errorStore = writable(null);
514
+
515
+ /** @type {'loading' | 'connected' | 'reconnecting' | 'error'} */
516
+ let _status = 'loading';
517
+ const _statusStore = writable(/** @type {'loading' | 'connected' | 'reconnecting' | 'error'} */ ('loading'));
518
+
519
+ function _setError(/** @type {RpcError} */ err) {
520
+ _error = err;
521
+ _errorStore.set(err);
522
+ _status = 'error';
523
+ _statusStore.set('error');
524
+ }
525
+
526
+ function _clearError() {
527
+ if (_error !== null) {
528
+ _error = null;
529
+ _errorStore.set(null);
530
+ }
531
+ }
532
+
511
533
  /** @type {string | null} */
512
534
  let topic = null;
513
535
 
@@ -841,7 +863,7 @@ function _createStream(path, options, dynamicArgs) {
841
863
  function fetchAndSubscribe() {
842
864
  if (fetching) return;
843
865
  if (_terminated) {
844
- store.set({ error: new RpcError('CONNECTION_CLOSED', 'Connection permanently closed') });
866
+ _setError(new RpcError('CONNECTION_CLOSED', 'Connection permanently closed'));
845
867
  return;
846
868
  }
847
869
  fetching = true;
@@ -871,13 +893,13 @@ function _createStream(path, options, dynamicArgs) {
871
893
  pending.delete(id);
872
894
  pendingId = null;
873
895
  fetching = false;
874
- store.set({ error: new RpcError('DISCONNECTED', 'Connection interrupted (device sleep)') });
896
+ _setError(new RpcError('DISCONNECTED', 'Connection interrupted (device sleep)'));
875
897
  return;
876
898
  }
877
899
  pending.delete(id);
878
900
  pendingId = null;
879
901
  fetching = false;
880
- store.set({ error: new RpcError('TIMEOUT', `Stream '${path}' timed out after 30s`) });
902
+ _setError(new RpcError('TIMEOUT', `Stream '${path}' timed out after 30s`));
881
903
  }, _getTimeout());
882
904
 
883
905
  pending.set(id, {
@@ -886,6 +908,9 @@ function _createStream(path, options, dynamicArgs) {
886
908
  fetching = false;
887
909
  pendingId = null;
888
910
  _reconnectAttempts = 0;
911
+ _clearError();
912
+ _status = 'connected';
913
+ _statusStore.set('connected');
889
914
  topic = response.topic || null;
890
915
 
891
916
  // Track sequence number for replay
@@ -985,7 +1010,7 @@ function _createStream(path, options, dynamicArgs) {
985
1010
  reject(err) {
986
1011
  fetching = false;
987
1012
  pendingId = null;
988
- store.set({ error: err instanceof RpcError ? err : new RpcError('STREAM_ERROR', err?.message || 'Stream failed') });
1013
+ _setError(err instanceof RpcError ? err : new RpcError('STREAM_ERROR', err?.message || 'Stream failed'));
989
1014
  },
990
1015
  timer
991
1016
  });
@@ -1035,6 +1060,10 @@ function _createStream(path, options, dynamicArgs) {
1035
1060
  buffer = [];
1036
1061
  currentValue = undefined;
1037
1062
  store.set(undefined);
1063
+ _error = null;
1064
+ _errorStore.set(null);
1065
+ _status = 'loading';
1066
+ _statusStore.set('loading');
1038
1067
  _index.clear();
1039
1068
  _history = [];
1040
1069
  _historyIndex = -1;
@@ -1046,6 +1075,8 @@ function _createStream(path, options, dynamicArgs) {
1046
1075
  let _pendingCleanup = false;
1047
1076
 
1048
1077
  return {
1078
+ error: { subscribe: _errorStore.subscribe },
1079
+ status: { subscribe: _statusStore.subscribe },
1049
1080
  subscribe(fn) {
1050
1081
  if (subCount++ === 0) {
1051
1082
  if (_pendingCleanup) {
@@ -1064,6 +1095,8 @@ function _createStream(path, options, dynamicArgs) {
1064
1095
  return;
1065
1096
  }
1066
1097
  if (s === 'open' && subCount > 0) {
1098
+ _status = 'reconnecting';
1099
+ _statusStore.set('reconnecting');
1067
1100
  if (_reconnectTimer) clearTimeout(_reconnectTimer);
1068
1101
  let delay;
1069
1102
  if (_reconnectAttempts < 2) {
@@ -1093,7 +1126,7 @@ function _createStream(path, options, dynamicArgs) {
1093
1126
  if (conn && typeof conn.ready === 'function') {
1094
1127
  conn.ready().catch((/** @type {any} */ err) => {
1095
1128
  if (subCount > 0) {
1096
- store.set({ error: new RpcError(err?.code || 'CONNECTION_CLOSED', err?.message || 'Connection permanently closed') });
1129
+ _setError(new RpcError(err?.code || 'CONNECTION_CLOSED', err?.message || 'Connection permanently closed'));
1097
1130
  }
1098
1131
  });
1099
1132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
4
4
  "description": "Realtime RPC and reactive subscriptions for SvelteKit, built on svelte-adapter-uws",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
package/server.js CHANGED
@@ -778,7 +778,7 @@ live.derived = function derived(sources, fn, options) {
778
778
  /** @type {Map<string, any[]>} */
779
779
  const topicArgs = new Map();
780
780
  const topicFn = (...args) => {
781
- const t = baseTopic + '\x00' + args.map(a => String(a).replace(/\x00/g, '')).join('\x00');
781
+ const t = baseTopic + '~' + args.map(a => String(a).replace(/~/g, '')).join('~');
782
782
  topicArgs.set(t, args);
783
783
  if (topicArgs.size > 10000) {
784
784
  const iter = topicArgs.keys();
@@ -791,7 +791,7 @@ live.derived = function derived(sources, fn, options) {
791
791
  /** @type {any} */ (fn).__derivedTopicArgs = topicArgs;
792
792
 
793
793
  /** @type {any} */ (fn).__onSubscribe = function (_ctx, resolvedTopic) {
794
- _activateDynamicDerived(fn, resolvedTopic);
794
+ _activateDynamicDerived(fn, resolvedTopic, _ctx && _ctx.user);
795
795
  };
796
796
  /** @type {any} */ (fn).__onUnsubscribe = function (_ctx, resolvedTopic) {
797
797
  _deactivateDynamicDerived(fn, resolvedTopic);
@@ -815,6 +815,12 @@ const _dynamicDerivedByFn = new Map();
815
815
  /** @type {import('svelte-adapter-uws').Platform | null} Captured platform for dynamic derived recomputation */
816
816
  let _derivedPlatform = null;
817
817
 
818
+ /** @type {boolean} Whether _activateDerived has been called at least once */
819
+ let _activateDerivedCalled = false;
820
+
821
+ /** @type {boolean} Whether the missing _activateDerived warning has already fired */
822
+ let _warnedActivateDerived = false;
823
+
818
824
  /** @type {Map<string, { sources: string[], fn: Function, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
819
825
  const effectRegistry = new Map();
820
826
 
@@ -1387,6 +1393,7 @@ live.breaker = function breaker(options, fn) {
1387
1393
  export function __registerDerived(path, fn) {
1388
1394
  if (/** @type {any} */ (fn).__lazy) {
1389
1395
  _lazyQueue.push({ type: 'derived', path, loader: fn });
1396
+ _hasDynamicDerived = true;
1390
1397
  return;
1391
1398
  }
1392
1399
 
@@ -1397,7 +1404,7 @@ export function __registerDerived(path, fn) {
1397
1404
  /** @type {Map<string, any[]>} */
1398
1405
  const topicArgs = new Map();
1399
1406
  const topicFn = (...args) => {
1400
- const t = path + '\x00' + args.map(a => String(a).replace(/\x00/g, '')).join('\x00');
1407
+ const t = path + '~' + args.map(a => String(a).replace(/~/g, '')).join('~');
1401
1408
  topicArgs.set(t, args);
1402
1409
  if (topicArgs.size > 10000) {
1403
1410
  const iter = topicArgs.keys();
@@ -1443,6 +1450,7 @@ const _activatedPlatforms = new WeakSet();
1443
1450
 
1444
1451
  export function _activateDerived(platform) {
1445
1452
  _derivedPlatform = platform;
1453
+ _activateDerivedCalled = true;
1446
1454
  if (_activatedPlatforms.has(platform)) return;
1447
1455
 
1448
1456
  // Only wrap platform.publish if there are actual reactive registrations
@@ -1543,7 +1551,7 @@ async function _recomputeDerived(entry, platform) {
1543
1551
  let result;
1544
1552
  if (entry.args) {
1545
1553
  const _h = _getCtxHelpers(platform);
1546
- const ctx = _buildCtx(null, null, platform, _h, null);
1554
+ const ctx = _buildCtx(entry.user || null, null, platform, _h, null);
1547
1555
  result = await entry.fn(ctx, ...entry.args);
1548
1556
  } else {
1549
1557
  result = await entry.fn();
@@ -1563,8 +1571,9 @@ async function _recomputeDerived(entry, platform) {
1563
1571
  * Wires the instance's resolved sources into _derivedBySource so publishes trigger recomputation.
1564
1572
  * @param {Function} fn - The derived compute function
1565
1573
  * @param {string} resolvedTopic - The resolved output topic (e.g. '__derived:5:org_123')
1574
+ * @param {any} [user] - The subscribing client's user data, used for ctx during recomputation
1566
1575
  */
1567
- function _activateDynamicDerived(fn, resolvedTopic) {
1576
+ function _activateDynamicDerived(fn, resolvedTopic, user) {
1568
1577
  const entry = _dynamicDerivedByFn.get(fn);
1569
1578
  if (!entry) return;
1570
1579
 
@@ -1600,7 +1609,8 @@ function _activateDynamicDerived(fn, resolvedTopic) {
1600
1609
  resolvedSources,
1601
1610
  debounce: entry.debounce,
1602
1611
  timer: null,
1603
- refCount: 1
1612
+ refCount: 1,
1613
+ user: user || null
1604
1614
  };
1605
1615
 
1606
1616
  entry.instances.set(resolvedTopic, instance);
@@ -1852,6 +1862,8 @@ export function _prepareHmr() {
1852
1862
  _streamsWithUnsubscribe.clear();
1853
1863
  _hasDynamicDerived = false;
1854
1864
  _dynamicDerivedByFn.clear();
1865
+ _activateDerivedCalled = false;
1866
+ _warnedActivateDerived = false;
1855
1867
 
1856
1868
  return snap;
1857
1869
  }
@@ -2334,6 +2346,13 @@ async function _executeSingleRpc(ws, msg, platform, options) {
2334
2346
  try { await /** @type {any} */ (fn).__onSubscribe(ctx, topic); } catch {}
2335
2347
  }
2336
2348
 
2349
+ if (/** @type {any} */ (fn).__isDerived && !_activateDerivedCalled && !_warnedActivateDerived) {
2350
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
2351
+ _warnedActivateDerived = true;
2352
+ console.warn('[svelte-realtime] live.derived() subscribed but _activateDerived(platform) was never called. Derived streams will not receive live updates.\n Call _activateDerived(platform) in your WebSocket open hook.\n See: https://svti.me/derived');
2353
+ }
2354
+ }
2355
+
2337
2356
  // Channel fast-path
2338
2357
  if (/** @type {any} */ (fn).__isChannel) {
2339
2358
  const emptyValue = streamOpts.merge === 'set' ? null : [];