svelte-realtime 0.1.4 → 0.1.6
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/client.js +86 -10
- package/package.json +1 -1
- package/server.js +38 -16
- package/vite.js +8 -4
package/client.js
CHANGED
|
@@ -4,6 +4,14 @@ import { writable } from 'svelte/store';
|
|
|
4
4
|
|
|
5
5
|
const _textEncoder = new TextEncoder();
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* RAF-based event batching for high-frequency streams (cursors, presence).
|
|
9
|
+
* In the browser, incoming pub/sub events are queued and flushed once per
|
|
10
|
+
* animation frame, reducing Svelte reactive updates from N-per-event to
|
|
11
|
+
* 1-per-frame. In Node/SSR, events apply synchronously (no DOM to protect).
|
|
12
|
+
*/
|
|
13
|
+
const _useRAF = typeof window !== 'undefined' && typeof requestAnimationFrame === 'function';
|
|
14
|
+
|
|
7
15
|
/**
|
|
8
16
|
* Typed error for RPC failures.
|
|
9
17
|
*/
|
|
@@ -22,6 +30,12 @@ export class RpcError extends Error {
|
|
|
22
30
|
const _idPrefix = Math.random().toString(36).slice(2, 6);
|
|
23
31
|
let idCounter = 0;
|
|
24
32
|
|
|
33
|
+
/** Generate a unique correlation ID, wrapping the counter before exceeding safe integer range */
|
|
34
|
+
function _nextId() {
|
|
35
|
+
if (idCounter >= 0x1FFFFFFFFFFFFF) idCounter = 0;
|
|
36
|
+
return _idPrefix + (idCounter++).toString(36);
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/** @type {Array<{ rpc: string, id: string, args: any[] }> | null} */
|
|
26
40
|
let _batchCollector = null;
|
|
27
41
|
|
|
@@ -160,7 +174,7 @@ function _sendRpc(path, args) {
|
|
|
160
174
|
});
|
|
161
175
|
}
|
|
162
176
|
|
|
163
|
-
const id =
|
|
177
|
+
const id = _nextId();
|
|
164
178
|
|
|
165
179
|
// If inside a batch() call, collect instead of sending
|
|
166
180
|
if (_batchCollector) {
|
|
@@ -201,7 +215,7 @@ export function __binaryRpc(path) {
|
|
|
201
215
|
ensureListener();
|
|
202
216
|
ensureDisconnectListener();
|
|
203
217
|
|
|
204
|
-
const id =
|
|
218
|
+
const id = _nextId();
|
|
205
219
|
|
|
206
220
|
_devtoolsStart(path, id, args);
|
|
207
221
|
const conn = _connect();
|
|
@@ -238,6 +252,9 @@ export function __binaryRpc(path) {
|
|
|
238
252
|
/** @type {Map<string, { store: any, refCount: number }>} */
|
|
239
253
|
const _streamCache = new Map();
|
|
240
254
|
|
|
255
|
+
/** Hard cap on cached stream instances to prevent memory exhaustion */
|
|
256
|
+
const _STREAM_CACHE_MAX = 1000;
|
|
257
|
+
|
|
241
258
|
/**
|
|
242
259
|
* Create a reactive stream store for a given path.
|
|
243
260
|
* Used by generated client stubs.
|
|
@@ -268,6 +285,17 @@ export function __stream(path, options, isDynamic) {
|
|
|
268
285
|
const store = _createStream(path, options, args);
|
|
269
286
|
// Capture the original subscribe once, before wrapping
|
|
270
287
|
const rawSubscribe = store.subscribe.bind(store);
|
|
288
|
+
|
|
289
|
+
// Evict idle entries (refCount 0) if cache is at capacity
|
|
290
|
+
if (_streamCache.size >= _STREAM_CACHE_MAX) {
|
|
291
|
+
for (const [k, v] of _streamCache) {
|
|
292
|
+
if (v.refCount <= 0) {
|
|
293
|
+
_streamCache.delete(k);
|
|
294
|
+
if (_streamCache.size < _STREAM_CACHE_MAX) break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
271
299
|
_streamCache.set(cacheKey, { store, refCount: 0 });
|
|
272
300
|
|
|
273
301
|
// Wrap subscribe to track ref count and clean up cache on last unsubscribe
|
|
@@ -515,18 +543,49 @@ function _createStream(path, options, dynamicArgs) {
|
|
|
515
543
|
return false;
|
|
516
544
|
}
|
|
517
545
|
|
|
546
|
+
/** @type {Array<{ event: string, data: any }>} Queued events waiting for next animation frame */
|
|
547
|
+
let _eventQueue = [];
|
|
548
|
+
|
|
549
|
+
/** @type {number | null} */
|
|
550
|
+
let _rafId = null;
|
|
551
|
+
|
|
518
552
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
* @param {{ event: string, data: any }} envelope
|
|
553
|
+
* Flush all queued events in a single batch, then update the store once.
|
|
554
|
+
* Reduces reactive updates from N-per-event to 1-per-frame.
|
|
522
555
|
*/
|
|
523
|
-
function
|
|
524
|
-
|
|
525
|
-
if (
|
|
556
|
+
function _flushEvents() {
|
|
557
|
+
_rafId = null;
|
|
558
|
+
if (_eventQueue.length === 0) return;
|
|
559
|
+
const queue = _eventQueue;
|
|
560
|
+
_eventQueue = [];
|
|
561
|
+
for (let i = 0; i < queue.length; i++) {
|
|
562
|
+
_applyMerge(queue[i]);
|
|
563
|
+
}
|
|
564
|
+
if (Array.isArray(currentValue)) currentValue = currentValue.slice();
|
|
526
565
|
store.set(currentValue);
|
|
527
566
|
_recordHistory();
|
|
528
567
|
}
|
|
529
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Apply a pub/sub event to the store. In the browser, events are queued
|
|
571
|
+
* and flushed once per animation frame to reduce reactive updates from
|
|
572
|
+
* N-per-event to 1-per-frame. In Node/SSR, events apply immediately.
|
|
573
|
+
* @param {{ event: string, data: any }} envelope
|
|
574
|
+
*/
|
|
575
|
+
function applyEvent(envelope) {
|
|
576
|
+
if (_useRAF) {
|
|
577
|
+
_eventQueue.push(envelope);
|
|
578
|
+
if (_rafId === null) {
|
|
579
|
+
_rafId = requestAnimationFrame(_flushEvents);
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
const replaced = _applyMerge(envelope);
|
|
583
|
+
if (!replaced && Array.isArray(currentValue)) currentValue = currentValue.slice();
|
|
584
|
+
store.set(currentValue);
|
|
585
|
+
_recordHistory();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
530
589
|
/**
|
|
531
590
|
* Fetch initial data and subscribe to live updates.
|
|
532
591
|
*/
|
|
@@ -549,7 +608,7 @@ function _createStream(path, options, dynamicArgs) {
|
|
|
549
608
|
ensureListener();
|
|
550
609
|
ensureDisconnectListener();
|
|
551
610
|
|
|
552
|
-
const id =
|
|
611
|
+
const id = _nextId();
|
|
553
612
|
pendingId = id;
|
|
554
613
|
const conn = _connect();
|
|
555
614
|
|
|
@@ -696,6 +755,11 @@ function _createStream(path, options, dynamicArgs) {
|
|
|
696
755
|
clearTimeout(_reconnectTimer);
|
|
697
756
|
_reconnectTimer = null;
|
|
698
757
|
}
|
|
758
|
+
if (_rafId !== null) {
|
|
759
|
+
cancelAnimationFrame(_rafId);
|
|
760
|
+
_rafId = null;
|
|
761
|
+
}
|
|
762
|
+
_eventQueue = [];
|
|
699
763
|
topic = null;
|
|
700
764
|
initialLoaded = false;
|
|
701
765
|
fetching = false;
|
|
@@ -791,7 +855,7 @@ function _createStream(path, options, dynamicArgs) {
|
|
|
791
855
|
_loadingMore = true;
|
|
792
856
|
|
|
793
857
|
ensureListener();
|
|
794
|
-
const id =
|
|
858
|
+
const id = _nextId();
|
|
795
859
|
const conn = _connect();
|
|
796
860
|
|
|
797
861
|
return new Promise((resolve, reject) => {
|
|
@@ -1114,6 +1178,18 @@ export function batch(fn, options) {
|
|
|
1114
1178
|
|
|
1115
1179
|
if (collected.length === 0) return Promise.resolve([]);
|
|
1116
1180
|
|
|
1181
|
+
if (collected.length > 50) {
|
|
1182
|
+
for (const call of collected) {
|
|
1183
|
+
const entry = pending.get(call.id);
|
|
1184
|
+
if (entry) {
|
|
1185
|
+
pending.delete(call.id);
|
|
1186
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
1187
|
+
entry.reject(new RpcError('INVALID_REQUEST', 'Batch exceeds maximum of 50 calls'));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return Promise.reject(new RpcError('INVALID_REQUEST', 'Batch exceeds maximum of 50 calls'));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1117
1193
|
// Set a batch-level timeout
|
|
1118
1194
|
const batchTimer = setTimeout(() => {
|
|
1119
1195
|
for (const call of collected) {
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -284,6 +284,9 @@ const _rateLimits = new Map();
|
|
|
284
284
|
/** @type {number} */
|
|
285
285
|
let _rateLimitLastSweep = Date.now();
|
|
286
286
|
|
|
287
|
+
/** Hard cap on rate limit buckets to prevent memory exhaustion */
|
|
288
|
+
const _RATE_LIMIT_MAX = 5000;
|
|
289
|
+
|
|
287
290
|
/**
|
|
288
291
|
* Declarative per-function rate limiting.
|
|
289
292
|
* Wraps a live() function with a sliding window rate limiter.
|
|
@@ -301,23 +304,19 @@ live.rateLimit = function rateLimit(config, fn) {
|
|
|
301
304
|
const bucketKey = /** @type {any} */ (wrapper).__rateLimitPath + '\0' + userKey;
|
|
302
305
|
const now = Date.now();
|
|
303
306
|
|
|
304
|
-
// Lazy sweep: prune stale entries
|
|
305
|
-
if (now - _rateLimitLastSweep >
|
|
307
|
+
// Lazy sweep: prune stale entries every 30s, sweep all entries
|
|
308
|
+
if (now - _rateLimitLastSweep > 30000) {
|
|
306
309
|
_rateLimitLastSweep = now;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
for (const [k, bucket] of _rateLimits) {
|
|
311
|
-
if (now - bucket.windowStart >= windowMs * 2) {
|
|
312
|
-
_rateLimits.delete(k);
|
|
313
|
-
}
|
|
314
|
-
if (++swept >= maxSweep) break;
|
|
310
|
+
for (const [k, bucket] of _rateLimits) {
|
|
311
|
+
if (now - bucket.windowStart >= windowMs * 2) {
|
|
312
|
+
_rateLimits.delete(k);
|
|
315
313
|
}
|
|
316
314
|
}
|
|
317
315
|
}
|
|
318
316
|
|
|
319
|
-
|
|
320
|
-
|
|
317
|
+
// Hard cap: evict oldest entries (Map iteration order = insertion order)
|
|
318
|
+
if (_rateLimits.size > _RATE_LIMIT_MAX) {
|
|
319
|
+
let excess = _rateLimits.size - _RATE_LIMIT_MAX;
|
|
321
320
|
for (const k of _rateLimits.keys()) {
|
|
322
321
|
if (excess-- <= 0) break;
|
|
323
322
|
_rateLimits.delete(k);
|
|
@@ -1475,7 +1474,11 @@ export function handleRpc(ws, data, platform, options) {
|
|
|
1475
1474
|
_executeBinaryRpc(ws, header, payload, platform, options);
|
|
1476
1475
|
return true;
|
|
1477
1476
|
}
|
|
1478
|
-
} catch {
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
1479
|
+
console.warn('[svelte-realtime] Failed to parse binary RPC header:', err);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1479
1482
|
}
|
|
1480
1483
|
return false;
|
|
1481
1484
|
}
|
|
@@ -2030,6 +2033,9 @@ function _runWithMiddleware(ctx, handler) {
|
|
|
2030
2033
|
|
|
2031
2034
|
// -- Throttle / Debounce infrastructure ----------------------------------------
|
|
2032
2035
|
|
|
2036
|
+
/** Hard cap on throttle/debounce entries to prevent memory exhaustion */
|
|
2037
|
+
const _THROTTLE_DEBOUNCE_MAX = 5000;
|
|
2038
|
+
|
|
2033
2039
|
/** @type {Map<string, { timer: ReturnType<typeof setTimeout>, lastData: any, lastEvent: string, platform: any, lastRun: number }>} */
|
|
2034
2040
|
const _throttles = new Map();
|
|
2035
2041
|
|
|
@@ -2052,6 +2058,13 @@ function _throttlePublish(platform, topic, event, data, ms) {
|
|
|
2052
2058
|
const now = Date.now();
|
|
2053
2059
|
|
|
2054
2060
|
if (!existing) {
|
|
2061
|
+
// Hard cap: evict oldest entry if at limit
|
|
2062
|
+
if (_throttles.size >= _THROTTLE_DEBOUNCE_MAX) {
|
|
2063
|
+
const oldest = _throttles.keys().next().value;
|
|
2064
|
+
const entry = _throttles.get(oldest);
|
|
2065
|
+
if (entry) clearTimeout(entry.timer);
|
|
2066
|
+
_throttles.delete(oldest);
|
|
2067
|
+
}
|
|
2055
2068
|
// First call -- publish immediately, set up trailing edge
|
|
2056
2069
|
platform.publish(topic, event, data);
|
|
2057
2070
|
_throttles.set(key, {
|
|
@@ -2089,6 +2102,13 @@ function _debouncePublish(platform, topic, event, data, ms) {
|
|
|
2089
2102
|
const existing = _debounces.get(key);
|
|
2090
2103
|
if (existing) clearTimeout(existing);
|
|
2091
2104
|
|
|
2105
|
+
// Hard cap: evict oldest entry if at limit
|
|
2106
|
+
if (!existing && _debounces.size >= _THROTTLE_DEBOUNCE_MAX) {
|
|
2107
|
+
const oldest = _debounces.keys().next().value;
|
|
2108
|
+
clearTimeout(_debounces.get(oldest));
|
|
2109
|
+
_debounces.delete(oldest);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2092
2112
|
_debounces.set(key, setTimeout(() => {
|
|
2093
2113
|
_debounces.delete(key);
|
|
2094
2114
|
platform.publish(topic, event, data);
|
|
@@ -2157,9 +2177,11 @@ function _respond(ws, platform, correlationId, payload) {
|
|
|
2157
2177
|
`[svelte-realtime] RPC response was not delivered (backpressure or closed connection)`
|
|
2158
2178
|
);
|
|
2159
2179
|
}
|
|
2160
|
-
} catch {
|
|
2161
|
-
// uWS throws when accessing a closed WebSocket
|
|
2162
|
-
|
|
2180
|
+
} catch (err) {
|
|
2181
|
+
// uWS throws when accessing a closed WebSocket -- expected during mid-RPC disconnect.
|
|
2182
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
2183
|
+
console.warn(`[svelte-realtime] RPC response for '${correlationId}' could not be delivered (client likely disconnected)`);
|
|
2184
|
+
}
|
|
2163
2185
|
}
|
|
2164
2186
|
}
|
|
2165
2187
|
|
package/vite.js
CHANGED
|
@@ -315,28 +315,32 @@ function _generateSsrStubs(filePath, modulePath) {
|
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
// Escape paths for safe embedding in generated code
|
|
319
|
+
const safePath = JSON.stringify(normalized);
|
|
320
|
+
const safeModulePath = (name) => JSON.stringify(modulePath + '/' + name);
|
|
321
|
+
|
|
318
322
|
// If no store-like exports, simple re-export
|
|
319
323
|
if (storeNames.length === 0) {
|
|
320
|
-
return `export * from
|
|
324
|
+
return `export * from ${safePath};\n`;
|
|
321
325
|
}
|
|
322
326
|
|
|
323
327
|
// Re-export non-stream exports, wrap store-like exports in readable() for SSR $ prefix support
|
|
324
328
|
const lines = [
|
|
325
329
|
`import { readable } from 'svelte/store';`,
|
|
326
330
|
`import { __directCall } from 'svelte-realtime/server';`,
|
|
327
|
-
`export * from
|
|
331
|
+
`export * from ${safePath};`
|
|
328
332
|
];
|
|
329
333
|
|
|
330
334
|
for (const name of storeNames) {
|
|
331
335
|
if (dynamicNames.has(name)) {
|
|
332
336
|
// Dynamic stream: return a readable store from a function so name(args) works during SSR
|
|
333
337
|
lines.push(`const _${name} = (...args) => readable(undefined);`);
|
|
334
|
-
lines.push(`_${name}.load = (platform, options) => __directCall(
|
|
338
|
+
lines.push(`_${name}.load = (platform, options) => __directCall(${safeModulePath(name)}, options?.args || [], platform, options);`);
|
|
335
339
|
lines.push(`export { _${name} as ${name} };`);
|
|
336
340
|
} else {
|
|
337
341
|
// Static stream: plain readable store
|
|
338
342
|
lines.push(`const _${name} = readable(undefined);`);
|
|
339
|
-
lines.push(`_${name}.load = (platform, options) => __directCall(
|
|
343
|
+
lines.push(`_${name}.load = (platform, options) => __directCall(${safeModulePath(name)}, options?.args || [], platform, options);`);
|
|
340
344
|
lines.push(`export { _${name} as ${name} };`);
|
|
341
345
|
}
|
|
342
346
|
}
|