svelte-realtime 0.1.4 → 0.1.5

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 +36 -4
  2. package/package.json +1 -1
  3. package/server.js +38 -16
  4. package/vite.js +8 -4
package/client.js CHANGED
@@ -22,6 +22,12 @@ export class RpcError extends Error {
22
22
  const _idPrefix = Math.random().toString(36).slice(2, 6);
23
23
  let idCounter = 0;
24
24
 
25
+ /** Generate a unique correlation ID, wrapping the counter before exceeding safe integer range */
26
+ function _nextId() {
27
+ if (idCounter >= 0x1FFFFFFFFFFFFF) idCounter = 0;
28
+ return _idPrefix + (idCounter++).toString(36);
29
+ }
30
+
25
31
  /** @type {Array<{ rpc: string, id: string, args: any[] }> | null} */
26
32
  let _batchCollector = null;
27
33
 
@@ -160,7 +166,7 @@ function _sendRpc(path, args) {
160
166
  });
161
167
  }
162
168
 
163
- const id = _idPrefix + (idCounter++).toString(36);
169
+ const id = _nextId();
164
170
 
165
171
  // If inside a batch() call, collect instead of sending
166
172
  if (_batchCollector) {
@@ -201,7 +207,7 @@ export function __binaryRpc(path) {
201
207
  ensureListener();
202
208
  ensureDisconnectListener();
203
209
 
204
- const id = _idPrefix + (idCounter++).toString(36);
210
+ const id = _nextId();
205
211
 
206
212
  _devtoolsStart(path, id, args);
207
213
  const conn = _connect();
@@ -238,6 +244,9 @@ export function __binaryRpc(path) {
238
244
  /** @type {Map<string, { store: any, refCount: number }>} */
239
245
  const _streamCache = new Map();
240
246
 
247
+ /** Hard cap on cached stream instances to prevent memory exhaustion */
248
+ const _STREAM_CACHE_MAX = 1000;
249
+
241
250
  /**
242
251
  * Create a reactive stream store for a given path.
243
252
  * Used by generated client stubs.
@@ -268,6 +277,17 @@ export function __stream(path, options, isDynamic) {
268
277
  const store = _createStream(path, options, args);
269
278
  // Capture the original subscribe once, before wrapping
270
279
  const rawSubscribe = store.subscribe.bind(store);
280
+
281
+ // Evict idle entries (refCount 0) if cache is at capacity
282
+ if (_streamCache.size >= _STREAM_CACHE_MAX) {
283
+ for (const [k, v] of _streamCache) {
284
+ if (v.refCount <= 0) {
285
+ _streamCache.delete(k);
286
+ if (_streamCache.size < _STREAM_CACHE_MAX) break;
287
+ }
288
+ }
289
+ }
290
+
271
291
  _streamCache.set(cacheKey, { store, refCount: 0 });
272
292
 
273
293
  // Wrap subscribe to track ref count and clean up cache on last unsubscribe
@@ -549,7 +569,7 @@ function _createStream(path, options, dynamicArgs) {
549
569
  ensureListener();
550
570
  ensureDisconnectListener();
551
571
 
552
- const id = _idPrefix + (idCounter++).toString(36);
572
+ const id = _nextId();
553
573
  pendingId = id;
554
574
  const conn = _connect();
555
575
 
@@ -791,7 +811,7 @@ function _createStream(path, options, dynamicArgs) {
791
811
  _loadingMore = true;
792
812
 
793
813
  ensureListener();
794
- const id = _idPrefix + (idCounter++).toString(36);
814
+ const id = _nextId();
795
815
  const conn = _connect();
796
816
 
797
817
  return new Promise((resolve, reject) => {
@@ -1114,6 +1134,18 @@ export function batch(fn, options) {
1114
1134
 
1115
1135
  if (collected.length === 0) return Promise.resolve([]);
1116
1136
 
1137
+ if (collected.length > 50) {
1138
+ for (const call of collected) {
1139
+ const entry = pending.get(call.id);
1140
+ if (entry) {
1141
+ pending.delete(call.id);
1142
+ if (entry.timer) clearTimeout(entry.timer);
1143
+ entry.reject(new RpcError('INVALID_REQUEST', 'Batch exceeds maximum of 50 calls'));
1144
+ }
1145
+ }
1146
+ return Promise.reject(new RpcError('INVALID_REQUEST', 'Batch exceeds maximum of 50 calls'));
1147
+ }
1148
+
1117
1149
  // Set a batch-level timeout
1118
1150
  const batchTimer = setTimeout(() => {
1119
1151
  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.5",
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
  }