svelte-realtime 0.1.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,19 @@ Write server functions. Import them in components. Call them over WebSocket. No
6
6
 
7
7
  ---
8
8
 
9
- ## Getting started
9
+ ## Quick start
10
+
11
+ ```bash
12
+ npx svelte-realtime my-app
13
+ cd my-app
14
+ npm run dev
15
+ ```
16
+
17
+ This creates a SvelteKit project with svelte-realtime fully wired: adapter, vite plugins, WebSocket hooks, and a working counter example you can open in your browser right away.
18
+
19
+ ---
20
+
21
+ ## Manual setup
10
22
 
11
23
  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
24
 
@@ -19,7 +31,7 @@ npm install -D ws
19
31
  ```
20
32
 
21
33
  What each package does:
22
- - `svelte-adapter-uws` -- the SvelteKit adapter that runs your app on uWebSockets.js with built-in WebSocket support
34
+ - `svelte-adapter-uws` (>=0.4.0) -- the SvelteKit adapter that runs your app on uWebSockets.js with built-in WebSocket support
23
35
  - `svelte-realtime` -- this library (RPC + streams on top of the adapter)
24
36
  - `uWebSockets.js` -- the native C++ HTTP/WebSocket server (installed from GitHub, not npm)
25
37
  - `ws` -- dev dependency used by the adapter during `npm run dev` (not needed in production)
@@ -164,6 +176,9 @@ The `ctx` object passed to every server function contains:
164
176
  | `ctx.throttle` | `(topic, event, data, ms)` -- publish at most once per `ms` ms |
165
177
  | `ctx.debounce` | `(topic, event, data, ms)` -- publish after `ms` ms of silence |
166
178
  | `ctx.signal` | `(userId, event, data)` -- point-to-point message |
179
+ | `ctx.batch` | `(messages)` -- publish multiple messages in one call via `platform.batch()` |
180
+
181
+ Note: `ctx.user` may contain adapter-injected properties (`__subscriptions`, `remoteAddress`) in addition to whatever your `upgrade()` function returned. These are stripped automatically by the adapter before broadcasting to other clients.
167
182
 
168
183
  ---
169
184
 
@@ -387,6 +402,16 @@ try {
387
402
  }
388
403
  ```
389
404
 
405
+ ### Terminal close codes
406
+
407
+ When the adapter's `ready()` promise rejects (terminal close codes 1008, 4401, 4403, exhausted retries, or explicit `close()`), svelte-realtime:
408
+
409
+ - Rejects all pending RPCs immediately with `RpcError('CONNECTION_CLOSED', ...)`
410
+ - Sets an `{ error }` state on all active stream stores
411
+ - Drains the offline queue with errors
412
+
413
+ RPCs called after a terminal close reject immediately without sending.
414
+
390
415
  ### Reusable error boundary component
391
416
 
392
417
  For Svelte 5, you can build a reusable boundary that handles all three stream states:
