svelte-realtime 0.4.20 → 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 +64 -18
- package/client.d.ts +7 -1
- package/client.js +38 -5
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
|
384
|
-
|
|
385
|
-
| `undefined` |
|
|
386
|
-
| `
|
|
387
|
-
| `
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
466
|
+
{:else if $err}
|
|
449
467
|
{#if error}
|
|
450
|
-
{@render error(
|
|
468
|
+
{@render error($err)}
|
|
451
469
|
{:else}
|
|
452
|
-
<p>Error: {
|
|
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
|
|
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
|
|
@@ -2013,6 +2057,8 @@ Import from `svelte-realtime/client`.
|
|
|
2013
2057
|
|
|
2014
2058
|
| Method/Property | Description |
|
|
2015
2059
|
|---|---|
|
|
2060
|
+
| `error` | `Readable<RpcError \| null>` -- current error, or `null` when healthy |
|
|
2061
|
+
| `status` | `Readable<'loading' \| 'connected' \| 'reconnecting' \| 'error'>` -- connection status |
|
|
2016
2062
|
| `optimistic(event, data)` | Apply instant UI update, returns rollback function |
|
|
2017
2063
|
| `hydrate(initialData)` | Pre-populate with SSR data |
|
|
2018
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1129
|
+
_setError(new RpcError(err?.code || 'CONNECTION_CLOSED', err?.message || 'Connection permanently closed'));
|
|
1097
1130
|
}
|
|
1098
1131
|
});
|
|
1099
1132
|
}
|