svelte-realtime 0.1.4

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 ADDED
@@ -0,0 +1,1790 @@
1
+ # svelte-realtime
2
+
3
+ Realtime RPC and reactive subscriptions for SvelteKit, built on [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws).
4
+
5
+ Write server functions. Import them in components. Call them over WebSocket. No boilerplate, no manual pub/sub wiring, no protocol design.
6
+
7
+ ---
8
+
9
+ ## Getting started
10
+
11
+ Starting from a SvelteKit project. If you do not have one yet, run `npx sv create my-app && cd my-app && npm install` first.
12
+
13
+ ### Step 1: Install everything
14
+
15
+ ```bash
16
+ npm install svelte-adapter-uws svelte-realtime
17
+ npm install uNetworking/uWebSockets.js#v20.60.0
18
+ npm install -D ws
19
+ ```
20
+
21
+ What each package does:
22
+ - `svelte-adapter-uws` -- the SvelteKit adapter that runs your app on uWebSockets.js with built-in WebSocket support
23
+ - `svelte-realtime` -- this library (RPC + streams on top of the adapter)
24
+ - `uWebSockets.js` -- the native C++ HTTP/WebSocket server (installed from GitHub, not npm)
25
+ - `ws` -- dev dependency used by the adapter during `npm run dev` (not needed in production)
26
+
27
+ ### Step 2: Configure the adapter
28
+
29
+ Open `svelte.config.js` and replace the default adapter:
30
+
31
+ ```js
32
+ // svelte.config.js
33
+ import adapter from 'svelte-adapter-uws';
34
+
35
+ export default {
36
+ kit: {
37
+ adapter: adapter({ websocket: true })
38
+ }
39
+ };
40
+ ```
41
+
42
+ ### Step 3: Add the Vite plugins
43
+
44
+ Open `vite.config.js` and add the adapter and realtime plugins:
45
+
46
+ ```js
47
+ // vite.config.js
48
+ import { sveltekit } from '@sveltejs/kit/vite';
49
+ import uws from 'svelte-adapter-uws/vite';
50
+ import realtime from 'svelte-realtime/vite';
51
+
52
+ export default {
53
+ plugins: [sveltekit(), uws(), realtime()]
54
+ };
55
+ ```
56
+
57
+ All three plugins are required. Order does not matter.
58
+
59
+ ### Step 4: Create the WebSocket hooks file
60
+
61
+ Create `src/hooks.ws.js` in your project root. This file tells the adapter how to handle WebSocket connections and messages.
62
+
63
+ ```js
64
+ // src/hooks.ws.js
65
+ export { message } from 'svelte-realtime/server';
66
+
67
+ export function upgrade({ cookies }) {
68
+ // Return user data to attach to the connection, or false to reject.
69
+ // This runs on every new WebSocket connection.
70
+ const session = validateSession(cookies.session_id);
71
+ if (!session) return false;
72
+ return { id: session.userId, name: session.name };
73
+ }
74
+ ```
75
+
76
+ `message` is a ready-made hook that routes incoming WebSocket messages to your live functions. `upgrade` decides who can connect and attaches user data to the connection.
77
+
78
+ ### Step 5: Write a server function
79
+
80
+ Create the `src/live/` directory. Every `.js` file in this directory becomes a module of callable server functions.
81
+
82
+ ```js
83
+ // src/live/chat.js
84
+ import { live, LiveError } from 'svelte-realtime/server';
85
+ import { db } from '$lib/server/db';
86
+
87
+ // A plain RPC function -- clients can call this like a regular async function
88
+ export const sendMessage = live(async (ctx, text) => {
89
+ if (!ctx.user) throw new LiveError('UNAUTHORIZED', 'Login required');
90
+
91
+ const msg = await db.messages.insert({ userId: ctx.user.id, text });
92
+ ctx.publish('messages', 'created', msg);
93
+ return msg;
94
+ });
95
+
96
+ // A stream -- clients get a Svelte store with initial data + live updates
97
+ export const messages = live.stream('messages', async (ctx) => {
98
+ return db.messages.latest(50);
99
+ }, { merge: 'crud', key: 'id', prepend: true });
100
+ ```
101
+
102
+ `live()` marks a function as callable over WebSocket. The first argument is always `ctx` (context), which contains the user data from `upgrade()`, the WebSocket connection, and a `publish` function for sending events to all subscribers.
103
+
104
+ `live.stream()` creates a reactive subscription. When a client subscribes, it gets the initial data from the function, then receives live updates whenever someone publishes to that topic.
105
+
106
+ ### Step 6: Use it in a component
107
+
108
+ ```svelte
109
+ <!-- src/routes/chat/+page.svelte -->
110
+ <script>
111
+ import { sendMessage, messages } from '$live/chat';
112
+ let text = $state('');
113
+
114
+ async function send() {
115
+ await sendMessage(text);
116
+ text = '';
117
+ }
118
+ </script>
119
+
120
+ {#if $messages === undefined}
121
+ <p>Loading...</p>
122
+ {:else if $messages?.error}
123
+ <p>Failed to load: {$messages.error.message}</p>
124
+ {:else}
125
+ {#each $messages as msg (msg.id)}
126
+ <p><b>{msg.userId}:</b> {msg.text}</p>
127
+ {/each}
128
+ {/if}
129
+
130
+ <input bind:value={text} />
131
+ <button onclick={send}>Send</button>
132
+ ```
133
+
134
+ `$live/chat` is a virtual import. The Vite plugin reads your `src/live/chat.js` file, sees which functions are wrapped in `live()` and `live.stream()`, and generates lightweight client stubs that call them over WebSocket.
135
+
136
+ - `sendMessage` becomes a regular async function on the client.
137
+ - `messages` becomes a Svelte store. It starts as `undefined` (loading), then populates with the initial data, then merges live updates as they arrive.
138
+
139
+ That is the entire setup. Run `npm run dev` and it works.
140
+
141
+ ---
142
+
143
+ ## How it works
144
+
145
+ ```
146
+ You write: src/live/chat.js (server functions)
147
+ You import: $live/chat (auto-generated client stubs)
148
+
149
+ live() -> RPC call over WebSocket (async function)
150
+ live.stream() -> Svelte store with initial data + live updates
151
+ ```
152
+
153
+ The `ctx` object passed to every server function contains:
154
+
155
+ | Field | Description |
156
+ |---|---|
157
+ | `ctx.user` | Whatever `upgrade()` returned (your user data) |
158
+ | `ctx.ws` | The raw WebSocket connection |
159
+ | `ctx.platform` | The adapter platform API |
160
+ | `ctx.publish` | Shorthand for `platform.publish()` |
161
+ | `ctx.cursor` | Cursor from a `loadMore()` call, or `null` |
162
+ | `ctx.throttle` | `(topic, event, data, ms)` -- publish at most once per `ms` ms |
163
+ | `ctx.debounce` | `(topic, event, data, ms)` -- publish after `ms` ms of silence |
164
+ | `ctx.signal` | `(userId, event, data)` -- point-to-point message |
165
+
166
+ ---
167
+
168
+ ## Table of contents
169
+
170
+ **Core**
171
+ - [Getting started](#getting-started)
172
+ - [Merge strategies](#merge-strategies)
173
+ - [Error handling](#error-handling)
174
+ - [Per-module auth](#per-module-auth)
175
+ - [Dynamic topics](#dynamic-topics)
176
+ - [Schema validation](#schema-validation)
177
+ - [Channels](#channels)
178
+ - [SSR hydration](#ssr-hydration)
179
+
180
+ **Client features**
181
+ - [Batching](#batching)
182
+ - [Optimistic updates](#optimistic-updates)
183
+ - [Stream pagination](#stream-pagination)
184
+ - [Undo and redo](#undo-and-redo)
185
+ - [Request deduplication](#request-deduplication)
186
+ - [Offline queue](#offline-queue)
187
+ - [Connection hooks](#connection-hooks)
188
+ - [Combine stores](#combine-stores)
189
+
190
+ **Server features**
191
+ - [Global middleware](#global-middleware)
192
+ - [Throttle and debounce](#throttle-and-debounce)
193
+ - [Stream lifecycle hooks](#stream-lifecycle-hooks)
194
+ - [Access control](#access-control)
195
+ - [Rate limiting](#rate-limiting)
196
+ - [Cron scheduling](#cron-scheduling)
197
+ - [Derived streams](#derived-streams)
198
+ - [Effects](#effects)
199
+ - [Aggregates](#aggregates)
200
+ - [Gates](#gates)
201
+ - [Pipes](#pipes)
202
+ - [Binary RPC](#binary-rpc)
203
+ - [Rooms](#rooms)
204
+ - [Webhooks](#webhooks)
205
+ - [Signals](#signals)
206
+ - [Schema evolution](#schema-evolution)
207
+ - [Delta sync and replay](#delta-sync-and-replay)
208
+
209
+ **Deployment**
210
+ - [Redis multi-instance](#redis-multi-instance)
211
+ - [Postgres NOTIFY](#postgres-notify)
212
+ - [Clustering](#clustering)
213
+ - [Limits and gotchas](#limits-and-gotchas)
214
+
215
+ **Reference**
216
+ - [Error reporting](#error-reporting)
217
+ - [Custom message handling](#custom-message-handling)
218
+ - [DevTools](#devtools)
219
+ - [Testing](#testing)
220
+ - [Server API reference](#server-api-reference)
221
+ - [Client API reference](#client-api-reference)
222
+ - [Vite plugin options](#vite-plugin-options)
223
+ - [Benchmarks](#benchmarks)
224
+
225
+ ---
226
+
227
+ ## Merge strategies
228
+
229
+ The `merge` option on `live.stream()` controls how live pub/sub events are applied to the store.
230
+
231
+ ### crud (default)
232
+
233
+ Handles `created`, `updated`, `deleted` events. The store maintains an array, keyed by `id` (configurable with `key`).
234
+
235
+ ```js
236
+ // Server
237
+ export const todos = live.stream('todos', async (ctx) => {
238
+ return db.todos.all();
239
+ }, { merge: 'crud', key: 'id' });
240
+
241
+ export const addTodo = live(async (ctx, text) => {
242
+ const todo = await db.todos.insert({ text });
243
+ ctx.publish('todos', 'created', todo);
244
+ return todo;
245
+ });
246
+ ```
247
+
248
+ ```svelte
249
+ <!-- Client -->
250
+ <script>
251
+ import { todos, addTodo } from '$live/todos';
252
+ </script>
253
+
254
+ {#each $todos as todo (todo.id)}
255
+ <p>{todo.text}</p>
256
+ {/each}
257
+ ```
258
+
259
+ ### latest
260
+
261
+ Ring buffer of the last N events. Good for activity feeds and logs.
262
+
263
+ ```js
264
+ export const activity = live.stream('activity', async (ctx) => {
265
+ return db.activity.recent(100);
266
+ }, { merge: 'latest', max: 100 });
267
+ ```
268
+
269
+ ### set
270
+
271
+ Replaces the entire value. Good for counters, status indicators, and aggregated data.
272
+
273
+ ```js
274
+ export const stats = live.stream('stats', async (ctx) => {
275
+ return { users: 42, messages: 1337 };
276
+ }, { merge: 'set' });
277
+ ```
278
+
279
+ ### presence
280
+
281
+ Tracks connected users with `join` and `leave` events. Items are keyed by `.key`.
282
+
283
+ ```js
284
+ export const presence = live.stream(
285
+ (ctx, roomId) => 'presence:' + roomId,
286
+ async (ctx, roomId) => [],
287
+ { merge: 'presence' }
288
+ );
289
+
290
+ export const join = live(async (ctx, roomId) => {
291
+ ctx.publish('presence:' + roomId, 'join', { key: ctx.user.id, name: ctx.user.name });
292
+ });
293
+ ```
294
+
295
+ ```svelte
296
+ <script>
297
+ import { presence } from '$live/room';
298
+ const users = presence(data.roomId);
299
+ </script>
300
+
301
+ {#each $users as user (user.key)}
302
+ <span>{user.name}</span>
303
+ {/each}
304
+ ```
305
+
306
+ Events: `join` (add/update by key), `leave` (remove by key), `set` (replace all).
307
+
308
+ ### cursor
309
+
310
+ Tracks cursor positions with `update` and `remove` events. Items are keyed by `.key`.
311
+
312
+ ```js
313
+ export const cursors = live.stream(
314
+ (ctx, docId) => 'cursors:' + docId,
315
+ async (ctx, docId) => [],
316
+ { merge: 'cursor' }
317
+ );
318
+
319
+ export const moveCursor = live(async (ctx, docId, x, y) => {
320
+ ctx.publish('cursors:' + docId, 'update', { key: ctx.user.id, x, y, color: ctx.user.color });
321
+ });
322
+ ```
323
+
324
+ Events: `update` (add/update by key), `remove` (remove by key), `set` (replace all).
325
+
326
+ ### Stream options reference
327
+
328
+ | Option | Default | Description |
329
+ |---|---|---|
330
+ | `merge` | `'crud'` | Merge strategy: `'crud'`, `'latest'`, `'set'`, `'presence'`, `'cursor'` |
331
+ | `key` | `'id'` | Key field for `crud` mode |
332
+ | `prepend` | `false` | Prepend new items instead of appending (`crud` mode) |
333
+ | `max` | `50` | Max items to keep (`latest` mode) |
334
+ | `replay` | `false` | Enable seq-based replay for gap-free reconnection |
335
+ | `onSubscribe` | -- | Callback `(ctx, topic)` fired when a client subscribes |
336
+ | `onUnsubscribe` | -- | Callback `(ctx, topic)` fired when a client disconnects |
337
+ | `filter` / `access` | -- | Per-connection publish filter (see [Access control](#access-control)) |
338
+ | `delta` | -- | Delta sync config (see [Delta sync and replay](#delta-sync-and-replay)) |
339
+ | `version` | -- | Schema version (see [Schema evolution](#schema-evolution)) |
340
+ | `migrate` | -- | Migration functions (see [Schema evolution](#schema-evolution)) |
341
+
342
+ ### Reconnection
343
+
344
+ When the WebSocket reconnects, streams automatically refetch initial data and resubscribe. The store keeps showing stale data during the refetch -- it does not reset to `undefined`.
345
+
346
+ ---
347
+
348
+ ## Error handling
349
+
350
+ Stream stores have three states:
351
+
352
+ | Value | Meaning |
353
+ |---|---|
354
+ | `undefined` | Loading (initial fetch in progress) |
355
+ | `Array` / `any` | Data loaded and receiving live updates |
356
+ | `{ error: RpcError }` | Initial fetch failed |
357
+
358
+ Handle all three in your template:
359
+
360
+ ```svelte
361
+ {#if $messages === undefined}
362
+ <p>Loading...</p>
363
+ {:else if $messages?.error}
364
+ <p>Failed: {$messages.error.message}</p>
365
+ {:else}
366
+ {#each $messages as msg (msg.id)}
367
+ <p>{msg.text}</p>
368
+ {/each}
369
+ {/if}
370
+ ```
371
+
372
+ For RPC calls, errors are thrown as `RpcError` with a `code` field:
373
+
374
+ ```js
375
+ import { sendMessage } from '$live/chat';
376
+
377
+ try {
378
+ await sendMessage(text);
379
+ } catch (err) {
380
+ if (err.code === 'VALIDATION') {
381
+ // handle validation error -- err.issues has details
382
+ } else if (err.code === 'UNAUTHORIZED') {
383
+ // redirect to login
384
+ }
385
+ }
386
+ ```
387
+
388
+ ### Reusable error boundary component
389
+
390
+ For Svelte 5, you can build a reusable boundary that handles all three stream states:
391
+
392
+ ```svelte
393
+ <!-- src/lib/StreamView.svelte -->
394
+ <script>
395
+ /** @type {{ store: import('svelte/store').Readable, children: import('svelte').Snippet, loading?: import('svelte').Snippet, error?: import('svelte').Snippet<[any]> }} */
396
+ let { store, children, loading, error } = $props();
397
+
398
+ let value = $derived($store);
399
+ </script>
400
+
401
+ {#if value === undefined}
402
+ {#if loading}
403
+ {@render loading()}
404
+ {:else}
405
+ <p>Loading...</p>
406
+ {/if}
407
+ {:else if value?.error}
408
+ {#if error}
409
+ {@render error(value.error)}
410
+ {:else}
411
+ <p>Error: {value.error.message}</p>
412
+ {/if}
413
+ {:else}
414
+ {@render children()}
415
+ {/if}
416
+ ```
417
+
418
+ Use it to wrap any stream:
419
+
420
+ ```svelte
421
+ <script>
422
+ import StreamView from '$lib/StreamView.svelte';
423
+ import { messages, sendMessage } from '$live/chat';
424
+ </script>
425
+
426
+ <StreamView store={messages}>
427
+ {#each $messages as msg (msg.id)}
428
+ <p>{msg.text}</p>
429
+ {/each}
430
+
431
+ {#snippet loading()}
432
+ <div class="skeleton-loader">Loading messages...</div>
433
+ {/snippet}
434
+
435
+ {#snippet error(err)}
436
+ <div class="error-banner">
437
+ <p>Could not load messages: {err.message}</p>
438
+ <button onclick={() => location.reload()}>Retry</button>
439
+ </div>
440
+ {/snippet}
441
+ </StreamView>
442
+ ```
443
+
444
+ With default slots, the minimal version is just:
445
+
446
+ ```svelte
447
+ <StreamView store={messages}>
448
+ {#each $messages as msg (msg.id)}
449
+ <p>{msg.text}</p>
450
+ {/each}
451
+ </StreamView>
452
+ ```
453
+
454
+ This removes the `{#if}/{:else if}/{:else}` boilerplate from every page that uses a stream.
455
+
456
+ ---
457
+
458
+ ## Per-module auth
459
+
460
+ Every file in `src/live/` can export a `_guard` that runs before all functions in that file.
461
+
462
+ ```js
463
+ // src/live/admin.js
464
+ import { live, guard, LiveError } from 'svelte-realtime/server';
465
+
466
+ export const _guard = guard((ctx) => {
467
+ if (ctx.user?.role !== 'admin')
468
+ throw new LiveError('FORBIDDEN', 'Admin only');
469
+ });
470
+
471
+ export const deleteUser = live(async (ctx, userId) => {
472
+ await db.users.delete(userId);
473
+ });
474
+
475
+ export const banUser = live(async (ctx, userId) => {
476
+ await db.users.ban(userId);
477
+ });
478
+ ```
479
+
480
+ Both `deleteUser` and `banUser` require admin access. No need to check in each function.
481
+
482
+ `guard()` accepts multiple functions for composable middleware chains. They run in order, and earlier ones can enrich `ctx` for later ones:
483
+
484
+ ```js
485
+ export const _guard = guard(
486
+ (ctx) => { if (!ctx.user) throw new LiveError('UNAUTHORIZED'); },
487
+ (ctx) => { ctx.permissions = lookupPermissions(ctx.user.id); },
488
+ (ctx) => { if (!ctx.permissions.includes('write')) throw new LiveError('FORBIDDEN'); }
489
+ );
490
+ ```
491
+
492
+ ---
493
+
494
+ ## Dynamic topics
495
+
496
+ Use a function instead of a string as the first argument to `live.stream()` for per-entity streams. The client-side stub becomes a factory function -- call it with arguments to get a cached store for that entity.
497
+
498
+ ```js
499
+ // src/live/rooms.js
500
+ import { live } from 'svelte-realtime/server';
501
+
502
+ export const roomMessages = live.stream(
503
+ (ctx, roomId) => 'chat:' + roomId,
504
+ async (ctx, roomId) => db.messages.forRoom(roomId),
505
+ { merge: 'crud', key: 'id' }
506
+ );
507
+
508
+ export const sendToRoom = live(async (ctx, roomId, text) => {
509
+ const msg = await db.messages.insert({ roomId, userId: ctx.user.id, text });
510
+ ctx.publish('chat:' + roomId, 'created', msg);
511
+ return msg;
512
+ });
513
+ ```
514
+
515
+ ```svelte
516
+ <!-- src/routes/rooms/[id]/+page.svelte -->
517
+ <script>
518
+ import { roomMessages, sendToRoom } from '$live/rooms';
519
+ let { data } = $props();
520
+
521
+ // roomMessages is a function -- call it with the room ID to get a store
522
+ const messages = roomMessages(data.roomId);
523
+ </script>
524
+
525
+ {#each $messages as msg (msg.id)}
526
+ <p>{msg.text}</p>
527
+ {/each}
528
+ ```
529
+
530
+ Same arguments return the same cached store instance. The cache is cleaned up when all subscribers unsubscribe.
531
+
532
+ ---
533
+
534
+ ## Schema validation
535
+
536
+ Use `live.validated(schema, fn)` to validate the first argument against a Zod or Valibot schema before the function runs.
537
+
538
+ ```js
539
+ import { z } from 'zod';
540
+ import { live } from 'svelte-realtime/server';
541
+
542
+ const CreateTodo = z.object({
543
+ text: z.string().min(1).max(200),
544
+ priority: z.enum(['low', 'medium', 'high']).optional()
545
+ });
546
+
547
+ export const addTodo = live.validated(CreateTodo, async (ctx, input) => {
548
+ const todo = await db.todos.insert({ ...input, userId: ctx.user.id });
549
+ ctx.publish('todos', 'created', todo);
550
+ return todo;
551
+ });
552
+ ```
553
+
554
+ On the client, validated exports work like regular `live()` calls. Validation errors are thrown as `RpcError` with `code: 'VALIDATION'` and an `issues` array. Valibot schemas are also supported -- the adapter detects the schema type automatically.
555
+
556
+ ---
557
+
558
+ ## Channels
559
+
560
+ Ephemeral pub/sub topics with no database initialization. Clients subscribe and receive live events immediately.
561
+
562
+ ```js
563
+ // src/live/typing.js
564
+ import { live } from 'svelte-realtime/server';
565
+
566
+ export const typing = live.channel('typing:lobby', { merge: 'presence' });
567
+ ```
568
+
569
+ ```svelte
570
+ <script>
571
+ import { typing } from '$live/typing';
572
+ </script>
573
+
574
+ {#each $typing as user (user.key)}
575
+ <span>{user.data.name} is typing...</span>
576
+ {/each}
577
+ ```
578
+
579
+ Dynamic channels work the same way:
580
+
581
+ ```js
582
+ export const cursors = live.channel(
583
+ (ctx, docId) => 'cursors:' + docId,
584
+ { merge: 'cursor' }
585
+ );
586
+ ```
587
+
588
+ ---
589
+
590
+ ## SSR hydration
591
+
592
+ Call live functions from `+page.server.js` to load data server-side, then hydrate the client-side stream store to avoid loading spinners.
593
+
594
+ ```js
595
+ // src/routes/chat/+page.server.js
596
+ export async function load({ platform }) {
597
+ const { messages } = await import('$live/chat');
598
+ const data = await messages.load(platform);
599
+ return { messages: data };
600
+ }
601
+ ```
602
+
603
+ ```svelte
604
+ <!-- src/routes/chat/+page.svelte -->
605
+ <script>
606
+ import { messages } from '$live/chat';
607
+ let { data } = $props();
608
+
609
+ // Pre-populate the store with SSR data -- no loading spinner
610
+ const msgs = messages.hydrate(data.messages);
611
+ </script>
612
+
613
+ {#each $msgs as msg (msg.id)}
614
+ <p>{msg.text}</p>
615
+ {/each}
616
+ ```
617
+
618
+ 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.
619
+
620
+ ---
621
+
622
+ ## Batching
623
+
624
+ Group multiple RPC calls into a single WebSocket frame to reduce round trips.
625
+
626
+ ```svelte
627
+ <script>
628
+ import { batch } from 'svelte-realtime/client';
629
+ import { createBoard, addColumn, addCard } from '$live/boards';
630
+
631
+ async function setupBoard() {
632
+ const [board, column, card] = await batch(() => [
633
+ createBoard('My Board'),
634
+ addColumn('To Do'),
635
+ addCard('First task')
636
+ ]);
637
+ }
638
+ </script>
639
+ ```
640
+
641
+ By default, calls in a batch run in parallel on the server. Pass `{ sequential: true }` when order matters:
642
+
643
+ ```js
644
+ const [board, column] = await batch(() => [
645
+ createBoard('My Board'),
646
+ addColumn(boardId, 'To Do')
647
+ ], { sequential: true });
648
+ ```
649
+
650
+ Each call resolves or rejects independently -- one failure does not cancel the others. Batches are limited to 50 calls per frame.
651
+
652
+ ---
653
+
654
+ ## Optimistic updates
655
+
656
+ Apply changes to a stream store instantly, then roll back if the server call fails.
657
+
658
+ ```svelte
659
+ <script>
660
+ import { todos, addTodo } from '$live/todos';
661
+
662
+ async function add(text) {
663
+ const tempId = 'temp-' + Date.now();
664
+ const rollback = todos.optimistic('created', { id: tempId, text });
665
+
666
+ try {
667
+ await addTodo(text);
668
+ // Server broadcasts the real 'created' event, which replaces the
669
+ // optimistic entry (matched by key) with the confirmed data.
670
+ } catch {
671
+ rollback();
672
+ }
673
+ }
674
+ </script>
675
+ ```
676
+
677
+ `optimistic(event, data)` returns a rollback function that restores the store to its previous state. It works with all merge strategies:
678
+
679
+ | Merge | Events | Behavior |
680
+ |---|---|---|
681
+ | `crud` | `created`, `updated`, `deleted` | Modifies array by key. Server event with same key replaces the optimistic entry. |
682
+ | `latest` | any event name | Appends data to the ring buffer. |
683
+ | `set` | any event name | Replaces the entire value. |
684
+
685
+ ---
686
+
687
+ ## Stream pagination
688
+
689
+ For large datasets, return `{ data, hasMore, cursor }` from your stream init function to enable cursor-based pagination.
690
+
691
+ ```js
692
+ // src/live/feed.js
693
+ import { live } from 'svelte-realtime/server';
694
+
695
+ export const posts = live.stream('posts', async (ctx) => {
696
+ const limit = 20;
697
+ const rows = await db.posts.list({ limit: limit + 1, after: ctx.cursor });
698
+ const hasMore = rows.length > limit;
699
+ const data = hasMore ? rows.slice(0, limit) : rows;
700
+ const cursor = data.length > 0 ? data[data.length - 1].id : null;
701
+ return { data, hasMore, cursor };
702
+ }, { merge: 'crud', key: 'id' });
703
+ ```
704
+
705
+ ```svelte
706
+ <script>
707
+ import { posts } from '$live/feed';
708
+
709
+ async function loadNext() {
710
+ await posts.loadMore();
711
+ }
712
+ </script>
713
+
714
+ {#each $posts as post (post.id)}
715
+ <p>{post.title}</p>
716
+ {/each}
717
+
718
+ {#if posts.hasMore}
719
+ <button onclick={loadNext}>Load more</button>
720
+ {/if}
721
+ ```
722
+
723
+ The server detects the `{ data, hasMore }` shape automatically. `ctx.cursor` contains the cursor value sent by the client on subsequent `loadMore()` calls (`null` on the first request).
724
+
725
+ ---
726
+
727
+ ## Undo and redo
728
+
729
+ Stream stores support history tracking for undo/redo.
730
+
731
+ ```svelte
732
+ <script>
733
+ import { todos } from '$live/todos';
734
+
735
+ todos.enableHistory(100); // max 100 entries
736
+
737
+ function handleUndo() {
738
+ todos.undo();
739
+ }
740
+ </script>
741
+
742
+ <button onclick={handleUndo} disabled={!todos.canUndo}>Undo</button>
743
+ <button onclick={() => todos.redo()} disabled={!todos.canRedo}>Redo</button>
744
+ ```
745
+
746
+ History is recorded after every mutation (both live events and optimistic updates). Call `enableHistory()` once to start tracking.
747
+
748
+ ---
749
+
750
+ ## Request deduplication
751
+
752
+ Identical RPC calls made within the same microtask are automatically coalesced into a single request.
753
+
754
+ ```js
755
+ // These two calls happen in the same microtask -- only one request is sent
756
+ const [a, b] = await Promise.all([
757
+ getUser(userId),
758
+ getUser(userId) // same call, same args -- reuses the first request
759
+ ]);
760
+ ```
761
+
762
+ To bypass deduplication and force a fresh request:
763
+
764
+ ```js
765
+ const result = await getUser.fresh(userId); // always sends a new request
766
+ ```
767
+
768
+ ---
769
+
770
+ ## Offline queue
771
+
772
+ Queue RPC calls when the WebSocket is disconnected and replay them on reconnect.
773
+
774
+ ```js
775
+ import { configure } from 'svelte-realtime/client';
776
+
777
+ configure({
778
+ offline: {
779
+ queue: true, // enable offline queuing
780
+ maxQueue: 100, // drop oldest if queue exceeds this (default: 100)
781
+ maxAge: 60000, // auto-reject queued calls older than this (ms)
782
+ beforeReplay(call) {
783
+ // Return false to drop stale mutations
784
+ return Date.now() - call.queuedAt < 60000; // drop if older than 1 minute
785
+ },
786
+ onReplayError(call, error) {
787
+ console.warn('Replay failed:', call.path, error);
788
+ }
789
+ }
790
+ });
791
+ ```
792
+
793
+ When offline queuing is enabled, RPC calls made while disconnected return promises that resolve when the call is replayed after reconnection. If the queue overflows, the oldest entry is dropped and its promise rejects with `QUEUE_FULL`. If `maxAge` is set, queued calls older than that threshold are rejected with `STALE` at replay time.
794
+
795
+ ---
796
+
797
+ ## Connection hooks
798
+
799
+ Use `configure()` on the client to react to WebSocket connection state changes.
800
+
801
+ ```svelte
802
+ <!-- src/routes/+layout.svelte -->
803
+ <script>
804
+ import { configure } from 'svelte-realtime/client';
805
+
806
+ configure({
807
+ onConnect() {
808
+ // Reconnected after a drop
809
+ invalidateAll();
810
+ },
811
+ onDisconnect() {
812
+ showBanner('Connection lost, reconnecting...');
813
+ }
814
+ });
815
+ </script>
816
+ ```
817
+
818
+ Call `configure()` once at app startup. The hooks fire on state transitions only (not on the initial connection).
819
+
820
+ | Option | Description |
821
+ |---|---|
822
+ | `onConnect()` | Called when the WebSocket connection opens after a reconnect |
823
+ | `onDisconnect()` | Called when the WebSocket connection closes |
824
+ | `beforeReconnect()` | Called before each reconnection attempt (can be async) |
825
+
826
+ ---
827
+
828
+ ## Combine stores
829
+
830
+ Compose multiple stream stores into a single derived store. When any source updates, the combining function re-runs.
831
+
832
+ ```svelte
833
+ <script>
834
+ import { combine } from 'svelte-realtime/client';
835
+ import { orders, inventory } from '$live/dashboard';
836
+
837
+ const dashboard = combine(orders, inventory, (o, i) => ({
838
+ pendingOrders: o?.filter(x => x.status === 'pending').length ?? 0,
839
+ lowStock: i?.filter(x => x.qty < 10) ?? []
840
+ }));
841
+ </script>
842
+
843
+ <p>Pending: {$dashboard.pendingOrders}</p>
844
+ ```
845
+
846
+ `combine()` accepts 2-6 stores with typed overloads, plus a variadic fallback for more. Zero network overhead -- all computation happens client-side.
847
+
848
+ ---
849
+
850
+ ## Global middleware
851
+
852
+ Use `live.middleware()` to register cross-cutting logic that runs before per-module guards on every RPC and stream call.
853
+
854
+ ```js
855
+ import { live, LiveError } from 'svelte-realtime/server';
856
+
857
+ // Logging middleware
858
+ live.middleware(async (ctx, next) => {
859
+ const start = Date.now();
860
+ const result = await next();
861
+ console.log(`[${ctx.user?.id}] took ${Date.now() - start}ms`);
862
+ return result;
863
+ });
864
+
865
+ // Auth middleware -- rejects unauthenticated requests globally
866
+ live.middleware(async (ctx, next) => {
867
+ if (!ctx.user) throw new LiveError('UNAUTHORIZED', 'Login required');
868
+ return next();
869
+ });
870
+ ```
871
+
872
+ Middleware runs in registration order. Each must call `next()` to continue the chain. When no middleware is registered, there is zero overhead.
873
+
874
+ ---
875
+
876
+ ## Throttle and debounce
877
+
878
+ Use `ctx.throttle()` and `ctx.debounce()` inside any `live()` function to rate-limit publishes.
879
+
880
+ ```js
881
+ export const updatePosition = live(async (ctx, x, y) => {
882
+ // Throttle: publishes immediately, then at most once per 50ms (trailing edge guaranteed)
883
+ ctx.throttle('cursors', 'update', { key: ctx.user.id, x, y }, 50);
884
+ });
885
+
886
+ export const saveSearch = live(async (ctx, query) => {
887
+ // Debounce: waits for 300ms of silence before publishing
888
+ ctx.debounce('search:' + ctx.user.id, 'set', { query }, 300);
889
+ });
890
+ ```
891
+
892
+ `ctx.throttle` publishes the first call immediately, stores subsequent calls, and sends the last value when the interval expires (trailing edge). `ctx.debounce` resets the timer on each call and only publishes after silence.
893
+
894
+ ---
895
+
896
+ ## Stream lifecycle hooks
897
+
898
+ Use `onSubscribe` and `onUnsubscribe` in stream options to run logic when clients join or leave a stream.
899
+
900
+ ```js
901
+ export const presence = live.stream('room:lobby', async (ctx) => {
902
+ return db.presence.list('lobby');
903
+ }, {
904
+ merge: 'presence',
905
+ onSubscribe(ctx, topic) {
906
+ ctx.publish(topic, 'join', { key: ctx.user.id, name: ctx.user.name });
907
+ },
908
+ onUnsubscribe(ctx, topic) {
909
+ ctx.publish(topic, 'leave', { key: ctx.user.id });
910
+ }
911
+ });
912
+ ```
913
+
914
+ `onSubscribe` fires after `ws.subscribe(topic)` and the initial data fetch. `onUnsubscribe` fires when the WebSocket closes (requires exporting `close` from your `hooks.ws.js`):
915
+
916
+ ```js
917
+ export { message, close } from 'svelte-realtime/server';
918
+ ```
919
+
920
+ `onUnsubscribe` fires for both static and dynamic topics. For dynamic topics, the server tracks which stream produced each subscription and only fires the correct hook on disconnect.
921
+
922
+ ---
923
+
924
+ ## Access control
925
+
926
+ Use the `filter` / `access` option on `live.stream()` to control who can subscribe. The predicate receives `ctx` and is checked once at subscription time. If it returns `false`, the subscription is denied with `{ ok: false, code: 'FORBIDDEN', error: 'Access denied' }` and no data is sent. For per-event filtering, use `pipe.filter()`.
927
+
928
+ ```js
929
+ import { live } from 'svelte-realtime/server';
930
+
931
+ // Only admins can subscribe
932
+ export const adminFeed = live.stream('admin-feed', async (ctx) => {
933
+ return db.adminEvents.recent();
934
+ }, {
935
+ merge: 'crud',
936
+ access: (ctx) => ctx.user?.role === 'admin'
937
+ });
938
+
939
+ // Role-based: different roles get different access
940
+ export const items = live.stream('items', async (ctx) => {
941
+ return db.items.all();
942
+ }, {
943
+ merge: 'crud',
944
+ access: live.access.role({
945
+ admin: true,
946
+ viewer: false
947
+ })
948
+ });
949
+ ```
950
+
951
+ For **per-user data isolation**, use dynamic topics so each user subscribes to their own topic:
952
+
953
+ ```js
954
+ // Each user gets their own topic -- no cross-user data leakage
955
+ export const myOrders = live.stream(
956
+ (ctx) => `orders:${ctx.user.id}`,
957
+ async (ctx) => db.orders.forUser(ctx.user.id),
958
+ { merge: 'crud', key: 'id' }
959
+ );
960
+ ```
961
+
962
+ | Helper | Description |
963
+ |---|---|
964
+ | `live.access.owner(field?)` | Subscription allowed if `ctx.user[field]` is present (default: `'id'`) |
965
+ | `live.access.team()` | Subscription allowed if `ctx.user.teamId` is present |
966
+ | `live.access.role(map)` | Role-based: `{ admin: true, viewer: (ctx) => ... }` |
967
+ | `live.access.any(...predicates)` | OR: any predicate returning true allows the subscription |
968
+ | `live.access.all(...predicates)` | AND: all predicates must return true |
969
+
970
+ ---
971
+
972
+ ## Rate limiting
973
+
974
+ ### Per-function rate limiting
975
+
976
+ Use `live.rateLimit()` to apply a sliding window rate limiter to a single function:
977
+
978
+ ```js
979
+ export const sendMessage = live.rateLimit({ points: 5, window: 10000 }, async (ctx, text) => {
980
+ const msg = await db.messages.insert({ userId: ctx.user.id, text });
981
+ ctx.publish('messages', 'created', msg);
982
+ return msg;
983
+ });
984
+ ```
985
+
986
+ ### Global rate limiting with Redis
987
+
988
+ Use the `beforeExecute` hook with the rate limit extension for global per-connection throttling:
989
+
990
+ ```js
991
+ import { createMessage, LiveError } from 'svelte-realtime/server';
992
+ import { createRedis, createRateLimit } from 'svelte-adapter-uws-extensions/redis';
993
+
994
+ const redis = createRedis();
995
+ const limiter = createRateLimit(redis, { points: 30, interval: 10000 });
996
+
997
+ export const message = createMessage({
998
+ async beforeExecute(ws, rpcPath) {
999
+ const { allowed, resetMs } = await limiter.consume(ws);
1000
+ if (!allowed)
1001
+ throw new LiveError('RATE_LIMITED', `Too many requests. Retry in ${Math.ceil(resetMs / 1000)}s`);
1002
+ }
1003
+ });
1004
+ ```
1005
+
1006
+ ---
1007
+
1008
+ ## Cron scheduling
1009
+
1010
+ Use `live.cron()` to run server-side functions on a schedule and publish results to a topic.
1011
+
1012
+ ```js
1013
+ import { live } from 'svelte-realtime/server';
1014
+
1015
+ export const refreshStats = live.cron('*/5 * * * *', 'stats', async () => {
1016
+ return { users: await db.users.count(), orders: await db.orders.todayCount() };
1017
+ });
1018
+ ```
1019
+
1020
+ The cron function publishes its return value as a `set` event on the given topic. Pair it with a `merge: 'set'` stream:
1021
+
1022
+ ```js
1023
+ export const stats = live.stream('stats', async (ctx) => {
1024
+ return db.stats();
1025
+ }, { merge: 'set' });
1026
+ ```
1027
+
1028
+ Cron expressions use 5 fields: `minute hour day month weekday`. Supported syntax: `*`, single values, ranges (`9-17`), lists (`0,15,30`), and steps (`*/5`).
1029
+
1030
+ The platform is captured automatically from the first RPC call. If your app starts cron jobs before any WebSocket connections, call `setCronPlatform(platform)` in your `open` hook.
1031
+
1032
+ ---
1033
+
1034
+ ## Derived streams
1035
+
1036
+ Server-side computed streams that recompute when any source topic publishes.
1037
+
1038
+ ```js
1039
+ import { live } from 'svelte-realtime/server';
1040
+
1041
+ export const dashboardStats = live.derived(
1042
+ ['orders', 'inventory', 'users'],
1043
+ async () => {
1044
+ return {
1045
+ totalOrders: await db.orders.count(),
1046
+ lowStock: await db.inventory.lowStockCount(),
1047
+ activeUsers: await db.users.activeCount()
1048
+ };
1049
+ },
1050
+ { debounce: 500 }
1051
+ );
1052
+ ```
1053
+
1054
+ On the client, derived streams work like regular streams:
1055
+
1056
+ ```svelte
1057
+ <script>
1058
+ import { dashboardStats } from '$live/dashboard';
1059
+ </script>
1060
+
1061
+ <p>Orders: {$dashboardStats?.totalOrders}</p>
1062
+ ```
1063
+
1064
+ Call `_activateDerived(platform)` in your `open` hook to enable derived stream listeners:
1065
+
1066
+ ```js
1067
+ import { _activateDerived } from 'svelte-realtime/server';
1068
+
1069
+ export function open(ws, { platform }) {
1070
+ _activateDerived(platform);
1071
+ }
1072
+ ```
1073
+
1074
+ | Option | Default | Description |
1075
+ |---|---|---|
1076
+ | `merge` | `'set'` | Merge strategy for the derived topic |
1077
+ | `debounce` | `0` | Debounce recomputation by this many milliseconds |
1078
+
1079
+ ---
1080
+
1081
+ ## Effects
1082
+
1083
+ Server-side reactive side effects that fire when source topics publish. Fire-and-forget -- no topic, no client subscription.
1084
+
1085
+ ```js
1086
+ // src/live/notifications.js
1087
+ import { live } from 'svelte-realtime/server';
1088
+
1089
+ export const orderNotifications = live.effect(['orders'], async (event, data, platform) => {
1090
+ if (event === 'created') {
1091
+ await email.send(data.userEmail, 'Order confirmed', templates.orderConfirm(data));
1092
+ }
1093
+ });
1094
+ ```
1095
+
1096
+ Effects are server-only. They fire whenever a matching topic publishes and cannot be subscribed to from the client.
1097
+
1098
+ ---
1099
+
1100
+ ## Aggregates
1101
+
1102
+ Real-time incremental aggregations. Reducers run on each event, maintaining O(1) state.
1103
+
1104
+ ```js
1105
+ // src/live/stats.js
1106
+ import { live } from 'svelte-realtime/server';
1107
+
1108
+ export const orderStats = live.aggregate('orders', {
1109
+ count: { init: () => 0, reduce: (acc, event) => event === 'created' ? acc + 1 : acc },
1110
+ total: { init: () => 0, reduce: (acc, event, data) => event === 'created' ? acc + data.amount : acc },
1111
+ avg: { compute: (state) => state.count > 0 ? state.total / state.count : 0 }
1112
+ }, { topic: 'order-stats' });
1113
+ ```
1114
+
1115
+ The aggregate publishes its state to the output topic on every event. Clients subscribe to the output topic as a regular stream.
1116
+
1117
+ ---
1118
+
1119
+ ## Gates
1120
+
1121
+ Conditional stream activation. On the server, a predicate controls whether the client subscribes. On the client, `.when()` manages the subscription lifecycle.
1122
+
1123
+ ```js
1124
+ // src/live/beta.js
1125
+ import { live } from 'svelte-realtime/server';
1126
+
1127
+ export const betaFeed = live.gate(
1128
+ (ctx) => ctx.user?.flags?.includes('beta'),
1129
+ live.stream('beta-feed', async (ctx) => db.betaFeed.latest(50), { merge: 'latest' })
1130
+ );
1131
+ ```
1132
+
1133
+ ```svelte
1134
+ <script>
1135
+ import { betaFeed } from '$live/beta';
1136
+
1137
+ import { writable } from 'svelte/store';
1138
+
1139
+ const tabActive = writable(true);
1140
+ const feed = betaFeed.when(tabActive);
1141
+ </script>
1142
+
1143
+ {#if $feed !== undefined}
1144
+ {#each $feed as item (item.id)}
1145
+ <p>{item.title}</p>
1146
+ {/each}
1147
+ {/if}
1148
+ ```
1149
+
1150
+ When the predicate returns false, the server responds with a graceful no-op (no error, no subscription). The client store stays `undefined`. `.when()` accepts a boolean, a Svelte store, or a getter function. When given a store, it subscribes/unsubscribes reactively as the value changes. Getter functions are evaluated once at subscribe time; for reactivity with Svelte 5 `$state`, wrap in `$derived` or pass a store.
1151
+
1152
+ ---
1153
+
1154
+ ## Pipes
1155
+
1156
+ Composable server-side stream transforms. Apply filter, sort, limit, and join operations to both initial data and live events.
1157
+
1158
+ ```js
1159
+ // src/live/notifications.js
1160
+ import { live, pipe } from 'svelte-realtime/server';
1161
+
1162
+ export const myNotifications = pipe(
1163
+ live.stream('notifications', async (ctx) => {
1164
+ return db.notifications.forUser(ctx.user.id);
1165
+ }, { merge: 'crud', key: 'id' }),
1166
+
1167
+ pipe.filter((ctx, item) => !item.dismissed),
1168
+ pipe.sort('createdAt', 'desc'),
1169
+ pipe.limit(20),
1170
+ pipe.join('authorId', async (id) => db.users.getName(id), 'authorName')
1171
+ );
1172
+ ```
1173
+
1174
+ | Transform | Initial data | Live events |
1175
+ |-----------|-------------|-------------|
1176
+ | `pipe.filter(predicate)` | Filters the array | Initial data only |
1177
+ | `pipe.sort(field, dir)` | Sorts the array | Initial data only |
1178
+ | `pipe.limit(n)` | Slices to N items | Initial data only |
1179
+ | `pipe.join(field, resolver, as)` | Enriches each item | Initial data only |
1180
+
1181
+ Piped functions preserve all stream metadata. The client receives already-transformed data.
1182
+
1183
+ ---
1184
+
1185
+ ## Binary RPC
1186
+
1187
+ Use `live.binary()` to send raw binary data (file uploads, images, protobuf) over WebSocket without base64 encoding.
1188
+
1189
+ ```js
1190
+ // src/live/upload.js
1191
+ import { live } from 'svelte-realtime/server';
1192
+
1193
+ export const uploadAvatar = live.binary(async (ctx, buffer, filename) => {
1194
+ await storage.put(`avatars/${ctx.user.id}/${filename}`, buffer);
1195
+ return { url: `/avatars/${ctx.user.id}/${filename}` };
1196
+ }, { maxSize: 5 * 1024 * 1024 }); // reject payloads over 5MB (default: 10MB)
1197
+ ```
1198
+
1199
+ ```svelte
1200
+ <script>
1201
+ import { uploadAvatar } from '$live/upload';
1202
+
1203
+ async function handleFile(e) {
1204
+ const file = e.target.files[0];
1205
+ const buffer = await file.arrayBuffer();
1206
+ const { url } = await uploadAvatar(buffer, file.name);
1207
+ }
1208
+ </script>
1209
+
1210
+ <input type="file" accept="image/*" onchange={handleFile} />
1211
+ ```
1212
+
1213
+ The wire format uses a compact binary frame: `0x00` marker byte + uint16 BE header length + JSON header + raw binary payload. This avoids base64 overhead entirely.
1214
+
1215
+ ---
1216
+
1217
+ ## Rooms
1218
+
1219
+ Bundle data, presence, cursors, and scoped actions into a single declaration.
1220
+
1221
+ ```js
1222
+ // src/live/collab.js
1223
+ import { live } from 'svelte-realtime/server';
1224
+
1225
+ export const board = live.room({
1226
+ topic: (ctx, boardId) => 'board:' + boardId,
1227
+ init: async (ctx, boardId) => db.cards.forBoard(boardId),
1228
+ presence: (ctx) => ({ name: ctx.user.name, avatar: ctx.user.avatar }),
1229
+ cursors: true,
1230
+ guard: async (ctx) => {
1231
+ if (!ctx.user) throw new LiveError('UNAUTHORIZED');
1232
+ },
1233
+ actions: {
1234
+ addCard: async (ctx, boardId, title) => {
1235
+ const card = await db.cards.insert({ boardId, title });
1236
+ ctx.publish('created', card);
1237
+ return card;
1238
+ }
1239
+ }
1240
+ });
1241
+ ```
1242
+
1243
+ On the client, the room export becomes an object with sub-streams and actions. Room actions receive the same leading arguments as the topic function (boardId in this case), followed by any action-specific arguments:
1244
+
1245
+ ```svelte
1246
+ <script>
1247
+ import { board } from '$live/collab';
1248
+
1249
+ const data = board.data(boardId); // main data stream
1250
+ const users = board.presence(boardId); // presence stream
1251
+ const cursors = board.cursors(boardId); // cursor stream
1252
+ </script>
1253
+
1254
+ {#each $data as card (card.id)}
1255
+ <Card {card} />
1256
+ {/each}
1257
+
1258
+ <button onclick={() => board.addCard(boardId, 'New card')}>Add</button>
1259
+ ```
1260
+
1261
+ ---
1262
+
1263
+ ## Webhooks
1264
+
1265
+ Bridge external HTTP webhooks into your pub/sub topics.
1266
+
1267
+ ```js
1268
+ // src/live/integrations.js
1269
+ import { live } from 'svelte-realtime/server';
1270
+
1271
+ export const stripeEvents = live.webhook('payments', {
1272
+ verify({ body, headers }) {
1273
+ return stripe.webhooks.constructEvent(body, headers['stripe-signature'], webhookSecret);
1274
+ },
1275
+ transform(event) {
1276
+ if (event.type === 'payment_intent.succeeded') {
1277
+ return { event: 'created', data: event.data.object };
1278
+ }
1279
+ return null; // ignore other event types
1280
+ }
1281
+ });
1282
+ ```
1283
+
1284
+ Use the handler in a SvelteKit endpoint:
1285
+
1286
+ ```js
1287
+ // src/routes/api/stripe/+server.js
1288
+ import { stripeEvents } from '$live/integrations';
1289
+
1290
+ export async function POST({ request, platform }) {
1291
+ const body = await request.text();
1292
+ const headers = Object.fromEntries(request.headers);
1293
+ const result = await stripeEvents.handle({ body, headers, platform });
1294
+ return new Response(result.body, { status: result.status });
1295
+ }
1296
+ ```
1297
+
1298
+ ---
1299
+
1300
+ ## Signals
1301
+
1302
+ Point-to-point ephemeral messaging. Send a signal to a specific user without broadcasting to a topic.
1303
+
1304
+ ```js
1305
+ // Server: send a signal
1306
+ const handler = live(async (ctx, targetUserId, offer) => {
1307
+ ctx.signal(targetUserId, 'call:offer', offer);
1308
+ });
1309
+ ```
1310
+
1311
+ ```js
1312
+ // Client: receive signals
1313
+ import { onSignal } from 'svelte-realtime/client';
1314
+
1315
+ const unsub = onSignal(currentUser.id, (event, data) => {
1316
+ if (event === 'call:offer') showIncomingCall(data);
1317
+ });
1318
+ ```
1319
+
1320
+ Enable signal delivery in your `open` hook:
1321
+
1322
+ ```js
1323
+ import { enableSignals } from 'svelte-realtime/server';
1324
+ export function open(ws) { enableSignals(ws); }
1325
+ ```
1326
+
1327
+ ---
1328
+
1329
+ ## Schema evolution
1330
+
1331
+ Versioned streams with declarative migration functions. When you change a data shape, old clients receive migrated data automatically.
1332
+
1333
+ ```js
1334
+ export const todos = live.stream('todos', async (ctx) => {
1335
+ return db.todos.all();
1336
+ }, {
1337
+ merge: 'crud',
1338
+ key: 'id',
1339
+ version: 3,
1340
+ migrate: {
1341
+ // v1 -> v2: add priority field
1342
+ 1: (item) => ({ ...item, priority: item.priority ?? 'medium' }),
1343
+ // v2 -> v3: rename 'done' to 'completed'
1344
+ 2: (item) => {
1345
+ const { done, ...rest } = item;
1346
+ return { ...rest, completed: done ?? false };
1347
+ }
1348
+ }
1349
+ });
1350
+ ```
1351
+
1352
+ The Vite plugin includes the stream version in the client stub. On reconnect, the client sends its version. If the server is ahead, migration functions chain in order (v1 -> v2 -> v3). If versions match, no migration runs.
1353
+
1354
+ ---
1355
+
1356
+ ## Delta sync and replay
1357
+
1358
+ ### Delta sync
1359
+
1360
+ Enable delta sync for efficient reconnection on streams with large datasets. Instead of refetching all data, the server sends only what changed since the client's last known version.
1361
+
1362
+ ```js
1363
+ export const inventory = live.stream('inventory', async (ctx) => {
1364
+ return db.inventory.all();
1365
+ }, {
1366
+ merge: 'crud',
1367
+ key: 'sku',
1368
+ delta: {
1369
+ version: () => db.inventory.lastModified(),
1370
+ diff: async (sinceVersion) => {
1371
+ const changes = await db.inventory.changedSince(sinceVersion);
1372
+ return changes; // null to force full refetch
1373
+ }
1374
+ }
1375
+ });
1376
+ ```
1377
+
1378
+ How it works:
1379
+ - On first connect, the client gets the full dataset plus a `version` value
1380
+ - On reconnect, the client sends its last known `version`
1381
+ - If versions match: server responds with `{ unchanged: true }` (nearly zero bytes)
1382
+ - If versions differ: server calls `diff(sinceVersion)` and sends only the changes
1383
+ - If diff returns `null`: falls back to full refetch
1384
+
1385
+ ### Replay
1386
+
1387
+ Enable seq-based replay for gap-free stream reconnection. When a client reconnects, it sends its last known sequence number. If the server has the missed events buffered, it sends only those instead of a full refetch.
1388
+
1389
+ ```js
1390
+ export const feed = live.stream('feed', async (ctx) => {
1391
+ return db.feed.latest(50);
1392
+ }, { merge: 'latest', max: 50, replay: true });
1393
+ ```
1394
+
1395
+ Replay requires the replay extension from `svelte-adapter-uws-extensions`. When replay is not available or the gap is too large, the client falls back to a full refetch automatically.
1396
+
1397
+ ---
1398
+
1399
+ ## Redis multi-instance
1400
+
1401
+ Use `createMessage` with the Redis pub/sub bus for multi-instance deployments. `ctx.publish` automatically goes through Redis when the platform is wrapped.
1402
+
1403
+ ```js
1404
+ // src/hooks.ws.js
1405
+ import { createMessage } from 'svelte-realtime/server';
1406
+ import { createRedis, createPubSubBus } from 'svelte-adapter-uws-extensions/redis';
1407
+
1408
+ const redis = createRedis();
1409
+ const bus = createPubSubBus(redis);
1410
+
1411
+ export function open(ws, { platform }) {
1412
+ bus.activate(platform);
1413
+ }
1414
+
1415
+ export function upgrade({ cookies }) {
1416
+ return validateSession(cookies.session_id) || false;
1417
+ }
1418
+
1419
+ export const message = createMessage({ platform: (p) => bus.wrap(p) });
1420
+ ```
1421
+
1422
+ No changes needed in your live modules. `ctx.publish` delegates to whatever platform was passed in, so Redis wrapping is transparent.
1423
+
1424
+ ### Combined: Redis + rate limiting
1425
+
1426
+ ```js
1427
+ import { createMessage, LiveError } from 'svelte-realtime/server';
1428
+ import { createRedis, createPubSubBus, createRateLimit } from 'svelte-adapter-uws-extensions/redis';
1429
+
1430
+ const redis = createRedis();
1431
+ const bus = createPubSubBus(redis);
1432
+ const limiter = createRateLimit(redis, { points: 30, interval: 10000 });
1433
+
1434
+ export function open(ws, { platform }) { bus.activate(platform); }
1435
+ export function upgrade({ cookies }) { return validateSession(cookies.session_id) || false; }
1436
+
1437
+ export const message = createMessage({
1438
+ platform: (p) => bus.wrap(p),
1439
+ async beforeExecute(ws, rpcPath) {
1440
+ const { allowed, resetMs } = await limiter.consume(ws);
1441
+ if (!allowed)
1442
+ throw new LiveError('RATE_LIMITED', `Retry in ${Math.ceil(resetMs / 1000)}s`);
1443
+ }
1444
+ });
1445
+ ```
1446
+
1447
+ ---
1448
+
1449
+ ## Postgres NOTIFY
1450
+
1451
+ Combine live.stream with the Postgres NOTIFY bridge for zero-code reactivity. A DB trigger fires `pg_notify()`, the bridge calls `platform.publish()`, and the stream auto-updates.
1452
+
1453
+ ```js
1454
+ // src/hooks.ws.js
1455
+ export { message } from 'svelte-realtime/server';
1456
+ import { createPgClient, createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres';
1457
+
1458
+ const pg = createPgClient({ connectionString: process.env.DATABASE_URL });
1459
+ const notify = createNotifyBridge(pg, {
1460
+ channel: 'table_changes',
1461
+ parse: (payload) => JSON.parse(payload)
1462
+ });
1463
+
1464
+ export function open(ws, { platform }) {
1465
+ notify.activate(platform);
1466
+ }
1467
+ ```
1468
+
1469
+ ```js
1470
+ // src/live/orders.js -- no ctx.publish needed, the DB trigger handles it
1471
+ export const createOrder = live(async (ctx, items) => {
1472
+ return db.orders.insert({ userId: ctx.user.id, items });
1473
+ });
1474
+
1475
+ export const orders = live.stream('orders', async (ctx) => {
1476
+ return db.orders.forUser(ctx.user.id);
1477
+ }, { merge: 'crud', key: 'id' });
1478
+ ```
1479
+
1480
+ ---
1481
+
1482
+ ## Clustering
1483
+
1484
+ svelte-realtime works with the adapter's `CLUSTER_WORKERS` mode.
1485
+
1486
+ | Method | Cross-worker? | Safe in `live()`? |
1487
+ |---|---|---|
1488
+ | `ctx.publish()` | Yes (relayed) | Yes |
1489
+ | `ctx.platform.send()` | N/A (single ws) | Yes |
1490
+ | `ctx.platform.sendTo()` | **No** (local only) | Use with caution |
1491
+ | `ctx.platform.subscribers()` | **No** (local only) | Use with caution |
1492
+ | `ctx.platform.connections` | **No** (local only) | Use with caution |
1493
+
1494
+ `ctx.publish()` is always safe -- it relays across workers and, with Redis wrapping, across instances. For targeted messaging, prefer `publish()` with a user-specific topic over `sendTo()`.
1495
+
1496
+ ---
1497
+
1498
+ ## Limits and gotchas
1499
+
1500
+ ### maxPayloadLength (default: 16KB)
1501
+
1502
+ If an RPC request exceeds this, the adapter closes the connection silently (uWS behavior). If your app sends large payloads, increase `maxPayloadLength` in the adapter's websocket config.
1503
+
1504
+ ### maxBackpressure (default: 1MB)
1505
+
1506
+ If a connection's send buffer exceeds this, messages are silently dropped. `handleRpc` checks the return value of `platform.send()` and warns in dev mode if a response was not delivered.
1507
+
1508
+ ### sendQueue cap (client-side, max 1000)
1509
+
1510
+ The adapter's `sendQueued()` drops the oldest item if the queue exceeds 1000. Unlikely in practice, but worth knowing for offline-heavy apps.
1511
+
1512
+ ### Batch size (max 50 calls)
1513
+
1514
+ A single `batch()` call is limited to 50 RPC calls. If exceeded, the server rejects the entire batch. Split into multiple `batch()` calls if you need more.
1515
+
1516
+ ### ws.subscribe() vs the subscribe hook
1517
+
1518
+ `live.stream()` calls `ws.subscribe(topic)` server-side, bypassing the adapter's `subscribe` hook entirely. This is correct -- stream topics are gated by `guard()`, not the subscribe hook.
1519
+
1520
+ ---
1521
+
1522
+ ## Error reporting
1523
+
1524
+ ### onError hook
1525
+
1526
+ Both `handleRpc` and `createMessage` accept an `onError` callback for non-LiveError exceptions. `LiveError` throws are expected errors sent to the client; everything else is an unexpected failure that should be reported.
1527
+
1528
+ ```js
1529
+ export const message = createMessage({
1530
+ onError(path, error, ctx) {
1531
+ sentry.captureException(error, {
1532
+ tags: { rpc: path },
1533
+ user: { id: ctx.user?.id }
1534
+ });
1535
+ }
1536
+ });
1537
+ ```
1538
+
1539
+ ### onCronError hook
1540
+
1541
+ For cron jobs, use the standalone `onCronError` function:
1542
+
1543
+ ```js
1544
+ import { onCronError } from 'svelte-realtime/server';
1545
+
1546
+ onCronError((path, error) => {
1547
+ sentry.captureException(error, { tags: { cron: path } });
1548
+ });
1549
+ ```
1550
+
1551
+ ---
1552
+
1553
+ ## Custom message handling
1554
+
1555
+ When you need to mix RPC with custom WebSocket messages, use `onUnhandled` or drop to `handleRpc` directly.
1556
+
1557
+ **With createMessage:**
1558
+ ```js
1559
+ export const message = createMessage({
1560
+ onUnhandled(ws, data, platform) {
1561
+ // handle non-RPC messages (binary data, custom protocols, etc.)
1562
+ }
1563
+ });
1564
+ ```
1565
+
1566
+ **With handleRpc:**
1567
+ ```js
1568
+ import { handleRpc } from 'svelte-realtime/server';
1569
+
1570
+ export function message(ws, { data, platform }) {
1571
+ if (handleRpc(ws, data, platform)) return;
1572
+ // your custom message handling
1573
+ }
1574
+ ```
1575
+
1576
+ **Progression:** `export { message }` -> `createMessage({...})` -> manual `handleRpc`. Start simple, add options when needed, drop to full control only if you have to.
1577
+
1578
+ ---
1579
+
1580
+ ## Server-Side HMR
1581
+
1582
+ Changes to files in `src/live/` are hot-reloaded on the server without restarting `npm run dev`. When you save a file, the plugin:
1583
+
1584
+ 1. Invalidates the changed module in Vite's server module graph
1585
+ 2. Clears all server-side registrations (RPC handlers, guards, cron jobs, derived streams, effects, aggregates)
1586
+ 3. Re-imports the registry module so every `__register*` call runs with the updated handler functions
1587
+
1588
+ This applies to all handler types -- `live()`, `live.stream()`, `live.cron()`, `live.derived()`, `live.effect()`, `live.aggregate()`, `live.room()`, `guard()`, and everything else. Adding or deleting files in `src/live/` also triggers a full re-registration.
1589
+
1590
+ **Error recovery:** if the edited file has a syntax error, the previous handlers are restored so the server keeps working. Fix the error and save again.
1591
+
1592
+ **Active subscriptions:** existing stream subscribers keep their current data and connection. They will receive new events published by the updated handler, but the init function only runs on new subscriptions. A full page reload picks up the latest init logic.
1593
+
1594
+ **Cron jobs:** old intervals are cleared and restarted with the updated schedule and handler.
1595
+
1596
+ ---
1597
+
1598
+ ## DevTools
1599
+
1600
+ In dev mode, the Vite plugin injects an in-browser overlay that shows active streams, RPC history, and connection status. Toggle with `Ctrl+Shift+L`.
1601
+
1602
+ The overlay is stripped from production builds. Disable it in dev with:
1603
+
1604
+ ```js
1605
+ realtime({ devtools: false })
1606
+ ```
1607
+
1608
+ ---
1609
+
1610
+ ## Testing
1611
+
1612
+ Use `createTestEnv()` from `svelte-realtime/test` to test your live functions without a real WebSocket server.
1613
+
1614
+ ```js
1615
+ import { describe, it, expect, afterEach } from 'vitest';
1616
+ import { createTestEnv } from 'svelte-realtime/test';
1617
+ import * as chat from '../src/live/chat.js';
1618
+
1619
+ describe('chat module', () => {
1620
+ const env = createTestEnv();
1621
+ afterEach(() => env.cleanup());
1622
+
1623
+ it('sends and receives messages', async () => {
1624
+ env.register('chat', chat);
1625
+
1626
+ const alice = env.connect({ id: 'alice', name: 'Alice' });
1627
+ const bob = env.connect({ id: 'bob', name: 'Bob' });
1628
+
1629
+ // Subscribe Bob to the messages stream
1630
+ const stream = bob.subscribe('chat/messages');
1631
+ await new Promise(r => setTimeout(r, 10));
1632
+
1633
+ // Alice sends a message
1634
+ const msg = await alice.call('chat/sendMessage', 'Hello!');
1635
+ expect(msg.text).toBe('Hello!');
1636
+
1637
+ // Bob receives the live update
1638
+ await new Promise(r => setTimeout(r, 10));
1639
+ expect(stream.events).toHaveLength(1);
1640
+ });
1641
+ });
1642
+ ```
1643
+
1644
+ **TestEnv API:**
1645
+
1646
+ | Method | Description |
1647
+ |---|---|
1648
+ | `register(moduleName, exports)` | Register a module's live functions |
1649
+ | `connect(userData)` | Create a fake connected client |
1650
+ | `cleanup()` | Clear all state (call in `afterEach`) |
1651
+ | `platform` | The mock platform object |
1652
+
1653
+ **TestClient API:**
1654
+
1655
+ | Method | Description |
1656
+ |---|---|
1657
+ | `call(path, ...args)` | Call a `live()` function |
1658
+ | `subscribe(path, ...args)` | Subscribe to a `live.stream()` |
1659
+ | `binary(path, buffer, ...args)` | Call a `live.binary()` function |
1660
+ | `disconnect()` / `reconnect()` | Simulate connection state changes |
1661
+
1662
+ **TestStream API:**
1663
+
1664
+ | Property | Description |
1665
+ |---|---|
1666
+ | `value` | Latest value from the stream |
1667
+ | `error` | Error if the stream failed |
1668
+ | `topic` | The topic the stream is subscribed to |
1669
+ | `events` | All pub/sub events received |
1670
+ | `hasMore` | Whether more pages are available |
1671
+ | `waitFor(predicate, timeout?)` | Wait for a value matching a predicate |
1672
+
1673
+ ---
1674
+
1675
+ ## Server API reference
1676
+
1677
+ Import from `svelte-realtime/server`.
1678
+
1679
+ | Export | Description |
1680
+ |---|---|
1681
+ | `live(fn)` | Mark a function as RPC-callable |
1682
+ | `live.stream(topic, initFn, options?)` | Create a reactive stream |
1683
+ | `live.channel(topic, options?)` | Create an ephemeral pub/sub channel |
1684
+ | `live.binary(fn, options?)` | Mark a function as a binary RPC handler (`maxSize` limits payload, default 10MB) |
1685
+ | `live.validated(schema, fn)` | RPC with Zod/Valibot input validation |
1686
+ | `live.cron(schedule, topic, fn)` | Server-side scheduled function |
1687
+ | `live.derived(sources, fn, options?)` | Server-side computed stream |
1688
+ | `live.effect(sources, fn, options?)` | Server-side reactive side effect |
1689
+ | `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
1690
+ | `live.room(config)` | Collaborative room (data + presence + cursors + actions) |
1691
+ | `live.webhook(topic, config)` | HTTP webhook-to-stream bridge |
1692
+ | `live.gate(predicate, fn)` | Conditional stream activation |
1693
+ | `live.rateLimit(config, fn)` | Per-function sliding window rate limiter |
1694
+ | `live.middleware(fn)` | Global middleware (runs before guards) |
1695
+ | `live.access.*` | Subscribe-time access control helpers |
1696
+ | `guard(...fns)` | Per-module auth middleware |
1697
+ | `LiveError(code, message?)` | Typed error (propagates to client) |
1698
+ | `handleRpc(ws, data, platform, options?)` | Low-level RPC handler |
1699
+ | `message` | Ready-made message hook |
1700
+ | `createMessage(options?)` | Custom message hook factory |
1701
+ | `pipe(stream, ...transforms)` | Composable stream transforms |
1702
+ | `close` | Ready-made close hook (fires onUnsubscribe) |
1703
+ | `setCronPlatform(platform)` | Capture platform for cron jobs |
1704
+ | `onCronError(handler)` | Global cron error handler |
1705
+ | `enableSignals(ws)` | Enable point-to-point signal delivery |
1706
+ | `_activateDerived(platform)` | Enable derived stream listeners |
1707
+
1708
+ ---
1709
+
1710
+ ## Client API reference
1711
+
1712
+ Import from `svelte-realtime/client`.
1713
+
1714
+ | Export | Description |
1715
+ |---|---|
1716
+ | `RpcError` | Typed error with `code` field |
1717
+ | `batch(fn, options?)` | Group RPC calls into one WebSocket frame |
1718
+ | `configure(config)` | Connection hooks and offline queue setup |
1719
+ | `combine(...stores, fn)` | Multi-store composition |
1720
+ | `onSignal(userId, callback)` | Listen for point-to-point signals |
1721
+
1722
+ **Stream store methods** (on `$live/` stream imports):
1723
+
1724
+ | Method/Property | Description |
1725
+ |---|---|
1726
+ | `optimistic(event, data)` | Apply instant UI update, returns rollback function |
1727
+ | `hydrate(initialData)` | Pre-populate with SSR data |
1728
+ | `loadMore(...extraArgs)` | Load next page (cursor-based) |
1729
+ | `hasMore` | Whether more pages are available |
1730
+ | `enableHistory(maxSize?)` | Start tracking for undo/redo |
1731
+ | `undo()` / `redo()` | Navigate history |
1732
+ | `canUndo` / `canRedo` | Whether undo/redo is available |
1733
+ | `when(condition)` | Conditional subscription |
1734
+
1735
+ ---
1736
+
1737
+ ## Vite plugin options
1738
+
1739
+ Import from `svelte-realtime/vite`.
1740
+
1741
+ ```js
1742
+ import realtime from 'svelte-realtime/vite';
1743
+
1744
+ export default {
1745
+ plugins: [sveltekit(), uws(), realtime({ dir: 'src/live' })]
1746
+ };
1747
+ ```
1748
+
1749
+ | Option | Default | Description |
1750
+ |---|---|---|
1751
+ | `dir` | `'src/live'` | Directory containing live modules |
1752
+ | `typedImports` | `true` | Generate `.d.ts` for typed `$live/` imports |
1753
+ | `devtools` | `true` | Enable the in-browser DevTools overlay in dev mode |
1754
+
1755
+ The plugin resolves `$live/chat` to `src/live/chat.js`, generates client stubs, supports nested directories (`$live/rooms/lobby`), and watches for file changes in dev mode. When `typedImports` is enabled, it generates type declarations that strip the `ctx` parameter and infer return types.
1756
+
1757
+ ---
1758
+
1759
+ ## Benchmarks
1760
+
1761
+ The benchmark suite measures overhead added by svelte-realtime on top of raw WebSocket messaging.
1762
+
1763
+ Run with:
1764
+
1765
+ ```bash
1766
+ node bench/rpc.js
1767
+ ```
1768
+
1769
+ What gets measured:
1770
+ - **RPC dispatch overhead**: time for `handleRpc` to parse, look up the registry, build ctx, execute, and respond -- compared to calling the function directly
1771
+ - **Stream merge throughput**: operations per second for each merge strategy (`crud`, `latest`, `set`, `presence`, `cursor`) applying events to arrays of varying sizes
1772
+ - **Fast-path rejection**: how quickly non-RPC messages are identified and skipped
1773
+
1774
+ Merge strategies use an internal `Map<key, index>` for O(1) lookups instead of linear scans. Updates and upserts on keyed strategies (crud, presence, cursor) are constant-time regardless of array size. Deletes and prepends require an index rebuild (linear), which matches the cost of the delete itself.
1775
+
1776
+ These benchmarks run in-process with mock objects (no real network). They isolate the framework overhead from network latency. See [bench/rpc.js](bench/rpc.js) for the full source.
1777
+
1778
+ ---
1779
+
1780
+ You can also run the package's own tests:
1781
+
1782
+ ```bash
1783
+ npm test
1784
+ ```
1785
+
1786
+ ---
1787
+
1788
+ ## License
1789
+
1790
+ MIT