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/vite.js ADDED
@@ -0,0 +1,1246 @@
1
+ // @ts-check
2
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
3
+ import { resolve, relative, dirname, sep, posix } from 'path';
4
+
5
+ const VIRTUAL_PREFIX = '\0live:';
6
+ const REGISTRY_ID = '\0live:__registry';
7
+ const LIVE_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\s*\(/g;
8
+ const VALIDATED_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.validated\s*\(/g;
9
+ const STREAM_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.stream\s*\(/g;
10
+ const GUARD_EXPORT_RE = /export\s+const\s+(_guard)\s*=\s*guard\s*\(/g;
11
+ const DYNAMIC_STREAM_RE = /export\s+const\s+(\w+)\s*=\s*live\.stream\s*\(\s*(?:\([^)]*\)|[a-zA-Z_$][\w$]*)\s*=>/g;
12
+ const CRON_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.cron\s*\(/g;
13
+ const BINARY_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.binary\s*\(/g;
14
+ const DERIVED_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.derived\s*\(/g;
15
+ const ROOM_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.room\s*\(/g;
16
+ const WEBHOOK_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.webhook\s*\(/g;
17
+ const CHANNEL_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.channel\s*\(/g;
18
+ const DYNAMIC_CHANNEL_RE = /export\s+const\s+(\w+)\s*=\s*live\.channel\s*\(\s*(?:\([^)]*\)|[a-zA-Z_$][\w$]*)\s*=>/g;
19
+ const RATE_LIMIT_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.rateLimit\s*\(/g;
20
+ const EFFECT_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.effect\s*\(/g;
21
+ const AGGREGATE_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.aggregate\s*\(/g;
22
+
23
+ /** @type {Map<string, string>} Cache file contents to avoid redundant reads within a build cycle */
24
+ const _fileCache = new Map();
25
+
26
+ /**
27
+ * Read a file with caching. Returns cached content if available.
28
+ * @param {string} filePath
29
+ * @returns {string}
30
+ */
31
+ function _readCached(filePath) {
32
+ let content = _fileCache.get(filePath);
33
+ if (content === undefined) {
34
+ content = readFileSync(filePath, 'utf-8');
35
+ _fileCache.set(filePath, content);
36
+ }
37
+ return content;
38
+ }
39
+
40
+ /**
41
+ * Vite plugin for svelte-realtime.
42
+ * Resolves `$live/` imports to virtual modules with auto-generated client stubs.
43
+ *
44
+ * @param {{ dir?: string }} [options]
45
+ * @returns {import('vite').Plugin}
46
+ */
47
+ export default function svelteRealtime(options) {
48
+ const dir = options?.dir || 'src/live';
49
+ /** @type {string} */
50
+ let root = '';
51
+ /** @type {string} */
52
+ let liveDir = '';
53
+ /** @type {boolean} */
54
+ let isSsr = false;
55
+ /** @type {boolean} */
56
+ let typedImports = options?.typedImports !== false;
57
+ /** @type {boolean} */
58
+ let devtools = options?.devtools !== false;
59
+ /** @type {boolean} */
60
+ let isDev = false;
61
+
62
+ return {
63
+ name: 'svelte-realtime',
64
+
65
+ configResolved(config) {
66
+ root = config.root;
67
+ liveDir = resolve(root, dir);
68
+ isSsr = !!config.build?.ssr;
69
+ isDev = config.command === 'serve';
70
+ _fileCache.clear();
71
+ },
72
+
73
+ buildStart() {
74
+ _fileCache.clear();
75
+ if (!existsSync(liveDir)) {
76
+ console.warn(
77
+ `[svelte-realtime] Plugin loaded but no live modules found in ${dir}/`
78
+ );
79
+ } else if (typedImports) {
80
+ _writeTypeDeclarations(liveDir, dir);
81
+ }
82
+ },
83
+
84
+ resolveId(id) {
85
+ if (id === REGISTRY_ID) return REGISTRY_ID;
86
+ if (id === '/@svelte-realtime-registry') return REGISTRY_ID;
87
+ if (id.startsWith('$live/')) {
88
+ const modulePath = id.slice(6); // strip '$live/'
89
+ return VIRTUAL_PREFIX + modulePath;
90
+ }
91
+ return null;
92
+ },
93
+
94
+ load(id, loadOptions) {
95
+ const ssr = loadOptions?.ssr ?? isSsr;
96
+
97
+ // Registry module
98
+ if (id === REGISTRY_ID) {
99
+ return _generateRegistry(liveDir, dir);
100
+ }
101
+
102
+ // $live/ virtual module
103
+ if (id.startsWith(VIRTUAL_PREFIX)) {
104
+ const modulePath = id.slice(VIRTUAL_PREFIX.length);
105
+ const filePath = _resolveFile(liveDir, modulePath);
106
+
107
+ if (!filePath) {
108
+ this.error(`[svelte-realtime] Could not resolve $live/${modulePath} -- file not found in ${dir}/`);
109
+ return null;
110
+ }
111
+
112
+ if (ssr) {
113
+ // SSR: re-export the real server module, add .load() for streams
114
+ return _generateSsrStubs(filePath, modulePath);
115
+ }
116
+
117
+ // Client: generate stubs
118
+ return _generateClientStubs(filePath, modulePath, dir);
119
+ }
120
+
121
+ return null;
122
+ },
123
+
124
+ config(config, { command }) {
125
+ // During SSR build, inject the registry as an additional input
126
+ if (command === 'build' && config.build?.ssr) {
127
+ config.build.rollupOptions ??= {};
128
+ const input = config.build.rollupOptions.input;
129
+ if (typeof input === 'object' && !Array.isArray(input)) {
130
+ input['__live-registry'] = REGISTRY_ID;
131
+ } else if (Array.isArray(input)) {
132
+ const obj = {};
133
+ for (let i = 0; i < input.length; i++) obj[`entry${i}`] = input[i];
134
+ obj['__live-registry'] = REGISTRY_ID;
135
+ config.build.rollupOptions.input = obj;
136
+ } else if (typeof input === 'string') {
137
+ config.build.rollupOptions.input = { index: input, '__live-registry': REGISTRY_ID };
138
+ } else {
139
+ config.build.rollupOptions.input = { '__live-registry': REGISTRY_ID };
140
+ }
141
+
142
+ // Keep svelte-realtime imports as bare specifiers so the registry
143
+ // and ws-handler resolve to the same Node module instance at runtime.
144
+ // Without this, Vite rewrites the import to a chunk-relative path,
145
+ // creating a separate module instance with its own registry Map.
146
+ const ext = config.build.rollupOptions.external;
147
+ const svelteRealtimeRe = /^svelte-realtime(\/.*)?$/;
148
+ if (Array.isArray(ext)) {
149
+ ext.push(svelteRealtimeRe);
150
+ } else if (typeof ext === 'string' || ext instanceof RegExp || typeof ext === 'function') {
151
+ config.build.rollupOptions.external = [ext, svelteRealtimeRe];
152
+ } else {
153
+ config.build.rollupOptions.external = [svelteRealtimeRe];
154
+ }
155
+ }
156
+ },
157
+
158
+ configureServer(server) {
159
+ // Inject devtools script into HTML responses (works with SvelteKit + traditional Vite)
160
+ if (devtools) {
161
+ const devtoolsScript = `<script type="module">
162
+ import { __devtools } from 'svelte-realtime/client';
163
+ if (__devtools) window.__svelte_realtime_devtools = __devtools;
164
+ import('svelte-realtime/devtools');
165
+ </script>`;
166
+ server.middlewares.use((_req, res, next) => {
167
+ const originalEnd = res.end;
168
+ /** @type {any} */
169
+ const _res = res;
170
+ _res.end = function (/** @type {any} */ chunk, /** @type {any} */ ...args) {
171
+ const contentType = res.getHeader('content-type');
172
+ if (typeof contentType === 'string' && contentType.includes('text/html') && chunk) {
173
+ const html = typeof chunk === 'string' ? chunk : chunk.toString();
174
+ if (html.includes('</body>')) {
175
+ chunk = html.replace('</body>', devtoolsScript + '</body>');
176
+ } else if (html.includes('</head>')) {
177
+ chunk = html.replace('</head>', devtoolsScript + '</head>');
178
+ }
179
+ }
180
+ return originalEnd.call(this, chunk, ...args);
181
+ };
182
+ next();
183
+ });
184
+ }
185
+
186
+ // On first transform, load the registry to populate server-side state
187
+ let registryLoaded = false;
188
+ const originalLoad = this.load;
189
+
190
+ server.httpServer?.once('listening', async () => {
191
+ if (registryLoaded) return;
192
+ registryLoaded = true;
193
+
194
+ if (!existsSync(liveDir)) return;
195
+
196
+ try {
197
+ const code = _generateRegistry(liveDir, dir);
198
+ // Use a temporary virtual module to load the registry
199
+ const tempId = '/@svelte-realtime-registry';
200
+ server.moduleGraph.ensureEntryFromUrl(tempId);
201
+ await server.ssrLoadModule(tempId).catch(() => {
202
+ // Fallback: try to load modules individually
203
+ _loadRegistryDirect(server, liveDir, dir);
204
+ });
205
+ } catch {
206
+ _loadRegistryDirect(server, liveDir, dir);
207
+ }
208
+ });
209
+
210
+ // Watch for new or deleted files in src/live/ -- these don't trigger
211
+ // handleHotUpdate since they're not in the module graph yet (add) or
212
+ // have already been removed (unlink).
213
+ for (const event of ['add', 'unlink']) {
214
+ server.watcher.on(event, async (file) => {
215
+ file = file.split(sep).join('/');
216
+ if (!file.includes('/' + dir + '/') && !file.startsWith(dir + '/')) return;
217
+ if (!/\.[jt]s$/.test(file) || file.endsWith('.d.ts') || file.endsWith('.test.js') || file.endsWith('.test.ts')) return;
218
+
219
+ _fileCache.clear();
220
+
221
+ if (typedImports) {
222
+ _writeTypeDeclarations(liveDir, dir);
223
+ }
224
+
225
+ const rel = relative(liveDir, file).replace(/\\/g, '/').replace(/\.[jt]s$/, '');
226
+ await _hmrReloadRegistry(server, liveDir, dir, rel);
227
+ });
228
+ }
229
+ },
230
+
231
+ async handleHotUpdate({ file, server }) {
232
+ if (!file.startsWith(liveDir)) return;
233
+ _fileCache.delete(file);
234
+
235
+ // Regenerate type declarations on file change
236
+ if (typedImports) {
237
+ _writeTypeDeclarations(liveDir, dir);
238
+ }
239
+
240
+ const rel = relative(liveDir, file)
241
+ .replace(/\\/g, '/')
242
+ .replace(/\.[jt]s$/, '');
243
+
244
+ // Server-side HMR: invalidate the changed module so Vite re-executes it
245
+ const ssrMods = server.moduleGraph.getModulesByFile(file);
246
+ if (ssrMods) {
247
+ for (const m of ssrMods) server.moduleGraph.invalidateModule(m);
248
+ }
249
+
250
+ // Re-register all server handlers
251
+ await _hmrReloadRegistry(server, liveDir, dir, rel);
252
+
253
+ // Client-side: invalidate the virtual module so the browser picks up new stubs
254
+ const mod = server.moduleGraph.getModuleById(VIRTUAL_PREFIX + rel);
255
+ if (mod) {
256
+ server.moduleGraph.invalidateModule(mod);
257
+ return [mod];
258
+ }
259
+ },
260
+
261
+ transformIndexHtml() {
262
+ // Devtools injection handled via configureServer middleware (works with SvelteKit)
263
+ return [];
264
+ }
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Resolve a module path to a real file.
270
+ * @param {string} liveDir
271
+ * @param {string} modulePath
272
+ * @returns {string | null}
273
+ */
274
+ function _resolveFile(liveDir, modulePath) {
275
+ const extensions = ['.js', '.ts', '.mjs'];
276
+ for (const ext of extensions) {
277
+ const full = resolve(liveDir, modulePath + ext);
278
+ // Prevent path traversal outside the live directory
279
+ if (!full.startsWith(liveDir + sep)) return null;
280
+ if (existsSync(full)) return full;
281
+ }
282
+ return null;
283
+ }
284
+
285
+ /**
286
+ * Generate SSR stubs for a live module.
287
+ * Re-exports everything from the real module, and adds .load() wrappers for streams.
288
+ * @param {string} filePath
289
+ * @param {string} modulePath
290
+ * @returns {string}
291
+ */
292
+ function _generateSsrStubs(filePath, modulePath) {
293
+ const normalized = filePath.split(sep).join(posix.sep);
294
+ const source = _readCached(filePath);
295
+
296
+ /** @type {string[]} */
297
+ const storeNames = [];
298
+ /** @type {Set<string>} */
299
+ const dynamicNames = new Set();
300
+ let match;
301
+
302
+ // Detect dynamic (function-returning) streams and channels
303
+ for (const re of [DYNAMIC_STREAM_RE, DYNAMIC_CHANNEL_RE]) {
304
+ re.lastIndex = 0;
305
+ while ((match = re.exec(source)) !== null) {
306
+ dynamicNames.add(match[1]);
307
+ }
308
+ }
309
+
310
+ // Collect all stream-like exports that need readable() wrappers for SSR
311
+ for (const re of [STREAM_EXPORT_RE, CHANNEL_EXPORT_RE, DERIVED_EXPORT_RE, AGGREGATE_EXPORT_RE]) {
312
+ re.lastIndex = 0;
313
+ while ((match = re.exec(source)) !== null) {
314
+ storeNames.push(match[1]);
315
+ }
316
+ }
317
+
318
+ // If no store-like exports, simple re-export
319
+ if (storeNames.length === 0) {
320
+ return `export * from '${normalized}';\n`;
321
+ }
322
+
323
+ // Re-export non-stream exports, wrap store-like exports in readable() for SSR $ prefix support
324
+ const lines = [
325
+ `import { readable } from 'svelte/store';`,
326
+ `import { __directCall } from 'svelte-realtime/server';`,
327
+ `export * from '${normalized}';`
328
+ ];
329
+
330
+ for (const name of storeNames) {
331
+ if (dynamicNames.has(name)) {
332
+ // Dynamic stream: return a readable store from a function so name(args) works during SSR
333
+ lines.push(`const _${name} = (...args) => readable(undefined);`);
334
+ lines.push(`_${name}.load = (platform, options) => __directCall('${modulePath}/${name}', options?.args || [], platform, options);`);
335
+ lines.push(`export { _${name} as ${name} };`);
336
+ } else {
337
+ // Static stream: plain readable store
338
+ lines.push(`const _${name} = readable(undefined);`);
339
+ lines.push(`_${name}.load = (platform, options) => __directCall('${modulePath}/${name}', options?.args || [], platform, options);`);
340
+ lines.push(`export { _${name} as ${name} };`);
341
+ }
342
+ }
343
+
344
+ return lines.join('\n') + '\n';
345
+ }
346
+
347
+ /**
348
+ * Generate client stubs for a live module.
349
+ * @param {string} filePath
350
+ * @param {string} modulePath
351
+ * @param {string} dir
352
+ * @returns {string}
353
+ */
354
+ function _generateClientStubs(filePath, modulePath, dir) {
355
+ const source = _readCached(filePath);
356
+
357
+ /** @type {string[]} */
358
+ const lines = [];
359
+ /** @type {Set<string>} */
360
+ const imports = new Set();
361
+ /** @type {Set<string>} */
362
+ const exportedNames = new Set();
363
+ /** @type {boolean} */
364
+ let hasGuard = false;
365
+
366
+ // Detect live() exports
367
+ let match;
368
+ LIVE_EXPORT_RE.lastIndex = 0;
369
+ while ((match = LIVE_EXPORT_RE.exec(source)) !== null) {
370
+ const name = match[1];
371
+ if (!/^\w+$/.test(name)) continue;
372
+ exportedNames.add(name);
373
+ imports.add('__rpc');
374
+ lines.push(`export const ${name} = __rpc('${modulePath}/${name}');`);
375
+ }
376
+
377
+ // Detect live.validated() exports (treated same as live() on the client)
378
+ VALIDATED_EXPORT_RE.lastIndex = 0;
379
+ while ((match = VALIDATED_EXPORT_RE.exec(source)) !== null) {
380
+ const name = match[1];
381
+ if (!/^\w+$/.test(name)) continue;
382
+ if (!exportedNames.has(name)) {
383
+ exportedNames.add(name);
384
+ imports.add('__rpc');
385
+ lines.push(`export const ${name} = __rpc('${modulePath}/${name}');`);
386
+ }
387
+ }
388
+
389
+ // Detect live.rateLimit() exports (treated same as live() on the client)
390
+ RATE_LIMIT_EXPORT_RE.lastIndex = 0;
391
+ while ((match = RATE_LIMIT_EXPORT_RE.exec(source)) !== null) {
392
+ const name = match[1];
393
+ if (!/^\w+$/.test(name)) continue;
394
+ if (!exportedNames.has(name)) {
395
+ exportedNames.add(name);
396
+ imports.add('__rpc');
397
+ lines.push(`export const ${name} = __rpc('${modulePath}/${name}');`);
398
+ }
399
+ }
400
+
401
+ // Detect live.stream() exports — check for dynamic vs static topic
402
+ /** @type {Set<string>} */
403
+ const dynamicStreams = new Set();
404
+ DYNAMIC_STREAM_RE.lastIndex = 0;
405
+ while ((match = DYNAMIC_STREAM_RE.exec(source)) !== null) {
406
+ dynamicStreams.add(match[1]);
407
+ }
408
+
409
+ STREAM_EXPORT_RE.lastIndex = 0;
410
+ while ((match = STREAM_EXPORT_RE.exec(source)) !== null) {
411
+ const name = match[1];
412
+ if (!/^\w+$/.test(name)) continue;
413
+ exportedNames.add(name);
414
+ imports.add('__stream');
415
+ const streamOptions = _extractStreamOptions(source, name);
416
+ if (dynamicStreams.has(name)) {
417
+ // Dynamic topic: generate a function wrapper that passes args
418
+ lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify(streamOptions)}, true);`);
419
+ } else {
420
+ lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify(streamOptions)});`);
421
+ }
422
+ }
423
+
424
+ // Detect live.channel() exports -- treated as streams on the client
425
+ /** @type {Set<string>} */
426
+ const dynamicChannels = new Set();
427
+ DYNAMIC_CHANNEL_RE.lastIndex = 0;
428
+ while ((match = DYNAMIC_CHANNEL_RE.exec(source)) !== null) {
429
+ dynamicChannels.add(match[1]);
430
+ }
431
+
432
+ CHANNEL_EXPORT_RE.lastIndex = 0;
433
+ while ((match = CHANNEL_EXPORT_RE.exec(source)) !== null) {
434
+ const name = match[1];
435
+ if (!/^\w+$/.test(name)) continue;
436
+ if (!exportedNames.has(name)) {
437
+ exportedNames.add(name);
438
+ imports.add('__stream');
439
+ const channelOpts = _extractChannelOptions(source, name);
440
+ if (dynamicChannels.has(name)) {
441
+ lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify(channelOpts)}, true);`);
442
+ } else {
443
+ lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify(channelOpts)});`);
444
+ }
445
+ }
446
+ }
447
+
448
+ // Detect guard
449
+ GUARD_EXPORT_RE.lastIndex = 0;
450
+ if (GUARD_EXPORT_RE.exec(source) !== null) {
451
+ hasGuard = true;
452
+ }
453
+
454
+ // Detect live.binary() exports
455
+ BINARY_EXPORT_RE.lastIndex = 0;
456
+ while ((match = BINARY_EXPORT_RE.exec(source)) !== null) {
457
+ const name = match[1];
458
+ if (!/^\w+$/.test(name)) continue;
459
+ if (!exportedNames.has(name)) {
460
+ exportedNames.add(name);
461
+ imports.add('__binaryRpc');
462
+ lines.push(`export const ${name} = __binaryRpc('${modulePath}/${name}');`);
463
+ }
464
+ }
465
+
466
+ // Detect live.derived() exports (treated as streams on the client)
467
+ DERIVED_EXPORT_RE.lastIndex = 0;
468
+ while ((match = DERIVED_EXPORT_RE.exec(source)) !== null) {
469
+ const name = match[1];
470
+ if (!/^\w+$/.test(name)) continue;
471
+ if (!exportedNames.has(name)) {
472
+ exportedNames.add(name);
473
+ imports.add('__stream');
474
+ // Derived streams use 'set' merge by default
475
+ lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify({ merge: 'set', key: 'id' })});`);
476
+ }
477
+ }
478
+
479
+ // Detect live.room() exports -- generates data stream + presence stream + cursor stream + actions
480
+ ROOM_EXPORT_RE.lastIndex = 0;
481
+ while ((match = ROOM_EXPORT_RE.exec(source)) !== null) {
482
+ const name = match[1];
483
+ if (!/^\w+$/.test(name)) continue;
484
+ if (!exportedNames.has(name)) {
485
+ exportedNames.add(name);
486
+ imports.add('__stream');
487
+ imports.add('__rpc');
488
+ // Room generates a namespace object with data, presence, cursors, and actions
489
+ // Extract room config to determine which sub-streams exist
490
+ const roomInfo = _extractRoomInfo(source, name);
491
+ const roomLines = [];
492
+ roomLines.push(`export const ${name} = {`);
493
+ roomLines.push(` data: __stream('${modulePath}/${name}/__data', ${JSON.stringify(roomInfo.dataOpts)}, true),`);
494
+ if (roomInfo.hasPresence) {
495
+ roomLines.push(` presence: __stream('${modulePath}/${name}/__presence', ${JSON.stringify({ merge: 'presence', key: 'key' })}, true),`);
496
+ }
497
+ if (roomInfo.hasCursors) {
498
+ roomLines.push(` cursors: __stream('${modulePath}/${name}/__cursors', ${JSON.stringify({ merge: 'cursor', key: 'key' })}, true),`);
499
+ }
500
+ // Actions are RPCs
501
+ for (const action of roomInfo.actions) {
502
+ roomLines.push(` ${action}: __rpc('${modulePath}/${name}/__action/${action}'),`);
503
+ }
504
+ roomLines.push(`};`);
505
+ lines.push(roomLines.join('\n'));
506
+ }
507
+ }
508
+
509
+ // Mark webhook exports as known (server-only, no client stub)
510
+ WEBHOOK_EXPORT_RE.lastIndex = 0;
511
+ while ((match = WEBHOOK_EXPORT_RE.exec(source)) !== null) {
512
+ exportedNames.add(match[1]);
513
+ }
514
+
515
+ // Mark cron exports as known (they are server-only, no client stub needed)
516
+ CRON_EXPORT_RE.lastIndex = 0;
517
+ while ((match = CRON_EXPORT_RE.exec(source)) !== null) {
518
+ exportedNames.add(match[1]);
519
+ }
520
+
521
+ // Mark effect exports as known (server-only, no client stub needed)
522
+ EFFECT_EXPORT_RE.lastIndex = 0;
523
+ while ((match = EFFECT_EXPORT_RE.exec(source)) !== null) {
524
+ exportedNames.add(match[1]);
525
+ }
526
+
527
+ // Detect live.aggregate() exports (treated as streams on the client)
528
+ AGGREGATE_EXPORT_RE.lastIndex = 0;
529
+ while ((match = AGGREGATE_EXPORT_RE.exec(source)) !== null) {
530
+ const name = match[1];
531
+ if (!/^\w+$/.test(name)) continue;
532
+ if (!exportedNames.has(name)) {
533
+ exportedNames.add(name);
534
+ imports.add('__stream');
535
+ lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify({ merge: 'set', key: 'id' })});`);
536
+ }
537
+ }
538
+
539
+ // Dev warnings for non-live exports
540
+ const allExportRe = /export\s+(?:const|function|let|var|class)\s+(\w+)/g;
541
+ allExportRe.lastIndex = 0;
542
+ while ((match = allExportRe.exec(source)) !== null) {
543
+ const name = match[1];
544
+ if (name === '_guard' || exportedNames.has(name)) continue;
545
+ if (name.startsWith('_')) {
546
+ // Reserved names starting with _ (except _guard)
547
+ console.warn(
548
+ `[svelte-realtime] ${dir}/${modulePath} exports '${name}' starting with _ -- reserved for internal use`
549
+ );
550
+ continue;
551
+ }
552
+ console.warn(
553
+ `[svelte-realtime] ${dir}/${modulePath} exports '${name}' which is not wrapped in live() -- it won't be callable from the client. Did you forget live()?`
554
+ );
555
+ }
556
+
557
+ if (exportedNames.size === 0 && !hasGuard) {
558
+ console.warn(
559
+ `[svelte-realtime] ${dir}/${modulePath} has no live() or live.stream() exports`
560
+ );
561
+ }
562
+
563
+ const importLine = imports.size > 0
564
+ ? `import { ${[...imports].join(', ')} } from 'svelte-realtime/client';\n`
565
+ : '';
566
+
567
+ return importLine + lines.join('\n') + '\n';
568
+ }
569
+
570
+ /**
571
+ * Extract stream options from source code.
572
+ * @param {string} source
573
+ * @param {string} name
574
+ * @returns {{ merge?: string, key?: string, prepend?: boolean, max?: number }}
575
+ */
576
+ function _extractStreamOptions(source, name) {
577
+ // Find the live.stream() call for this export
578
+ const pattern = new RegExp(
579
+ `export\\s+const\\s+${name}\\s*=\\s*live\\.stream\\s*\\(\\s*(['"\`])([^'"\`]+)\\1`,
580
+ 's'
581
+ );
582
+ const match = pattern.exec(source);
583
+
584
+ /** @type {any} */
585
+ const opts = { merge: 'crud', key: 'id' };
586
+
587
+ if (!match) return opts;
588
+
589
+ // Try to extract the options object (3rd argument)
590
+ // Find the closing of live.stream( ... )
591
+ const startIdx = /** @type {number} */ (match.index) + match[0].length;
592
+ const rest = source.slice(startIdx);
593
+
594
+ // Look for options object like { merge: 'crud', key: 'id', prepend: true }
595
+ const optMatch = rest.match(/,\s*\{([^}]+)\}/);
596
+ if (optMatch) {
597
+ const optStr = optMatch[1];
598
+
599
+ const mergeMatch = optStr.match(/merge\s*:\s*['"](\w+)['"]/);
600
+ if (mergeMatch) opts.merge = mergeMatch[1];
601
+
602
+ const keyMatch = optStr.match(/key\s*:\s*['"](\w+)['"]/);
603
+ if (keyMatch) opts.key = keyMatch[1];
604
+
605
+ const prependMatch = optStr.match(/prepend\s*:\s*(true|false)/);
606
+ if (prependMatch) opts.prepend = prependMatch[1] === 'true';
607
+
608
+ const maxMatch = optStr.match(/max\s*:\s*(\d+)/);
609
+ if (maxMatch) opts.max = parseInt(maxMatch[1], 10);
610
+
611
+ const replayMatch = optStr.match(/replay\s*:\s*(true|false|\{[^}]*\})/);
612
+ if (replayMatch && replayMatch[1] !== 'false') opts.replay = true;
613
+
614
+ const versionMatch = optStr.match(/version\s*:\s*(\d+)/);
615
+ if (versionMatch) opts.version = parseInt(versionMatch[1], 10);
616
+ }
617
+
618
+ return opts;
619
+ }
620
+
621
+ /**
622
+ * Extract channel options from source code.
623
+ * @param {string} source
624
+ * @param {string} name
625
+ * @returns {{ merge?: string, key?: string, max?: number }}
626
+ */
627
+ function _extractChannelOptions(source, name) {
628
+ /** @type {any} */
629
+ const opts = { merge: 'set', key: 'id' };
630
+
631
+ // Look for the options object in live.channel(topic, { ... })
632
+ const pattern = new RegExp(
633
+ `export\\s+const\\s+${name}\\s*=\\s*live\\.channel\\s*\\(`,
634
+ 's'
635
+ );
636
+ const match = pattern.exec(source);
637
+ if (!match) return opts;
638
+
639
+ const startIdx = match.index + match[0].length;
640
+ const rest = source.slice(startIdx);
641
+
642
+ const optMatch = rest.match(/,\s*\{([^}]+)\}/);
643
+ if (optMatch) {
644
+ const optStr = optMatch[1];
645
+ const mergeMatch = optStr.match(/merge\s*:\s*['"](\w+)['"]/);
646
+ if (mergeMatch) opts.merge = mergeMatch[1];
647
+ const keyMatch = optStr.match(/key\s*:\s*['"](\w+)['"]/);
648
+ if (keyMatch) opts.key = keyMatch[1];
649
+ const maxMatch = optStr.match(/max\s*:\s*(\d+)/);
650
+ if (maxMatch) opts.max = parseInt(maxMatch[1], 10);
651
+ }
652
+
653
+ return opts;
654
+ }
655
+
656
+ /**
657
+ * Extract room configuration info from source code for client stub generation.
658
+ * @param {string} source
659
+ * @param {string} name
660
+ * @returns {{ dataOpts: any, hasPresence: boolean, hasCursors: boolean, actions: string[] }}
661
+ */
662
+ function _extractRoomInfo(source, name) {
663
+ const info = { dataOpts: { merge: 'crud', key: 'id' }, hasPresence: false, hasCursors: false, actions: [] };
664
+
665
+ // Find the start of live.room({ ... }) call
666
+ const startPattern = new RegExp(
667
+ `export\\s+const\\s+${name}\\s*=\\s*live\\.room\\s*\\(`
668
+ );
669
+ const startMatch = startPattern.exec(source);
670
+ if (!startMatch) return info;
671
+
672
+ // Extract the config object body using brace matching
673
+ const afterOpen = source.slice(startMatch.index + startMatch[0].length);
674
+ const body = _extractBraceContent(afterOpen);
675
+ if (!body) return info;
676
+
677
+ // Check for presence
678
+ if (/presence\s*:/.test(body)) info.hasPresence = true;
679
+ // Check for cursors
680
+ if (/cursors\s*:/.test(body)) info.hasCursors = true;
681
+
682
+ // Extract merge mode
683
+ const mergeMatch = body.match(/merge\s*:\s*['"](\w+)['"]/);
684
+ if (mergeMatch) info.dataOpts.merge = mergeMatch[1];
685
+
686
+ // Extract key field
687
+ const keyMatch = body.match(/key\s*:\s*['"](\w+)['"]/);
688
+ if (keyMatch) info.dataOpts.key = keyMatch[1];
689
+
690
+ // Extract action names from actions: { ... }
691
+ const actionsIdx = body.search(/actions\s*:\s*\{/);
692
+ if (actionsIdx !== -1) {
693
+ const afterActions = body.slice(body.indexOf('{', actionsIdx));
694
+ const actionsBody = _extractBraceContent(afterActions);
695
+ if (actionsBody) {
696
+ // Match property names: "name:" or "async name(" patterns
697
+ const actionPattern = /(?:async\s+)?(\w+)\s*(?:\(|:)/g;
698
+ let m;
699
+ while ((m = actionPattern.exec(actionsBody)) !== null) {
700
+ const actionName = m[1];
701
+ if (actionName !== 'async') info.actions.push(actionName);
702
+ }
703
+ }
704
+ }
705
+
706
+ return info;
707
+ }
708
+
709
+ /**
710
+ * Extract content between matching braces { ... } respecting nesting.
711
+ * Input should start at or before the opening brace.
712
+ * @param {string} str
713
+ * @returns {string | null}
714
+ */
715
+ function _extractBraceContent(str) {
716
+ const start = str.indexOf('{');
717
+ if (start === -1) return null;
718
+ let depth = 0;
719
+ for (let i = start; i < str.length; i++) {
720
+ if (str[i] === '{') depth++;
721
+ else if (str[i] === '}') {
722
+ depth--;
723
+ if (depth === 0) return str.slice(start + 1, i);
724
+ }
725
+ }
726
+ return null;
727
+ }
728
+
729
+ /**
730
+ * Generate the registry module that imports all live functions.
731
+ * @param {string} liveDir
732
+ * @param {string} dir
733
+ * @returns {string}
734
+ */
735
+ function _generateRegistry(liveDir, dir) {
736
+ if (!existsSync(liveDir)) return '// No live modules found\n';
737
+
738
+ const files = _findLiveFiles(liveDir);
739
+ const lines = [
740
+ `import { __register, __registerGuard, __registerCron, __registerDerived, __registerEffect, __registerAggregate, __registerRoomActions } from 'svelte-realtime/server';`,
741
+ `const __L = fn => (fn.__lazy = true, fn);\n`
742
+ ];
743
+
744
+ /** @type {Set<string>} */
745
+ const seenTopics = new Set();
746
+
747
+ for (const filePath of files) {
748
+ const rel = relative(liveDir, filePath).replace(/\\/g, '/').replace(/\.[jt]s$/, '');
749
+ const source = _readCached(filePath);
750
+ const normalizedPath = filePath.split(sep).join(posix.sep);
751
+
752
+ /** @param {string} name */
753
+ const _lazy = (name) => `__L(() => import('${normalizedPath}').then(m => m.${name}))`;
754
+
755
+ // Register live() exports
756
+ /** @type {Set<string>} */
757
+ const registered = new Set();
758
+ let match;
759
+ LIVE_EXPORT_RE.lastIndex = 0;
760
+ while ((match = LIVE_EXPORT_RE.exec(source)) !== null) {
761
+ const name = match[1];
762
+ if (!/^\w+$/.test(name)) continue;
763
+ registered.add(name);
764
+ lines.push(`__register('${rel}/${name}', ${_lazy(name)});`);
765
+ }
766
+
767
+ // Register live.validated() exports
768
+ VALIDATED_EXPORT_RE.lastIndex = 0;
769
+ while ((match = VALIDATED_EXPORT_RE.exec(source)) !== null) {
770
+ const name = match[1];
771
+ if (!/^\w+$/.test(name)) continue;
772
+ if (!registered.has(name)) {
773
+ registered.add(name);
774
+ lines.push(`__register('${rel}/${name}', ${_lazy(name)});`);
775
+ }
776
+ }
777
+
778
+ // Register live.rateLimit() exports
779
+ RATE_LIMIT_EXPORT_RE.lastIndex = 0;
780
+ while ((match = RATE_LIMIT_EXPORT_RE.exec(source)) !== null) {
781
+ const name = match[1];
782
+ if (!/^\w+$/.test(name)) continue;
783
+ if (!registered.has(name)) {
784
+ registered.add(name);
785
+ lines.push(`__register('${rel}/${name}', ${_lazy(name)});`);
786
+ }
787
+ }
788
+
789
+ // Register live.stream() exports
790
+ STREAM_EXPORT_RE.lastIndex = 0;
791
+ while ((match = STREAM_EXPORT_RE.exec(source)) !== null) {
792
+ const name = match[1];
793
+ if (!/^\w+$/.test(name)) continue;
794
+ lines.push(`__register('${rel}/${name}', ${_lazy(name)});`);
795
+
796
+ // Check for duplicate stream topics
797
+ const topicPattern = new RegExp(
798
+ `export\\s+const\\s+${name}\\s*=\\s*live\\.stream\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]`
799
+ );
800
+ const topicMatch = topicPattern.exec(source);
801
+ if (topicMatch) {
802
+ const topic = topicMatch[1];
803
+ if (topic.startsWith('__')) {
804
+ console.error(
805
+ `[svelte-realtime] ${dir}/${rel} uses reserved topic '${topic}' -- topics starting with __ are reserved for internal use`
806
+ );
807
+ }
808
+ if (seenTopics.has(topic)) {
809
+ console.error(
810
+ `[svelte-realtime] Duplicate stream topic '${topic}' in ${dir}/${rel} -- each topic must be unique across all modules`
811
+ );
812
+ }
813
+ seenTopics.add(topic);
814
+ }
815
+ }
816
+
817
+ // Register guard
818
+ GUARD_EXPORT_RE.lastIndex = 0;
819
+ if (GUARD_EXPORT_RE.exec(source) !== null) {
820
+ lines.push(`__registerGuard('${rel}', ${_lazy('_guard')});`);
821
+ }
822
+
823
+ // Register live.binary() exports
824
+ BINARY_EXPORT_RE.lastIndex = 0;
825
+ while ((match = BINARY_EXPORT_RE.exec(source)) !== null) {
826
+ const name = match[1];
827
+ if (!/^\w+$/.test(name)) continue;
828
+ if (!registered.has(name)) {
829
+ registered.add(name);
830
+ lines.push(`__register('${rel}/${name}', ${_lazy(name)});`);
831
+ }
832
+ }
833
+
834
+ // Register cron jobs
835
+ CRON_EXPORT_RE.lastIndex = 0;
836
+ while ((match = CRON_EXPORT_RE.exec(source)) !== null) {
837
+ const name = match[1];
838
+ if (!/^\w+$/.test(name)) continue;
839
+ lines.push(`__registerCron('${rel}/${name}', ${_lazy(name)});`);
840
+ }
841
+
842
+ // Register live.derived() exports
843
+ DERIVED_EXPORT_RE.lastIndex = 0;
844
+ while ((match = DERIVED_EXPORT_RE.exec(source)) !== null) {
845
+ const name = match[1];
846
+ if (!/^\w+$/.test(name)) continue;
847
+ if (!registered.has(name)) {
848
+ registered.add(name);
849
+ lines.push(`__register('${rel}/${name}', ${_lazy(name)});`);
850
+ lines.push(`__registerDerived('${rel}/${name}', ${_lazy(name)});`);
851
+ }
852
+ }
853
+
854
+ // Register live.room() exports -- register sub-streams and actions lazily
855
+ ROOM_EXPORT_RE.lastIndex = 0;
856
+ while ((match = ROOM_EXPORT_RE.exec(source)) !== null) {
857
+ const name = match[1];
858
+ if (!/^\w+$/.test(name)) continue;
859
+ if (!registered.has(name)) {
860
+ registered.add(name);
861
+ // Register the data stream
862
+ lines.push(`__register('${rel}/${name}/__data', __L(() => import('${normalizedPath}').then(m => m.${name}.__dataStream)));`);
863
+ // Register presence stream if present
864
+ lines.push(`__register('${rel}/${name}/__presence', __L(() => import('${normalizedPath}').then(m => m.${name}.__presenceStream)));`);
865
+ // Register cursor stream if present
866
+ lines.push(`__register('${rel}/${name}/__cursors', __L(() => import('${normalizedPath}').then(m => m.${name}.__cursorStream)));`);
867
+ // Register actions (deferred -- resolved on first RPC or cron tick)
868
+ lines.push(`__registerRoomActions('${rel}/${name}', ${_lazy(name)});`);
869
+ }
870
+ }
871
+
872
+ // Webhook exports are server-only (no registration needed in client registry)
873
+ WEBHOOK_EXPORT_RE.lastIndex = 0;
874
+ while ((match = WEBHOOK_EXPORT_RE.exec(source)) !== null) {
875
+ registered.add(match[1]);
876
+ }
877
+
878
+ // Register live.channel() exports (treated like streams)
879
+ CHANNEL_EXPORT_RE.lastIndex = 0;
880
+ while ((match = CHANNEL_EXPORT_RE.exec(source)) !== null) {
881
+ const name = match[1];
882
+ if (!/^\w+$/.test(name)) continue;
883
+ if (!registered.has(name)) {
884
+ registered.add(name);
885
+ lines.push(`__register('${rel}/${name}', ${_lazy(name)});`);
886
+ }
887
+ }
888
+
889
+ // Register live.effect() exports
890
+ EFFECT_EXPORT_RE.lastIndex = 0;
891
+ while ((match = EFFECT_EXPORT_RE.exec(source)) !== null) {
892
+ const name = match[1];
893
+ if (!/^\w+$/.test(name)) continue;
894
+ if (!registered.has(name)) {
895
+ registered.add(name);
896
+ lines.push(`__registerEffect('${rel}/${name}', ${_lazy(name)});`);
897
+ }
898
+ }
899
+
900
+ // Register live.aggregate() exports
901
+ AGGREGATE_EXPORT_RE.lastIndex = 0;
902
+ while ((match = AGGREGATE_EXPORT_RE.exec(source)) !== null) {
903
+ const name = match[1];
904
+ if (!/^\w+$/.test(name)) continue;
905
+ if (!registered.has(name)) {
906
+ registered.add(name);
907
+ lines.push(`__register('${rel}/${name}', ${_lazy(name)});`);
908
+ lines.push(`__registerAggregate('${rel}/${name}', ${_lazy(name)});`);
909
+ }
910
+ }
911
+ }
912
+
913
+ return lines.join('\n') + '\n';
914
+ }
915
+
916
+ /**
917
+ * Recursively find all .js/.ts files in the live directory.
918
+ * @param {string} dir
919
+ * @returns {string[]}
920
+ */
921
+ function _findLiveFiles(dir) {
922
+ /** @type {string[]} */
923
+ const results = [];
924
+ if (!existsSync(dir)) return results;
925
+
926
+ for (const entry of readdirSync(dir)) {
927
+ const full = resolve(dir, entry);
928
+ const stat = statSync(full);
929
+ if (stat.isDirectory()) {
930
+ results.push(..._findLiveFiles(full));
931
+ } else if (/\.[jt]s$/.test(entry) && !entry.endsWith('.d.ts') && !entry.endsWith('.test.js') && !entry.endsWith('.test.ts')) {
932
+ results.push(full);
933
+ }
934
+ }
935
+
936
+ return results;
937
+ }
938
+
939
+ /**
940
+ * Generate and write type declarations for all $live/ modules.
941
+ * Creates `$types.d.ts` in the live directory with ambient module declarations.
942
+ * @param {string} liveDir
943
+ * @param {string} dir
944
+ */
945
+ function _writeTypeDeclarations(liveDir, dir) {
946
+ const content = _generateTypeDeclarations(liveDir, dir);
947
+ if (content) {
948
+ writeFileSync(resolve(liveDir, '$types.d.ts'), content);
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Generate ambient module declarations for all $live/ modules.
954
+ * For TS files, attempts to extract real parameter/return types.
955
+ * For JS files, falls back to `any`.
956
+ * @param {string} liveDir
957
+ * @param {string} dir
958
+ * @returns {string}
959
+ */
960
+ function _generateTypeDeclarations(liveDir, dir) {
961
+ if (!existsSync(liveDir)) return '';
962
+
963
+ const files = _findLiveFiles(liveDir);
964
+ if (files.length === 0) return '';
965
+
966
+ const declarations = [
967
+ '// Auto-generated by svelte-realtime — do not edit',
968
+ '// Provides client-side types for $live/ imports',
969
+ ''
970
+ ];
971
+
972
+ for (const filePath of files) {
973
+ const rel = relative(liveDir, filePath).replace(/\\/g, '/').replace(/\.[jt]s$/, '');
974
+ const source = _readCached(filePath);
975
+ const isTS = filePath.endsWith('.ts');
976
+
977
+ /** @type {string[]} */
978
+ const exports = [];
979
+ let needsReadable = false;
980
+ let needsRpcError = false;
981
+
982
+ // Detect live() exports
983
+ let match;
984
+ LIVE_EXPORT_RE.lastIndex = 0;
985
+ while ((match = LIVE_EXPORT_RE.exec(source)) !== null) {
986
+ const name = match[1];
987
+ if (isTS) {
988
+ const sig = _extractFunctionSignature(source, name);
989
+ exports.push(` export const ${name}: ${sig};`);
990
+ } else {
991
+ exports.push(` export const ${name}: (...args: any[]) => Promise<any>;`);
992
+ }
993
+ }
994
+
995
+ // Detect live.validated() exports (same as live() for types)
996
+ VALIDATED_EXPORT_RE.lastIndex = 0;
997
+ while ((match = VALIDATED_EXPORT_RE.exec(source)) !== null) {
998
+ const name = match[1];
999
+ // Only add if not already detected by LIVE_EXPORT_RE
1000
+ if (!exports.some(e => e.includes(`export const ${name}:`))) {
1001
+ exports.push(` export const ${name}: (...args: any[]) => Promise<any>;`);
1002
+ }
1003
+ }
1004
+
1005
+ // Extract thrown LiveError codes for typed error unions
1006
+ const errorCodes = _extractErrorCodes(source);
1007
+ if (errorCodes.length > 0) {
1008
+ needsRpcError = true;
1009
+ const union = errorCodes.map(c => `'${c}'`).join(' | ');
1010
+ exports.push(` export type ErrorCode = ${union};`);
1011
+ }
1012
+
1013
+ // Detect live.stream() exports
1014
+ STREAM_EXPORT_RE.lastIndex = 0;
1015
+ while ((match = STREAM_EXPORT_RE.exec(source)) !== null) {
1016
+ const name = match[1];
1017
+ needsReadable = true;
1018
+ needsRpcError = true;
1019
+ if (isTS) {
1020
+ const returnType = _extractStreamReturnType(source, name);
1021
+ exports.push(` export const ${name}: Readable<${returnType} | undefined | { error: RpcError }>;`);
1022
+ } else {
1023
+ exports.push(` export const ${name}: Readable<any>;`);
1024
+ }
1025
+ }
1026
+
1027
+ if (exports.length > 0) {
1028
+ declarations.push(`declare module '$live/${rel}' {`);
1029
+ if (needsReadable) {
1030
+ declarations.push(" import type { Readable } from 'svelte/store';");
1031
+ }
1032
+ if (needsRpcError) {
1033
+ declarations.push(" import type { RpcError } from 'svelte-realtime/client';");
1034
+ }
1035
+ if (needsReadable || needsRpcError) {
1036
+ declarations.push('');
1037
+ }
1038
+ declarations.push(...exports);
1039
+ declarations.push('}');
1040
+ declarations.push('');
1041
+ }
1042
+ }
1043
+
1044
+ return declarations.join('\n');
1045
+ }
1046
+
1047
+ /**
1048
+ * Extract thrown LiveError codes from source for typed error unions.
1049
+ * Scans for `throw new LiveError('CODE', ...)` patterns.
1050
+ * @param {string} source
1051
+ * @returns {string[]}
1052
+ */
1053
+ function _extractErrorCodes(source) {
1054
+ const re = /throw\s+new\s+LiveError\s*\(\s*['"](\w+)['"]/g;
1055
+ /** @type {Set<string>} */
1056
+ const codes = new Set();
1057
+ let m;
1058
+ while ((m = re.exec(source)) !== null) {
1059
+ codes.add(m[1]);
1060
+ }
1061
+ return [...codes].sort();
1062
+ }
1063
+
1064
+ /**
1065
+ * Extract client-side function signature from a TS source, stripping the ctx first param.
1066
+ * Falls back to `(...args: any[]) => Promise<any>` if parsing fails.
1067
+ * @param {string} source
1068
+ * @param {string} name
1069
+ * @returns {string}
1070
+ */
1071
+ function _extractFunctionSignature(source, name) {
1072
+ const fallback = '(...args: any[]) => Promise<any>';
1073
+
1074
+ // Match: export const name = live(async (ctx: Type, param1: T1, param2: T2): ReturnType => {
1075
+ // or: export const name = live(async (ctx, param1: T1) => {
1076
+ // Uses .+? for return type to handle nested generics like Promise<{ id: number }>
1077
+ const pattern = new RegExp(
1078
+ `export\\s+const\\s+${name}\\s*=\\s*live\\s*\\(\\s*(?:async\\s+)?` +
1079
+ `\\(([^)]*)\\)\\s*(?::\\s*(.+?))?\\s*=>`,
1080
+ 's'
1081
+ );
1082
+ const match = pattern.exec(source);
1083
+ if (!match) return fallback;
1084
+
1085
+ const paramsStr = match[1].trim();
1086
+ const returnType = match[2]?.trim() || 'Promise<any>';
1087
+
1088
+ // Split params, drop the first one (ctx)
1089
+ const params = _splitParams(paramsStr);
1090
+ params.shift(); // remove ctx
1091
+
1092
+ const clientParams = params.length > 0 ? params.join(', ') : '';
1093
+ const clientParamsStr = clientParams ? `(${clientParams})` : '()';
1094
+
1095
+ // Normalize return type - ensure it's wrapped in Promise
1096
+ let normalizedReturn = returnType;
1097
+ if (!normalizedReturn.startsWith('Promise<')) {
1098
+ normalizedReturn = `Promise<${normalizedReturn}>`;
1099
+ }
1100
+
1101
+ return `${clientParamsStr} => ${normalizedReturn}`;
1102
+ }
1103
+
1104
+ /**
1105
+ * Extract the return type of a stream's initFn for type declarations.
1106
+ * Falls back to `any` if parsing fails.
1107
+ * @param {string} source
1108
+ * @param {string} name
1109
+ * @returns {string}
1110
+ */
1111
+ function _extractStreamReturnType(source, name) {
1112
+ // Match: export const name = live.stream('topic', async (ctx): Promise<Type[]> => {
1113
+ // Uses balanced angle bracket matching for nested generics
1114
+ const pattern = new RegExp(
1115
+ `export\\s+const\\s+${name}\\s*=\\s*live\\.stream\\s*\\([^,]+,\\s*` +
1116
+ `(?:async\\s+)?\\([^)]*\\)\\s*(?::\\s*(.+?))?\\s*=>`,
1117
+ 's'
1118
+ );
1119
+ const match = pattern.exec(source);
1120
+ if (!match || !match[1]) return 'any';
1121
+
1122
+ const returnAnnotation = match[1].trim();
1123
+ // Unwrap Promise<T> to get T
1124
+ const promiseMatch = returnAnnotation.match(/^Promise\s*<\s*(.+)\s*>$/s);
1125
+ if (promiseMatch) return promiseMatch[1].trim();
1126
+ return returnAnnotation;
1127
+ }
1128
+
1129
+ /**
1130
+ * Split a parameter string respecting nested generics and destructuring.
1131
+ * @param {string} str
1132
+ * @returns {string[]}
1133
+ */
1134
+ function _splitParams(str) {
1135
+ if (!str.trim()) return [];
1136
+ const params = [];
1137
+ let depth = 0;
1138
+ let current = '';
1139
+ for (const ch of str) {
1140
+ if (ch === '<' || ch === '(' || ch === '{' || ch === '[') depth++;
1141
+ else if (ch === '>' || ch === ')' || ch === '}' || ch === ']') depth--;
1142
+ if (ch === ',' && depth === 0) {
1143
+ params.push(current.trim());
1144
+ current = '';
1145
+ } else {
1146
+ current += ch;
1147
+ }
1148
+ }
1149
+ if (current.trim()) params.push(current.trim());
1150
+ return params;
1151
+ }
1152
+
1153
+ /**
1154
+ * Invalidate the registry virtual module, clear all server-side registrations,
1155
+ * and re-import the registry so handlers are updated. If the re-import fails
1156
+ * (e.g. syntax error), restores the previous handlers so the server keeps working.
1157
+ * @param {import('vite').ViteDevServer} server
1158
+ * @param {string} liveDir
1159
+ * @param {string} dir
1160
+ * @param {string} rel - Relative path of the changed file (for logging)
1161
+ */
1162
+ async function _hmrReloadRegistry(server, liveDir, dir, rel) {
1163
+ // Invalidate the registry virtual module so Vite regenerates it
1164
+ const registryMod = server.moduleGraph.getModuleById(REGISTRY_ID);
1165
+ if (registryMod) {
1166
+ server.moduleGraph.invalidateModule(registryMod);
1167
+ }
1168
+
1169
+ /** @type {any} */
1170
+ let serverMod;
1171
+ try {
1172
+ serverMod = await server.ssrLoadModule('svelte-realtime/server');
1173
+ } catch {
1174
+ console.error('[svelte-realtime] HMR failed: could not load svelte-realtime/server');
1175
+ return;
1176
+ }
1177
+
1178
+ // Snapshot current state, then clear everything
1179
+ const snap = serverMod._prepareHmr();
1180
+
1181
+ try {
1182
+ await server.ssrLoadModule('/@svelte-realtime-registry');
1183
+ console.log(`[svelte-realtime] Hot-reloaded: ${dir}/${rel}`);
1184
+ } catch (e) {
1185
+ // Re-import failed -- restore old handlers so the server keeps working
1186
+ serverMod._restoreHmr(snap);
1187
+ console.error(`[svelte-realtime] HMR failed for ${dir}/${rel}:`, /** @type {Error} */ (e).message);
1188
+ console.error('[svelte-realtime] Previous handlers restored -- fix the error and save again');
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Directly load live modules in dev mode.
1194
+ * @param {import('vite').ViteDevServer} server
1195
+ * @param {string} liveDir
1196
+ * @param {string} dir
1197
+ */
1198
+ async function _loadRegistryDirect(server, liveDir, dir) {
1199
+ let serverMod;
1200
+ try {
1201
+ serverMod = await server.ssrLoadModule('svelte-realtime/server');
1202
+ } catch {
1203
+ console.warn('[svelte-realtime] Could not load svelte-realtime/server for direct registration');
1204
+ return;
1205
+ }
1206
+
1207
+ const { __register, __registerGuard, __registerDerived, __registerCron, __registerEffect, __registerAggregate } = serverMod;
1208
+ const files = _findLiveFiles(liveDir);
1209
+
1210
+ for (const filePath of files) {
1211
+ try {
1212
+ const mod = await server.ssrLoadModule(filePath);
1213
+ const rel = relative(liveDir, filePath).replace(/\\/g, '/').replace(/\.[jt]s$/, '');
1214
+
1215
+ for (const [name, fn] of Object.entries(mod)) {
1216
+ if (name === '_guard' && /** @type {any} */ (fn)?.__isGuard) {
1217
+ __registerGuard(rel, fn);
1218
+ } else if (/** @type {any} */ (fn)?.__isRoom) {
1219
+ // Room: register sub-streams and actions
1220
+ if (fn.__dataStream) __register(rel + '/' + name + '/__data', fn.__dataStream);
1221
+ if (fn.__presenceStream) __register(rel + '/' + name + '/__presence', fn.__presenceStream);
1222
+ if (fn.__cursorStream) __register(rel + '/' + name + '/__cursors', fn.__cursorStream);
1223
+ if (fn.__actions) {
1224
+ for (const [k, v] of Object.entries(fn.__actions)) {
1225
+ __register(rel + '/' + name + '/__action/' + k, v);
1226
+ }
1227
+ }
1228
+ } else if (/** @type {any} */ (fn)?.__isEffect) {
1229
+ __registerEffect(rel + '/' + name, fn);
1230
+ } else if (/** @type {any} */ (fn)?.__isAggregate) {
1231
+ __register(rel + '/' + name, fn);
1232
+ __registerAggregate(rel + '/' + name, fn);
1233
+ } else if (/** @type {any} */ (fn)?.__isDerived) {
1234
+ __register(rel + '/' + name, fn);
1235
+ __registerDerived(rel + '/' + name, fn);
1236
+ } else if (/** @type {any} */ (fn)?.__isCron) {
1237
+ __registerCron(rel + '/' + name, fn);
1238
+ } else if (/** @type {any} */ (fn)?.__isLive) {
1239
+ __register(rel + '/' + name, fn);
1240
+ }
1241
+ }
1242
+ } catch (err) {
1243
+ console.warn(`[svelte-realtime] Failed to load ${relative(liveDir, filePath)}:`, err);
1244
+ }
1245
+ }
1246
+ }