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/LICENSE +21 -0
- package/README.md +1790 -0
- package/client.d.ts +239 -0
- package/client.js +1428 -0
- package/devtools.d.ts +2 -0
- package/devtools.js +214 -0
- package/package.json +80 -0
- package/server.d.ts +815 -0
- package/server.js +2311 -0
- package/test.d.ts +110 -0
- package/test.js +330 -0
- package/vite.d.ts +43 -0
- package/vite.js +1246 -0
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
|