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.
Files changed (4) hide show
  1. package/client.js +86 -10
  2. package/package.json +1 -1
  3. package/server.js +38 -16
  4. 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 = _idPrefix + (idCounter++).toString(36);
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 = _idPrefix + (idCounter++).toString(36);
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
- * Apply a single pub/sub event to the current value using the merge strategy.
520
- * Creates a new array reference for Svelte reactivity only when needed.
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 applyEvent(envelope) {
524
- const replaced = _applyMerge(envelope);
525
- if (!replaced && Array.isArray(currentValue)) currentValue = currentValue.slice();
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 = _idPrefix + (idCounter++).toString(36);
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 = _idPrefix + (idCounter++).toString(36);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Realtime RPC and reactive subscriptions for SvelteKit, built on svelte-adapter-uws",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
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, but limit work per sweep
305
- if (now - _rateLimitLastSweep > 60000) {
307
+ // Lazy sweep: prune stale entries every 30s, sweep all entries
308
+ if (now - _rateLimitLastSweep > 30000) {
306
309
  _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;
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
- if (_rateLimits.size > 10000) {
320
- let excess = _rateLimits.size - 10000;
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 silently discard.
2162
- // This is expected when the client disconnects mid-RPC.
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 '${normalized}';\n`;
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 '${normalized}';`
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('${modulePath}/${name}', options?.args || [], platform, options);`);
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('${modulePath}/${name}', options?.args || [], platform, options);`);
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
  }