svelte-realtime 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1790 -0
- package/client.d.ts +239 -0
- package/client.js +1428 -0
- package/devtools.d.ts +2 -0
- package/devtools.js +214 -0
- package/package.json +80 -0
- package/server.d.ts +815 -0
- package/server.js +2311 -0
- package/test.d.ts +110 -0
- package/test.js +330 -0
- package/vite.d.ts +43 -0
- package/vite.js +1246 -0
package/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
|
+
}
|