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.
- package/client.js +36 -4
- package/package.json +1 -1
- package/server.js +38 -16
- 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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
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
|
}
|