@@ -651,6 +676,20 @@ const [board, column] = await batch(() => [
651
676
 
652
677
  Each call resolves or rejects independently -- one failure does not cancel the others. Batches are limited to 50 calls -- enforced both client-side (rejects before sending) and server-side.
653
678
 
679
+ ### Server-side batching
680
+
681
+ Use `ctx.batch()` inside RPC handlers to publish multiple messages in a single call:
682
+
683
+ ```js
684
+ export const resetBoard = live(async (ctx, boardId) => {
685
+ await db.boards.reset(boardId);
686
+ ctx.batch([
687
+ { topic: `board:${boardId}`, event: 'set', data: [] },
688
+ { topic: `board:${boardId}:presence`, event: 'set', data: [] }
689
+ ]);
690
+ });
691
+ ```
692
+
654
693
  ---
655
694
 
656
695
  ## Optimistic updates
@@ -913,13 +952,13 @@ export const presence = live.stream('room:lobby', async (ctx) => {
913
952
  });
914
953
  ```
915
954
 
916
- `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`):
955
+ `onSubscribe` fires after `ws.subscribe(topic)` and the initial data fetch. `onUnsubscribe` fires in real time when a client unsubscribes from a topic (adapter 0.4.0+), and also when the WebSocket closes for any remaining topics. Export both hooks from your `hooks.ws.js`:
917
956
 
918
957
  ```js
919
- export { message, close } from 'svelte-realtime/server';
958
+ export { message, close, unsubscribe } from 'svelte-realtime/server';
920
959
  ```
921
960
 
922
- `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.
961
+ `onUnsubscribe` fires for both static and dynamic topics. For dynamic topics, the server tracks which stream produced each subscription and fires the correct hook. The `unsubscribe` hook fires as soon as the client drops a topic; `close` only fires for topics still active at disconnect time. There is no double-firing.
923
962
 
924
963
  ---
925
964
 
@@ -1007,6 +1046,49 @@ export const message = createMessage({
1007
1046
 
1008
1047
  ---
1009
1048
 
1049
+ ## Prometheus metrics
1050
+
1051
+ Opt-in instrumentation for RPC calls, stream subscriptions, and cron executions. Zero overhead if not called.
1052
+
1053
+ ```js
1054
+ import { live } from 'svelte-realtime/server';
1055
+ import { createMetricsRegistry } from 'svelte-adapter-uws-extensions/prometheus';
1056
+
1057
+ const registry = createMetricsRegistry();
1058
+ live.metrics(registry);
1059
+ ```
1060
+
1061
+ This registers counters/histograms for:
1062
+ - `svelte_realtime_rpc_total` -- RPC call count by path and status
1063
+ - `svelte_realtime_rpc_duration_seconds` -- RPC latency by path
1064
+ - `svelte_realtime_rpc_errors_total` -- RPC errors by path and code
1065
+ - `svelte_realtime_stream_subscriptions` -- active stream subscription gauge by topic
1066
+ - `svelte_realtime_cron_total` -- cron execution count by path and status
1067
+ - `svelte_realtime_cron_errors_total` -- cron errors by path
1068
+
1069
+ ---
1070
+
1071
+ ## Circuit breaker
1072
+
1073
+ Wrap a stream or RPC init function with a circuit breaker from `svelte-adapter-uws-extensions`. When the breaker is open, returns a fallback value or throws `SERVICE_UNAVAILABLE`.
1074
+
1075
+ ```js
1076
+ import { live } from 'svelte-realtime/server';
1077
+ import { createBreaker } from 'svelte-adapter-uws-extensions/breaker';
1078
+
1079
+ const dbBreaker = createBreaker({ threshold: 5, resetMs: 30000 });
1080
+
1081
+ export const items = live.stream('items',
1082
+ live.breaker({ breaker: dbBreaker, fallback: [] }, async (ctx) => {
1083
+ return db.items.list();
1084
+ })
1085
+ );
1086
+ ```
1087
+
1088
+ If `fallback` is omitted and the circuit is open, the call throws `LiveError('SERVICE_UNAVAILABLE', ...)`.
1089
+
1090
+ ---
1091
+
1010
1092
  ## Cron scheduling
1011
1093
 
1012
1094
  Use `live.cron()` to run server-side functions on a schedule and publish results to a topic.
@@ -1275,6 +1357,17 @@ On the client, the room export becomes an object with sub-streams and actions. R
1275
1357
  <button onclick={() => board.addCard(boardId, 'New card')}>Add</button>
1276
1358
  ```
1277
1359
 
1360
+ ### Room hooks shortcut
1361
+
1362
+ Rooms expose a `.hooks` property for one-liner wiring in `hooks.ws.js`:
1363
+
1364
+ ```js
1365
+ // src/hooks.ws.js
1366
+ import { board } from './live/collab.js';
1367
+
1368
+ export const { message, close, unsubscribe } = board.hooks;
1369
+ ```
1370
+
1278
1371
  ---
1279
1372
 
1280
1373
  ## Webhooks
@@ -1411,6 +1504,8 @@ export const feed = live.stream('feed', async (ctx) => {
1411
1504
 
1412
1505
  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.
1413
1506
 
1507
+ With adapter 0.4.0+, the replay end marker sends `{ reqId }` (replay complete) or `{ reqId, truncated: true }` (cache miss). When truncated, the client automatically resets its sequence number and triggers a full refetch.
1508
+
1414
1509
  ---
1415
1510
 
1416
1511
  ## Redis multi-instance
@@ -1716,11 +1811,14 @@ Import from `svelte-realtime/server`.
1716
1811
  | `message` | Ready-made message hook |
1717
1812
  | `createMessage(options?)` | Custom message hook factory |
1718
1813
  | `pipe(stream, ...transforms)` | Composable stream transforms |
1719
- | `close` | Ready-made close hook (fires onUnsubscribe) |
1814
+ | `close` | Ready-made close hook (fires onUnsubscribe for remaining topics) |
1815
+ | `unsubscribe` | Ready-made unsubscribe hook (fires onUnsubscribe in real time) |
1720
1816
  | `setCronPlatform(platform)` | Capture platform for cron jobs |
1721
1817
  | `onCronError(handler)` | Global cron error handler |
1722
1818
  | `enableSignals(ws)` | Enable point-to-point signal delivery |
1723
1819
  | `_activateDerived(platform)` | Enable derived stream listeners |
1820
+ | `live.metrics(registry)` | Opt-in Prometheus metrics |
1821
+ | `live.breaker(options, fn)` | Circuit breaker wrapper |
1724
1822
 
1725
1823
  ---
1726
1824
 
@@ -1735,6 +1833,7 @@ Import from `svelte-realtime/client`.
1735
1833
  | `configure(config)` | Connection hooks and offline queue setup |
1736
1834
  | `combine(...stores, fn)` | Multi-store composition |
1737
1835
  | `onSignal(userId, callback)` | Listen for point-to-point signals |
1836
+ | `onDerived` | Re-exported from adapter: reactive derived topic subscription |
1738
1837
 
1739
1838
  **Stream store methods** (on `$live/` stream imports):
1740
1839
 
@@ -1810,6 +1909,43 @@ npm test
1810
1909
 
1811
1910
  ---
1812
1911
 
1912
+ ## Tauri and Capacitor
1913
+
1914
+ svelte-realtime works with Tauri and Capacitor without any static build or architectural changes.
1915
+
1916
+ Both runtimes let you point their webview at a live URL instead of local files. Your SvelteKit app runs on the server as normal -- SSR, WebSocket hydration, live stores, RPC -- and the native wrapper adds platform APIs (camera, push notifications, filesystem, etc.) on top.
1917
+
1918
+ **Capacitor** -- `capacitor.config.ts`:
1919
+
1920
+ ```ts
1921
+ import { CapacitorConfig } from '@capacitor/cli';
1922
+
1923
+ const config: CapacitorConfig = {
1924
+ appId: 'com.example.app',
1925
+ appName: 'My App',
1926
+ server: {
1927
+ url: 'https://yourapp.com'
1928
+ }
1929
+ };
1930
+
1931
+ export default config;
1932
+ ```
1933
+
1934
+ **Tauri** -- `tauri.conf.json`:
1935
+
1936
+ ```json
1937
+ {
1938
+ "build": {
1939
+ "devPath": "https://yourapp.com",
1940
+ "distDir": "https://yourapp.com"
1941
+ }
1942
+ }
1943
+ ```
1944
+
1945
+ The webview loads your server directly. No static adapter, no URL configuration in the client, nothing special in your SvelteKit code.
1946
+
1947
+ ---
1948
+
1813
1949
  ## License
1814
1950
 
1815
1951
  MIT
package/cli-utils.js ADDED
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+
3
+ const VALID_NAME_RE = /^[a-zA-Z0-9_-]+$/;
4
+ const VALID_TEMPLATES = ['minimal', 'example', 'demo'];
5
+
6
+ /**
7
+ * @param {string} [ua]
8
+ * @returns {'npm' | 'pnpm' | 'yarn' | 'bun'}
9
+ */
10
+ export function detectAgent(ua) {
11
+ const agent = ua ?? '';
12
+ if (agent.startsWith('pnpm')) return 'pnpm';
13
+ if (agent.startsWith('yarn')) return 'yarn';
14
+ if (agent.startsWith('bun')) return 'bun';
15
+ return 'npm';
16
+ }
17
+
18
+ /**
19
+ * Parse and validate CLI arguments. Returns an object or an error string.
20
+ * Parses in one pass so that flag values (e.g. --template minimal) are consumed
21
+ * and not mistaken for positional args.
22
+ * @param {string[]} argv - arguments (without node/script prefix)
23
+ * @param {{ dirExists?: (name: string) => boolean }} [opts]
24
+ * @returns {{ help: true } | { error: string } | { name?: string, template?: string }}
25
+ */
26
+ export function parseArgs(argv, opts) {
27
+ const positional = [];
28
+ let template;
29
+
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const arg = argv[i];
32
+
33
+ if (arg === '--help' || arg === '-h') {
34
+ return { help: true };
35
+ }
36
+
37
+ if (arg === '--template') {
38
+ const next = argv[i + 1];
39
+ if (next === undefined || next.startsWith('-')) {
40
+ if (next === '--help' || next === '-h') return { help: true };
41
+ return { error: '--template requires a value: minimal, example, or demo.' };
42
+ }
43
+ template = argv[++i];
44
+ if (!VALID_TEMPLATES.includes(template)) {
45
+ return { error: `Unknown template: "${template}". Use minimal, example, or demo.` };
46
+ }
47
+ continue;
48
+ }
49
+
50
+ if (arg.startsWith('--template=')) {
51
+ template = arg.slice('--template='.length);
52
+ if (!template) {
53
+ return { error: '--template requires a value: minimal, example, or demo.' };
54
+ }
55
+ if (!VALID_TEMPLATES.includes(template)) {
56
+ return { error: `Unknown template: "${template}". Use minimal, example, or demo.` };
57
+ }
58
+ continue;
59
+ }
60
+
61
+ if (arg.startsWith('-')) continue;
62
+
63
+ positional.push(arg);
64
+ }
65
+
66
+ const name = positional[0];
67
+ if (name !== undefined) {
68
+ if (!VALID_NAME_RE.test(name)) {
69
+ return { error: `Invalid project name: "${name}". Use only letters, numbers, hyphens, and underscores.` };
70
+ }
71
+ if (opts?.dirExists?.(name)) {
72
+ return { error: `Directory "${name}" already exists.` };
73
+ }
74
+ }
75
+
76
+ return { name, template };
77
+ }
package/cli.js ADDED
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ import { execSync } from 'child_process';
4
+ import { writeFileSync, existsSync, mkdirSync } from 'fs';
5
+ import { resolve, join } from 'path';
6
+ import * as p from '@clack/prompts';
7
+ import { detectAgent, parseArgs } from './cli-utils.js';
8
+
9
+ const DEMO_REPO = 'https://github.com/lanteanio/svelte-realtime-demo.git';
10
+
11
+ const parsed = parseArgs(process.argv.slice(2), {
12
+ dirExists: (name) => existsSync(resolve(process.cwd(), name))
13
+ });
14
+
15
+ if ('help' in parsed) {
16
+ console.log(`
17
+ Usage: npx svelte-realtime [project-name] [--template minimal|example|demo]
18
+
19
+ Scaffolds a SvelteKit project with svelte-realtime wired up and ready to go.
20
+ `);
21
+ process.exit(0);
22
+ }
23
+
24
+ if ('error' in parsed) {
25
+ console.error(parsed.error);
26
+ process.exit(1);
27
+ }
28
+
29
+ p.intro('svelte-realtime');
30
+
31
+ const name =
32
+ parsed.name ||
33
+ /** @type {string} */ (
34
+ await p.text({
35
+ message: 'Project name',
36
+ placeholder: 'my-app',
37
+ validate(value) {
38
+ if (!value) return 'Required.';
39
+ if (!/^[a-zA-Z0-9_-]+$/.test(value))
40
+ return 'Use only letters, numbers, hyphens, and underscores.';
41
+ if (existsSync(resolve(process.cwd(), value))) return `Directory "${value}" already exists.`;
42
+ }
43
+ })
44
+ );
45
+
46
+ if (p.isCancel(name)) {
47
+ p.cancel('Cancelled.');
48
+ process.exit(0);
49
+ }
50
+
51
+ const dest = resolve(process.cwd(), name);
52
+
53
+ const template =
54
+ parsed.template ||
55
+ /** @type {string} */ (
56
+ await p.select({
57
+ message: 'Which template would you like?',
58
+ options: [
59
+ {
60
+ value: 'minimal',
61
+ label: 'Wiring only',
62
+ hint: 'SvelteKit + svelte-realtime, no example code'
63
+ },
64
+ {
65
+ value: 'example',
66
+ label: 'Barebones example',
67
+ hint: 'SvelteKit + svelte-realtime with a working counter'
68
+ },
69
+ {
70
+ value: 'demo',
71
+ label: 'Full demo app',
72
+ hint: 'clone svelte-realtime-demo'
73
+ }
74
+ ]
75
+ })
76
+ );
77
+
78
+ if (p.isCancel(template)) {
79
+ p.cancel('Cancelled.');
80
+ process.exit(0);
81
+ }
82
+
83
+ const agent = detectAgent(process.env.npm_config_user_agent);
84
+
85
+ if (template === 'demo') {
86
+ const s = p.spinner();
87
+ s.start('Cloning demo repository');
88
+ run(`git clone ${DEMO_REPO} "${name}"`);
89
+ s.stop('Cloned.');
90
+
91
+ s.start('Installing dependencies');
92
+ run(`${agent} install`, dest);
93
+ s.stop('Installed.');
94
+
95
+ p.outro(`Done. cd ${name} && ${agent} run dev`);
96
+ process.exit(0);
97
+ }
98
+
99
+ const s = p.spinner();
100
+
101
+ s.start('Creating SvelteKit project');
102
+ run(`npx -y sv create "${name}" --template minimal --types ts`);
103
+ s.stop('Project created.');
104
+
105
+ s.start('Installing dependencies');
106
+ const add = agent === 'npm' ? 'install' : 'add';
107
+ run(`${agent} ${add} svelte-adapter-uws svelte-realtime`, dest);
108
+ run(`${agent} ${add} uNetworking/uWebSockets.js#v20.60.0`, dest);
109
+ run(`${agent} ${add} -D ws`, dest);
110
+ s.stop('Dependencies installed.');
111
+
112
+ s.start('Configuring svelte-realtime');
113
+
114
+ writeFileSync(
115
+ join(dest, 'svelte.config.js'),
116
+ `import adapter from 'svelte-adapter-uws';
117
+ import { vitePreprocess } from '@sveltejs/kit/vite';
118
+
119
+ export default {
120
+ \tkit: {
121
+ \t\tadapter: adapter({ websocket: true })
122
+ \t},
123
+ \tpreprocess: [vitePreprocess()]
124
+ };
125
+ `
126
+ );
127
+
128
+ writeFileSync(
129
+ join(dest, 'vite.config.ts'),
130
+ `import { sveltekit } from '@sveltejs/kit/vite';
131
+ import uws from 'svelte-adapter-uws/vite';
132
+ import realtime from 'svelte-realtime/vite';
133
+ import { defineConfig } from 'vite';
134
+
135
+ export default defineConfig({
136
+ \tplugins: [sveltekit(), uws(), realtime()]
137
+ });
138
+ `
139
+ );
140
+
141
+ writeFileSync(
142
+ join(dest, 'src', 'hooks.ws.ts'),
143
+ `import { message } from 'svelte-realtime/server';
144
+ export { message };
145
+
146
+ export function upgrade() {
147
+ \treturn { id: crypto.randomUUID() };
148
+ }
149
+ `
150
+ );
151
+
152
+ if (template === 'example') {
153
+ mkdirSync(join(dest, 'src', 'live'), { recursive: true });
154
+
155
+ writeFileSync(
156
+ join(dest, 'src', 'live', 'counter.ts'),
157
+ `import { live } from 'svelte-realtime/server';
158
+
159
+ let count = 0;
160
+
161
+ export const increment = live((ctx) => {
162
+ \tcount++;
163
+ \tctx.publish('count', 'set', count);
164
+ \treturn count;
165
+ });
166
+
167
+ export const counter = live.stream('count', () => {
168
+ \treturn count;
169
+ }, { merge: 'set' });
170
+ `
171
+ );
172
+
173
+ writeFileSync(
174
+ join(dest, 'src', 'routes', '+page.svelte'),
175
+ `<script lang="ts">
176
+ \timport { increment, counter } from '$live/counter';
177
+ </script>
178
+
179
+ <h1>svelte-realtime</h1>
180
+
181
+ {#if $counter === undefined}
182
+ \t<p>Connecting...</p>
183
+ {:else}
184
+ \t<p>Count: {$counter}</p>
185
+ {/if}
186
+
187
+ <button onclick={() => increment()}>+1</button>
188
+ `
189
+ );
190
+ }
191
+
192
+ s.stop('Configured.');
193
+
194
+ p.outro(`Done. cd ${name} && ${agent} run dev`);
195
+
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function run(cmd, cwd) {
199
+ try {
200
+ execSync(cmd, { cwd, stdio: 'pipe' });
201
+ } catch (e) {
202
+ p.cancel(`Command failed: ${cmd}\n${e.stderr || e.message}`);
203
+ process.exit(1);
204
+ }
205
+ }
package/client.d.ts CHANGED
@@ -129,7 +129,7 @@ export function batch<T extends Promise<any>[]>(
129
129
  *
130
130
  * @internal
131
131
  */
132
- export function __binaryRpc(path: string): (buffer: ArrayBuffer, ...args: any[]) => Promise<any>;
132
+ export function __binaryRpc(path: string): (buffer: ArrayBuffer | ArrayBufferView, ...args: any[]) => Promise<any>;
133
133
 
134
134
  /**
135
135
  * Configure client-side connection hooks.