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/server.js ADDED
@@ -0,0 +1,2311 @@
1
+ // @ts-check
2
+
3
+ const textDecoder = new TextDecoder();
4
+ const _validPathRe = /^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)+$/;
5
+
6
+ /** @type {Map<string, Function>} */
7
+ const registry = new Map();
8
+
9
+ /** @type {Map<string, Function>} */
10
+ const guards = new Map();
11
+
12
+ /** @type {Set<Function>} Streams with onUnsubscribe hooks for fast close() */
13
+ const _streamsWithUnsubscribe = new Set();
14
+
15
+ /** @type {WeakMap<object, Map<string, Function>>} Maps ws -> (topic -> stream fn) for dynamic unsubscribe */
16
+ const _dynamicSubscriptions = new WeakMap();
17
+
18
+ /** @type {Array<(ctx: any, next: () => Promise<any>) => Promise<any>>} */
19
+ const _globalMiddleware = [];
20
+
21
+ /** @type {WeakMap<any, Function>} Cache bound publish per platform to avoid repeated .bind() */
22
+ const _boundPublishCache = new WeakMap();
23
+
24
+ /**
25
+ * Get a cached bound publish function for a platform.
26
+ * @param {import('svelte-adapter-uws').Platform} platform
27
+ * @returns {Function}
28
+ */
29
+ function _getBoundPublish(platform) {
30
+ let bound = _boundPublishCache.get(platform);
31
+ if (!bound) {
32
+ bound = platform.publish.bind(platform);
33
+ _boundPublishCache.set(platform, bound);
34
+ }
35
+ return bound;
36
+ }
37
+
38
+ /** @type {WeakMap<any, { publish: Function, throttle: Function, debounce: Function, signal: Function }>} */
39
+ const _ctxHelpersCache = new WeakMap();
40
+
41
+ /**
42
+ * Get cached ctx helper methods for a platform.
43
+ * Avoids creating new closures on every RPC call.
44
+ * @param {import('svelte-adapter-uws').Platform} platform
45
+ * @returns {{ publish: Function, throttle: Function, debounce: Function, signal: Function }}
46
+ */
47
+ function _getCtxHelpers(platform) {
48
+ let helpers = _ctxHelpersCache.get(platform);
49
+ if (!helpers) {
50
+ const publish = _getBoundPublish(platform);
51
+ helpers = {
52
+ publish,
53
+ throttle: (topic, event, data, ms) => _throttlePublish(platform, topic, event, data, ms),
54
+ debounce: (topic, event, data, ms) => _debouncePublish(platform, topic, event, data, ms),
55
+ signal: (userId, event, data) => platform.publish('__signal:' + userId, event, data)
56
+ };
57
+ _ctxHelpersCache.set(platform, helpers);
58
+ }
59
+ return helpers;
60
+ }
61
+
62
+ /**
63
+ * Register a live function in the registry.
64
+ * Called by the Vite-generated registry module.
65
+ * Accepts either a live function directly or a lazy loader (tagged with __lazy).
66
+ * @param {string} path
67
+ * @param {Function} fn
68
+ */
69
+ export function __register(path, fn) {
70
+ registry.set(path, fn);
71
+ if (/** @type {any} */ (fn).__lazy) return;
72
+ // Set rate limit path for rate-limited functions
73
+ if (/** @type {any} */ (fn).__isRateLimited) {
74
+ /** @type {any} */ (fn).__rateLimitPath = path;
75
+ }
76
+ if (/** @type {any} */ (fn).__isStream && /** @type {any} */ (fn).__onUnsubscribe) {
77
+ _streamsWithUnsubscribe.add(fn);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Resolve a lazy registry entry. If the entry is a lazy loader (__lazy),
83
+ * dynamically import the module, cache the resolved function, and return it.
84
+ * @param {string} path
85
+ * @returns {Promise<Function | null>}
86
+ */
87
+ async function _resolveRegistryEntry(path) {
88
+ const entry = registry.get(path);
89
+ if (!entry) return null;
90
+ if (!/** @type {any} */ (entry).__lazy) return entry;
91
+ const fn = await entry();
92
+ if (!fn) {
93
+ registry.delete(path);
94
+ return null;
95
+ }
96
+ registry.set(path, fn);
97
+ if (/** @type {any} */ (fn).__isRateLimited) {
98
+ /** @type {any} */ (fn).__rateLimitPath = path;
99
+ }
100
+ if (/** @type {any} */ (fn).__isStream && /** @type {any} */ (fn).__onUnsubscribe) {
101
+ _streamsWithUnsubscribe.add(fn);
102
+ }
103
+ return fn;
104
+ }
105
+
106
+ /**
107
+ * Register a guard for a module.
108
+ * Called by the Vite-generated registry module.
109
+ * Accepts either a guard function directly or a lazy loader (tagged with __lazy).
110
+ * @param {string} modulePath
111
+ * @param {Function} fn
112
+ */
113
+ export function __registerGuard(modulePath, fn) {
114
+ guards.set(modulePath, fn);
115
+ }
116
+
117
+ /**
118
+ * Resolve a lazy guard entry.
119
+ * @param {string} modulePath
120
+ * @returns {Promise<Function | null>}
121
+ */
122
+ async function _resolveGuard(modulePath) {
123
+ const entry = guards.get(modulePath);
124
+ if (!entry) return null;
125
+ if (!/** @type {any} */ (entry).__lazy) return entry;
126
+ const fn = await entry();
127
+ if (!fn) {
128
+ guards.delete(modulePath);
129
+ return null;
130
+ }
131
+ guards.set(modulePath, fn);
132
+ return fn;
133
+ }
134
+
135
+ /**
136
+ * Mark a function as RPC-callable.
137
+ * @template {Function} T
138
+ * @param {T} fn
139
+ * @returns {T}
140
+ */
141
+ export function live(fn) {
142
+ /** @type {any} */ (fn).__isLive = true;
143
+ return fn;
144
+ }
145
+
146
+ /**
147
+ * Mark a function as a stream provider.
148
+ * Topic can be a static string or a function of (ctx, ...args) => string for dynamic topics.
149
+ * @param {string | Function} topic
150
+ * @param {Function} initFn
151
+ * @param {{ merge?: 'crud' | 'latest' | 'set' | 'presence' | 'cursor', key?: string, prepend?: boolean, max?: number, replay?: boolean | { size?: number } }} [options]
152
+ * @returns {Function}
153
+ */
154
+ live.stream = function stream(topic, initFn, options) {
155
+ const { replay, onSubscribe, onUnsubscribe, filter, access, delta, version, migrate, ...rest } = options || {};
156
+ const merged = { merge: 'crud', key: 'id', ...rest };
157
+ if (replay) /** @type {any} */ (initFn).__replay = typeof replay === 'object' ? replay : {};
158
+ if (delta) /** @type {any} */ (initFn).__delta = delta;
159
+ /** @type {any} */ (initFn).__isStream = true;
160
+ /** @type {any} */ (initFn).__isLive = true;
161
+ /** @type {any} */ (initFn).__streamTopic = topic;
162
+ /** @type {any} */ (initFn).__streamOptions = merged;
163
+ if (onSubscribe) /** @type {any} */ (initFn).__onSubscribe = onSubscribe;
164
+ if (onUnsubscribe) /** @type {any} */ (initFn).__onUnsubscribe = onUnsubscribe;
165
+ // Subscribe-time access predicate: (ctx) => boolean
166
+ const filterFn = access || filter;
167
+ if (filterFn) /** @type {any} */ (initFn).__streamFilter = filterFn;
168
+ // Schema versioning
169
+ if (version !== undefined) /** @type {any} */ (initFn).__streamVersion = version;
170
+ if (migrate) /** @type {any} */ (initFn).__streamMigrate = migrate;
171
+ return initFn;
172
+ };
173
+
174
+ /**
175
+ * Create an ephemeral pub/sub channel with no database initialization.
176
+ * Channels have no initFn -- clients subscribe to a topic and receive events immediately.
177
+ *
178
+ * @param {string | Function} topic - Static topic string or function (ctx, ...args) => string for dynamic channels
179
+ * @param {{ merge?: 'crud' | 'latest' | 'set' | 'presence' | 'cursor', key?: string, max?: number }} [options]
180
+ * @returns {Function}
181
+ */
182
+ live.channel = function channel(topic, options) {
183
+ const merged = { merge: options?.merge || 'set', key: options?.key || 'id' };
184
+ if (options?.max !== undefined) merged.max = options.max;
185
+ const emptyValue = (merged.merge === 'set') ? null : [];
186
+
187
+ const initFn = async function channelInit() { return emptyValue; };
188
+ /** @type {any} */ (initFn).__isStream = true;
189
+ /** @type {any} */ (initFn).__isLive = true;
190
+ /** @type {any} */ (initFn).__isChannel = true;
191
+ /** @type {any} */ (initFn).__streamTopic = topic;
192
+ /** @type {any} */ (initFn).__streamOptions = merged;
193
+ return initFn;
194
+ };
195
+
196
+ /**
197
+ * Mark a function as a binary RPC handler.
198
+ * The first argument after ctx is the raw ArrayBuffer.
199
+ * Remaining arguments are JSON-encoded in a header.
200
+ *
201
+ * @param {Function} fn - Handler function (ctx, buffer, ...jsonArgs)
202
+ * @returns {Function}
203
+ */
204
+ live.binary = function binary(fn, options) {
205
+ /** @type {any} */ (fn).__isLive = true;
206
+ /** @type {any} */ (fn).__isBinary = true;
207
+ if (options?.maxSize) /** @type {any} */ (fn).__maxBinarySize = options.maxSize;
208
+ return fn;
209
+ };
210
+
211
+ /**
212
+ * Register a global middleware that runs before per-module guards for every RPC/stream call.
213
+ * Middleware receives `(ctx, next)` -- call `next()` to continue the chain.
214
+ * Throw a LiveError to reject the call.
215
+ *
216
+ * @param {(ctx: any, next: () => Promise<any>) => Promise<any>} fn
217
+ */
218
+ live.middleware = function middleware(fn) {
219
+ _globalMiddleware.push(fn);
220
+ };
221
+
222
+ /**
223
+ * Declarative access control helpers for subscribe-time gating.
224
+ * These return predicates compatible with `live.stream({ access: ... })`.
225
+ * Access predicates receive only `ctx` and are checked once at subscription time.
226
+ * For per-event filtering, use `pipe.filter()`.
227
+ */
228
+ live.access = {
229
+ /**
230
+ * Only allow subscription if `ctx.user[field]` is present (authenticated with that field).
231
+ * For per-user data isolation, use dynamic topics instead: `(ctx) => \`items:\${ctx.user.id}\``.
232
+ * @param {string} [field] - The field on ctx.user to check (default: 'id')
233
+ * @returns {(ctx: any) => boolean}
234
+ */
235
+ owner(field = 'id') {
236
+ return (ctx) => ctx.user?.[field] != null;
237
+ },
238
+
239
+ /**
240
+ * Role-based access: map role names to boolean or predicate.
241
+ * @param {Record<string, true | ((ctx: any) => boolean)>} map
242
+ * @returns {(ctx: any) => boolean}
243
+ */
244
+ role(map) {
245
+ return (ctx) => {
246
+ const role = ctx.user?.role;
247
+ if (!role || !(role in map)) return false;
248
+ const rule = map[role];
249
+ return rule === true ? true : rule(ctx);
250
+ };
251
+ },
252
+
253
+ /**
254
+ * Only allow subscription if `ctx.user.teamId` is present.
255
+ * For per-team data isolation, use dynamic topics: `(ctx) => \`items:\${ctx.user.teamId}\``.
256
+ * @returns {(ctx: any) => boolean}
257
+ */
258
+ team() {
259
+ return (ctx) => ctx.user?.teamId != null;
260
+ },
261
+
262
+ /**
263
+ * OR logic: any predicate returning true allows the subscription.
264
+ * @param {...((ctx: any) => boolean)} predicates
265
+ * @returns {(ctx: any) => boolean}
266
+ */
267
+ any(...predicates) {
268
+ return (ctx) => predicates.some(p => p(ctx));
269
+ },
270
+
271
+ /**
272
+ * AND logic: all predicates must return true to allow the subscription.
273
+ * @param {...((ctx: any) => boolean)} predicates
274
+ * @returns {(ctx: any) => boolean}
275
+ */
276
+ all(...predicates) {
277
+ return (ctx) => predicates.every(p => p(ctx));
278
+ }
279
+ };
280
+
281
+ /** @type {Map<string, { prev: number, curr: number, windowStart: number }>} */
282
+ const _rateLimits = new Map();
283
+
284
+ /** @type {number} */
285
+ let _rateLimitLastSweep = Date.now();
286
+
287
+ /**
288
+ * Declarative per-function rate limiting.
289
+ * Wraps a live() function with a sliding window rate limiter.
290
+ *
291
+ * @param {{ points: number, window: number, key?: (ctx: any) => string }} config
292
+ * @param {Function} fn - Handler function (ctx, ...args)
293
+ * @returns {Function}
294
+ */
295
+ live.rateLimit = function rateLimit(config, fn) {
296
+ const { points, window: windowMs } = config;
297
+ const keyFn = config.key || ((ctx) => ctx.user?.id || 'anon');
298
+
299
+ const wrapper = async function rateLimitedWrapper(ctx, ...args) {
300
+ const userKey = keyFn(ctx);
301
+ const bucketKey = /** @type {any} */ (wrapper).__rateLimitPath + '\0' + userKey;
302
+ const now = Date.now();
303
+
304
+ // Lazy sweep: prune stale entries, but limit work per sweep
305
+ if (now - _rateLimitLastSweep > 60000) {
306
+ _rateLimitLastSweep = now;
307
+ if (_rateLimits.size > 0) {
308
+ const maxSweep = Math.max(200, _rateLimits.size >> 2);
309
+ let swept = 0;
310
+ for (const [k, bucket] of _rateLimits) {
311
+ if (now - bucket.windowStart >= windowMs * 2) {
312
+ _rateLimits.delete(k);
313
+ }
314
+ if (++swept >= maxSweep) break;
315
+ }
316
+ }
317
+ }
318
+
319
+ if (_rateLimits.size > 10000) {
320
+ let excess = _rateLimits.size - 10000;
321
+ for (const k of _rateLimits.keys()) {
322
+ if (excess-- <= 0) break;
323
+ _rateLimits.delete(k);
324
+ }
325
+ }
326
+
327
+ let bucket = _rateLimits.get(bucketKey);
328
+ if (!bucket) {
329
+ bucket = { prev: 0, curr: 0, windowStart: now };
330
+ _rateLimits.set(bucketKey, bucket);
331
+ }
332
+
333
+ // Rotate windows if needed
334
+ const elapsed = now - bucket.windowStart;
335
+ if (elapsed >= windowMs * 2) {
336
+ // Both windows expired, reset
337
+ bucket.prev = 0;
338
+ bucket.curr = 0;
339
+ bucket.windowStart = now;
340
+ } else if (elapsed >= windowMs) {
341
+ // Current window expired, rotate
342
+ bucket.prev = bucket.curr;
343
+ bucket.curr = 0;
344
+ bucket.windowStart += windowMs;
345
+ }
346
+
347
+ // Estimate count in sliding window using weighted average
348
+ const windowElapsed = now - bucket.windowStart;
349
+ const weight = Math.max(0, 1 - windowElapsed / windowMs);
350
+ const estimated = bucket.prev * weight + bucket.curr;
351
+
352
+ if (estimated >= points) {
353
+ const retryAfter = Math.ceil(windowMs - windowElapsed);
354
+ const err = new LiveError('RATE_LIMITED', 'Too many requests');
355
+ /** @type {any} */ (err).retryAfter = retryAfter;
356
+ throw err;
357
+ }
358
+
359
+ bucket.curr++;
360
+ return fn(ctx, ...args);
361
+ };
362
+
363
+ /** @type {any} */ (wrapper).__isLive = true;
364
+ /** @type {any} */ (wrapper).__isRateLimited = true;
365
+ /** @type {any} */ (wrapper).__rateLimitPath = '';
366
+ return wrapper;
367
+ };
368
+
369
+ /**
370
+ * Mark a function as RPC-callable with schema validation.
371
+ * Validates args[0] against the schema before calling fn.
372
+ * Supports Zod (.safeParse method on schema) and Valibot (safeParse as standalone).
373
+ *
374
+ * @param {any} schema - Zod or Valibot schema
375
+ * @param {Function} fn - Handler function (ctx, validatedInput, ...rest)
376
+ * @returns {Function}
377
+ */
378
+ live.validated = function validated(schema, fn) {
379
+ const wrapper = async function validatedWrapper(ctx, ...args) {
380
+ const input = args[0];
381
+ const result = _validate(schema, input);
382
+ if (!result.ok) {
383
+ const err = new LiveError('VALIDATION', result.message);
384
+ /** @type {any} */ (err).issues = result.issues;
385
+ throw err;
386
+ }
387
+ args[0] = result.data;
388
+ return fn(ctx, ...args);
389
+ };
390
+ /** @type {any} */ (wrapper).__isLive = true;
391
+ /** @type {any} */ (wrapper).__isValidated = true;
392
+ /** @type {any} */ (wrapper).__schema = schema;
393
+ return wrapper;
394
+ };
395
+
396
+ /**
397
+ * Validate input against a Zod or Valibot schema.
398
+ * @param {any} schema
399
+ * @param {any} input
400
+ * @returns {{ ok: true, data: any } | { ok: false, message: string, issues: Array<{ path: string[], message: string }> }}
401
+ */
402
+ function _validate(schema, input) {
403
+ // Zod-style: schema has .safeParse method
404
+ if (typeof schema?.safeParse === 'function') {
405
+ const result = schema.safeParse(input);
406
+ if (result.success) {
407
+ return { ok: true, data: result.data };
408
+ }
409
+ const issues = (result.error?.issues || result.error?.errors || []).map((/** @type {any} */ i) => ({
410
+ path: i.path?.map(String) || [],
411
+ message: i.message || 'Validation failed'
412
+ }));
413
+ return {
414
+ ok: false,
415
+ message: 'Validation failed',
416
+ issues
417
+ };
418
+ }
419
+
420
+ // Valibot-style: schema is passed to a standalone safeParse
421
+ // In Valibot v1, schemas have a ._run or .pipe method
422
+ // Try to use the schema directly as a Valibot schema
423
+ if (schema?._run || schema?.pipe || schema?.type) {
424
+ // Attempt to import valibot's safeParse at call-time
425
+ // Since we can't do static import (it's optional), check if schema has _run
426
+ try {
427
+ const entries = schema._run?.({ typed: false, value: input }, {});
428
+ if (entries && !entries.issues) {
429
+ return { ok: true, data: entries.output ?? input };
430
+ }
431
+ if (entries?.issues) {
432
+ const issues = entries.issues.map((/** @type {any} */ i) => ({
433
+ path: i.path?.map((/** @type {any} */ p) => String(p.key)) || [],
434
+ message: i.message || 'Validation failed'
435
+ }));
436
+ return { ok: false, message: 'Validation failed', issues };
437
+ }
438
+ } catch {
439
+ // Fall through
440
+ }
441
+ }
442
+
443
+ // Unknown schema type - pass through unchanged
444
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
445
+ console.warn('[svelte-realtime] live.validated() received an unrecognized schema type -- input passed through without validation');
446
+ }
447
+ return { ok: true, data: input };
448
+ }
449
+
450
+ /** @type {Map<string, { schedule: number[], fn: Function, topic: string }>} */
451
+ const cronRegistry = new Map();
452
+
453
+ /** @type {ReturnType<typeof setInterval> | null} */
454
+ let _cronInterval = null;
455
+
456
+ /** @type {import('svelte-adapter-uws').Platform | null} */
457
+ let _cronPlatform = null;
458
+
459
+ /** @type {((path: string, error: unknown) => void) | null} */
460
+ let _cronErrorHandler = null;
461
+
462
+ /**
463
+ * Set a global error handler for cron job failures.
464
+ * Without this, cron errors are logged in dev and silently swallowed in production.
465
+ * @param {(path: string, error: unknown) => void} handler
466
+ */
467
+ export function onCronError(handler) {
468
+ _cronErrorHandler = handler;
469
+ }
470
+
471
+ /**
472
+ * Create a server-side scheduled function that publishes to a topic on a cron schedule.
473
+ *
474
+ * @param {string} schedule - Cron expression (5 fields: minute hour day month weekday)
475
+ * @param {string} topic - Topic to publish results to
476
+ * @param {Function} fn - Async function to run on schedule
477
+ * @returns {Function}
478
+ */
479
+ live.cron = function cron(schedule, topic, fn) {
480
+ /** @type {any} */ (fn).__isCron = true;
481
+ /** @type {any} */ (fn).__cronSchedule = schedule;
482
+ /** @type {any} */ (fn).__cronTopic = topic;
483
+ /** @type {any} */ (fn).__cronParsed = _parseCron(schedule);
484
+ return fn;
485
+ };
486
+
487
+ /** @type {Map<string, { sources: string[], fn: Function, topic: string, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
488
+ const derivedRegistry = new Map();
489
+
490
+ /**
491
+ * Create a server-side computed stream that recomputes when any source topic publishes.
492
+ *
493
+ * @param {string[]} sources - Topic names to watch
494
+ * @param {Function} fn - Async function that computes the derived value
495
+ * @param {{ merge?: string, debounce?: number }} [options]
496
+ * @returns {Function}
497
+ */
498
+ live.derived = function derived(sources, fn, options) {
499
+ const topic = /** @type {any} */ (fn).__derivedTopic || ('__derived:' + (_derivedIdCounter++));
500
+ const merge = options?.merge || 'set';
501
+ const debounce = options?.debounce || 0;
502
+
503
+ /** @type {any} */ (fn).__isDerived = true;
504
+ /** @type {any} */ (fn).__isStream = true;
505
+ /** @type {any} */ (fn).__isLive = true;
506
+ /** @type {any} */ (fn).__streamTopic = topic;
507
+ /** @type {any} */ (fn).__streamOptions = { merge, key: 'id' };
508
+ /** @type {any} */ (fn).__derivedSources = sources;
509
+ /** @type {any} */ (fn).__derivedDebounce = debounce;
510
+ return fn;
511
+ };
512
+
513
+ let _derivedIdCounter = 0;
514
+
515
+ /** @type {Map<string, { sources: string[], fn: Function, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
516
+ const effectRegistry = new Map();
517
+
518
+ /**
519
+ * Create a server-side reactive side effect.
520
+ * Effects fire when source topics publish. They are fire-and-forget -- no data, no topic.
521
+ *
522
+ * @param {string[]} sources - Topic names to watch
523
+ * @param {Function} fn - Async function (event, data, platform) called on each matching publish
524
+ * @param {{ debounce?: number }} [options]
525
+ * @returns {Function}
526
+ */
527
+ live.effect = function effect(sources, fn, options) {
528
+ const debounce = options?.debounce || 0;
529
+ /** @type {any} */ (fn).__isEffect = true;
530
+ /** @type {any} */ (fn).__effectSources = sources;
531
+ /** @type {any} */ (fn).__effectDebounce = debounce;
532
+ return fn;
533
+ };
534
+
535
+ /**
536
+ * Register an effect. Called by the Vite-generated registry module.
537
+ * @param {string} path
538
+ * @param {Function} fn
539
+ */
540
+ export function __registerEffect(path, fn) {
541
+ if (/** @type {any} */ (fn).__lazy) {
542
+ _lazyQueue.push({ type: 'effect', path, loader: fn });
543
+ return;
544
+ }
545
+ const sources = /** @type {any} */ (fn).__effectSources;
546
+ const debounce = /** @type {any} */ (fn).__effectDebounce || 0;
547
+ if (!sources) return;
548
+ effectRegistry.set(path, { sources, fn, debounce, timer: null });
549
+ for (const src of sources) {
550
+ let set = _effectBySource.get(src);
551
+ if (!set) { set = new Set(); _effectBySource.set(src, set); }
552
+ set.add(effectRegistry.get(path));
553
+ _watchedTopics.add(src);
554
+ }
555
+ }
556
+
557
+ /** @type {Map<string, { source: string, reducers: any, topic: string, state: any, snapshot: Function | null, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
558
+ const aggregateRegistry = new Map();
559
+ /** @type {Map<string, any>} Topic-keyed lookup for aggregates */
560
+ const _aggregateByTopic = new Map();
561
+
562
+ /** @type {Map<string, Set<any>>} Source topic -> derived entries that watch it */
563
+ const _derivedBySource = new Map();
564
+
565
+ /** @type {Map<string, Set<any>>} Source topic -> effect entries that watch it */
566
+ const _effectBySource = new Map();
567
+
568
+ /** @type {Map<string, Set<any>>} Source topic -> aggregate entries that watch it */
569
+ const _aggregateBySource = new Map();
570
+
571
+ /** @type {Set<string>} All source topics watched by derived/effect/aggregate for fast bail-out */
572
+ const _watchedTopics = new Set();
573
+
574
+ /**
575
+ * Create a real-time incremental aggregation over a source topic.
576
+ * Each event runs O(1) reducers instead of requerying the database.
577
+ *
578
+ * @param {string} source - Topic to watch for events
579
+ * @param {Record<string, { init?: () => any, reduce?: (acc: any, event: string, data: any) => any, compute?: (state: any) => any }>} reducers
580
+ * @param {{ topic: string, snapshot?: () => Promise<any>, debounce?: number }} options
581
+ * @returns {Function}
582
+ */
583
+ live.aggregate = function aggregate(source, reducers, options) {
584
+ const topic = options.topic;
585
+ const debounce = options?.debounce || 0;
586
+
587
+ // Build initial state from init() functions
588
+ const initState = {};
589
+ for (const [field, r] of Object.entries(reducers)) {
590
+ if (r.init) initState[field] = r.init();
591
+ }
592
+
593
+ const initFn = async function aggregateInit() {
594
+ // If aggregate is active, return current state; otherwise return init state
595
+ const entry = _aggregateByTopic.get(topic);
596
+ if (entry) {
597
+ const computed = _computeAggregateState(entry.state, reducers);
598
+ return computed;
599
+ }
600
+ return _computeAggregateState(initState, reducers);
601
+ };
602
+
603
+ /** @type {any} */ (initFn).__isAggregate = true;
604
+ /** @type {any} */ (initFn).__isStream = true;
605
+ /** @type {any} */ (initFn).__isLive = true;
606
+ /** @type {any} */ (initFn).__streamTopic = topic;
607
+ /** @type {any} */ (initFn).__streamOptions = { merge: 'set', key: 'id' };
608
+ /** @type {any} */ (initFn).__aggregateSource = source;
609
+ /** @type {any} */ (initFn).__aggregateReducers = reducers;
610
+ /** @type {any} */ (initFn).__aggregateInitState = initState;
611
+ /** @type {any} */ (initFn).__aggregateSnapshot = options?.snapshot || null;
612
+ /** @type {any} */ (initFn).__aggregateDebounce = debounce;
613
+ return initFn;
614
+ };
615
+
616
+ /**
617
+ * Compute aggregate state including computed fields.
618
+ * @param {any} state
619
+ * @param {Record<string, any>} reducers
620
+ * @returns {any}
621
+ */
622
+ function _computeAggregateState(state, reducers) {
623
+ const result = { ...state };
624
+ for (const [field, r] of Object.entries(reducers)) {
625
+ if (r.compute) {
626
+ result[field] = r.compute(result);
627
+ }
628
+ }
629
+ return result;
630
+ }
631
+
632
+ /**
633
+ * Register an aggregate. Called by the Vite-generated registry module.
634
+ * @param {string} path
635
+ * @param {Function} fn
636
+ */
637
+ export function __registerAggregate(path, fn) {
638
+ if (/** @type {any} */ (fn).__lazy) {
639
+ _lazyQueue.push({ type: 'aggregate', path, loader: fn });
640
+ return;
641
+ }
642
+ const source = /** @type {any} */ (fn).__aggregateSource;
643
+ const reducers = /** @type {any} */ (fn).__aggregateReducers;
644
+ const topic = /** @type {any} */ (fn).__streamTopic;
645
+ const initState = /** @type {any} */ (fn).__aggregateInitState;
646
+ const snapshot = /** @type {any} */ (fn).__aggregateSnapshot;
647
+ const debounce = /** @type {any} */ (fn).__aggregateDebounce || 0;
648
+ if (!source || !topic) return;
649
+ const entry = { source, reducers, topic, state: { ...initState }, snapshot, debounce, timer: null };
650
+ aggregateRegistry.set(path, entry);
651
+ _aggregateByTopic.set(topic, entry);
652
+ let srcSet = _aggregateBySource.get(source);
653
+ if (!srcSet) { srcSet = new Set(); _aggregateBySource.set(source, srcSet); }
654
+ srcSet.add(entry);
655
+ _watchedTopics.add(source);
656
+ }
657
+
658
+ /**
659
+ * Conditional stream activation. Wraps a stream function with a predicate.
660
+ * If the predicate returns false, responds with a gated (no-op) response.
661
+ *
662
+ * @param {(ctx: any, ...args: any[]) => boolean} predicate
663
+ * @param {Function} fn - The stream function to wrap
664
+ * @returns {Function}
665
+ */
666
+ live.gate = function gate(predicate, fn) {
667
+ const wrapper = async function gatedWrapper(ctx, ...args) {
668
+ return fn(ctx, ...args);
669
+ };
670
+
671
+ // Copy all metadata from the original function
672
+ /** @type {any} */ (wrapper).__isStream = /** @type {any} */ (fn).__isStream;
673
+ /** @type {any} */ (wrapper).__isLive = /** @type {any} */ (fn).__isLive;
674
+ /** @type {any} */ (wrapper).__streamTopic = /** @type {any} */ (fn).__streamTopic;
675
+ /** @type {any} */ (wrapper).__streamOptions = /** @type {any} */ (fn).__streamOptions;
676
+ /** @type {any} */ (wrapper).__isGated = true;
677
+ /** @type {any} */ (wrapper).__gatePredicate = predicate;
678
+ if (/** @type {any} */ (fn).__replay) /** @type {any} */ (wrapper).__replay = /** @type {any} */ (fn).__replay;
679
+ if (/** @type {any} */ (fn).__delta) /** @type {any} */ (wrapper).__delta = /** @type {any} */ (fn).__delta;
680
+ if (/** @type {any} */ (fn).__onSubscribe) /** @type {any} */ (wrapper).__onSubscribe = /** @type {any} */ (fn).__onSubscribe;
681
+ if (/** @type {any} */ (fn).__onUnsubscribe) /** @type {any} */ (wrapper).__onUnsubscribe = /** @type {any} */ (fn).__onUnsubscribe;
682
+ if (/** @type {any} */ (fn).__streamFilter) /** @type {any} */ (wrapper).__streamFilter = /** @type {any} */ (fn).__streamFilter;
683
+
684
+ return wrapper;
685
+ };
686
+
687
+ /**
688
+ * Compose stream transforms that apply to initial data (and optionally live events).
689
+ *
690
+ * @param {Function} stream - The stream function to wrap
691
+ * @param {...{ transformInit?: Function, transformEvent?: Function }} transforms
692
+ * @returns {Function}
693
+ */
694
+ export function pipe(stream, ...transforms) {
695
+ const wrapper = async function pipedWrapper(ctx, ...args) {
696
+ let data = await stream(ctx, ...args);
697
+
698
+ // Handle paginated responses
699
+ let isPaginated = false;
700
+ let paginationMeta = {};
701
+ if (data && typeof data === 'object' && !Array.isArray(data) && 'data' in data && 'hasMore' in data) {
702
+ isPaginated = true;
703
+ paginationMeta = { hasMore: data.hasMore, cursor: data.cursor };
704
+ data = data.data;
705
+ }
706
+
707
+ // Apply each transform to the initial data
708
+ for (const t of transforms) {
709
+ if (t.transformInit) {
710
+ data = await t.transformInit(data, ctx);
711
+ }
712
+ }
713
+
714
+ if (isPaginated) {
715
+ return { data, ...paginationMeta };
716
+ }
717
+ return data;
718
+ };
719
+
720
+ // Copy all metadata from the original stream
721
+ /** @type {any} */ (wrapper).__isStream = /** @type {any} */ (stream).__isStream;
722
+ /** @type {any} */ (wrapper).__isLive = /** @type {any} */ (stream).__isLive;
723
+ /** @type {any} */ (wrapper).__streamTopic = /** @type {any} */ (stream).__streamTopic;
724
+ /** @type {any} */ (wrapper).__streamOptions = /** @type {any} */ (stream).__streamOptions;
725
+ if (/** @type {any} */ (stream).__replay) /** @type {any} */ (wrapper).__replay = /** @type {any} */ (stream).__replay;
726
+ if (/** @type {any} */ (stream).__delta) /** @type {any} */ (wrapper).__delta = /** @type {any} */ (stream).__delta;
727
+ if (/** @type {any} */ (stream).__onSubscribe) /** @type {any} */ (wrapper).__onSubscribe = /** @type {any} */ (stream).__onSubscribe;
728
+ if (/** @type {any} */ (stream).__onUnsubscribe) /** @type {any} */ (wrapper).__onUnsubscribe = /** @type {any} */ (stream).__onUnsubscribe;
729
+ if (/** @type {any} */ (stream).__isGated) {
730
+ /** @type {any} */ (wrapper).__isGated = true;
731
+ /** @type {any} */ (wrapper).__gatePredicate = /** @type {any} */ (stream).__gatePredicate;
732
+ }
733
+
734
+ // Preserve subscribe-time access predicate from the underlying stream
735
+ if (/** @type {any} */ (stream).__streamFilter) {
736
+ /** @type {any} */ (wrapper).__streamFilter = /** @type {any} */ (stream).__streamFilter;
737
+ }
738
+
739
+ return wrapper;
740
+ }
741
+
742
+ /**
743
+ * Filter transform: removes items that don't match the predicate.
744
+ * @param {(ctx: any, item: any) => boolean} predicate
745
+ * @returns {{ transformInit: Function, transformEvent: Function }}
746
+ */
747
+ pipe.filter = function pipeFilter(predicate) {
748
+ return {
749
+ transformInit(data, ctx) {
750
+ if (!Array.isArray(data)) return data;
751
+ return data.filter(item => predicate(ctx, item));
752
+ },
753
+ transformEvent(ctx, event, data) {
754
+ return predicate(ctx, data);
755
+ }
756
+ };
757
+ };
758
+
759
+ /**
760
+ * Sort transform: sorts initial data by a field.
761
+ * @param {string} field
762
+ * @param {'asc' | 'desc'} [direction]
763
+ * @returns {{ transformInit: Function }}
764
+ */
765
+ pipe.sort = function pipeSort(field, direction = 'asc') {
766
+ return {
767
+ transformInit(data) {
768
+ if (!Array.isArray(data)) return data;
769
+ return [...data].sort((a, b) => {
770
+ const va = a[field], vb = b[field];
771
+ if (va < vb) return direction === 'asc' ? -1 : 1;
772
+ if (va > vb) return direction === 'asc' ? 1 : -1;
773
+ return 0;
774
+ });
775
+ }
776
+ };
777
+ };
778
+
779
+ /**
780
+ * Limit transform: caps the number of initial data items.
781
+ * @param {number} n
782
+ * @returns {{ transformInit: Function }}
783
+ */
784
+ pipe.limit = function pipeLimit(n) {
785
+ return {
786
+ transformInit(data) {
787
+ if (!Array.isArray(data)) return data;
788
+ return data.slice(0, n);
789
+ }
790
+ };
791
+ };
792
+
793
+ /**
794
+ * Join transform: enriches each item by resolving a field via an async function.
795
+ * @param {string} field - Field to look up
796
+ * @param {(value: any) => Promise<any>} resolver - Async resolver
797
+ * @param {string} as - Field name to attach the resolved value
798
+ * @returns {{ transformInit: Function }}
799
+ */
800
+ pipe.join = function pipeJoin(field, resolver, as) {
801
+ return {
802
+ async transformInit(data) {
803
+ if (!Array.isArray(data)) return data;
804
+ return Promise.all(data.map(async (item) => {
805
+ const resolved = await resolver(item[field]);
806
+ return { ...item, [as]: resolved };
807
+ }));
808
+ }
809
+ };
810
+ };
811
+
812
+ /**
813
+ * Create a collaborative room that bundles data stream, presence, cursors, and room-scoped RPC.
814
+ *
815
+ * @param {{ topic: (ctx: any, ...args: any[]) => string, init: (ctx: any, ...args: any[]) => Promise<any>, presence?: (ctx: any) => any, cursors?: boolean | { throttle?: number }, actions?: Record<string, Function>, guard?: Function, onJoin?: Function, onLeave?: Function, merge?: string, key?: string }} config
816
+ * @returns {any}
817
+ */
818
+ live.room = function room(config) {
819
+ const {
820
+ topic: topicFn,
821
+ init: initFn,
822
+ presence: presenceFn,
823
+ cursors: cursorConfig,
824
+ actions,
825
+ guard: guardFn,
826
+ onJoin,
827
+ onLeave,
828
+ merge: mergeMode = 'crud',
829
+ key: keyField = 'id'
830
+ } = config;
831
+
832
+ // The room is exposed as a collection of live functions that the Vite plugin
833
+ // will detect and register. We return an object with __isRoom = true and
834
+ // the necessary metadata for the Vite plugin to generate correct client stubs.
835
+ const roomExport = {};
836
+
837
+ // Data stream
838
+ const dataStream = live.stream(topicFn, async function roomInit(ctx, ...args) {
839
+ if (guardFn) await guardFn(ctx, ...args);
840
+ if (onJoin) {
841
+ try { await onJoin(ctx, ...args); } catch {}
842
+ }
843
+ return initFn(ctx, ...args);
844
+ }, {
845
+ merge: mergeMode,
846
+ key: keyField,
847
+ onSubscribe: presenceFn ? (ctx, topic) => {
848
+ const presenceData = presenceFn(ctx);
849
+ if (presenceData) {
850
+ ctx.publish(topic + ':presence', 'join', { key: ctx.user?.id || 'anon', data: presenceData });
851
+ }
852
+ } : undefined,
853
+ onUnsubscribe: presenceFn ? (ctx, topic) => {
854
+ ctx.publish(topic + ':presence', 'leave', { key: ctx.user?.id || 'anon' });
855
+ if (onLeave) {
856
+ try { onLeave(ctx); } catch {}
857
+ }
858
+ } : undefined
859
+ });
860
+
861
+ /** @type {any} */ (roomExport).__isRoom = true;
862
+ /** @type {any} */ (roomExport).__dataStream = dataStream;
863
+ /** @type {any} */ (roomExport).__topicFn = topicFn;
864
+ /** @type {any} */ (roomExport).__hasPresence = !!presenceFn;
865
+ /** @type {any} */ (roomExport).__hasCursors = !!cursorConfig;
866
+ /** @type {any} */ (roomExport).__cursorThrottle = typeof cursorConfig === 'object' ? cursorConfig.throttle || 50 : 50;
867
+
868
+ // Presence stream (if enabled)
869
+ if (presenceFn) {
870
+ /** @type {any} */ (roomExport).__presenceStream = live.stream(
871
+ (ctx, ...args) => topicFn(ctx, ...args) + ':presence',
872
+ async (ctx, ...args) => [],
873
+ { merge: 'presence' }
874
+ );
875
+ }
876
+
877
+ // Cursor stream (if enabled)
878
+ if (cursorConfig) {
879
+ /** @type {any} */ (roomExport).__cursorStream = live.stream(
880
+ (ctx, ...args) => topicFn(ctx, ...args) + ':cursors',
881
+ async (ctx, ...args) => [],
882
+ { merge: 'cursor' }
883
+ );
884
+ }
885
+
886
+ // Room-scoped actions
887
+ if (actions) {
888
+ /** @type {any} */ (roomExport).__actions = {};
889
+ for (const [name, fn] of Object.entries(actions)) {
890
+ const wrappedAction = live(async function roomAction(ctx, ...args) {
891
+ if (guardFn) await guardFn(ctx, ...args);
892
+ // Scope ctx.publish to the room's topic
893
+ const roomTopic = topicFn(ctx, ...args);
894
+ const originalPublish = ctx.publish;
895
+ ctx.publish = (event, data) => originalPublish(roomTopic, event, data);
896
+ try {
897
+ return await fn(ctx, ...args);
898
+ } finally {
899
+ ctx.publish = originalPublish;
900
+ }
901
+ });
902
+ /** @type {any} */ (roomExport).__actions[name] = wrappedAction;
903
+ }
904
+ }
905
+
906
+ return roomExport;
907
+ };
908
+
909
+ /**
910
+ * Register a derived stream. Called by the Vite-generated registry module.
911
+ * @param {string} path
912
+ * @param {Function} fn
913
+ */
914
+ export function __registerDerived(path, fn) {
915
+ if (/** @type {any} */ (fn).__lazy) {
916
+ _lazyQueue.push({ type: 'derived', path, loader: fn });
917
+ return;
918
+ }
919
+ const sources = /** @type {any} */ (fn).__derivedSources;
920
+ const topic = /** @type {any} */ (fn).__streamTopic;
921
+ const debounce = /** @type {any} */ (fn).__derivedDebounce || 0;
922
+ if (!sources || !topic) return;
923
+ derivedRegistry.set(path, { sources, fn, topic, debounce, timer: null });
924
+ for (const src of sources) {
925
+ let set = _derivedBySource.get(src);
926
+ if (!set) { set = new Set(); _derivedBySource.set(src, set); }
927
+ set.add(derivedRegistry.get(path));
928
+ _watchedTopics.add(src);
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Activate derived stream listeners. Call after platform is available.
934
+ * Source topics are watched via a simple polling mechanism or should be
935
+ * triggered externally when the platform fires publish.
936
+ * @param {import('svelte-adapter-uws').Platform} platform
937
+ */
938
+ /** @type {WeakSet<object>} Guard against double-wrapping platform.publish during HMR */
939
+ const _activatedPlatforms = new WeakSet();
940
+
941
+ export function _activateDerived(platform) {
942
+ if (_activatedPlatforms.has(platform)) return;
943
+
944
+ // Only wrap platform.publish if there are actual reactive registrations
945
+ if (_derivedBySource.size === 0 && _effectBySource.size === 0 && _aggregateBySource.size === 0) {
946
+ return;
947
+ }
948
+
949
+ _activatedPlatforms.add(platform);
950
+
951
+ const originalPublish = platform.publish.bind(platform);
952
+
953
+ platform.publish = function derivedPublish(topic, event, data, opts) {
954
+ const result = originalPublish(topic, event, data, opts);
955
+
956
+ if (!_watchedTopics.has(topic)) return result;
957
+
958
+ // Check if any derived stream watches this topic
959
+ const derivedEntries = _derivedBySource.get(topic);
960
+ if (derivedEntries) {
961
+ for (const entry of derivedEntries) {
962
+ if (entry.debounce > 0) {
963
+ if (entry.timer) clearTimeout(entry.timer);
964
+ entry.timer = setTimeout(() => {
965
+ entry.timer = null;
966
+ _recomputeDerived(entry, platform);
967
+ }, entry.debounce);
968
+ } else {
969
+ _recomputeDerived(entry, platform);
970
+ }
971
+ }
972
+ }
973
+
974
+ // Fire matching effects
975
+ const effectEntries = _effectBySource.get(topic);
976
+ if (effectEntries) {
977
+ for (const entry of effectEntries) {
978
+ if (entry.debounce > 0) {
979
+ if (entry.timer) clearTimeout(entry.timer);
980
+ entry.timer = setTimeout(() => {
981
+ entry.timer = null;
982
+ _fireEffect(entry, event, data, platform);
983
+ }, entry.debounce);
984
+ } else {
985
+ // Fire-and-forget: don't block the publish path
986
+ Promise.resolve().then(() => _fireEffect(entry, event, data, platform));
987
+ }
988
+ }
989
+ }
990
+
991
+ // Run matching aggregates
992
+ const aggregateEntries = _aggregateBySource.get(topic);
993
+ if (aggregateEntries) {
994
+ for (const entry of aggregateEntries) {
995
+ // Apply reducers
996
+ for (const [field, reducer] of Object.entries(entry.reducers)) {
997
+ if (reducer.reduce) {
998
+ entry.state[field] = reducer.reduce(entry.state[field], event, data);
999
+ }
1000
+ }
1001
+
1002
+ const computed = _computeAggregateState(entry.state, entry.reducers);
1003
+
1004
+ if (entry.debounce > 0) {
1005
+ if (entry.timer) clearTimeout(entry.timer);
1006
+ entry.timer = setTimeout(() => {
1007
+ entry.timer = null;
1008
+ originalPublish(entry.topic, 'set', computed);
1009
+ }, entry.debounce);
1010
+ } else {
1011
+ originalPublish(entry.topic, 'set', computed);
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ return result;
1017
+ };
1018
+ }
1019
+
1020
+ /**
1021
+ * Recompute a derived stream and publish the result.
1022
+ * @param {{ fn: Function, topic: string }} entry
1023
+ * @param {import('svelte-adapter-uws').Platform} platform
1024
+ */
1025
+ async function _recomputeDerived(entry, platform) {
1026
+ try {
1027
+ const result = await entry.fn();
1028
+ platform.publish(entry.topic, 'set', result);
1029
+ } catch (err) {
1030
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1031
+ console.error(`[svelte-realtime] Derived stream '${entry.topic}' error:`, err);
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ /**
1037
+ * Fire an effect handler. Errors are caught and routed to the error handler.
1038
+ * @param {{ fn: Function }} entry
1039
+ * @param {string} event
1040
+ * @param {any} data
1041
+ * @param {import('svelte-adapter-uws').Platform} platform
1042
+ */
1043
+ async function _fireEffect(entry, event, data, platform) {
1044
+ try {
1045
+ await entry.fn(event, data, platform);
1046
+ } catch (err) {
1047
+ if (_cronErrorHandler) {
1048
+ try { _cronErrorHandler('effect', err); } catch {}
1049
+ } else if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1050
+ console.error('[svelte-realtime] Effect error:', err);
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Register a cron job. Called by the Vite-generated registry module.
1057
+ * @param {string} path
1058
+ * @param {Function} fn
1059
+ */
1060
+ export function __registerCron(path, fn) {
1061
+ if (/** @type {any} */ (fn).__lazy) {
1062
+ _lazyQueue.push({ type: 'cron', path, loader: fn });
1063
+ _ensureCronInterval();
1064
+ return;
1065
+ }
1066
+ const parsed = /** @type {any} */ (fn).__cronParsed;
1067
+ const topic = /** @type {any} */ (fn).__cronTopic;
1068
+ if (!parsed || !topic) return;
1069
+ cronRegistry.set(path, { schedule: parsed, fn, topic });
1070
+ _ensureCronInterval();
1071
+ }
1072
+
1073
+ /**
1074
+ * Capture a platform reference for cron jobs.
1075
+ * Call this in your `open` hook or pass to `handleRpc`.
1076
+ * @param {import('svelte-adapter-uws').Platform} platform
1077
+ */
1078
+ export function setCronPlatform(platform) {
1079
+ _cronPlatform = platform;
1080
+ }
1081
+
1082
+ /** @type {ReturnType<typeof setTimeout> | null} */
1083
+ let _cronStartupTimer = null;
1084
+
1085
+ function _ensureCronInterval() {
1086
+ if (_cronInterval) return;
1087
+ // Set sentinel immediately to prevent duplicate timers from concurrent calls
1088
+ _cronInterval = /** @type {any} */ (-1);
1089
+ _cronInterval = setInterval(_tickCron, 60000);
1090
+ // Run an initial tick after a short delay to catch jobs on startup
1091
+ _cronStartupTimer = setTimeout(_tickCron, 1000);
1092
+ }
1093
+
1094
+ /**
1095
+ * Queue of deferred registrations for cron/derived/effect/aggregate/room-actions.
1096
+ * Populated when lazy loaders are passed to __registerCron, __registerDerived, etc.
1097
+ * Resolved on first RPC call or cron tick via _resolveAllLazy().
1098
+ * @type {Array<{ type: string, path: string, loader: Function }>}
1099
+ */
1100
+ const _lazyQueue = [];
1101
+
1102
+ /** @type {Promise<void> | null} */
1103
+ let _lazyInitPromise = null;
1104
+
1105
+ /**
1106
+ * Resolve all deferred (lazy) cron/derived/effect/aggregate/room-action registrations.
1107
+ * Safe to call multiple times -- only the first call does work, concurrent callers
1108
+ * await the same promise.
1109
+ */
1110
+ async function _resolveAllLazy() {
1111
+ if (_lazyInitPromise) return _lazyInitPromise;
1112
+ if (_lazyQueue.length === 0) return;
1113
+ _lazyInitPromise = (async () => {
1114
+ const queue = _lazyQueue.splice(0);
1115
+ for (const { type, path, loader } of queue) {
1116
+ try {
1117
+ const fn = await loader();
1118
+ if (!fn) continue;
1119
+ switch (type) {
1120
+ case 'cron':
1121
+ __registerCron(path, fn);
1122
+ break;
1123
+ case 'derived':
1124
+ __register(path, fn);
1125
+ __registerDerived(path, fn);
1126
+ break;
1127
+ case 'effect':
1128
+ __registerEffect(path, fn);
1129
+ break;
1130
+ case 'aggregate':
1131
+ __register(path, fn);
1132
+ __registerAggregate(path, fn);
1133
+ break;
1134
+ case 'room-actions':
1135
+ if (/** @type {any} */ (fn).__actions) {
1136
+ for (const [k, v] of Object.entries(/** @type {any} */ (fn).__actions)) {
1137
+ registry.set(path + '/__action/' + k, v);
1138
+ }
1139
+ }
1140
+ break;
1141
+ }
1142
+ } catch (err) {
1143
+ console.error(`[svelte-realtime] Failed to resolve lazy registration for '${path}':`, err);
1144
+ }
1145
+ }
1146
+ })();
1147
+ return _lazyInitPromise;
1148
+ }
1149
+
1150
+ /**
1151
+ * Register room actions lazily. Called by the Vite-generated registry module
1152
+ * when a live.room() export needs its __actions registered.
1153
+ * @param {string} basePath
1154
+ * @param {Function} loader - Lazy loader that resolves to the room export
1155
+ */
1156
+ export function __registerRoomActions(basePath, loader) {
1157
+ _lazyQueue.push({ type: 'room-actions', path: basePath, loader });
1158
+ }
1159
+
1160
+ /**
1161
+ * Clear all cron timers. Called during HMR to prevent orphan intervals.
1162
+ */
1163
+ export function _clearCron() {
1164
+ if (_cronInterval) {
1165
+ clearInterval(_cronInterval);
1166
+ _cronInterval = null;
1167
+ }
1168
+ if (_cronStartupTimer) {
1169
+ clearTimeout(_cronStartupTimer);
1170
+ _cronStartupTimer = null;
1171
+ }
1172
+ cronRegistry.clear();
1173
+ }
1174
+
1175
+ /**
1176
+ * Snapshot and clear all registries for HMR. Returns a snapshot that can be
1177
+ * passed to `_restoreHmr()` if the re-import fails, so old handlers survive
1178
+ * a syntax error in the edited file.
1179
+ * @returns {object}
1180
+ */
1181
+ export function _prepareHmr() {
1182
+ const snap = {
1183
+ registry: new Map(registry),
1184
+ guards: new Map(guards),
1185
+ cron: new Map(cronRegistry),
1186
+ derived: new Map(derivedRegistry),
1187
+ effects: new Map(effectRegistry),
1188
+ aggregates: new Map(aggregateRegistry),
1189
+ hadCron: _cronInterval !== null,
1190
+ };
1191
+
1192
+ // Clear debounce timers
1193
+ for (const e of derivedRegistry.values()) { if (e.timer) clearTimeout(e.timer); }
1194
+ for (const e of effectRegistry.values()) { if (e.timer) clearTimeout(e.timer); }
1195
+ for (const e of aggregateRegistry.values()) { if (e.timer) clearTimeout(e.timer); }
1196
+
1197
+ // Clear cron timers (but keep _cronPlatform -- it stays valid across HMR)
1198
+ _clearCron();
1199
+
1200
+ // Clear lazy queue and reset lazy init state
1201
+ _lazyQueue.length = 0;
1202
+ _lazyInitPromise = null;
1203
+
1204
+ // Clear all registries and lookup maps
1205
+ registry.clear();
1206
+ guards.clear();
1207
+ derivedRegistry.clear();
1208
+ effectRegistry.clear();
1209
+ aggregateRegistry.clear();
1210
+ _derivedBySource.clear();
1211
+ _effectBySource.clear();
1212
+ _aggregateBySource.clear();
1213
+ _aggregateByTopic.clear();
1214
+ _watchedTopics.clear();
1215
+ _streamsWithUnsubscribe.clear();
1216
+
1217
+ return snap;
1218
+ }
1219
+
1220
+ /**
1221
+ * Restore registries from a snapshot produced by `_prepareHmr()`.
1222
+ * Called when re-import fails so the server keeps working with old handlers.
1223
+ * @param {object} snap
1224
+ */
1225
+ export function _restoreHmr(snap) {
1226
+ for (const [k, v] of snap.registry) {
1227
+ registry.set(k, v);
1228
+ if (/** @type {any} */ (v).__isStream && /** @type {any} */ (v).__onUnsubscribe) {
1229
+ _streamsWithUnsubscribe.add(v);
1230
+ }
1231
+ }
1232
+ for (const [k, v] of snap.guards) guards.set(k, v);
1233
+
1234
+ // Restore cron
1235
+ for (const [k, v] of snap.cron) cronRegistry.set(k, v);
1236
+ if (snap.hadCron && cronRegistry.size > 0) _ensureCronInterval();
1237
+
1238
+ // Restore derived (rebuild source maps from entries)
1239
+ for (const [k, v] of snap.derived) {
1240
+ v.timer = null;
1241
+ derivedRegistry.set(k, v);
1242
+ for (const src of v.sources) {
1243
+ let set = _derivedBySource.get(src);
1244
+ if (!set) { set = new Set(); _derivedBySource.set(src, set); }
1245
+ set.add(v);
1246
+ _watchedTopics.add(src);
1247
+ }
1248
+ }
1249
+
1250
+ // Restore effects
1251
+ for (const [k, v] of snap.effects) {
1252
+ v.timer = null;
1253
+ effectRegistry.set(k, v);
1254
+ for (const src of v.sources) {
1255
+ let set = _effectBySource.get(src);
1256
+ if (!set) { set = new Set(); _effectBySource.set(src, set); }
1257
+ set.add(v);
1258
+ _watchedTopics.add(src);
1259
+ }
1260
+ }
1261
+
1262
+ // Restore aggregates
1263
+ for (const [k, v] of snap.aggregates) {
1264
+ v.timer = null;
1265
+ aggregateRegistry.set(k, v);
1266
+ _aggregateByTopic.set(v.topic, v);
1267
+ let srcSet = _aggregateBySource.get(v.source);
1268
+ if (!srcSet) { srcSet = new Set(); _aggregateBySource.set(v.source, srcSet); }
1269
+ srcSet.add(v);
1270
+ _watchedTopics.add(v.source);
1271
+ }
1272
+ }
1273
+
1274
+ async function _tickCron() {
1275
+ if (_lazyQueue.length) await _resolveAllLazy();
1276
+ const now = new Date();
1277
+ const minute = now.getMinutes();
1278
+ const hour = now.getHours();
1279
+ const day = now.getDate();
1280
+ const month = now.getMonth() + 1;
1281
+ const weekday = now.getDay();
1282
+
1283
+ for (const [path, entry] of cronRegistry) {
1284
+ const [mf, hf, df, monthf, wf] = entry.schedule;
1285
+ if (!_cronFieldMatch(mf, minute)) continue;
1286
+ if (!_cronFieldMatch(hf, hour)) continue;
1287
+ if (!_cronFieldMatch(df, day)) continue;
1288
+ if (!_cronFieldMatch(monthf, month)) continue;
1289
+ if (!_cronFieldMatch(wf, weekday)) continue;
1290
+
1291
+ // Match - run the job
1292
+ (async () => {
1293
+ try {
1294
+ if (!_cronPlatform) {
1295
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1296
+ console.warn(`[svelte-realtime] Cron '${path}' fired but no platform captured. Call setCronPlatform(platform) in your open hook.`);
1297
+ }
1298
+ return;
1299
+ }
1300
+ const result = await entry.fn();
1301
+ _cronPlatform.publish(entry.topic, 'set', result);
1302
+ } catch (err) {
1303
+ if (_cronErrorHandler) {
1304
+ _cronErrorHandler(path, err);
1305
+ } else if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1306
+ console.error(`[svelte-realtime] Cron '${path}' error:`, err);
1307
+ }
1308
+ }
1309
+ })();
1310
+ }
1311
+ }
1312
+
1313
+ /**
1314
+ * Parse a 5-field cron expression into an array of field matchers.
1315
+ * Supports: *, N, N-M, N,M, and *\/N
1316
+ * @param {string} expr
1317
+ * @returns {any[]}
1318
+ */
1319
+ function _parseCron(expr) {
1320
+ const parts = expr.trim().split(/\s+/);
1321
+ if (parts.length !== 5) {
1322
+ throw new Error(`[svelte-realtime] Invalid cron expression '${expr}' -- expected 5 fields (minute hour day month weekday)`);
1323
+ }
1324
+ return parts.map(_parseCronField);
1325
+ }
1326
+
1327
+ /**
1328
+ * Parse a single cron field.
1329
+ * Returns null for '*' (match all), or an array/Set of allowed values.
1330
+ * For step values, returns { step: N }.
1331
+ * @param {string} field
1332
+ * @returns {any}
1333
+ */
1334
+ function _parseCronField(field) {
1335
+ if (field === '*') return null; // match all
1336
+
1337
+ // Step: */N
1338
+ if (field.startsWith('*/')) {
1339
+ return { step: parseInt(field.slice(2), 10) };
1340
+ }
1341
+
1342
+ // Range: N-M
1343
+ if (field.includes('-') && !field.includes(',')) {
1344
+ const [a, b] = field.split('-').map(Number);
1345
+ const vals = new Set();
1346
+ for (let i = a; i <= b; i++) vals.add(i);
1347
+ return vals;
1348
+ }
1349
+
1350
+ // List: N,M,P
1351
+ if (field.includes(',')) {
1352
+ return new Set(field.split(',').map(Number));
1353
+ }
1354
+
1355
+ // Single value
1356
+ return new Set([parseInt(field, 10)]);
1357
+ }
1358
+
1359
+ /**
1360
+ * Check if a value matches a cron field matcher.
1361
+ * @param {any} matcher
1362
+ * @param {number} value
1363
+ * @returns {boolean}
1364
+ */
1365
+ function _cronFieldMatch(matcher, value) {
1366
+ if (matcher === null) return true; // * matches all
1367
+ if (matcher.step) return value % matcher.step === 0;
1368
+ return matcher.has(value);
1369
+ }
1370
+
1371
+ /**
1372
+ * Create a webhook-to-stream bridge.
1373
+ * The Vite plugin detects `live.webhook()` exports and generates a SvelteKit `+server.js` endpoint.
1374
+ *
1375
+ * @param {string} topic - Topic to publish events to
1376
+ * @param {{ verify: (req: { body: string, headers: Record<string, string> }) => any, transform: (event: any) => { event: string, data: any } | null }} config
1377
+ * @returns {any}
1378
+ */
1379
+ live.webhook = function webhook(topic, config) {
1380
+ const handler = {
1381
+ __isWebhook: true,
1382
+ __webhookTopic: topic,
1383
+ __verify: config.verify,
1384
+ __transform: config.transform,
1385
+
1386
+ /**
1387
+ * Handle an incoming webhook request.
1388
+ * Call this from a SvelteKit +server.js POST handler.
1389
+ *
1390
+ * @param {{ body: string, headers: Record<string, string>, platform: any }} req
1391
+ * @returns {{ status: number, body?: string }}
1392
+ */
1393
+ async handle(req) {
1394
+ let event;
1395
+ try {
1396
+ event = config.verify({ body: req.body, headers: req.headers });
1397
+ } catch {
1398
+ return { status: 400, body: 'Verification failed' };
1399
+ }
1400
+
1401
+ const mapped = config.transform(event);
1402
+ if (!mapped) return { status: 200, body: 'Ignored' };
1403
+
1404
+ if (req.platform) {
1405
+ req.platform.publish(topic, mapped.event, mapped.data);
1406
+ }
1407
+ return { status: 200, body: 'OK' };
1408
+ }
1409
+ };
1410
+
1411
+ return handler;
1412
+ };
1413
+
1414
+ /**
1415
+ * Create a per-module guard. Accepts one or more middleware functions.
1416
+ * When multiple are provided, they run in order — if any throws, the chain stops.
1417
+ * Earlier middleware can enrich `ctx` for later ones.
1418
+ * @param {...Function} fns
1419
+ * @returns {Function}
1420
+ */
1421
+ export function guard(...fns) {
1422
+ if (fns.length === 1) {
1423
+ /** @type {any} */ (fns[0]).__isGuard = true;
1424
+ return fns[0];
1425
+ }
1426
+ const composite = async (ctx) => {
1427
+ for (const fn of fns) {
1428
+ await fn(ctx);
1429
+ }
1430
+ };
1431
+ /** @type {any} */ (composite).__isGuard = true;
1432
+ return composite;
1433
+ }
1434
+
1435
+ /**
1436
+ * Typed error that propagates code to the client.
1437
+ */
1438
+ export class LiveError extends Error {
1439
+ /**
1440
+ * @param {string} code
1441
+ * @param {string} [message]
1442
+ */
1443
+ constructor(code, message) {
1444
+ super(message || code);
1445
+ this.code = code;
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * Check whether a raw WebSocket message is an RPC request and handle it.
1451
+ *
1452
+ * @param {any} ws
1453
+ * @param {ArrayBuffer} data - Raw message data from the adapter message hook
1454
+ * @param {import('svelte-adapter-uws').Platform} platform
1455
+ * @param {{ beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void }} [options]
1456
+ * @returns {boolean} true if the message was an RPC request
1457
+ */
1458
+ export function handleRpc(ws, data, platform, options) {
1459
+ // Auto-capture platform for cron jobs
1460
+ if (!_cronPlatform && cronRegistry.size > 0) _cronPlatform = platform;
1461
+
1462
+ // Fast path: only process ArrayBuffer
1463
+ if (!(data instanceof ArrayBuffer) || data.byteLength < 4) return false;
1464
+ const bytes = new Uint8Array(data);
1465
+
1466
+ // Binary RPC: byte[0] = 0x00, byte[1-2] = header length (uint16 BE)
1467
+ if (bytes[0] === 0x00 && data.byteLength > 3) {
1468
+ const headerLen = (bytes[1] << 8) | bytes[2];
1469
+ if (headerLen > 0 && 3 + headerLen <= data.byteLength) {
1470
+ try {
1471
+ const headerJson = textDecoder.decode(data.slice(3, 3 + headerLen));
1472
+ const header = JSON.parse(headerJson);
1473
+ if (typeof header.rpc === 'string' && typeof header.id === 'string') {
1474
+ const payload = data.slice(3 + headerLen);
1475
+ _executeBinaryRpc(ws, header, payload, platform, options);
1476
+ return true;
1477
+ }
1478
+ } catch {}
1479
+ }
1480
+ return false;
1481
+ }
1482
+
1483
+ // Text RPC: must start with {"r or {"b
1484
+ if (data.byteLength < 10) return false;
1485
+ // byte[0] = '{' (0x7B), byte[1] = '"' (0x22)
1486
+ if (bytes[0] !== 0x7B) return false;
1487
+ // byte[2] = 'r' (0x72) for RPC, or 'b' (0x62) for batch
1488
+ if (bytes[2] !== 0x72 && bytes[2] !== 0x62) return false;
1489
+
1490
+ /** @type {any} */
1491
+ let msg;
1492
+ try {
1493
+ msg = JSON.parse(textDecoder.decode(data));
1494
+ } catch {
1495
+ return false;
1496
+ }
1497
+
1498
+ // Batch request: {"batch": [...]}
1499
+ if (Array.isArray(msg.batch)) {
1500
+ _executeBatch(ws, msg, platform, options);
1501
+ return true;
1502
+ }
1503
+
1504
+ if (typeof msg.rpc !== 'string' || typeof msg.id !== 'string') return false;
1505
+
1506
+ // Validated as RPC - handle asynchronously, return true synchronously
1507
+ _executeRpc(ws, msg, platform, options);
1508
+ return true;
1509
+ }
1510
+
1511
+ /**
1512
+ * @param {any} ws
1513
+ * @param {{ rpc: string, id: string, args?: any[], stream?: boolean, seq?: number, version?: any }} msg
1514
+ * @param {import('svelte-adapter-uws').Platform} platform
1515
+ * @param {{ beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void }} [options]
1516
+ */
1517
+ async function _executeRpc(ws, msg, platform, options) {
1518
+ const { rpc: path, id, args: rawArgs, stream: isStream, seq: clientSeq, cursor: clientCursor, schemaVersion: clientSchemaVersion } = msg;
1519
+
1520
+ if (!_validPathRe.test(path)) {
1521
+ _respond(ws, platform, id, { ok: false, code: 'INVALID_REQUEST', error: 'Invalid path' });
1522
+ return;
1523
+ }
1524
+
1525
+ // Validate args
1526
+ if (rawArgs !== undefined && !Array.isArray(rawArgs)) {
1527
+ _respond(ws, platform, id, { ok: false, code: 'INVALID_REQUEST', error: 'args must be an array' });
1528
+ return;
1529
+ }
1530
+
1531
+ const args = rawArgs || [];
1532
+
1533
+ // Resolve lazy registrations (cron/derived/effect/aggregate) on first call
1534
+ if (_lazyQueue.length) await _resolveAllLazy();
1535
+
1536
+ // Lookup function in registry (resolves lazy loader on first access)
1537
+ const fn = await _resolveRegistryEntry(path);
1538
+ if (!fn) {
1539
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1540
+ console.warn(`[svelte-realtime] RPC call to '${path}' -- no such live function registered`);
1541
+ }
1542
+ _respond(ws, platform, id, { ok: false, code: 'NOT_FOUND', error: 'Not found' });
1543
+ return;
1544
+ }
1545
+
1546
+ // Build context
1547
+ const _h = _getCtxHelpers(platform);
1548
+ const ctx = {
1549
+ user: ws.getUserData(),
1550
+ ws,
1551
+ platform,
1552
+ publish: _h.publish,
1553
+ cursor: clientCursor !== undefined ? clientCursor : null,
1554
+ throttle: _h.throttle,
1555
+ debounce: _h.debounce,
1556
+ signal: _h.signal
1557
+ };
1558
+
1559
+ try {
1560
+ // Run global middleware chain, then guard, then execution
1561
+ await _runWithMiddleware(ctx, async () => {
1562
+ // Run module guard if registered
1563
+ const modulePath = path.substring(0, path.lastIndexOf('/'));
1564
+ const guardFn = await _resolveGuard(modulePath);
1565
+ if (guardFn) await guardFn(ctx);
1566
+
1567
+ // Run beforeExecute hook
1568
+ if (options?.beforeExecute) {
1569
+ await options.beforeExecute(ws, path, args);
1570
+ }
1571
+
1572
+ // Handle stream: subscribe BEFORE loading data (gap-free)
1573
+ if (isStream && /** @type {any} */ (fn).__isStream) {
1574
+ // Gate check: if predicate returns false, respond with gated no-op
1575
+ if (/** @type {any} */ (fn).__isGated) {
1576
+ const predicate = /** @type {any} */ (fn).__gatePredicate;
1577
+ if (!predicate(ctx, ...args)) {
1578
+ _respond(ws, platform, id, { ok: true, data: null, gated: true });
1579
+ return;
1580
+ }
1581
+ }
1582
+
1583
+ const rawTopic = /** @type {any} */ (fn).__streamTopic;
1584
+ const topic = typeof rawTopic === 'function' ? rawTopic(ctx, ...args) : rawTopic;
1585
+ if (typeof rawTopic === 'function' && topic.startsWith('__')) {
1586
+ _respond(ws, platform, id, { ok: false, code: 'INVALID_REQUEST', error: 'Reserved topic prefix' });
1587
+ return;
1588
+ }
1589
+ const streamOpts = /** @type {any} */ (fn).__streamOptions;
1590
+ const replayOpts = /** @type {any} */ (fn).__replay;
1591
+
1592
+ // Enforce stream filter/access predicate before subscribing
1593
+ const streamFilter = /** @type {any} */ (fn).__streamFilter;
1594
+ if (streamFilter && !streamFilter(ctx)) {
1595
+ _respond(ws, platform, id, { ok: false, code: 'FORBIDDEN', error: 'Access denied' });
1596
+ return;
1597
+ }
1598
+
1599
+ try { ws.subscribe(topic); } catch { return; }
1600
+
1601
+ // Track dynamic topic -> stream mapping for accurate onUnsubscribe dispatch
1602
+ if (typeof rawTopic === 'function' && /** @type {any} */ (fn).__onUnsubscribe) {
1603
+ let map = _dynamicSubscriptions.get(ws);
1604
+ if (!map) { map = new Map(); _dynamicSubscriptions.set(ws, map); }
1605
+ map.set(topic, fn);
1606
+ }
1607
+
1608
+ // Fire onSubscribe lifecycle hook
1609
+ if (/** @type {any} */ (fn).__onSubscribe) {
1610
+ try { await /** @type {any} */ (fn).__onSubscribe(ctx, topic); } catch {}
1611
+ }
1612
+
1613
+ // Channel fast-path: no database, respond immediately with empty data
1614
+ if (/** @type {any} */ (fn).__isChannel) {
1615
+ const emptyValue = streamOpts.merge === 'set' ? null : [];
1616
+ _respond(ws, platform, id, {
1617
+ ok: true,
1618
+ data: emptyValue,
1619
+ topic,
1620
+ merge: streamOpts.merge,
1621
+ key: streamOpts.key,
1622
+ max: streamOpts.max
1623
+ });
1624
+ return;
1625
+ }
1626
+
1627
+ // Delta sync: if client sent a version and delta is configured, try to send only changes
1628
+ const deltaOpts = /** @type {any} */ (fn).__delta;
1629
+ const clientVersion = msg.version;
1630
+ if (deltaOpts && clientVersion !== undefined && deltaOpts.version && deltaOpts.diff) {
1631
+ try {
1632
+ const currentVersion = await deltaOpts.version();
1633
+ if (currentVersion === clientVersion) {
1634
+ // Nothing changed -- respond with unchanged flag
1635
+ _respond(ws, platform, id, {
1636
+ ok: true,
1637
+ data: [],
1638
+ topic,
1639
+ merge: streamOpts.merge,
1640
+ key: streamOpts.key,
1641
+ prepend: streamOpts.prepend,
1642
+ max: streamOpts.max,
1643
+ unchanged: true,
1644
+ version: currentVersion
1645
+ });
1646
+ return;
1647
+ }
1648
+ // Version differs -- try to get diff
1649
+ const diff = await deltaOpts.diff(clientVersion);
1650
+ if (diff !== null && diff !== undefined) {
1651
+ _respond(ws, platform, id, {
1652
+ ok: true,
1653
+ data: diff,
1654
+ topic,
1655
+ merge: streamOpts.merge,
1656
+ key: streamOpts.key,
1657
+ prepend: streamOpts.prepend,
1658
+ max: streamOpts.max,
1659
+ delta: true,
1660
+ version: currentVersion
1661
+ });
1662
+ return;
1663
+ }
1664
+ // diff returned null/undefined -- fall through to full refetch
1665
+ } catch {
1666
+ // Delta failed -- fall through to full refetch
1667
+ }
1668
+ }
1669
+
1670
+ // Replay: if client sent a seq and replay is enabled, try to send only missed events
1671
+ if (replayOpts && typeof clientSeq === 'number' && platform.replay) {
1672
+ try {
1673
+ const missed = await platform.replay.since(topic, clientSeq);
1674
+ if (missed) {
1675
+ const currentSeq = await platform.replay.seq(topic);
1676
+ _respond(ws, platform, id, {
1677
+ ok: true,
1678
+ data: missed,
1679
+ topic,
1680
+ merge: streamOpts.merge,
1681
+ key: streamOpts.key,
1682
+ prepend: streamOpts.prepend,
1683
+ max: streamOpts.max,
1684
+ seq: currentSeq,
1685
+ replay: true
1686
+ });
1687
+ return;
1688
+ }
1689
+ } catch {
1690
+ // Fallback to full refetch below
1691
+ }
1692
+ }
1693
+
1694
+ const result = await fn(ctx, ...args);
1695
+
1696
+ // Support paginated responses: initFn can return { data, hasMore, cursor }
1697
+ const isPaginated = result && typeof result === 'object' && !Array.isArray(result) && 'data' in result && 'hasMore' in result;
1698
+ let resultData = isPaginated ? result.data : result;
1699
+
1700
+ // Schema migration: apply migration functions if client version is behind server
1701
+ const serverVersion = /** @type {any} */ (fn).__streamVersion;
1702
+ const migrateFns = /** @type {any} */ (fn).__streamMigrate;
1703
+ if (serverVersion !== undefined && migrateFns && typeof clientSchemaVersion === 'number' && clientSchemaVersion < serverVersion) {
1704
+ resultData = _migrateData(resultData, clientSchemaVersion, serverVersion, migrateFns);
1705
+ }
1706
+
1707
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1708
+ if (streamOpts.merge === 'crud' && !Array.isArray(resultData)) {
1709
+ console.warn(
1710
+ `[svelte-realtime] live.stream '${topic}' initFn returned ${typeof resultData} but merge:'crud' expects an array`
1711
+ );
1712
+ }
1713
+ }
1714
+
1715
+ /** @type {any} */
1716
+ const response = {
1717
+ ok: true,
1718
+ data: resultData,
1719
+ topic,
1720
+ merge: streamOpts.merge,
1721
+ key: streamOpts.key,
1722
+ prepend: streamOpts.prepend,
1723
+ max: streamOpts.max
1724
+ };
1725
+
1726
+ // Include pagination info
1727
+ if (isPaginated) {
1728
+ response.hasMore = result.hasMore;
1729
+ if (result.cursor !== undefined) response.cursor = result.cursor;
1730
+ }
1731
+
1732
+ // Include seq for replay-enabled streams
1733
+ if (replayOpts && platform.replay) {
1734
+ try {
1735
+ response.seq = await platform.replay.seq(topic);
1736
+ } catch {}
1737
+ }
1738
+ if (typeof clientSeq === 'number') {
1739
+ response.replay = false; // Full refetch fallback
1740
+ }
1741
+
1742
+ // Include version for delta-enabled streams (full refetch path)
1743
+ if (deltaOpts && deltaOpts.version) {
1744
+ try {
1745
+ response.version = await deltaOpts.version();
1746
+ } catch {}
1747
+ }
1748
+
1749
+ // Include schema version in response
1750
+ if (serverVersion !== undefined) {
1751
+ response.schemaVersion = serverVersion;
1752
+ }
1753
+
1754
+ _respond(ws, platform, id, response);
1755
+ } else {
1756
+ // Regular RPC
1757
+ const result = await fn(ctx, ...args);
1758
+ _respond(ws, platform, id, { ok: true, data: result });
1759
+ }
1760
+ }); // end _runWithMiddleware
1761
+ } catch (err) {
1762
+ if (err instanceof LiveError) {
1763
+ /** @type {any} */
1764
+ const response = { ok: false, code: err.code, error: err.message };
1765
+ if (/** @type {any} */ (err).issues) response.issues = /** @type {any} */ (err).issues;
1766
+ _respond(ws, platform, id, response);
1767
+ } else {
1768
+ if (options?.onError) {
1769
+ try { options.onError(path, err, ctx); } catch {}
1770
+ }
1771
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1772
+ console.warn(
1773
+ `[svelte-realtime] '${path}' threw a non-LiveError:`,
1774
+ err,
1775
+ '\nUse throw new LiveError(code, message) for client-visible errors. Raw errors are hidden from clients.'
1776
+ );
1777
+ console.error(`[svelte-realtime] Error in '${path}':`, err);
1778
+ }
1779
+ _respond(ws, platform, id, { ok: false, code: 'INTERNAL_ERROR', error: 'Internal server error' });
1780
+ }
1781
+ }
1782
+ }
1783
+
1784
+ /**
1785
+ * Execute a batch of RPC calls. Supports parallel (default) and sequential modes.
1786
+ *
1787
+ * @param {any} ws
1788
+ * @param {{ batch: Array<{ rpc: string, id: string, args?: any[], stream?: boolean }>, sequential?: boolean }} msg
1789
+ * @param {import('svelte-adapter-uws').Platform} platform
1790
+ * @param {{ beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void }} [options]
1791
+ */
1792
+ async function _executeBatch(ws, msg, platform, options) {
1793
+ const { batch, sequential } = msg;
1794
+
1795
+ if (batch.length > 50) {
1796
+ _respond(ws, platform, '__batch', {
1797
+ batch: [{ id: '', ok: false, code: 'INVALID_REQUEST', error: 'Batch exceeds maximum of 50 calls' }]
1798
+ });
1799
+ return;
1800
+ }
1801
+
1802
+ /** @type {Array<{ id: string, ok: boolean, data?: any, code?: string, error?: string }>} */
1803
+ const results = [];
1804
+
1805
+ if (sequential) {
1806
+ for (const call of batch) {
1807
+ if (!call || typeof call.rpc !== 'string' || typeof call.id !== 'string') {
1808
+ results.push({ id: call?.id || '', ok: false, code: 'INVALID_REQUEST', error: 'Each batch entry requires rpc and id' });
1809
+ continue;
1810
+ }
1811
+ results.push(await _executeSingleRpc(ws, call, platform, options));
1812
+ }
1813
+ } else {
1814
+ const promises = batch.map((call) => {
1815
+ if (!call || typeof call.rpc !== 'string' || typeof call.id !== 'string') {
1816
+ return Promise.resolve({ id: call?.id || '', ok: false, code: 'INVALID_REQUEST', error: 'Each batch entry requires rpc and id' });
1817
+ }
1818
+ return _executeSingleRpc(ws, call, platform, options);
1819
+ });
1820
+ results.push(...(await Promise.all(promises)));
1821
+ }
1822
+
1823
+ _respond(ws, platform, '__batch', { batch: results });
1824
+ }
1825
+
1826
+ /**
1827
+ * Execute a single RPC call and return the result (used by batch and single execution).
1828
+ *
1829
+ * @param {any} ws
1830
+ * @param {{ rpc: string, id: string, args?: any[], stream?: boolean }} msg
1831
+ * @param {import('svelte-adapter-uws').Platform} platform
1832
+ * @param {{ beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void }} [options]
1833
+ * @returns {Promise<{ id: string, ok: boolean, data?: any, code?: string, error?: string }>}
1834
+ */
1835
+ async function _executeSingleRpc(ws, msg, platform, options) {
1836
+ const { rpc: path, id, args: rawArgs, stream: isStream } = msg;
1837
+
1838
+ if (!_validPathRe.test(path)) {
1839
+ return { id, ok: false, code: 'INVALID_REQUEST', error: 'Invalid path' };
1840
+ }
1841
+
1842
+ if (rawArgs !== undefined && !Array.isArray(rawArgs)) {
1843
+ return { id, ok: false, code: 'INVALID_REQUEST', error: 'args must be an array' };
1844
+ }
1845
+
1846
+ const args = rawArgs || [];
1847
+ const fn = await _resolveRegistryEntry(path);
1848
+ if (!fn) {
1849
+ return { id, ok: false, code: 'NOT_FOUND', error: 'Not found' };
1850
+ }
1851
+
1852
+ const _h = _getCtxHelpers(platform);
1853
+ const ctx = {
1854
+ user: ws.getUserData(),
1855
+ ws,
1856
+ platform,
1857
+ publish: _h.publish,
1858
+ throttle: _h.throttle,
1859
+ debounce: _h.debounce,
1860
+ signal: _h.signal
1861
+ };
1862
+
1863
+ try {
1864
+ return await _runWithMiddleware(ctx, async () => {
1865
+ const modulePath = path.substring(0, path.lastIndexOf('/'));
1866
+ const guardFn = await _resolveGuard(modulePath);
1867
+ if (guardFn) await guardFn(ctx);
1868
+
1869
+ if (options?.beforeExecute) {
1870
+ await options.beforeExecute(ws, path, args);
1871
+ }
1872
+
1873
+ if (isStream && /** @type {any} */ (fn).__isStream) {
1874
+ // Gate check: if predicate returns false, respond with gated no-op
1875
+ if (/** @type {any} */ (fn).__isGated) {
1876
+ const predicate = /** @type {any} */ (fn).__gatePredicate;
1877
+ if (!predicate(ctx, ...args)) {
1878
+ return { id, ok: true, data: null, gated: true };
1879
+ }
1880
+ }
1881
+
1882
+ const rawTopic = /** @type {any} */ (fn).__streamTopic;
1883
+ const topic = typeof rawTopic === 'function' ? rawTopic(ctx, ...args) : rawTopic;
1884
+ if (typeof rawTopic === 'function' && topic.startsWith('__')) {
1885
+ return { id, ok: false, code: 'INVALID_REQUEST', error: 'Reserved topic prefix' };
1886
+ }
1887
+ const streamOpts = /** @type {any} */ (fn).__streamOptions;
1888
+
1889
+ // Enforce stream filter/access predicate before subscribing
1890
+ const streamFilter = /** @type {any} */ (fn).__streamFilter;
1891
+ if (streamFilter && !streamFilter(ctx)) {
1892
+ return { id, ok: false, code: 'FORBIDDEN', error: 'Access denied' };
1893
+ }
1894
+
1895
+ try { ws.subscribe(topic); } catch { return { id, ok: false, code: 'CONNECTION_CLOSED', error: 'WebSocket closed' }; }
1896
+
1897
+ // Track dynamic topic -> stream mapping for accurate onUnsubscribe dispatch
1898
+ if (typeof rawTopic === 'function' && /** @type {any} */ (fn).__onUnsubscribe) {
1899
+ let map = _dynamicSubscriptions.get(ws);
1900
+ if (!map) { map = new Map(); _dynamicSubscriptions.set(ws, map); }
1901
+ map.set(topic, fn);
1902
+ }
1903
+
1904
+ const result = await fn(ctx, ...args);
1905
+ return {
1906
+ id, ok: true, data: result, topic, merge: streamOpts.merge,
1907
+ key: streamOpts.key, prepend: streamOpts.prepend, max: streamOpts.max
1908
+ };
1909
+ } else {
1910
+ const result = await fn(ctx, ...args);
1911
+ return { id, ok: true, data: result };
1912
+ }
1913
+ }); // end _runWithMiddleware
1914
+ } catch (err) {
1915
+ if (err instanceof LiveError) {
1916
+ /** @type {any} */
1917
+ const result = { id, ok: false, code: err.code, error: err.message };
1918
+ if (/** @type {any} */ (err).issues) result.issues = /** @type {any} */ (err).issues;
1919
+ return result;
1920
+ }
1921
+ if (options?.onError) {
1922
+ try { options.onError(path, err, ctx); } catch {}
1923
+ }
1924
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1925
+ console.warn(
1926
+ `[svelte-realtime] '${path}' threw a non-LiveError:`,
1927
+ err,
1928
+ '\nUse throw new LiveError(code, message) for client-visible errors. Raw errors are hidden from clients.'
1929
+ );
1930
+ console.error(`[svelte-realtime] Error in '${path}':`, err);
1931
+ }
1932
+ return { id, ok: false, code: 'INTERNAL_ERROR', error: 'Internal server error' };
1933
+ }
1934
+ }
1935
+
1936
+ /**
1937
+ * Execute a binary RPC call.
1938
+ *
1939
+ * @param {any} ws
1940
+ * @param {{ rpc: string, id: string, args?: any[] }} header
1941
+ * @param {ArrayBuffer} payload - Raw binary data
1942
+ * @param {import('svelte-adapter-uws').Platform} platform
1943
+ * @param {{ beforeExecute?: Function, onError?: Function }} [options]
1944
+ */
1945
+ async function _executeBinaryRpc(ws, header, payload, platform, options) {
1946
+ const { rpc: path, id, args: extraArgs } = header;
1947
+
1948
+ if (!_validPathRe.test(path)) {
1949
+ _respond(ws, platform, id, { ok: false, code: 'INVALID_REQUEST', error: 'Invalid path' });
1950
+ return;
1951
+ }
1952
+
1953
+ if (_lazyQueue.length) await _resolveAllLazy();
1954
+ const fn = await _resolveRegistryEntry(path);
1955
+ if (!fn) {
1956
+ _respond(ws, platform, id, { ok: false, code: 'NOT_FOUND', error: 'Not found' });
1957
+ return;
1958
+ }
1959
+
1960
+ if (!/** @type {any} */ (fn).__isBinary) {
1961
+ _respond(ws, platform, id, { ok: false, code: 'INVALID_REQUEST', error: 'Not a binary endpoint' });
1962
+ return;
1963
+ }
1964
+
1965
+ const maxBinarySize = /** @type {any} */ (fn).__maxBinarySize || 10485760;
1966
+ if (payload.byteLength > maxBinarySize) {
1967
+ _respond(ws, platform, id, { ok: false, code: 'PAYLOAD_TOO_LARGE', error: 'Binary payload exceeds size limit' });
1968
+ return;
1969
+ }
1970
+
1971
+ const _h = _getCtxHelpers(platform);
1972
+ const ctx = {
1973
+ user: ws.getUserData(),
1974
+ ws,
1975
+ platform,
1976
+ publish: _h.publish,
1977
+ cursor: null,
1978
+ throttle: _h.throttle,
1979
+ debounce: _h.debounce,
1980
+ signal: _h.signal
1981
+ };
1982
+
1983
+ try {
1984
+ await _runWithMiddleware(ctx, async () => {
1985
+ const modulePath = path.substring(0, path.lastIndexOf('/'));
1986
+ const guardFn = await _resolveGuard(modulePath);
1987
+ if (guardFn) await guardFn(ctx);
1988
+
1989
+ if (options?.beforeExecute) {
1990
+ await options.beforeExecute(ws, path, [payload, ...(extraArgs || [])]);
1991
+ }
1992
+
1993
+ const result = await fn(ctx, payload, ...(extraArgs || []));
1994
+ _respond(ws, platform, id, { ok: true, data: result });
1995
+ });
1996
+ } catch (err) {
1997
+ if (err instanceof LiveError) {
1998
+ _respond(ws, platform, id, { ok: false, code: err.code, error: err.message });
1999
+ } else {
2000
+ if (options?.onError) {
2001
+ try { options.onError(path, err, ctx); } catch {}
2002
+ }
2003
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
2004
+ console.error(`[svelte-realtime] Error in binary '${path}':`, err);
2005
+ }
2006
+ _respond(ws, platform, id, { ok: false, code: 'INTERNAL_ERROR', error: 'Internal server error' });
2007
+ }
2008
+ }
2009
+ }
2010
+
2011
+ /**
2012
+ * Run global middleware chain, then call `handler`.
2013
+ * If no middleware is registered, calls handler directly (zero overhead).
2014
+ * @param {any} ctx
2015
+ * @param {() => Promise<any>} handler
2016
+ * @returns {Promise<any>}
2017
+ */
2018
+ function _runWithMiddleware(ctx, handler) {
2019
+ if (_globalMiddleware.length === 0) return handler();
2020
+
2021
+ let idx = 0;
2022
+ function next() {
2023
+ if (idx < _globalMiddleware.length) {
2024
+ return _globalMiddleware[idx++](ctx, next);
2025
+ }
2026
+ return handler();
2027
+ }
2028
+ return next();
2029
+ }
2030
+
2031
+ // -- Throttle / Debounce infrastructure ----------------------------------------
2032
+
2033
+ /** @type {Map<string, { timer: ReturnType<typeof setTimeout>, lastData: any, lastEvent: string, platform: any, lastRun: number }>} */
2034
+ const _throttles = new Map();
2035
+
2036
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
2037
+ const _debounces = new Map();
2038
+
2039
+ /**
2040
+ * Throttle a publish to a topic. Sends at most once per `ms` milliseconds.
2041
+ * The last value always arrives (trailing edge).
2042
+ *
2043
+ * @param {import('svelte-adapter-uws').Platform} platform
2044
+ * @param {string} topic
2045
+ * @param {string} event
2046
+ * @param {any} data
2047
+ * @param {number} ms - Throttle interval in milliseconds
2048
+ */
2049
+ function _throttlePublish(platform, topic, event, data, ms) {
2050
+ const key = topic + '\0' + event;
2051
+ const existing = _throttles.get(key);
2052
+ const now = Date.now();
2053
+
2054
+ if (!existing) {
2055
+ // First call -- publish immediately, set up trailing edge
2056
+ platform.publish(topic, event, data);
2057
+ _throttles.set(key, {
2058
+ timer: setTimeout(() => {
2059
+ const entry = _throttles.get(key);
2060
+ if (entry && entry.lastData !== undefined) {
2061
+ platform.publish(topic, event, entry.lastData);
2062
+ }
2063
+ _throttles.delete(key);
2064
+ }, ms),
2065
+ lastData: undefined,
2066
+ lastEvent: event,
2067
+ platform,
2068
+ lastRun: now
2069
+ });
2070
+ return;
2071
+ }
2072
+
2073
+ // Subsequent calls within the window -- store for trailing edge
2074
+ existing.lastData = data;
2075
+ existing.lastEvent = event;
2076
+ }
2077
+
2078
+ /**
2079
+ * Debounce a publish to a topic. Only sends after `ms` milliseconds of silence.
2080
+ *
2081
+ * @param {import('svelte-adapter-uws').Platform} platform
2082
+ * @param {string} topic
2083
+ * @param {string} event
2084
+ * @param {any} data
2085
+ * @param {number} ms - Debounce interval in milliseconds
2086
+ */
2087
+ function _debouncePublish(platform, topic, event, data, ms) {
2088
+ const key = topic + '\0' + event;
2089
+ const existing = _debounces.get(key);
2090
+ if (existing) clearTimeout(existing);
2091
+
2092
+ _debounces.set(key, setTimeout(() => {
2093
+ _debounces.delete(key);
2094
+ platform.publish(topic, event, data);
2095
+ }, ms));
2096
+ }
2097
+
2098
+ /**
2099
+ * Send an RPC response to a single client.
2100
+ * @param {any} ws
2101
+ * @param {import('svelte-adapter-uws').Platform} platform
2102
+ * @param {string} correlationId
2103
+ * @param {Record<string, any>} payload
2104
+ */
2105
+ /**
2106
+ * Apply schema migration functions to data.
2107
+ * Chains migrations from clientVersion to serverVersion.
2108
+ * @param {any} data
2109
+ * @param {number} fromVersion
2110
+ * @param {number} toVersion
2111
+ * @param {Record<number, (item: any) => any>} migrateFns
2112
+ * @returns {any}
2113
+ */
2114
+ function _migrateData(data, fromVersion, toVersion, migrateFns) {
2115
+ if (Array.isArray(data)) {
2116
+ return data.map(item => _migrateItem(item, fromVersion, toVersion, migrateFns));
2117
+ }
2118
+ return _migrateItem(data, fromVersion, toVersion, migrateFns);
2119
+ }
2120
+
2121
+ /**
2122
+ * Apply chained migrations to a single item.
2123
+ * @param {any} item
2124
+ * @param {number} fromVersion
2125
+ * @param {number} toVersion
2126
+ * @param {Record<number, (item: any) => any>} migrateFns
2127
+ * @returns {any}
2128
+ */
2129
+ function _migrateItem(item, fromVersion, toVersion, migrateFns) {
2130
+ let result = item;
2131
+ for (let v = fromVersion; v < toVersion; v++) {
2132
+ const fn = migrateFns[v];
2133
+ if (fn) {
2134
+ result = fn(result);
2135
+ } else if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
2136
+ console.warn(`[svelte-realtime] Missing migration function for version ${v} -> ${v + 1}`);
2137
+ }
2138
+ }
2139
+ return result;
2140
+ }
2141
+
2142
+ function _respond(ws, platform, correlationId, payload) {
2143
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
2144
+ // Estimate size without double-serialization.
2145
+ const data = payload.data;
2146
+ if ((Array.isArray(data) && data.length > 100) || (typeof data === 'string' && data.length > 12000)) {
2147
+ console.warn(
2148
+ `[svelte-realtime] RPC response for '${correlationId}' contains ${data.length} items -- ` +
2149
+ 'large responses may exceed maxPayloadLength (16KB). Increase maxPayloadLength in adapter config if needed.'
2150
+ );
2151
+ }
2152
+ }
2153
+ try {
2154
+ const result = platform.send(ws, '__rpc', correlationId, payload);
2155
+ if (result === 0 && typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
2156
+ console.warn(
2157
+ `[svelte-realtime] RPC response was not delivered (backpressure or closed connection)`
2158
+ );
2159
+ }
2160
+ } catch {
2161
+ // uWS throws when accessing a closed WebSocket — silently discard.
2162
+ // This is expected when the client disconnects mid-RPC.
2163
+ }
2164
+ }
2165
+
2166
+ /**
2167
+ * Execute a live function directly (in-process), without WebSocket.
2168
+ * Used by SSR load functions to call live functions server-side.
2169
+ *
2170
+ * @param {string} path - RPC path (e.g. 'chat/messages')
2171
+ * @param {any[]} args - Arguments to pass (excluding ctx)
2172
+ * @param {import('svelte-adapter-uws').Platform} platform
2173
+ * @param {{ user?: any }} [options]
2174
+ * @returns {Promise<any>}
2175
+ */
2176
+ export async function __directCall(path, args, platform, options) {
2177
+ if (_lazyQueue.length) await _resolveAllLazy();
2178
+ const fn = await _resolveRegistryEntry(path);
2179
+ if (!fn) {
2180
+ throw new LiveError('NOT_FOUND', `Live function '${path}' not found`);
2181
+ }
2182
+
2183
+ const _h = _getCtxHelpers(platform);
2184
+ const ctx = {
2185
+ user: options?.user || null,
2186
+ ws: null,
2187
+ platform,
2188
+ publish: _h.publish,
2189
+ cursor: null,
2190
+ throttle: _h.throttle,
2191
+ debounce: _h.debounce,
2192
+ signal: _h.signal
2193
+ };
2194
+
2195
+ // Run global middleware chain, then guard, then execution
2196
+ return _runWithMiddleware(ctx, async () => {
2197
+ // Run module guard
2198
+ const modulePath = path.substring(0, path.lastIndexOf('/'));
2199
+ const guardFn = await _resolveGuard(modulePath);
2200
+ if (guardFn) await guardFn(ctx);
2201
+
2202
+ if (/** @type {any} */ (fn).__isStream) {
2203
+ // For streams, just call the initFn and return data (no subscribe)
2204
+ return fn(ctx, ...args);
2205
+ }
2206
+
2207
+ return fn(ctx, ...args);
2208
+ });
2209
+ }
2210
+
2211
+ /**
2212
+ * Handle a WebSocket close event. Fires onUnsubscribe lifecycle hooks
2213
+ * for any stream functions that define them.
2214
+ *
2215
+ * Call this from your `close` hook in hooks.ws.js:
2216
+ * ```js
2217
+ * export { close } from 'svelte-realtime/server';
2218
+ * ```
2219
+ *
2220
+ * @param {any} ws
2221
+ * @param {{ platform: import('svelte-adapter-uws').Platform }} ctx
2222
+ */
2223
+ /**
2224
+ * Subscribe a WebSocket to its user's signal topic.
2225
+ * Call this in your `open` hook to enable `ctx.signal()` delivery.
2226
+ *
2227
+ * @param {any} ws - The WebSocket connection
2228
+ * @param {{ idField?: string }} [options] - Options (defaults to `ws.getUserData().id`)
2229
+ */
2230
+ export function enableSignals(ws, options) {
2231
+ const idField = options?.idField || 'id';
2232
+ const userData = ws.getUserData();
2233
+ const userId = userData?.[idField];
2234
+ if (userId !== undefined && userId !== null) {
2235
+ ws.subscribe('__signal:' + userId);
2236
+ }
2237
+ }
2238
+
2239
+ export function close(ws, { platform }) {
2240
+ if (_streamsWithUnsubscribe.size === 0) return;
2241
+
2242
+ const user = ws.getUserData();
2243
+ const closeCtx = { user, ws, platform, publish: _getCtxHelpers(platform).publish, cursor: null };
2244
+
2245
+ // Get the actual topics this socket was subscribed to
2246
+ const subscribedTopics = typeof ws.getTopics === 'function' ? ws.getTopics() : null;
2247
+ /** @type {Set<string> | null} */
2248
+ let subscribedSet = null;
2249
+
2250
+ for (const fn of _streamsWithUnsubscribe) {
2251
+ const rawTopic = /** @type {any} */ (fn).__streamTopic;
2252
+ if (typeof rawTopic === 'string') {
2253
+ // Static topic: only fire if the socket was actually subscribed
2254
+ if (!subscribedTopics) {
2255
+ try { /** @type {any} */ (fn).__onUnsubscribe(closeCtx, rawTopic); } catch {}
2256
+ } else {
2257
+ if (!subscribedSet) subscribedSet = new Set(subscribedTopics);
2258
+ if (subscribedSet.has(rawTopic)) {
2259
+ try { /** @type {any} */ (fn).__onUnsubscribe(closeCtx, rawTopic); } catch {}
2260
+ }
2261
+ }
2262
+ }
2263
+ }
2264
+
2265
+ // For dynamic streams, use the recorded topic -> stream mapping
2266
+ const dynamicMap = _dynamicSubscriptions.get(ws);
2267
+ if (dynamicMap) {
2268
+ for (const [topic, fn] of dynamicMap) {
2269
+ try { /** @type {any} */ (fn).__onUnsubscribe(closeCtx, topic); } catch {}
2270
+ }
2271
+ _dynamicSubscriptions.delete(ws);
2272
+ }
2273
+ }
2274
+
2275
+ /**
2276
+ * Ready-made message hook. Re-export from hooks.ws.js for zero-config RPC routing.
2277
+ *
2278
+ * Signature matches the adapter's message hook exactly.
2279
+ *
2280
+ * @param {any} ws
2281
+ * @param {{ data: ArrayBuffer, platform: import('svelte-adapter-uws').Platform }} ctx
2282
+ */
2283
+ export function message(ws, { data, platform }) {
2284
+ handleRpc(ws, data, platform);
2285
+ }
2286
+
2287
+ /**
2288
+ * Create a custom message hook with options baked in.
2289
+ *
2290
+ * @param {{ platform?: (p: import('svelte-adapter-uws').Platform) => import('svelte-adapter-uws').Platform, beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void, onUnhandled?: (ws: any, data: ArrayBuffer, platform: import('svelte-adapter-uws').Platform) => void }} [options]
2291
+ * @returns {(ws: any, ctx: { data: ArrayBuffer, platform: import('svelte-adapter-uws').Platform }) => void}
2292
+ */
2293
+ export function createMessage(options) {
2294
+ if (!options) return message;
2295
+
2296
+ const { platform: transformPlatform, beforeExecute, onError, onUnhandled } = options;
2297
+
2298
+ /** @type {any} */
2299
+ const rpcOpts = {};
2300
+ if (beforeExecute) rpcOpts.beforeExecute = beforeExecute;
2301
+ if (onError) rpcOpts.onError = onError;
2302
+ const hasRpcOpts = beforeExecute || onError;
2303
+
2304
+ return function customMessage(ws, { data, platform }) {
2305
+ const p = transformPlatform ? transformPlatform(platform) : platform;
2306
+ const handled = handleRpc(ws, data, p, hasRpcOpts ? rpcOpts : undefined);
2307
+ if (!handled && onUnhandled) {
2308
+ onUnhandled(ws, data, p);
2309
+ }
2310
+ };
2311
+ }