layercache 1.2.4 → 1.2.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/LICENSE +190 -21
- package/README.md +254 -906
- package/dist/{chunk-7V7XAB74.js → chunk-4PPBOOXT.js} +37 -3
- package/dist/{chunk-QHWG7QS5.js → chunk-BQLL6IM5.js} +47 -1
- package/dist/{chunk-KOYGHLVP.js → chunk-GJBKCFE6.js} +65 -10
- package/dist/cli.cjs +85 -19
- package/dist/cli.js +4 -18
- package/dist/{edge-Dw97n89L.d.ts → edge-DLstcDMn.d.cts} +32 -13
- package/dist/{edge-Dw97n89L.d.cts → edge-DLstcDMn.d.ts} +32 -13
- package/dist/edge.cjs +101 -12
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +2 -2
- package/dist/index.cjs +1160 -350
- package/dist/index.d.cts +42 -4
- package/dist/index.d.ts +42 -4
- package/dist/index.js +1017 -342
- package/package.json +29 -3
- package/packages/nestjs/dist/index.cjs +793 -270
- package/packages/nestjs/dist/index.d.cts +23 -12
- package/packages/nestjs/dist/index.d.ts +23 -12
- package/packages/nestjs/dist/index.js +793 -270
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RedisTagIndex
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-BQLL6IM5.js";
|
|
4
4
|
import {
|
|
5
5
|
MemoryLayer,
|
|
6
6
|
TagIndex,
|
|
7
7
|
createHonoCacheMiddleware
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-GJBKCFE6.js";
|
|
9
9
|
import {
|
|
10
10
|
PatternMatcher,
|
|
11
11
|
createStoredValueEnvelope,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
remainingStoredTtlSeconds,
|
|
16
16
|
resolveStoredValue,
|
|
17
17
|
unwrapStoredValue
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-4PPBOOXT.js";
|
|
19
19
|
|
|
20
20
|
// src/CacheStack.ts
|
|
21
21
|
import { EventEmitter } from "events";
|
|
@@ -26,22 +26,23 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
26
26
|
constructor(cache, prefix) {
|
|
27
27
|
this.cache = cache;
|
|
28
28
|
this.prefix = prefix;
|
|
29
|
+
validateNamespaceKey(prefix);
|
|
29
30
|
}
|
|
30
31
|
cache;
|
|
31
32
|
prefix;
|
|
32
33
|
static metricsMutexes = /* @__PURE__ */ new WeakMap();
|
|
33
34
|
metrics = emptyMetrics();
|
|
34
35
|
async get(key, fetcher, options) {
|
|
35
|
-
return this.trackMetrics(() => this.cache.get(this.qualify(key), fetcher, options));
|
|
36
|
+
return this.trackMetrics(() => this.cache.get(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
|
|
36
37
|
}
|
|
37
38
|
async getOrSet(key, fetcher, options) {
|
|
38
|
-
return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, options));
|
|
39
|
+
return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
|
|
39
40
|
}
|
|
40
41
|
/**
|
|
41
42
|
* Like `get()`, but throws `CacheMissError` instead of returning `null`.
|
|
42
43
|
*/
|
|
43
44
|
async getOrThrow(key, fetcher, options) {
|
|
44
|
-
return this.trackMetrics(() => this.cache.getOrThrow(this.qualify(key), fetcher, options));
|
|
45
|
+
return this.trackMetrics(() => this.cache.getOrThrow(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
|
|
45
46
|
}
|
|
46
47
|
async has(key) {
|
|
47
48
|
return this.trackMetrics(() => this.cache.has(this.qualify(key)));
|
|
@@ -50,7 +51,7 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
50
51
|
return this.trackMetrics(() => this.cache.ttl(this.qualify(key)));
|
|
51
52
|
}
|
|
52
53
|
async set(key, value, options) {
|
|
53
|
-
await this.trackMetrics(() => this.cache.set(this.qualify(key), value, options));
|
|
54
|
+
await this.trackMetrics(() => this.cache.set(this.qualify(key), value, this.qualifyWriteOptions(options)));
|
|
54
55
|
}
|
|
55
56
|
async delete(key) {
|
|
56
57
|
await this.trackMetrics(() => this.cache.delete(this.qualify(key)));
|
|
@@ -66,7 +67,8 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
66
67
|
() => this.cache.mget(
|
|
67
68
|
entries.map((entry) => ({
|
|
68
69
|
...entry,
|
|
69
|
-
key: this.qualify(entry.key)
|
|
70
|
+
key: this.qualify(entry.key),
|
|
71
|
+
options: this.qualifyGetOptions(entry.options)
|
|
70
72
|
}))
|
|
71
73
|
)
|
|
72
74
|
);
|
|
@@ -76,16 +78,22 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
76
78
|
() => this.cache.mset(
|
|
77
79
|
entries.map((entry) => ({
|
|
78
80
|
...entry,
|
|
79
|
-
key: this.qualify(entry.key)
|
|
81
|
+
key: this.qualify(entry.key),
|
|
82
|
+
options: this.qualifyWriteOptions(entry.options)
|
|
80
83
|
}))
|
|
81
84
|
)
|
|
82
85
|
);
|
|
83
86
|
}
|
|
84
87
|
async invalidateByTag(tag) {
|
|
85
|
-
await this.trackMetrics(() => this.cache.invalidateByTag(tag));
|
|
88
|
+
await this.trackMetrics(() => this.cache.invalidateByTag(this.qualifyTag(tag)));
|
|
86
89
|
}
|
|
87
90
|
async invalidateByTags(tags, mode = "any") {
|
|
88
|
-
await this.trackMetrics(
|
|
91
|
+
await this.trackMetrics(
|
|
92
|
+
() => this.cache.invalidateByTags(
|
|
93
|
+
tags.map((tag) => this.qualifyTag(tag)),
|
|
94
|
+
mode
|
|
95
|
+
)
|
|
96
|
+
);
|
|
89
97
|
}
|
|
90
98
|
async invalidateByPattern(pattern) {
|
|
91
99
|
await this.trackMetrics(() => this.cache.invalidateByPattern(this.qualify(pattern)));
|
|
@@ -97,16 +105,24 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
97
105
|
* Returns detailed metadata about a single cache key within this namespace.
|
|
98
106
|
*/
|
|
99
107
|
async inspect(key) {
|
|
100
|
-
|
|
108
|
+
const result = await this.cache.inspect(this.qualify(key));
|
|
109
|
+
if (result === null) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
...result,
|
|
114
|
+
tags: result.tags.filter((tag) => tag.startsWith(`${this.prefix}:`)).map((tag) => tag.slice(this.prefix.length + 1))
|
|
115
|
+
};
|
|
101
116
|
}
|
|
102
117
|
wrap(keyPrefix, fetcher, options) {
|
|
103
|
-
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
118
|
+
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, this.qualifyWrapOptions(options));
|
|
104
119
|
}
|
|
105
120
|
warm(entries, options) {
|
|
106
121
|
return this.cache.warm(
|
|
107
122
|
entries.map((entry) => ({
|
|
108
123
|
...entry,
|
|
109
|
-
key: this.qualify(entry.key)
|
|
124
|
+
key: this.qualify(entry.key),
|
|
125
|
+
options: this.qualifyGetOptions(entry.options)
|
|
110
126
|
})),
|
|
111
127
|
options
|
|
112
128
|
);
|
|
@@ -136,11 +152,30 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
136
152
|
* ```
|
|
137
153
|
*/
|
|
138
154
|
namespace(childPrefix) {
|
|
155
|
+
validateNamespaceKey(childPrefix);
|
|
139
156
|
return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
|
|
140
157
|
}
|
|
141
158
|
qualify(key) {
|
|
142
159
|
return `${this.prefix}:${key}`;
|
|
143
160
|
}
|
|
161
|
+
qualifyTag(tag) {
|
|
162
|
+
return `${this.prefix}:${tag}`;
|
|
163
|
+
}
|
|
164
|
+
qualifyGetOptions(options) {
|
|
165
|
+
return this.qualifyWriteOptions(options);
|
|
166
|
+
}
|
|
167
|
+
qualifyWrapOptions(options) {
|
|
168
|
+
return this.qualifyWriteOptions(options);
|
|
169
|
+
}
|
|
170
|
+
qualifyWriteOptions(options) {
|
|
171
|
+
if (!options?.tags || options.tags.length === 0) {
|
|
172
|
+
return options;
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
...options,
|
|
176
|
+
tags: options.tags.map((tag) => this.qualifyTag(tag))
|
|
177
|
+
};
|
|
178
|
+
}
|
|
144
179
|
async trackMetrics(operation) {
|
|
145
180
|
return this.getMetricsMutex().runExclusive(async () => {
|
|
146
181
|
const before = this.cache.getMetrics();
|
|
@@ -265,6 +300,20 @@ function addMap(base, delta) {
|
|
|
265
300
|
}
|
|
266
301
|
return result;
|
|
267
302
|
}
|
|
303
|
+
function validateNamespaceKey(key) {
|
|
304
|
+
if (key.length === 0) {
|
|
305
|
+
throw new Error("Namespace prefix must not be empty.");
|
|
306
|
+
}
|
|
307
|
+
if (key.length > 256) {
|
|
308
|
+
throw new Error("Namespace prefix must be at most 256 characters.");
|
|
309
|
+
}
|
|
310
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
311
|
+
throw new Error("Namespace prefix contains unsupported control characters.");
|
|
312
|
+
}
|
|
313
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
314
|
+
throw new Error("Namespace prefix contains unsupported surrogate code points.");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
268
317
|
|
|
269
318
|
// src/internal/CacheKeyDiscovery.ts
|
|
270
319
|
var CacheKeyDiscovery = class {
|
|
@@ -272,21 +321,41 @@ var CacheKeyDiscovery = class {
|
|
|
272
321
|
this.options = options;
|
|
273
322
|
}
|
|
274
323
|
options;
|
|
275
|
-
async collectKeysWithPrefix(prefix) {
|
|
324
|
+
async collectKeysWithPrefix(prefix, maxMatches = false) {
|
|
276
325
|
const { tagIndex } = this.options;
|
|
277
|
-
const matches = new Set(
|
|
278
|
-
|
|
279
|
-
|
|
326
|
+
const matches = /* @__PURE__ */ new Set();
|
|
327
|
+
if (tagIndex.forEachKeyForPrefix) {
|
|
328
|
+
await tagIndex.forEachKeyForPrefix(prefix, async (key) => {
|
|
329
|
+
matches.add(key);
|
|
330
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
const initialMatches = tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`);
|
|
334
|
+
for (const key of initialMatches) {
|
|
335
|
+
matches.add(key);
|
|
336
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
280
339
|
await Promise.all(
|
|
281
340
|
this.options.layers.map(async (layer) => {
|
|
282
|
-
if (!layer.keys || this.options.shouldSkipLayer(layer)) {
|
|
341
|
+
if (!layer.keys && !layer.forEachKey || this.options.shouldSkipLayer(layer)) {
|
|
283
342
|
return;
|
|
284
343
|
}
|
|
285
344
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
345
|
+
if (layer.forEachKey) {
|
|
346
|
+
await layer.forEachKey(async (key) => {
|
|
347
|
+
if (key.startsWith(prefix)) {
|
|
348
|
+
matches.add(key);
|
|
349
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const keys = await layer.keys?.();
|
|
355
|
+
for (const key of keys ?? []) {
|
|
288
356
|
if (key.startsWith(prefix)) {
|
|
289
357
|
matches.add(key);
|
|
358
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
290
359
|
}
|
|
291
360
|
}
|
|
292
361
|
} catch (error) {
|
|
@@ -296,18 +365,39 @@ var CacheKeyDiscovery = class {
|
|
|
296
365
|
);
|
|
297
366
|
return [...matches];
|
|
298
367
|
}
|
|
299
|
-
async collectKeysMatchingPattern(pattern) {
|
|
300
|
-
const matches = new Set(
|
|
368
|
+
async collectKeysMatchingPattern(pattern, maxMatches = false) {
|
|
369
|
+
const matches = /* @__PURE__ */ new Set();
|
|
370
|
+
if (this.options.tagIndex.forEachKeyMatchingPattern) {
|
|
371
|
+
await this.options.tagIndex.forEachKeyMatchingPattern(pattern, async (key) => {
|
|
372
|
+
matches.add(key);
|
|
373
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
374
|
+
});
|
|
375
|
+
} else {
|
|
376
|
+
for (const key of await this.options.tagIndex.matchPattern(pattern)) {
|
|
377
|
+
matches.add(key);
|
|
378
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
301
381
|
await Promise.all(
|
|
302
382
|
this.options.layers.map(async (layer) => {
|
|
303
|
-
if (!layer.keys || this.options.shouldSkipLayer(layer)) {
|
|
383
|
+
if (!layer.keys && !layer.forEachKey || this.options.shouldSkipLayer(layer)) {
|
|
304
384
|
return;
|
|
305
385
|
}
|
|
306
386
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
387
|
+
if (layer.forEachKey) {
|
|
388
|
+
await layer.forEachKey(async (key) => {
|
|
389
|
+
if (PatternMatcher.matches(pattern, key)) {
|
|
390
|
+
matches.add(key);
|
|
391
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const keys = await layer.keys?.();
|
|
397
|
+
for (const key of keys ?? []) {
|
|
309
398
|
if (PatternMatcher.matches(pattern, key)) {
|
|
310
399
|
matches.add(key);
|
|
400
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
311
401
|
}
|
|
312
402
|
}
|
|
313
403
|
} catch (error) {
|
|
@@ -317,8 +407,280 @@ var CacheKeyDiscovery = class {
|
|
|
317
407
|
);
|
|
318
408
|
return [...matches];
|
|
319
409
|
}
|
|
410
|
+
assertWithinMatchLimit(matches, maxMatches) {
|
|
411
|
+
if (maxMatches !== false && matches.size > maxMatches) {
|
|
412
|
+
throw new Error(`Invalidation matched too many keys (${matches.size} > ${maxMatches}).`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
320
415
|
};
|
|
321
416
|
|
|
417
|
+
// src/internal/CacheKeySerialization.ts
|
|
418
|
+
var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
419
|
+
function normalizeForSerialization(value) {
|
|
420
|
+
if (Array.isArray(value)) {
|
|
421
|
+
return value.map((entry) => normalizeForSerialization(entry));
|
|
422
|
+
}
|
|
423
|
+
if (value && typeof value === "object") {
|
|
424
|
+
return Object.keys(value).sort().reduce((normalized, key) => {
|
|
425
|
+
if (DANGEROUS_OBJECT_KEYS.has(key)) {
|
|
426
|
+
return normalized;
|
|
427
|
+
}
|
|
428
|
+
normalized[key] = normalizeForSerialization(value[key]);
|
|
429
|
+
return normalized;
|
|
430
|
+
}, {});
|
|
431
|
+
}
|
|
432
|
+
return value;
|
|
433
|
+
}
|
|
434
|
+
function serializeKeyPart(value) {
|
|
435
|
+
if (typeof value === "string") {
|
|
436
|
+
return `s:${value}`;
|
|
437
|
+
}
|
|
438
|
+
if (typeof value === "number") {
|
|
439
|
+
return `n:${value}`;
|
|
440
|
+
}
|
|
441
|
+
if (typeof value === "boolean") {
|
|
442
|
+
return `b:${value}`;
|
|
443
|
+
}
|
|
444
|
+
return `j:${JSON.stringify(normalizeForSerialization(value))}`;
|
|
445
|
+
}
|
|
446
|
+
function serializeOptions(options) {
|
|
447
|
+
return JSON.stringify(normalizeForSerialization(options) ?? null);
|
|
448
|
+
}
|
|
449
|
+
function createInstanceId() {
|
|
450
|
+
if (globalThis.crypto?.randomUUID) {
|
|
451
|
+
return globalThis.crypto.randomUUID();
|
|
452
|
+
}
|
|
453
|
+
const bytes = new Uint8Array(16);
|
|
454
|
+
if (globalThis.crypto?.getRandomValues) {
|
|
455
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
456
|
+
} else {
|
|
457
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
458
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/internal/CacheSnapshotFile.ts
|
|
465
|
+
function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path) {
|
|
466
|
+
const relative = path.relative(realBaseDir, candidatePath);
|
|
467
|
+
return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path.isAbsolute(relative));
|
|
468
|
+
}
|
|
469
|
+
async function findExistingAncestor(directory, fs2, path) {
|
|
470
|
+
let current = directory;
|
|
471
|
+
while (true) {
|
|
472
|
+
try {
|
|
473
|
+
await fs2.lstat(current);
|
|
474
|
+
return current;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error.code !== "ENOENT") {
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const parent = path.dirname(current);
|
|
481
|
+
if (parent === current) {
|
|
482
|
+
return current;
|
|
483
|
+
}
|
|
484
|
+
current = parent;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async function validateSnapshotFilePath(filePath, mode, snapshotBaseDir, cwd = process.cwd()) {
|
|
488
|
+
if (filePath.length === 0) {
|
|
489
|
+
throw new Error("filePath must not be empty.");
|
|
490
|
+
}
|
|
491
|
+
if (filePath.includes("\0")) {
|
|
492
|
+
throw new Error("filePath must not contain null bytes.");
|
|
493
|
+
}
|
|
494
|
+
const { promises: fs2 } = await import("fs");
|
|
495
|
+
const path = await import("path");
|
|
496
|
+
const resolved = path.resolve(filePath);
|
|
497
|
+
const baseDir = snapshotBaseDir === false ? false : path.resolve(snapshotBaseDir ?? cwd);
|
|
498
|
+
if (baseDir === false) {
|
|
499
|
+
return resolved;
|
|
500
|
+
}
|
|
501
|
+
await fs2.mkdir(baseDir, { recursive: true });
|
|
502
|
+
const realBaseDir = await fs2.realpath(baseDir);
|
|
503
|
+
if (!isWithinSnapshotBase(realBaseDir, resolved, path.sep, path)) {
|
|
504
|
+
throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
|
|
505
|
+
}
|
|
506
|
+
if (mode === "read") {
|
|
507
|
+
const realTarget = await fs2.realpath(resolved);
|
|
508
|
+
if (!isWithinSnapshotBase(realBaseDir, realTarget, path.sep, path)) {
|
|
509
|
+
throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
|
|
510
|
+
}
|
|
511
|
+
return realTarget;
|
|
512
|
+
}
|
|
513
|
+
const parentDir = path.dirname(resolved);
|
|
514
|
+
const existingAncestor = await findExistingAncestor(parentDir, fs2, path);
|
|
515
|
+
const realExistingAncestor = await fs2.realpath(existingAncestor);
|
|
516
|
+
if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path.sep, path)) {
|
|
517
|
+
throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
|
|
518
|
+
}
|
|
519
|
+
await fs2.mkdir(parentDir, { recursive: true });
|
|
520
|
+
const realParentDir = await fs2.realpath(parentDir);
|
|
521
|
+
if (!isWithinSnapshotBase(realBaseDir, realParentDir, path.sep, path)) {
|
|
522
|
+
throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
|
|
523
|
+
}
|
|
524
|
+
const targetPath = path.join(realParentDir, path.basename(resolved));
|
|
525
|
+
try {
|
|
526
|
+
const existing = await fs2.lstat(targetPath);
|
|
527
|
+
if (existing.isSymbolicLink()) {
|
|
528
|
+
throw new Error("filePath must not point to a symbolic link.");
|
|
529
|
+
}
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (error.code !== "ENOENT") {
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return targetPath;
|
|
536
|
+
}
|
|
537
|
+
async function readUtf8HandleWithLimit(handle, byteLimit) {
|
|
538
|
+
if (byteLimit === false) {
|
|
539
|
+
return handle.readFile({ encoding: "utf8" });
|
|
540
|
+
}
|
|
541
|
+
const chunks = [];
|
|
542
|
+
let totalBytes = 0;
|
|
543
|
+
let position = 0;
|
|
544
|
+
while (true) {
|
|
545
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, byteLimit - totalBytes + 1));
|
|
546
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
|
|
547
|
+
if (bytesRead === 0) {
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
totalBytes += bytesRead;
|
|
551
|
+
if (totalBytes > byteLimit) {
|
|
552
|
+
throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${totalBytes} bytes > ${byteLimit} bytes).`);
|
|
553
|
+
}
|
|
554
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
555
|
+
position += bytesRead;
|
|
556
|
+
}
|
|
557
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/internal/CacheStackValidation.ts
|
|
561
|
+
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
562
|
+
var MAX_PATTERN_LENGTH = 1024;
|
|
563
|
+
var MAX_TAGS_PER_OPERATION = 128;
|
|
564
|
+
function validatePositiveNumber(name, value) {
|
|
565
|
+
if (value === void 0) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
569
|
+
throw new Error(`${name} must be a positive finite number.`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function validateNonNegativeNumber(name, value) {
|
|
573
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
574
|
+
throw new Error(`${name} must be a non-negative finite number.`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function validateLayerNumberOption(name, value) {
|
|
578
|
+
if (value === void 0) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (typeof value === "number") {
|
|
582
|
+
validateNonNegativeNumber(name, value);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
for (const [layerName, layerValue] of Object.entries(value)) {
|
|
586
|
+
if (layerValue === void 0) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function validateRateLimitOptions(name, options) {
|
|
593
|
+
if (!options) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
597
|
+
validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
598
|
+
validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
599
|
+
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
600
|
+
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
601
|
+
}
|
|
602
|
+
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
603
|
+
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function validateCacheKey(key) {
|
|
607
|
+
if (key.length === 0) {
|
|
608
|
+
throw new Error("Cache key must not be empty.");
|
|
609
|
+
}
|
|
610
|
+
if (key.length > MAX_CACHE_KEY_LENGTH) {
|
|
611
|
+
throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
612
|
+
}
|
|
613
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
614
|
+
throw new Error("Cache key contains unsupported control characters.");
|
|
615
|
+
}
|
|
616
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
617
|
+
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
618
|
+
}
|
|
619
|
+
return key;
|
|
620
|
+
}
|
|
621
|
+
function validateTag(tag) {
|
|
622
|
+
if (tag.length === 0) {
|
|
623
|
+
throw new Error("Cache tag must not be empty.");
|
|
624
|
+
}
|
|
625
|
+
if (tag.length > MAX_CACHE_KEY_LENGTH) {
|
|
626
|
+
throw new Error(`Cache tag length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
627
|
+
}
|
|
628
|
+
if (/[\u0000-\u001F\u007F]/.test(tag)) {
|
|
629
|
+
throw new Error("Cache tag contains unsupported control characters.");
|
|
630
|
+
}
|
|
631
|
+
if (/[\uD800-\uDFFF]/.test(tag)) {
|
|
632
|
+
throw new Error("Cache tag contains unsupported surrogate code points.");
|
|
633
|
+
}
|
|
634
|
+
return tag;
|
|
635
|
+
}
|
|
636
|
+
function validateTags(tags) {
|
|
637
|
+
if (!tags) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (tags.length > MAX_TAGS_PER_OPERATION) {
|
|
641
|
+
throw new Error(`options.tags must contain at most ${MAX_TAGS_PER_OPERATION} tags.`);
|
|
642
|
+
}
|
|
643
|
+
for (const tag of tags) {
|
|
644
|
+
validateTag(tag);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function validatePattern(pattern) {
|
|
648
|
+
if (pattern.length === 0) {
|
|
649
|
+
throw new Error("Pattern must not be empty.");
|
|
650
|
+
}
|
|
651
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
652
|
+
throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
|
|
653
|
+
}
|
|
654
|
+
if (/[\u0000-\u001F\u007F]/.test(pattern)) {
|
|
655
|
+
throw new Error("Pattern contains unsupported control characters.");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function validateTtlPolicy(name, policy) {
|
|
659
|
+
if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if ("alignTo" in policy) {
|
|
663
|
+
validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
throw new Error(`${name} is invalid.`);
|
|
667
|
+
}
|
|
668
|
+
function validateAdaptiveTtlOptions(options) {
|
|
669
|
+
if (!options || options === true) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
|
|
673
|
+
validateLayerNumberOption("adaptiveTtl.step", options.step);
|
|
674
|
+
validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
|
|
675
|
+
}
|
|
676
|
+
function validateCircuitBreakerOptions(options) {
|
|
677
|
+
if (!options) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
|
|
681
|
+
validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
|
|
682
|
+
}
|
|
683
|
+
|
|
322
684
|
// src/internal/CircuitBreakerManager.ts
|
|
323
685
|
var CircuitBreakerManager = class {
|
|
324
686
|
breakers = /* @__PURE__ */ new Map();
|
|
@@ -337,9 +699,7 @@ var CircuitBreakerManager = class {
|
|
|
337
699
|
}
|
|
338
700
|
const now = Date.now();
|
|
339
701
|
if (state.openUntil <= now) {
|
|
340
|
-
|
|
341
|
-
state.failures = 0;
|
|
342
|
-
this.breakers.set(key, state);
|
|
702
|
+
this.breakers.delete(key);
|
|
343
703
|
return;
|
|
344
704
|
}
|
|
345
705
|
const remainingMs = state.openUntil - now;
|
|
@@ -350,15 +710,15 @@ var CircuitBreakerManager = class {
|
|
|
350
710
|
if (!options) {
|
|
351
711
|
return;
|
|
352
712
|
}
|
|
713
|
+
this.pruneIfNeeded();
|
|
353
714
|
const failureThreshold = options.failureThreshold ?? 3;
|
|
354
715
|
const cooldownMs = options.cooldownMs ?? 3e4;
|
|
355
|
-
const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
|
|
716
|
+
const state = this.breakers.get(key) ?? { failures: 0, openUntil: null, createdAt: Date.now() };
|
|
356
717
|
state.failures += 1;
|
|
357
718
|
if (state.failures >= failureThreshold) {
|
|
358
719
|
state.openUntil = Date.now() + cooldownMs;
|
|
359
720
|
}
|
|
360
721
|
this.breakers.set(key, state);
|
|
361
|
-
this.pruneIfNeeded();
|
|
362
722
|
}
|
|
363
723
|
recordSuccess(key) {
|
|
364
724
|
this.breakers.delete(key);
|
|
@@ -369,8 +729,7 @@ var CircuitBreakerManager = class {
|
|
|
369
729
|
return false;
|
|
370
730
|
}
|
|
371
731
|
if (state.openUntil <= Date.now()) {
|
|
372
|
-
|
|
373
|
-
state.failures = 0;
|
|
732
|
+
this.breakers.delete(key);
|
|
374
733
|
return false;
|
|
375
734
|
}
|
|
376
735
|
return true;
|
|
@@ -394,15 +753,20 @@ var CircuitBreakerManager = class {
|
|
|
394
753
|
if (this.breakers.size <= this.maxEntries) {
|
|
395
754
|
return;
|
|
396
755
|
}
|
|
756
|
+
const now = Date.now();
|
|
397
757
|
for (const [key, state] of this.breakers.entries()) {
|
|
398
758
|
if (this.breakers.size <= this.maxEntries) {
|
|
399
|
-
|
|
759
|
+
return;
|
|
400
760
|
}
|
|
401
|
-
if (!state.openUntil || state.openUntil <=
|
|
761
|
+
if (!state.openUntil || state.openUntil <= now) {
|
|
402
762
|
this.breakers.delete(key);
|
|
403
763
|
}
|
|
404
764
|
}
|
|
405
|
-
|
|
765
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const sorted = [...this.breakers.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
769
|
+
for (const [key] of sorted) {
|
|
406
770
|
if (this.breakers.size <= this.maxEntries) {
|
|
407
771
|
break;
|
|
408
772
|
}
|
|
@@ -412,6 +776,7 @@ var CircuitBreakerManager = class {
|
|
|
412
776
|
};
|
|
413
777
|
|
|
414
778
|
// src/internal/FetchRateLimiter.ts
|
|
779
|
+
var MAX_BUCKETS = 1e4;
|
|
415
780
|
var FetchRateLimiter = class {
|
|
416
781
|
buckets = /* @__PURE__ */ new Map();
|
|
417
782
|
queuesByBucket = /* @__PURE__ */ new Map();
|
|
@@ -577,10 +942,25 @@ var FetchRateLimiter = class {
|
|
|
577
942
|
if (existing) {
|
|
578
943
|
return existing;
|
|
579
944
|
}
|
|
945
|
+
if (this.buckets.size >= MAX_BUCKETS) {
|
|
946
|
+
this.evictIdleBuckets();
|
|
947
|
+
}
|
|
580
948
|
const bucket = { active: 0, startedAt: [] };
|
|
581
949
|
this.buckets.set(bucketKey, bucket);
|
|
582
950
|
return bucket;
|
|
583
951
|
}
|
|
952
|
+
evictIdleBuckets() {
|
|
953
|
+
for (const [key, bucket] of this.buckets.entries()) {
|
|
954
|
+
if (this.buckets.size <= MAX_BUCKETS * 0.9) {
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
if (bucket.active === 0 && bucket.startedAt.length === 0 && !this.queuesByBucket.has(key)) {
|
|
958
|
+
this.buckets.delete(key);
|
|
959
|
+
this.queuesByBucket.delete(key);
|
|
960
|
+
this.pendingBuckets.delete(key);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
584
964
|
cleanupBucket(bucketKey, bucket, intervalMs) {
|
|
585
965
|
const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
|
|
586
966
|
if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
|
|
@@ -783,35 +1163,39 @@ var TtlResolver = class {
|
|
|
783
1163
|
return;
|
|
784
1164
|
}
|
|
785
1165
|
const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
|
|
786
|
-
|
|
787
|
-
for (
|
|
788
|
-
|
|
789
|
-
|
|
1166
|
+
const sorted = [...this.accessProfiles.entries()].sort((a, b) => a[1].lastAccessAt - b[1].lastAccessAt);
|
|
1167
|
+
for (let i = 0; i < toRemove && i < sorted.length; i++) {
|
|
1168
|
+
const entry = sorted[i];
|
|
1169
|
+
if (entry) {
|
|
1170
|
+
this.accessProfiles.delete(entry[0]);
|
|
790
1171
|
}
|
|
791
|
-
this.accessProfiles.delete(key);
|
|
792
|
-
removed += 1;
|
|
793
1172
|
}
|
|
794
1173
|
}
|
|
795
1174
|
};
|
|
796
1175
|
|
|
797
1176
|
// src/serialization/JsonSerializer.ts
|
|
798
1177
|
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
1178
|
+
var MAX_SANITIZE_NODES = 1e4;
|
|
799
1179
|
var JsonSerializer = class {
|
|
800
1180
|
serialize(value) {
|
|
801
1181
|
return JSON.stringify(value);
|
|
802
1182
|
}
|
|
803
1183
|
deserialize(payload) {
|
|
804
1184
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
805
|
-
return sanitizeJsonValue(JSON.parse(normalized), 0);
|
|
1185
|
+
return sanitizeJsonValue(JSON.parse(normalized), 0, { count: 0 });
|
|
806
1186
|
}
|
|
807
1187
|
};
|
|
808
1188
|
var MAX_SANITIZE_DEPTH = 200;
|
|
809
|
-
function sanitizeJsonValue(value, depth) {
|
|
1189
|
+
function sanitizeJsonValue(value, depth, state) {
|
|
1190
|
+
state.count += 1;
|
|
1191
|
+
if (state.count > MAX_SANITIZE_NODES) {
|
|
1192
|
+
throw new Error(`JSON payload exceeds max node count of ${MAX_SANITIZE_NODES}.`);
|
|
1193
|
+
}
|
|
810
1194
|
if (depth > MAX_SANITIZE_DEPTH) {
|
|
811
|
-
|
|
1195
|
+
throw new Error(`JSON payload exceeds max depth of ${MAX_SANITIZE_DEPTH}.`);
|
|
812
1196
|
}
|
|
813
1197
|
if (Array.isArray(value)) {
|
|
814
|
-
return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
|
|
1198
|
+
return value.map((entry) => sanitizeJsonValue(entry, depth + 1, state));
|
|
815
1199
|
}
|
|
816
1200
|
if (!isPlainObject(value)) {
|
|
817
1201
|
return value;
|
|
@@ -821,7 +1205,7 @@ function sanitizeJsonValue(value, depth) {
|
|
|
821
1205
|
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
822
1206
|
continue;
|
|
823
1207
|
}
|
|
824
|
-
sanitized[key] = sanitizeJsonValue(entry, depth + 1);
|
|
1208
|
+
sanitized[key] = sanitizeJsonValue(entry, depth + 1, state);
|
|
825
1209
|
}
|
|
826
1210
|
return sanitized;
|
|
827
1211
|
}
|
|
@@ -871,9 +1255,11 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
|
871
1255
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
872
1256
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
873
1257
|
var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
|
|
874
|
-
var
|
|
1258
|
+
var DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
|
|
1259
|
+
var DEFAULT_SNAPSHOT_MAX_ENTRIES = 1e4;
|
|
1260
|
+
var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
|
|
1261
|
+
var DEFAULT_INVALIDATION_MAX_KEYS = 1e4;
|
|
875
1262
|
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
876
|
-
var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
877
1263
|
var DebugLogger = class {
|
|
878
1264
|
enabled;
|
|
879
1265
|
constructor(enabled) {
|
|
@@ -960,6 +1346,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
960
1346
|
snapshotSerializer = new JsonSerializer();
|
|
961
1347
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
962
1348
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
1349
|
+
keyEpochs = /* @__PURE__ */ new Map();
|
|
963
1350
|
ttlResolver;
|
|
964
1351
|
circuitBreakerManager;
|
|
965
1352
|
currentGeneration;
|
|
@@ -967,6 +1354,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
967
1354
|
writeBehindTimer;
|
|
968
1355
|
writeBehindFlushPromise;
|
|
969
1356
|
generationCleanupPromise;
|
|
1357
|
+
clearEpoch = 0;
|
|
970
1358
|
isDisconnecting = false;
|
|
971
1359
|
disconnectPromise;
|
|
972
1360
|
/**
|
|
@@ -976,7 +1364,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
976
1364
|
* and no `fetcher` is provided.
|
|
977
1365
|
*/
|
|
978
1366
|
async get(key, fetcher, options) {
|
|
979
|
-
const normalizedKey = this.qualifyKey(
|
|
1367
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
980
1368
|
this.validateWriteOptions(options);
|
|
981
1369
|
await this.awaitStartup("get");
|
|
982
1370
|
return this.getPrepared(normalizedKey, fetcher, options);
|
|
@@ -1046,7 +1434,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1046
1434
|
* Returns true if the given key exists and is not expired in any layer.
|
|
1047
1435
|
*/
|
|
1048
1436
|
async has(key) {
|
|
1049
|
-
const normalizedKey = this.qualifyKey(
|
|
1437
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1050
1438
|
await this.awaitStartup("has");
|
|
1051
1439
|
for (const layer of this.layers) {
|
|
1052
1440
|
if (this.shouldSkipLayer(layer)) {
|
|
@@ -1079,7 +1467,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1079
1467
|
* that has it, or null if the key is not found / has no TTL.
|
|
1080
1468
|
*/
|
|
1081
1469
|
async ttl(key) {
|
|
1082
|
-
const normalizedKey = this.qualifyKey(
|
|
1470
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1083
1471
|
await this.awaitStartup("ttl");
|
|
1084
1472
|
for (const layer of this.layers) {
|
|
1085
1473
|
if (this.shouldSkipLayer(layer)) {
|
|
@@ -1101,7 +1489,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1101
1489
|
* Stores a value in all cache layers. Overwrites any existing value.
|
|
1102
1490
|
*/
|
|
1103
1491
|
async set(key, value, options) {
|
|
1104
|
-
const normalizedKey = this.qualifyKey(
|
|
1492
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1105
1493
|
this.validateWriteOptions(options);
|
|
1106
1494
|
await this.awaitStartup("set");
|
|
1107
1495
|
await this.storeEntry(normalizedKey, "value", value, options);
|
|
@@ -1110,7 +1498,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1110
1498
|
* Deletes the key from all layers and publishes an invalidation message.
|
|
1111
1499
|
*/
|
|
1112
1500
|
async delete(key) {
|
|
1113
|
-
const normalizedKey = this.qualifyKey(
|
|
1501
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1114
1502
|
await this.awaitStartup("delete");
|
|
1115
1503
|
await this.deleteKeys([normalizedKey]);
|
|
1116
1504
|
await this.publishInvalidation({
|
|
@@ -1122,6 +1510,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1122
1510
|
}
|
|
1123
1511
|
async clear() {
|
|
1124
1512
|
await this.awaitStartup("clear");
|
|
1513
|
+
this.beginClearEpoch();
|
|
1125
1514
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
1126
1515
|
await this.tagIndex.clear();
|
|
1127
1516
|
this.ttlResolver.clearProfiles();
|
|
@@ -1138,7 +1527,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1138
1527
|
return;
|
|
1139
1528
|
}
|
|
1140
1529
|
await this.awaitStartup("mdelete");
|
|
1141
|
-
const normalizedKeys = keys.map((k) =>
|
|
1530
|
+
const normalizedKeys = keys.map((k) => validateCacheKey(k));
|
|
1142
1531
|
const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key));
|
|
1143
1532
|
await this.deleteKeys(cacheKeys);
|
|
1144
1533
|
await this.publishInvalidation({
|
|
@@ -1155,7 +1544,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1155
1544
|
}
|
|
1156
1545
|
const normalizedEntries = entries.map((entry) => ({
|
|
1157
1546
|
...entry,
|
|
1158
|
-
key: this.qualifyKey(
|
|
1547
|
+
key: this.qualifyKey(validateCacheKey(entry.key))
|
|
1159
1548
|
}));
|
|
1160
1549
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
1161
1550
|
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
@@ -1164,7 +1553,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1164
1553
|
const pendingReads = /* @__PURE__ */ new Map();
|
|
1165
1554
|
return Promise.all(
|
|
1166
1555
|
normalizedEntries.map((entry) => {
|
|
1167
|
-
const optionsSignature =
|
|
1556
|
+
const optionsSignature = serializeOptions(entry.options);
|
|
1168
1557
|
const existing = pendingReads.get(entry.key);
|
|
1169
1558
|
if (!existing) {
|
|
1170
1559
|
const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
|
|
@@ -1233,7 +1622,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1233
1622
|
this.assertActive("mset");
|
|
1234
1623
|
const normalizedEntries = entries.map((entry) => ({
|
|
1235
1624
|
...entry,
|
|
1236
|
-
key: this.qualifyKey(
|
|
1625
|
+
key: this.qualifyKey(validateCacheKey(entry.key))
|
|
1237
1626
|
}));
|
|
1238
1627
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
1239
1628
|
await this.awaitStartup("mset");
|
|
@@ -1276,7 +1665,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1276
1665
|
*/
|
|
1277
1666
|
wrap(prefix, fetcher, options = {}) {
|
|
1278
1667
|
return (...args) => {
|
|
1279
|
-
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) =>
|
|
1668
|
+
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => serializeKeyPart(argument)).join(":");
|
|
1280
1669
|
const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
|
|
1281
1670
|
return this.get(key, () => fetcher(...args), options);
|
|
1282
1671
|
};
|
|
@@ -1286,11 +1675,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
1286
1675
|
* `prefix:`. Useful for multi-tenant or module-level isolation.
|
|
1287
1676
|
*/
|
|
1288
1677
|
namespace(prefix) {
|
|
1678
|
+
validateNamespaceKey(prefix);
|
|
1289
1679
|
return new CacheNamespace(this, prefix);
|
|
1290
1680
|
}
|
|
1291
1681
|
async invalidateByTag(tag) {
|
|
1682
|
+
validateTag(tag);
|
|
1292
1683
|
await this.awaitStartup("invalidateByTag");
|
|
1293
|
-
const keys = await this.
|
|
1684
|
+
const keys = await this.collectKeysForTag(tag);
|
|
1294
1685
|
await this.deleteKeys(keys);
|
|
1295
1686
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1296
1687
|
}
|
|
@@ -1298,22 +1689,28 @@ var CacheStack = class extends EventEmitter {
|
|
|
1298
1689
|
if (tags.length === 0) {
|
|
1299
1690
|
return;
|
|
1300
1691
|
}
|
|
1692
|
+
validateTags(tags);
|
|
1301
1693
|
await this.awaitStartup("invalidateByTags");
|
|
1302
|
-
const keysByTag = await Promise.all(tags.map((tag) => this.
|
|
1694
|
+
const keysByTag = await Promise.all(tags.map((tag) => this.collectKeysForTag(tag)));
|
|
1303
1695
|
const keys = mode === "all" ? this.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())];
|
|
1696
|
+
this.assertWithinInvalidationKeyLimit(keys.length);
|
|
1304
1697
|
await this.deleteKeys(keys);
|
|
1305
1698
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1306
1699
|
}
|
|
1307
1700
|
async invalidateByPattern(pattern) {
|
|
1701
|
+
validatePattern(pattern);
|
|
1308
1702
|
await this.awaitStartup("invalidateByPattern");
|
|
1309
|
-
const keys = await this.keyDiscovery.collectKeysMatchingPattern(
|
|
1703
|
+
const keys = await this.keyDiscovery.collectKeysMatchingPattern(
|
|
1704
|
+
this.qualifyPattern(pattern),
|
|
1705
|
+
this.invalidationMaxKeys()
|
|
1706
|
+
);
|
|
1310
1707
|
await this.deleteKeys(keys);
|
|
1311
1708
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1312
1709
|
}
|
|
1313
1710
|
async invalidateByPrefix(prefix) {
|
|
1314
1711
|
await this.awaitStartup("invalidateByPrefix");
|
|
1315
|
-
const qualifiedPrefix = this.qualifyKey(
|
|
1316
|
-
const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
|
|
1712
|
+
const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix));
|
|
1713
|
+
const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys());
|
|
1317
1714
|
await this.deleteKeys(keys);
|
|
1318
1715
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1319
1716
|
}
|
|
@@ -1383,7 +1780,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1383
1780
|
* Returns `null` if the key does not exist in any layer.
|
|
1384
1781
|
*/
|
|
1385
1782
|
async inspect(key) {
|
|
1386
|
-
const userKey =
|
|
1783
|
+
const userKey = validateCacheKey(key);
|
|
1387
1784
|
const normalizedKey = this.qualifyKey(userKey);
|
|
1388
1785
|
await this.awaitStartup("inspect");
|
|
1389
1786
|
const foundInLayers = [];
|
|
@@ -1420,50 +1817,79 @@ var CacheStack = class extends EventEmitter {
|
|
|
1420
1817
|
}
|
|
1421
1818
|
async exportState() {
|
|
1422
1819
|
await this.awaitStartup("exportState");
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
const keys = await layer.keys();
|
|
1429
|
-
for (const key of keys) {
|
|
1430
|
-
const exportedKey = this.stripQualifiedKey(key);
|
|
1431
|
-
if (exported.has(exportedKey)) {
|
|
1432
|
-
continue;
|
|
1433
|
-
}
|
|
1434
|
-
const stored = await this.readLayerEntry(layer, key);
|
|
1435
|
-
if (stored === null) {
|
|
1436
|
-
continue;
|
|
1437
|
-
}
|
|
1438
|
-
exported.set(exportedKey, {
|
|
1439
|
-
key: exportedKey,
|
|
1440
|
-
value: stored,
|
|
1441
|
-
ttl: remainingStoredTtlSeconds(stored)
|
|
1442
|
-
});
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
return [...exported.values()];
|
|
1820
|
+
const entries = [];
|
|
1821
|
+
await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
|
|
1822
|
+
entries.push(entry);
|
|
1823
|
+
});
|
|
1824
|
+
return entries;
|
|
1446
1825
|
}
|
|
1447
1826
|
async importState(entries) {
|
|
1448
1827
|
await this.awaitStartup("importState");
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1828
|
+
const normalizedEntries = entries.map((entry) => ({
|
|
1829
|
+
key: this.qualifyKey(validateCacheKey(entry.key)),
|
|
1830
|
+
value: entry.value,
|
|
1831
|
+
ttl: entry.ttl
|
|
1832
|
+
}));
|
|
1833
|
+
for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
|
|
1834
|
+
const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
|
|
1835
|
+
await Promise.all(
|
|
1836
|
+
batch.map(async (entry) => {
|
|
1837
|
+
await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
1838
|
+
await this.tagIndex.touch(entry.key);
|
|
1839
|
+
})
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1456
1842
|
}
|
|
1457
1843
|
async persistToFile(filePath) {
|
|
1458
1844
|
this.assertActive("persistToFile");
|
|
1459
|
-
const snapshot = await this.exportState();
|
|
1460
1845
|
const { promises: fs2 } = await import("fs");
|
|
1461
|
-
|
|
1846
|
+
const path = await import("path");
|
|
1847
|
+
const targetPath = await validateSnapshotFilePath(filePath, "write", this.options.snapshotBaseDir);
|
|
1848
|
+
const tempPath = path.join(
|
|
1849
|
+
path.dirname(targetPath),
|
|
1850
|
+
`.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
|
|
1851
|
+
);
|
|
1852
|
+
let handle;
|
|
1853
|
+
try {
|
|
1854
|
+
handle = await fs2.open(tempPath, "wx");
|
|
1855
|
+
const openedHandle = handle;
|
|
1856
|
+
await openedHandle.writeFile("[", "utf8");
|
|
1857
|
+
let wroteAny = false;
|
|
1858
|
+
await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
|
|
1859
|
+
await openedHandle.writeFile(wroteAny ? ",\n" : "\n", "utf8");
|
|
1860
|
+
await openedHandle.writeFile(JSON.stringify(entry, null, 2), "utf8");
|
|
1861
|
+
wroteAny = true;
|
|
1862
|
+
});
|
|
1863
|
+
await openedHandle.writeFile(wroteAny ? "\n]" : "]", "utf8");
|
|
1864
|
+
await openedHandle.close();
|
|
1865
|
+
handle = void 0;
|
|
1866
|
+
await fs2.rename(tempPath, targetPath);
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
await handle?.close().catch(() => void 0);
|
|
1869
|
+
await fs2.unlink(tempPath).catch(() => void 0);
|
|
1870
|
+
throw error;
|
|
1871
|
+
}
|
|
1462
1872
|
}
|
|
1463
1873
|
async restoreFromFile(filePath) {
|
|
1464
1874
|
this.assertActive("restoreFromFile");
|
|
1465
|
-
const { promises: fs2 } = await import("fs");
|
|
1466
|
-
const
|
|
1875
|
+
const { promises: fs2, constants } = await import("fs");
|
|
1876
|
+
const validatedPath = await validateSnapshotFilePath(filePath, "read", this.options.snapshotBaseDir);
|
|
1877
|
+
const handle = await fs2.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
1878
|
+
const snapshotMaxBytes = this.snapshotMaxBytes();
|
|
1879
|
+
let raw;
|
|
1880
|
+
try {
|
|
1881
|
+
if (snapshotMaxBytes !== false) {
|
|
1882
|
+
const stat = await handle.stat();
|
|
1883
|
+
if (stat.size > snapshotMaxBytes) {
|
|
1884
|
+
throw new Error(
|
|
1885
|
+
`Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${snapshotMaxBytes} bytes).`
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
raw = await readUtf8HandleWithLimit(handle, snapshotMaxBytes);
|
|
1890
|
+
} finally {
|
|
1891
|
+
await handle.close();
|
|
1892
|
+
}
|
|
1467
1893
|
let parsed;
|
|
1468
1894
|
try {
|
|
1469
1895
|
parsed = JSON.parse(raw);
|
|
@@ -1507,14 +1933,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1507
1933
|
await this.handleInvalidationMessage(message);
|
|
1508
1934
|
});
|
|
1509
1935
|
}
|
|
1510
|
-
async fetchWithGuards(key, fetcher, options) {
|
|
1936
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
1511
1937
|
const fetchTask = async () => {
|
|
1512
1938
|
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
1513
1939
|
if (secondHit.found) {
|
|
1514
1940
|
this.metricsCollector.increment("hits");
|
|
1515
1941
|
return secondHit.value;
|
|
1516
1942
|
}
|
|
1517
|
-
return this.fetchAndPopulate(key, fetcher, options);
|
|
1943
|
+
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
1518
1944
|
};
|
|
1519
1945
|
const singleFlightTask = async () => {
|
|
1520
1946
|
if (!this.options.singleFlightCoordinator) {
|
|
@@ -1524,7 +1950,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1524
1950
|
key,
|
|
1525
1951
|
this.resolveSingleFlightOptions(),
|
|
1526
1952
|
fetchTask,
|
|
1527
|
-
() => this.waitForFreshValue(key, fetcher, options)
|
|
1953
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
1528
1954
|
);
|
|
1529
1955
|
};
|
|
1530
1956
|
if (this.options.stampedePrevention === false) {
|
|
@@ -1532,7 +1958,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1532
1958
|
}
|
|
1533
1959
|
return this.stampedeGuard.execute(key, singleFlightTask);
|
|
1534
1960
|
}
|
|
1535
|
-
async waitForFreshValue(key, fetcher, options) {
|
|
1961
|
+
async waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
1536
1962
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
1537
1963
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
1538
1964
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -1546,9 +1972,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
1546
1972
|
}
|
|
1547
1973
|
await this.sleep(pollIntervalMs);
|
|
1548
1974
|
}
|
|
1549
|
-
return this.fetchAndPopulate(key, fetcher, options);
|
|
1975
|
+
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
1550
1976
|
}
|
|
1551
|
-
async fetchAndPopulate(key, fetcher, options) {
|
|
1977
|
+
async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
1552
1978
|
this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
1553
1979
|
this.metricsCollector.increment("fetches");
|
|
1554
1980
|
const fetchStart = Date.now();
|
|
@@ -1569,6 +1995,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
1569
1995
|
if (!this.shouldNegativeCache(options)) {
|
|
1570
1996
|
return null;
|
|
1571
1997
|
}
|
|
1998
|
+
if (this.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
|
|
1999
|
+
this.logger.debug?.("skip-negative-store-after-invalidation", {
|
|
2000
|
+
key,
|
|
2001
|
+
expectedClearEpoch,
|
|
2002
|
+
clearEpoch: this.clearEpoch,
|
|
2003
|
+
expectedKeyEpoch,
|
|
2004
|
+
keyEpoch: this.currentKeyEpoch(key)
|
|
2005
|
+
});
|
|
2006
|
+
return null;
|
|
2007
|
+
}
|
|
1572
2008
|
await this.storeEntry(key, "empty", null, options);
|
|
1573
2009
|
return null;
|
|
1574
2010
|
}
|
|
@@ -1581,11 +2017,26 @@ var CacheStack = class extends EventEmitter {
|
|
|
1581
2017
|
this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
|
|
1582
2018
|
}
|
|
1583
2019
|
}
|
|
2020
|
+
if (this.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
|
|
2021
|
+
this.logger.debug?.("skip-store-after-invalidation", {
|
|
2022
|
+
key,
|
|
2023
|
+
expectedClearEpoch,
|
|
2024
|
+
clearEpoch: this.clearEpoch,
|
|
2025
|
+
expectedKeyEpoch,
|
|
2026
|
+
keyEpoch: this.currentKeyEpoch(key)
|
|
2027
|
+
});
|
|
2028
|
+
return fetched;
|
|
2029
|
+
}
|
|
1584
2030
|
await this.storeEntry(key, "value", fetched, options);
|
|
1585
2031
|
return fetched;
|
|
1586
2032
|
}
|
|
1587
2033
|
async storeEntry(key, kind, value, options) {
|
|
2034
|
+
const clearEpoch = this.clearEpoch;
|
|
2035
|
+
const keyEpoch = this.currentKeyEpoch(key);
|
|
1588
2036
|
await this.writeAcrossLayers(key, kind, value, options);
|
|
2037
|
+
if (this.isWriteOutdated(key, clearEpoch, keyEpoch)) {
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
1589
2040
|
if (options?.tags) {
|
|
1590
2041
|
await this.tagIndex.track(key, options.tags);
|
|
1591
2042
|
} else {
|
|
@@ -1600,6 +2051,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
1600
2051
|
}
|
|
1601
2052
|
async writeBatch(entries) {
|
|
1602
2053
|
const now = Date.now();
|
|
2054
|
+
const clearEpoch = this.clearEpoch;
|
|
2055
|
+
const entryEpochs = new Map(entries.map((entry) => [entry.key, this.currentKeyEpoch(entry.key)]));
|
|
1603
2056
|
const entriesByLayer = /* @__PURE__ */ new Map();
|
|
1604
2057
|
const immediateOperations = [];
|
|
1605
2058
|
const deferredOperations = [];
|
|
@@ -1616,12 +2069,21 @@ var CacheStack = class extends EventEmitter {
|
|
|
1616
2069
|
}
|
|
1617
2070
|
for (const [layer, layerEntries] of entriesByLayer.entries()) {
|
|
1618
2071
|
const operation = async () => {
|
|
2072
|
+
if (clearEpoch !== this.clearEpoch) {
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
const activeEntries = layerEntries.filter(
|
|
2076
|
+
(entry) => (entryEpochs.get(entry.key) ?? 0) === this.currentKeyEpoch(entry.key)
|
|
2077
|
+
);
|
|
2078
|
+
if (activeEntries.length === 0) {
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
1619
2081
|
try {
|
|
1620
2082
|
if (layer.setMany) {
|
|
1621
|
-
await layer.setMany(
|
|
2083
|
+
await layer.setMany(activeEntries);
|
|
1622
2084
|
return;
|
|
1623
2085
|
}
|
|
1624
|
-
await Promise.all(
|
|
2086
|
+
await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
1625
2087
|
} catch (error) {
|
|
1626
2088
|
await this.handleLayerFailure(layer, "write", error);
|
|
1627
2089
|
}
|
|
@@ -1634,7 +2096,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
1634
2096
|
}
|
|
1635
2097
|
await this.executeLayerOperations(immediateOperations, { key: "batch", action: "mset" });
|
|
1636
2098
|
await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)));
|
|
2099
|
+
if (clearEpoch !== this.clearEpoch) {
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
1637
2102
|
for (const entry of entries) {
|
|
2103
|
+
if (this.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
1638
2106
|
if (entry.options?.tags) {
|
|
1639
2107
|
await this.tagIndex.track(entry.key, entry.options.tags);
|
|
1640
2108
|
} else {
|
|
@@ -1736,10 +2204,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
1736
2204
|
}
|
|
1737
2205
|
async writeAcrossLayers(key, kind, value, options) {
|
|
1738
2206
|
const now = Date.now();
|
|
2207
|
+
const clearEpoch = this.clearEpoch;
|
|
2208
|
+
const keyEpoch = this.currentKeyEpoch(key);
|
|
1739
2209
|
const immediateOperations = [];
|
|
1740
2210
|
const deferredOperations = [];
|
|
1741
2211
|
for (const layer of this.layers) {
|
|
1742
2212
|
const operation = async () => {
|
|
2213
|
+
if (this.isWriteOutdated(key, clearEpoch, keyEpoch)) {
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
1743
2216
|
if (this.shouldSkipLayer(layer)) {
|
|
1744
2217
|
return;
|
|
1745
2218
|
}
|
|
@@ -1803,10 +2276,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
1803
2276
|
if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
|
|
1804
2277
|
return;
|
|
1805
2278
|
}
|
|
2279
|
+
const clearEpoch = this.clearEpoch;
|
|
2280
|
+
const keyEpoch = this.currentKeyEpoch(key);
|
|
1806
2281
|
const refresh = (async () => {
|
|
1807
2282
|
this.metricsCollector.increment("refreshes");
|
|
1808
2283
|
try {
|
|
1809
|
-
await this.runBackgroundRefresh(key, fetcher, options);
|
|
2284
|
+
await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
|
|
1810
2285
|
} catch (error) {
|
|
1811
2286
|
this.metricsCollector.increment("refreshErrors");
|
|
1812
2287
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
@@ -1816,14 +2291,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
1816
2291
|
})();
|
|
1817
2292
|
this.backgroundRefreshes.set(key, refresh);
|
|
1818
2293
|
}
|
|
1819
|
-
async runBackgroundRefresh(key, fetcher, options) {
|
|
2294
|
+
async runBackgroundRefresh(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
1820
2295
|
const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
|
|
1821
2296
|
await this.fetchWithGuards(
|
|
1822
2297
|
key,
|
|
1823
2298
|
() => this.withTimeout(fetcher(), timeoutMs, () => {
|
|
1824
2299
|
return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
|
|
1825
2300
|
}),
|
|
1826
|
-
options
|
|
2301
|
+
options,
|
|
2302
|
+
expectedClearEpoch,
|
|
2303
|
+
expectedKeyEpoch
|
|
1827
2304
|
);
|
|
1828
2305
|
}
|
|
1829
2306
|
resolveSingleFlightOptions() {
|
|
@@ -1838,6 +2315,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1838
2315
|
if (keys.length === 0) {
|
|
1839
2316
|
return;
|
|
1840
2317
|
}
|
|
2318
|
+
this.bumpKeyEpochs(keys);
|
|
1841
2319
|
await this.deleteKeysFromLayers(this.layers, keys);
|
|
1842
2320
|
for (const key of keys) {
|
|
1843
2321
|
await this.tagIndex.remove(key);
|
|
@@ -1860,21 +2338,22 @@ var CacheStack = class extends EventEmitter {
|
|
|
1860
2338
|
return;
|
|
1861
2339
|
}
|
|
1862
2340
|
const localLayers = this.layers.filter((layer) => layer.isLocal);
|
|
1863
|
-
if (localLayers.length === 0) {
|
|
1864
|
-
return;
|
|
1865
|
-
}
|
|
1866
2341
|
if (message.scope === "clear") {
|
|
2342
|
+
this.beginClearEpoch();
|
|
1867
2343
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
1868
2344
|
await this.tagIndex.clear();
|
|
1869
2345
|
this.ttlResolver.clearProfiles();
|
|
2346
|
+
this.circuitBreakerManager.clear();
|
|
1870
2347
|
return;
|
|
1871
2348
|
}
|
|
1872
2349
|
const keys = message.keys ?? [];
|
|
2350
|
+
this.bumpKeyEpochs(keys);
|
|
1873
2351
|
await this.deleteKeysFromLayers(localLayers, keys);
|
|
1874
2352
|
if (message.operation !== "write") {
|
|
1875
2353
|
for (const key of keys) {
|
|
1876
2354
|
await this.tagIndex.remove(key);
|
|
1877
2355
|
this.ttlResolver.deleteProfile(key);
|
|
2356
|
+
this.circuitBreakerManager.delete(key);
|
|
1878
2357
|
}
|
|
1879
2358
|
}
|
|
1880
2359
|
}
|
|
@@ -1980,6 +2459,28 @@ var CacheStack = class extends EventEmitter {
|
|
|
1980
2459
|
shouldWriteBehind(layer) {
|
|
1981
2460
|
return this.options.writeStrategy === "write-behind" && !layer.isLocal;
|
|
1982
2461
|
}
|
|
2462
|
+
beginClearEpoch() {
|
|
2463
|
+
this.clearEpoch += 1;
|
|
2464
|
+
this.keyEpochs.clear();
|
|
2465
|
+
this.writeBehindQueue.length = 0;
|
|
2466
|
+
}
|
|
2467
|
+
currentKeyEpoch(key) {
|
|
2468
|
+
return this.keyEpochs.get(key) ?? 0;
|
|
2469
|
+
}
|
|
2470
|
+
bumpKeyEpochs(keys) {
|
|
2471
|
+
for (const key of keys) {
|
|
2472
|
+
this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch) {
|
|
2476
|
+
if (expectedClearEpoch !== void 0 && expectedClearEpoch !== this.clearEpoch) {
|
|
2477
|
+
return true;
|
|
2478
|
+
}
|
|
2479
|
+
if (expectedKeyEpoch !== void 0 && expectedKeyEpoch !== this.currentKeyEpoch(key)) {
|
|
2480
|
+
return true;
|
|
2481
|
+
}
|
|
2482
|
+
return false;
|
|
2483
|
+
}
|
|
1983
2484
|
async enqueueWriteBehind(operation) {
|
|
1984
2485
|
this.writeBehindQueue.push(operation);
|
|
1985
2486
|
const batchSize = this.options.writeBehind?.batchSize ?? 100;
|
|
@@ -2106,107 +2607,50 @@ var CacheStack = class extends EventEmitter {
|
|
|
2106
2607
|
if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
|
|
2107
2608
|
throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
|
|
2108
2609
|
}
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2610
|
+
validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
|
|
2611
|
+
validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
|
|
2612
|
+
validateLayerNumberOption("staleIfError", this.options.staleIfError);
|
|
2613
|
+
validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
|
|
2614
|
+
validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
|
|
2615
|
+
validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
|
|
2616
|
+
validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
2617
|
+
validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
2618
|
+
validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
|
|
2619
|
+
validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
|
|
2620
|
+
if (this.options.snapshotMaxBytes !== false) {
|
|
2621
|
+
validatePositiveNumber("snapshotMaxBytes", this.options.snapshotMaxBytes);
|
|
2622
|
+
}
|
|
2623
|
+
if (this.options.snapshotMaxEntries !== false) {
|
|
2624
|
+
validatePositiveNumber("snapshotMaxEntries", this.options.snapshotMaxEntries);
|
|
2625
|
+
}
|
|
2626
|
+
if (this.options.invalidationMaxKeys !== false) {
|
|
2627
|
+
validatePositiveNumber("invalidationMaxKeys", this.options.invalidationMaxKeys);
|
|
2628
|
+
}
|
|
2629
|
+
validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
|
|
2630
|
+
validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
2631
|
+
validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
2122
2632
|
if (typeof this.options.generationCleanup === "object") {
|
|
2123
|
-
|
|
2633
|
+
validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
|
|
2124
2634
|
}
|
|
2125
2635
|
if (this.options.generation !== void 0) {
|
|
2126
|
-
|
|
2636
|
+
validateNonNegativeNumber("generation", this.options.generation);
|
|
2127
2637
|
}
|
|
2128
2638
|
}
|
|
2129
2639
|
validateWriteOptions(options) {
|
|
2130
2640
|
if (!options) {
|
|
2131
2641
|
return;
|
|
2132
2642
|
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
validateLayerNumberOption(name, value) {
|
|
2145
|
-
if (value === void 0) {
|
|
2146
|
-
return;
|
|
2147
|
-
}
|
|
2148
|
-
if (typeof value === "number") {
|
|
2149
|
-
this.validateNonNegativeNumber(name, value);
|
|
2150
|
-
return;
|
|
2151
|
-
}
|
|
2152
|
-
for (const [layerName, layerValue] of Object.entries(value)) {
|
|
2153
|
-
if (layerValue === void 0) {
|
|
2154
|
-
continue;
|
|
2155
|
-
}
|
|
2156
|
-
this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
|
|
2157
|
-
}
|
|
2158
|
-
}
|
|
2159
|
-
validatePositiveNumber(name, value) {
|
|
2160
|
-
if (value === void 0) {
|
|
2161
|
-
return;
|
|
2162
|
-
}
|
|
2163
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
2164
|
-
throw new Error(`${name} must be a positive finite number.`);
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
validateRateLimitOptions(name, options) {
|
|
2168
|
-
if (!options) {
|
|
2169
|
-
return;
|
|
2170
|
-
}
|
|
2171
|
-
this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
2172
|
-
this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
2173
|
-
this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
2174
|
-
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
2175
|
-
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
2176
|
-
}
|
|
2177
|
-
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
2178
|
-
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
2179
|
-
}
|
|
2180
|
-
}
|
|
2181
|
-
validateNonNegativeNumber(name, value) {
|
|
2182
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
2183
|
-
throw new Error(`${name} must be a non-negative finite number.`);
|
|
2184
|
-
}
|
|
2185
|
-
}
|
|
2186
|
-
validateCacheKey(key) {
|
|
2187
|
-
if (key.length === 0) {
|
|
2188
|
-
throw new Error("Cache key must not be empty.");
|
|
2189
|
-
}
|
|
2190
|
-
if (key.length > MAX_CACHE_KEY_LENGTH) {
|
|
2191
|
-
throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
2192
|
-
}
|
|
2193
|
-
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
2194
|
-
throw new Error("Cache key contains unsupported control characters.");
|
|
2195
|
-
}
|
|
2196
|
-
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
2197
|
-
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
2198
|
-
}
|
|
2199
|
-
return key;
|
|
2200
|
-
}
|
|
2201
|
-
validateTtlPolicy(name, policy) {
|
|
2202
|
-
if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
|
|
2203
|
-
return;
|
|
2204
|
-
}
|
|
2205
|
-
if ("alignTo" in policy) {
|
|
2206
|
-
this.validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
|
|
2207
|
-
return;
|
|
2208
|
-
}
|
|
2209
|
-
throw new Error(`${name} is invalid.`);
|
|
2643
|
+
validateLayerNumberOption("options.ttl", options.ttl);
|
|
2644
|
+
validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
|
|
2645
|
+
validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
|
|
2646
|
+
validateLayerNumberOption("options.staleIfError", options.staleIfError);
|
|
2647
|
+
validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
|
|
2648
|
+
validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
|
|
2649
|
+
validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
|
|
2650
|
+
validateAdaptiveTtlOptions(options.adaptiveTtl);
|
|
2651
|
+
validateCircuitBreakerOptions(options.circuitBreaker);
|
|
2652
|
+
validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
|
|
2653
|
+
validateTags(options.tags);
|
|
2210
2654
|
}
|
|
2211
2655
|
assertActive(operation) {
|
|
2212
2656
|
if (this.isDisconnecting) {
|
|
@@ -2218,24 +2662,6 @@ var CacheStack = class extends EventEmitter {
|
|
|
2218
2662
|
await this.startup;
|
|
2219
2663
|
this.assertActive(operation);
|
|
2220
2664
|
}
|
|
2221
|
-
serializeOptions(options) {
|
|
2222
|
-
return JSON.stringify(this.normalizeForSerialization(options) ?? null);
|
|
2223
|
-
}
|
|
2224
|
-
validateAdaptiveTtlOptions(options) {
|
|
2225
|
-
if (!options || options === true) {
|
|
2226
|
-
return;
|
|
2227
|
-
}
|
|
2228
|
-
this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
|
|
2229
|
-
this.validateLayerNumberOption("adaptiveTtl.step", options.step);
|
|
2230
|
-
this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
|
|
2231
|
-
}
|
|
2232
|
-
validateCircuitBreakerOptions(options) {
|
|
2233
|
-
if (!options) {
|
|
2234
|
-
return;
|
|
2235
|
-
}
|
|
2236
|
-
this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
|
|
2237
|
-
this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
|
|
2238
|
-
}
|
|
2239
2665
|
async applyFreshReadPolicies(key, hit, options, fetcher) {
|
|
2240
2666
|
const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
|
|
2241
2667
|
const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
|
|
@@ -2303,18 +2729,6 @@ var CacheStack = class extends EventEmitter {
|
|
|
2303
2729
|
this.emit("error", { operation, ...context });
|
|
2304
2730
|
}
|
|
2305
2731
|
}
|
|
2306
|
-
serializeKeyPart(value) {
|
|
2307
|
-
if (typeof value === "string") {
|
|
2308
|
-
return `s:${value}`;
|
|
2309
|
-
}
|
|
2310
|
-
if (typeof value === "number") {
|
|
2311
|
-
return `n:${value}`;
|
|
2312
|
-
}
|
|
2313
|
-
if (typeof value === "boolean") {
|
|
2314
|
-
return `b:${value}`;
|
|
2315
|
-
}
|
|
2316
|
-
return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
|
|
2317
|
-
}
|
|
2318
2732
|
isCacheSnapshotEntries(value) {
|
|
2319
2733
|
return Array.isArray(value) && value.every((entry) => {
|
|
2320
2734
|
if (!entry || typeof entry !== "object") {
|
|
@@ -2327,43 +2741,72 @@ var CacheStack = class extends EventEmitter {
|
|
|
2327
2741
|
sanitizeSnapshotValue(value) {
|
|
2328
2742
|
return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
|
|
2329
2743
|
}
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2744
|
+
snapshotMaxBytes() {
|
|
2745
|
+
return this.options.snapshotMaxBytes === false ? false : this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES;
|
|
2746
|
+
}
|
|
2747
|
+
snapshotMaxEntries() {
|
|
2748
|
+
return this.options.snapshotMaxEntries === false ? false : this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES;
|
|
2749
|
+
}
|
|
2750
|
+
invalidationMaxKeys() {
|
|
2751
|
+
return this.options.invalidationMaxKeys === false ? false : this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS;
|
|
2752
|
+
}
|
|
2753
|
+
async collectKeysForTag(tag) {
|
|
2754
|
+
const keys = /* @__PURE__ */ new Set();
|
|
2755
|
+
if (this.tagIndex.forEachKeyForTag) {
|
|
2756
|
+
await this.tagIndex.forEachKeyForTag(tag, async (key) => {
|
|
2757
|
+
keys.add(key);
|
|
2758
|
+
this.assertWithinInvalidationKeyLimit(keys.size);
|
|
2759
|
+
});
|
|
2760
|
+
return [...keys];
|
|
2336
2761
|
}
|
|
2337
|
-
const
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
if (baseDir !== false) {
|
|
2341
|
-
const relative = path.relative(baseDir, resolved);
|
|
2342
|
-
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
2343
|
-
throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
|
|
2344
|
-
}
|
|
2762
|
+
for (const key of await this.tagIndex.keysForTag(tag)) {
|
|
2763
|
+
keys.add(key);
|
|
2764
|
+
this.assertWithinInvalidationKeyLimit(keys.size);
|
|
2345
2765
|
}
|
|
2346
|
-
return
|
|
2766
|
+
return [...keys];
|
|
2347
2767
|
}
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2768
|
+
assertWithinInvalidationKeyLimit(size) {
|
|
2769
|
+
const maxKeys = this.invalidationMaxKeys();
|
|
2770
|
+
if (maxKeys !== false && size > maxKeys) {
|
|
2771
|
+
throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`);
|
|
2351
2772
|
}
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2773
|
+
}
|
|
2774
|
+
async visitExportEntries(maxEntries, visitor) {
|
|
2775
|
+
const exported = /* @__PURE__ */ new Set();
|
|
2776
|
+
for (const layer of this.layers) {
|
|
2777
|
+
if (!layer.keys && !layer.forEachKey) {
|
|
2778
|
+
continue;
|
|
2779
|
+
}
|
|
2780
|
+
const visitKey = async (key) => {
|
|
2781
|
+
const exportedKey = this.stripQualifiedKey(key);
|
|
2782
|
+
if (exported.has(exportedKey)) {
|
|
2783
|
+
return;
|
|
2356
2784
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2785
|
+
const stored = await this.readLayerEntry(layer, key);
|
|
2786
|
+
if (stored === null) {
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
exported.add(exportedKey);
|
|
2790
|
+
if (maxEntries !== false && exported.size > maxEntries) {
|
|
2791
|
+
throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`);
|
|
2792
|
+
}
|
|
2793
|
+
await visitor({
|
|
2794
|
+
key: exportedKey,
|
|
2795
|
+
value: stored,
|
|
2796
|
+
ttl: remainingStoredTtlSeconds(stored)
|
|
2797
|
+
});
|
|
2798
|
+
};
|
|
2799
|
+
if (layer.forEachKey) {
|
|
2800
|
+
await layer.forEachKey(visitKey);
|
|
2801
|
+
continue;
|
|
2802
|
+
}
|
|
2803
|
+
const keys = await layer.keys?.();
|
|
2804
|
+
for (const key of keys ?? []) {
|
|
2805
|
+
await visitKey(key);
|
|
2806
|
+
}
|
|
2360
2807
|
}
|
|
2361
|
-
return value;
|
|
2362
2808
|
}
|
|
2363
2809
|
};
|
|
2364
|
-
function createInstanceId() {
|
|
2365
|
-
return globalThis.crypto?.randomUUID?.() ?? `layercache-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2366
|
-
}
|
|
2367
2810
|
|
|
2368
2811
|
// src/invalidation/RedisInvalidationBus.ts
|
|
2369
2812
|
var RedisInvalidationBus = class {
|
|
@@ -2404,7 +2847,7 @@ var RedisInvalidationBus = class {
|
|
|
2404
2847
|
async dispatchToHandlers(payload) {
|
|
2405
2848
|
let message;
|
|
2406
2849
|
try {
|
|
2407
|
-
const parsed = JSON.parse(payload);
|
|
2850
|
+
const parsed = sanitizeJsonValue2(JSON.parse(payload));
|
|
2408
2851
|
if (!this.isInvalidationMessage(parsed)) {
|
|
2409
2852
|
throw new Error("Invalid invalidation payload shape.");
|
|
2410
2853
|
}
|
|
@@ -2441,12 +2884,45 @@ var RedisInvalidationBus = class {
|
|
|
2441
2884
|
console.error(`[layercache] ${message}`, error);
|
|
2442
2885
|
}
|
|
2443
2886
|
};
|
|
2887
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2888
|
+
var MAX_SANITIZE_DEPTH2 = 64;
|
|
2889
|
+
var MAX_SANITIZE_NODES2 = 1e4;
|
|
2890
|
+
function sanitizeJsonValue2(value, depth = 0, state = { count: 0 }) {
|
|
2891
|
+
state.count += 1;
|
|
2892
|
+
if (state.count > MAX_SANITIZE_NODES2) {
|
|
2893
|
+
throw new Error(`Invalidation payload exceeds max node count of ${MAX_SANITIZE_NODES2}.`);
|
|
2894
|
+
}
|
|
2895
|
+
if (depth > MAX_SANITIZE_DEPTH2) {
|
|
2896
|
+
throw new Error(`Invalidation payload exceeds max depth of ${MAX_SANITIZE_DEPTH2}.`);
|
|
2897
|
+
}
|
|
2898
|
+
if (Array.isArray(value)) {
|
|
2899
|
+
return value.map((entry) => sanitizeJsonValue2(entry, depth + 1, state));
|
|
2900
|
+
}
|
|
2901
|
+
if (value && typeof value === "object") {
|
|
2902
|
+
const result = /* @__PURE__ */ Object.create(null);
|
|
2903
|
+
for (const key of Object.keys(value)) {
|
|
2904
|
+
if (!DANGEROUS_KEYS.has(key)) {
|
|
2905
|
+
result[key] = sanitizeJsonValue2(value[key], depth + 1, state);
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
return result;
|
|
2909
|
+
}
|
|
2910
|
+
return value;
|
|
2911
|
+
}
|
|
2444
2912
|
|
|
2445
2913
|
// src/http/createCacheStatsHandler.ts
|
|
2446
|
-
function createCacheStatsHandler(cache) {
|
|
2447
|
-
return async (
|
|
2448
|
-
response.statusCode = 200;
|
|
2914
|
+
function createCacheStatsHandler(cache, options = {}) {
|
|
2915
|
+
return async (request, response) => {
|
|
2449
2916
|
response.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
2917
|
+
response.setHeader?.("cache-control", "no-store");
|
|
2918
|
+
response.setHeader?.("x-content-type-options", "nosniff");
|
|
2919
|
+
const isAuthorized = options.allowPublicAccess === true || (options.authorize ? await options.authorize(request) : false);
|
|
2920
|
+
if (!isAuthorized) {
|
|
2921
|
+
response.statusCode = options.unauthorizedStatusCode ?? 403;
|
|
2922
|
+
response.end(JSON.stringify({ error: "Forbidden" }));
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
response.statusCode = 200;
|
|
2450
2926
|
response.end(JSON.stringify(cache.getStats(), null, 2));
|
|
2451
2927
|
};
|
|
2452
2928
|
}
|
|
@@ -2481,7 +2957,26 @@ function createFastifyLayercachePlugin(cache, options = {}) {
|
|
|
2481
2957
|
return async (fastify) => {
|
|
2482
2958
|
fastify.decorate("cache", cache);
|
|
2483
2959
|
if (options.exposeStatsRoute === true && fastify.get) {
|
|
2484
|
-
fastify.get(options.statsPath ?? "/cache/stats", async () =>
|
|
2960
|
+
fastify.get(options.statsPath ?? "/cache/stats", async (request, reply) => {
|
|
2961
|
+
const isAuthorized = options.allowPublicStatsRoute === true || (options.authorizeStatsRoute ? await options.authorizeStatsRoute(request) : false);
|
|
2962
|
+
reply.header?.("cache-control", "no-store");
|
|
2963
|
+
reply.header?.("x-content-type-options", "nosniff");
|
|
2964
|
+
if (!isAuthorized) {
|
|
2965
|
+
reply.statusCode = options.unauthorizedStatusCode ?? 403;
|
|
2966
|
+
const body2 = { error: "Forbidden" };
|
|
2967
|
+
if (reply.send) {
|
|
2968
|
+
reply.send(body2);
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
return body2;
|
|
2972
|
+
}
|
|
2973
|
+
const body = cache.getStats();
|
|
2974
|
+
if (reply.send) {
|
|
2975
|
+
reply.send(body);
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
return body;
|
|
2979
|
+
});
|
|
2485
2980
|
}
|
|
2486
2981
|
};
|
|
2487
2982
|
}
|
|
@@ -2496,6 +2991,10 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2496
2991
|
next();
|
|
2497
2992
|
return;
|
|
2498
2993
|
}
|
|
2994
|
+
if (!options.keyResolver && options.allowPrivateCaching !== true) {
|
|
2995
|
+
next();
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2499
2998
|
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
2500
2999
|
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
|
|
2501
3000
|
const cached = await cache.get(key, void 0, options);
|
|
@@ -2532,7 +3031,7 @@ function normalizeUrl(url) {
|
|
|
2532
3031
|
try {
|
|
2533
3032
|
const parsed = new URL(url, "http://localhost");
|
|
2534
3033
|
parsed.searchParams.sort();
|
|
2535
|
-
return
|
|
3034
|
+
return parsed.pathname + parsed.search;
|
|
2536
3035
|
} catch {
|
|
2537
3036
|
return url;
|
|
2538
3037
|
}
|
|
@@ -2540,6 +3039,11 @@ function normalizeUrl(url) {
|
|
|
2540
3039
|
|
|
2541
3040
|
// src/integrations/graphql.ts
|
|
2542
3041
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
3042
|
+
if (!options.keyResolver && options.allowImplicitContextCaching !== true) {
|
|
3043
|
+
throw new Error(
|
|
3044
|
+
"cacheGraphqlResolver requires a keyResolver or allowImplicitContextCaching=true because resolver output may depend on request context."
|
|
3045
|
+
);
|
|
3046
|
+
}
|
|
2543
3047
|
const wrapped = cache.wrap(prefix, resolver, {
|
|
2544
3048
|
...options,
|
|
2545
3049
|
keyResolver: options.keyResolver
|
|
@@ -2611,6 +3115,11 @@ function instrument(name, tracer, method, attributes) {
|
|
|
2611
3115
|
|
|
2612
3116
|
// src/integrations/trpc.ts
|
|
2613
3117
|
function createTrpcCacheMiddleware(cache, prefix, options = {}) {
|
|
3118
|
+
if (!options.keyResolver && options.allowImplicitContextCaching !== true) {
|
|
3119
|
+
throw new Error(
|
|
3120
|
+
"createTrpcCacheMiddleware requires a keyResolver or allowImplicitContextCaching=true because procedure output may depend on request context."
|
|
3121
|
+
);
|
|
3122
|
+
}
|
|
2614
3123
|
return async (context) => {
|
|
2615
3124
|
const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
|
|
2616
3125
|
let didFetch = false;
|
|
@@ -2635,13 +3144,12 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
|
|
|
2635
3144
|
}
|
|
2636
3145
|
|
|
2637
3146
|
// src/layers/RedisLayer.ts
|
|
3147
|
+
import { Readable } from "stream";
|
|
2638
3148
|
import { promisify } from "util";
|
|
2639
|
-
import { brotliCompress,
|
|
3149
|
+
import { brotliCompress, createBrotliDecompress, createGunzip, gzip } from "zlib";
|
|
2640
3150
|
var BATCH_DELETE_SIZE = 500;
|
|
2641
3151
|
var gzipAsync = promisify(gzip);
|
|
2642
|
-
var gunzipAsync = promisify(gunzip);
|
|
2643
3152
|
var brotliCompressAsync = promisify(brotliCompress);
|
|
2644
|
-
var brotliDecompressAsync = promisify(brotliDecompress);
|
|
2645
3153
|
var RedisLayer = class {
|
|
2646
3154
|
name;
|
|
2647
3155
|
defaultTtl;
|
|
@@ -2749,8 +3257,18 @@ var RedisLayer = class {
|
|
|
2749
3257
|
return remaining;
|
|
2750
3258
|
}
|
|
2751
3259
|
async size() {
|
|
2752
|
-
|
|
2753
|
-
|
|
3260
|
+
if (!this.prefix) {
|
|
3261
|
+
return this.client.dbsize();
|
|
3262
|
+
}
|
|
3263
|
+
const pattern = `${this.prefix}*`;
|
|
3264
|
+
let cursor = "0";
|
|
3265
|
+
let count = 0;
|
|
3266
|
+
do {
|
|
3267
|
+
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
|
|
3268
|
+
cursor = nextCursor;
|
|
3269
|
+
count += keys.length;
|
|
3270
|
+
} while (cursor !== "0");
|
|
3271
|
+
return count;
|
|
2754
3272
|
}
|
|
2755
3273
|
async ping() {
|
|
2756
3274
|
try {
|
|
@@ -2796,6 +3314,17 @@ var RedisLayer = class {
|
|
|
2796
3314
|
}
|
|
2797
3315
|
return keys.map((key) => key.slice(this.prefix.length));
|
|
2798
3316
|
}
|
|
3317
|
+
async forEachKey(visitor) {
|
|
3318
|
+
const pattern = `${this.prefix}*`;
|
|
3319
|
+
let cursor = "0";
|
|
3320
|
+
do {
|
|
3321
|
+
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
|
|
3322
|
+
cursor = nextCursor;
|
|
3323
|
+
for (const key of keys) {
|
|
3324
|
+
await visitor(this.prefix ? key.slice(this.prefix.length) : key);
|
|
3325
|
+
}
|
|
3326
|
+
} while (cursor !== "0");
|
|
3327
|
+
}
|
|
2799
3328
|
async scanKeys(pattern) {
|
|
2800
3329
|
const matches = [];
|
|
2801
3330
|
let cursor = "0";
|
|
@@ -2810,7 +3339,13 @@ var RedisLayer = class {
|
|
|
2810
3339
|
return `${this.prefix}${key}`;
|
|
2811
3340
|
}
|
|
2812
3341
|
async deserializeOrDelete(key, payload) {
|
|
2813
|
-
|
|
3342
|
+
let decodedPayload;
|
|
3343
|
+
try {
|
|
3344
|
+
decodedPayload = await this.decodePayload(payload);
|
|
3345
|
+
} catch {
|
|
3346
|
+
await this.deleteCorruptedKey(key);
|
|
3347
|
+
return null;
|
|
3348
|
+
}
|
|
2814
3349
|
for (const serializer of this.serializers) {
|
|
2815
3350
|
try {
|
|
2816
3351
|
const value = serializer.deserialize(decodedPayload);
|
|
@@ -2821,11 +3356,15 @@ var RedisLayer = class {
|
|
|
2821
3356
|
} catch {
|
|
2822
3357
|
}
|
|
2823
3358
|
}
|
|
3359
|
+
await this.deleteCorruptedKey(key);
|
|
3360
|
+
return null;
|
|
3361
|
+
}
|
|
3362
|
+
async deleteCorruptedKey(key) {
|
|
2824
3363
|
try {
|
|
2825
|
-
await this.client.del(this.withPrefix(key))
|
|
2826
|
-
} catch {
|
|
3364
|
+
await this.client.del(this.withPrefix(key));
|
|
3365
|
+
} catch (deleteError) {
|
|
3366
|
+
console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
|
|
2827
3367
|
}
|
|
2828
|
-
return null;
|
|
2829
3368
|
}
|
|
2830
3369
|
async rewriteWithPrimarySerializer(key, value) {
|
|
2831
3370
|
const serialized = this.primarySerializer().serialize(value);
|
|
@@ -2872,31 +3411,72 @@ var RedisLayer = class {
|
|
|
2872
3411
|
return payload;
|
|
2873
3412
|
}
|
|
2874
3413
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
2875
|
-
|
|
2876
|
-
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
2877
|
-
throw new Error(
|
|
2878
|
-
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
2879
|
-
);
|
|
2880
|
-
}
|
|
2881
|
-
return decompressed;
|
|
3414
|
+
return this.decompressWithLimit(createGunzip(), payload.subarray(10));
|
|
2882
3415
|
}
|
|
2883
3416
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
2884
|
-
|
|
2885
|
-
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
2886
|
-
throw new Error(
|
|
2887
|
-
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
2888
|
-
);
|
|
2889
|
-
}
|
|
2890
|
-
return decompressed;
|
|
3417
|
+
return this.decompressWithLimit(createBrotliDecompress(), payload.subarray(12));
|
|
2891
3418
|
}
|
|
2892
3419
|
return payload;
|
|
2893
3420
|
}
|
|
3421
|
+
async decompressWithLimit(decompressor, payload) {
|
|
3422
|
+
return new Promise((resolve2, reject) => {
|
|
3423
|
+
const source = Readable.from(payload);
|
|
3424
|
+
const chunks = [];
|
|
3425
|
+
let totalBytes = 0;
|
|
3426
|
+
let settled = false;
|
|
3427
|
+
const cleanup = () => {
|
|
3428
|
+
decompressor.removeAllListeners();
|
|
3429
|
+
};
|
|
3430
|
+
const fail = (error) => {
|
|
3431
|
+
if (settled) {
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
settled = true;
|
|
3435
|
+
cleanup();
|
|
3436
|
+
source.unpipe(decompressor);
|
|
3437
|
+
source.destroy();
|
|
3438
|
+
decompressor.destroy();
|
|
3439
|
+
reject(error);
|
|
3440
|
+
};
|
|
3441
|
+
decompressor.on("data", (chunk) => {
|
|
3442
|
+
const normalized = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3443
|
+
totalBytes += normalized.byteLength;
|
|
3444
|
+
if (totalBytes > this.decompressionMaxBytes) {
|
|
3445
|
+
fail(
|
|
3446
|
+
new Error(
|
|
3447
|
+
`Decompressed payload (${totalBytes} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
3448
|
+
)
|
|
3449
|
+
);
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
chunks.push(normalized);
|
|
3453
|
+
});
|
|
3454
|
+
decompressor.once("error", (error) => {
|
|
3455
|
+
if (settled) {
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
settled = true;
|
|
3459
|
+
cleanup();
|
|
3460
|
+
reject(error);
|
|
3461
|
+
});
|
|
3462
|
+
decompressor.once("end", () => {
|
|
3463
|
+
if (settled) {
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
settled = true;
|
|
3467
|
+
cleanup();
|
|
3468
|
+
resolve2(Buffer.concat(chunks));
|
|
3469
|
+
});
|
|
3470
|
+
source.pipe(decompressor);
|
|
3471
|
+
});
|
|
3472
|
+
}
|
|
2894
3473
|
};
|
|
2895
3474
|
|
|
2896
3475
|
// src/layers/DiskLayer.ts
|
|
2897
3476
|
import { createHash } from "crypto";
|
|
2898
3477
|
import { promises as fs } from "fs";
|
|
2899
3478
|
import { join, resolve } from "path";
|
|
3479
|
+
var FILE_SCAN_CONCURRENCY = 32;
|
|
2900
3480
|
var DiskLayer = class {
|
|
2901
3481
|
name;
|
|
2902
3482
|
defaultTtl;
|
|
@@ -2904,6 +3484,7 @@ var DiskLayer = class {
|
|
|
2904
3484
|
directory;
|
|
2905
3485
|
serializer;
|
|
2906
3486
|
maxFiles;
|
|
3487
|
+
maxEntryBytes;
|
|
2907
3488
|
writeQueue = Promise.resolve();
|
|
2908
3489
|
constructor(options) {
|
|
2909
3490
|
this.directory = this.resolveDirectory(options.directory);
|
|
@@ -2911,16 +3492,15 @@ var DiskLayer = class {
|
|
|
2911
3492
|
this.name = options.name ?? "disk";
|
|
2912
3493
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2913
3494
|
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
3495
|
+
this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
|
|
2914
3496
|
}
|
|
2915
3497
|
async get(key) {
|
|
2916
3498
|
return unwrapStoredValue(await this.getEntry(key));
|
|
2917
3499
|
}
|
|
2918
3500
|
async getEntry(key) {
|
|
2919
3501
|
const filePath = this.keyToPath(key);
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
raw = await fs.readFile(filePath);
|
|
2923
|
-
} catch {
|
|
3502
|
+
const raw = await this.readEntryFile(filePath);
|
|
3503
|
+
if (raw === null) {
|
|
2924
3504
|
return null;
|
|
2925
3505
|
}
|
|
2926
3506
|
let entry;
|
|
@@ -2971,10 +3551,8 @@ var DiskLayer = class {
|
|
|
2971
3551
|
}
|
|
2972
3552
|
async ttl(key) {
|
|
2973
3553
|
const filePath = this.keyToPath(key);
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
raw = await fs.readFile(filePath);
|
|
2977
|
-
} catch {
|
|
3554
|
+
const raw = await this.readEntryFile(filePath);
|
|
3555
|
+
if (raw === null) {
|
|
2978
3556
|
return null;
|
|
2979
3557
|
}
|
|
2980
3558
|
let entry;
|
|
@@ -2998,7 +3576,7 @@ var DiskLayer = class {
|
|
|
2998
3576
|
}
|
|
2999
3577
|
async deleteMany(keys) {
|
|
3000
3578
|
await this.enqueueWrite(async () => {
|
|
3001
|
-
await
|
|
3579
|
+
await this.deletePathsWithConcurrency(keys.map((key) => this.keyToPath(key)));
|
|
3002
3580
|
});
|
|
3003
3581
|
}
|
|
3004
3582
|
async clear() {
|
|
@@ -3009,8 +3587,8 @@ var DiskLayer = class {
|
|
|
3009
3587
|
} catch {
|
|
3010
3588
|
return;
|
|
3011
3589
|
}
|
|
3012
|
-
await
|
|
3013
|
-
entries.filter((name) => name.endsWith(".lc")).map((name) =>
|
|
3590
|
+
await this.deletePathsWithConcurrency(
|
|
3591
|
+
entries.filter((name) => name.endsWith(".lc")).map((name) => join(this.directory, name))
|
|
3014
3592
|
);
|
|
3015
3593
|
});
|
|
3016
3594
|
}
|
|
@@ -3019,42 +3597,23 @@ var DiskLayer = class {
|
|
|
3019
3597
|
* Expired entries are skipped and cleaned up during the scan.
|
|
3020
3598
|
*/
|
|
3021
3599
|
async keys() {
|
|
3022
|
-
let entries;
|
|
3023
|
-
try {
|
|
3024
|
-
entries = await fs.readdir(this.directory);
|
|
3025
|
-
} catch {
|
|
3026
|
-
return [];
|
|
3027
|
-
}
|
|
3028
|
-
const lcFiles = entries.filter((name) => name.endsWith(".lc"));
|
|
3029
3600
|
const keys = [];
|
|
3030
|
-
await
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
let raw;
|
|
3034
|
-
try {
|
|
3035
|
-
raw = await fs.readFile(filePath);
|
|
3036
|
-
} catch {
|
|
3037
|
-
return;
|
|
3038
|
-
}
|
|
3039
|
-
let entry;
|
|
3040
|
-
try {
|
|
3041
|
-
entry = this.deserializeEntry(raw);
|
|
3042
|
-
} catch {
|
|
3043
|
-
await this.safeDelete(filePath);
|
|
3044
|
-
return;
|
|
3045
|
-
}
|
|
3046
|
-
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
3047
|
-
await this.safeDelete(filePath);
|
|
3048
|
-
return;
|
|
3049
|
-
}
|
|
3050
|
-
keys.push(entry.key);
|
|
3051
|
-
})
|
|
3052
|
-
);
|
|
3601
|
+
await this.scanEntries(async (entry) => {
|
|
3602
|
+
keys.push(entry.key);
|
|
3603
|
+
});
|
|
3053
3604
|
return keys;
|
|
3054
3605
|
}
|
|
3606
|
+
async forEachKey(visitor) {
|
|
3607
|
+
await this.scanEntries(async (entry) => {
|
|
3608
|
+
await visitor(entry.key);
|
|
3609
|
+
});
|
|
3610
|
+
}
|
|
3055
3611
|
async size() {
|
|
3056
|
-
|
|
3057
|
-
|
|
3612
|
+
let count = 0;
|
|
3613
|
+
await this.scanEntries(async () => {
|
|
3614
|
+
count += 1;
|
|
3615
|
+
});
|
|
3616
|
+
return count;
|
|
3058
3617
|
}
|
|
3059
3618
|
async ping() {
|
|
3060
3619
|
try {
|
|
@@ -3088,6 +3647,113 @@ var DiskLayer = class {
|
|
|
3088
3647
|
}
|
|
3089
3648
|
return maxFiles;
|
|
3090
3649
|
}
|
|
3650
|
+
normalizeMaxEntryBytes(maxEntryBytes) {
|
|
3651
|
+
if (maxEntryBytes === false) {
|
|
3652
|
+
return false;
|
|
3653
|
+
}
|
|
3654
|
+
const normalized = maxEntryBytes ?? 16 * 1024 * 1024;
|
|
3655
|
+
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
3656
|
+
throw new Error("DiskLayer.maxEntryBytes must be a positive number or false.");
|
|
3657
|
+
}
|
|
3658
|
+
return normalized;
|
|
3659
|
+
}
|
|
3660
|
+
async readEntryFile(filePath) {
|
|
3661
|
+
let handle;
|
|
3662
|
+
try {
|
|
3663
|
+
handle = await fs.open(filePath, "r");
|
|
3664
|
+
return await this.readHandleWithLimit(handle);
|
|
3665
|
+
} catch {
|
|
3666
|
+
await this.safeDelete(filePath);
|
|
3667
|
+
return null;
|
|
3668
|
+
} finally {
|
|
3669
|
+
await handle?.close().catch(() => void 0);
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
async readHandleWithLimit(handle) {
|
|
3673
|
+
if (this.maxEntryBytes === false) {
|
|
3674
|
+
return handle.readFile();
|
|
3675
|
+
}
|
|
3676
|
+
const stat = await handle.stat();
|
|
3677
|
+
if (stat.size > this.maxEntryBytes) {
|
|
3678
|
+
throw new Error(`DiskLayer entry exceeds maxEntryBytes limit (${stat.size} bytes > ${this.maxEntryBytes} bytes).`);
|
|
3679
|
+
}
|
|
3680
|
+
const chunks = [];
|
|
3681
|
+
let totalBytes = 0;
|
|
3682
|
+
let position = 0;
|
|
3683
|
+
while (true) {
|
|
3684
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, this.maxEntryBytes - totalBytes + 1));
|
|
3685
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
|
|
3686
|
+
if (bytesRead === 0) {
|
|
3687
|
+
break;
|
|
3688
|
+
}
|
|
3689
|
+
totalBytes += bytesRead;
|
|
3690
|
+
if (totalBytes > this.maxEntryBytes) {
|
|
3691
|
+
throw new Error(
|
|
3692
|
+
`DiskLayer entry exceeds maxEntryBytes limit (${totalBytes} bytes > ${this.maxEntryBytes} bytes).`
|
|
3693
|
+
);
|
|
3694
|
+
}
|
|
3695
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
3696
|
+
position += bytesRead;
|
|
3697
|
+
}
|
|
3698
|
+
return Buffer.concat(chunks);
|
|
3699
|
+
}
|
|
3700
|
+
async scanEntries(visitor) {
|
|
3701
|
+
let entries;
|
|
3702
|
+
try {
|
|
3703
|
+
entries = await fs.readdir(this.directory);
|
|
3704
|
+
} catch {
|
|
3705
|
+
return;
|
|
3706
|
+
}
|
|
3707
|
+
const lcFiles = entries.filter((name) => name.endsWith(".lc"));
|
|
3708
|
+
let nextIndex = 0;
|
|
3709
|
+
const workerCount = Math.min(FILE_SCAN_CONCURRENCY, lcFiles.length);
|
|
3710
|
+
await Promise.all(
|
|
3711
|
+
Array.from({ length: workerCount }, async () => {
|
|
3712
|
+
while (true) {
|
|
3713
|
+
const currentIndex = nextIndex;
|
|
3714
|
+
nextIndex += 1;
|
|
3715
|
+
const name = lcFiles[currentIndex];
|
|
3716
|
+
if (name === void 0) {
|
|
3717
|
+
return;
|
|
3718
|
+
}
|
|
3719
|
+
const filePath = join(this.directory, name);
|
|
3720
|
+
const raw = await this.readEntryFile(filePath);
|
|
3721
|
+
if (raw === null) {
|
|
3722
|
+
continue;
|
|
3723
|
+
}
|
|
3724
|
+
let entry;
|
|
3725
|
+
try {
|
|
3726
|
+
entry = this.deserializeEntry(raw);
|
|
3727
|
+
} catch {
|
|
3728
|
+
await this.safeDelete(filePath);
|
|
3729
|
+
continue;
|
|
3730
|
+
}
|
|
3731
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
3732
|
+
await this.safeDelete(filePath);
|
|
3733
|
+
continue;
|
|
3734
|
+
}
|
|
3735
|
+
await visitor(entry);
|
|
3736
|
+
}
|
|
3737
|
+
})
|
|
3738
|
+
);
|
|
3739
|
+
}
|
|
3740
|
+
async deletePathsWithConcurrency(paths) {
|
|
3741
|
+
let nextIndex = 0;
|
|
3742
|
+
const workerCount = Math.min(FILE_SCAN_CONCURRENCY, paths.length);
|
|
3743
|
+
await Promise.all(
|
|
3744
|
+
Array.from({ length: workerCount }, async () => {
|
|
3745
|
+
while (true) {
|
|
3746
|
+
const currentIndex = nextIndex;
|
|
3747
|
+
nextIndex += 1;
|
|
3748
|
+
const filePath = paths[currentIndex];
|
|
3749
|
+
if (filePath === void 0) {
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
await this.safeDelete(filePath);
|
|
3753
|
+
}
|
|
3754
|
+
})
|
|
3755
|
+
);
|
|
3756
|
+
}
|
|
3091
3757
|
deserializeEntry(raw) {
|
|
3092
3758
|
const entry = this.serializer.deserialize(raw);
|
|
3093
3759
|
if (!isDiskEntry(entry)) {
|
|
@@ -3223,29 +3889,38 @@ var MemcachedLayer = class {
|
|
|
3223
3889
|
|
|
3224
3890
|
// src/serialization/MsgpackSerializer.ts
|
|
3225
3891
|
import { decode, encode } from "@msgpack/msgpack";
|
|
3226
|
-
var
|
|
3892
|
+
var DANGEROUS_KEYS2 = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
3893
|
+
var MAX_SANITIZE_DEPTH3 = 64;
|
|
3894
|
+
var MAX_SANITIZE_NODES3 = 1e4;
|
|
3227
3895
|
var MsgpackSerializer = class {
|
|
3228
3896
|
serialize(value) {
|
|
3229
3897
|
return Buffer.from(encode(value));
|
|
3230
3898
|
}
|
|
3231
3899
|
deserialize(payload) {
|
|
3232
3900
|
const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
3233
|
-
return sanitizeMsgpackValue(decode(normalized));
|
|
3901
|
+
return sanitizeMsgpackValue(decode(normalized), 0, { count: 0 });
|
|
3234
3902
|
}
|
|
3235
3903
|
};
|
|
3236
|
-
function sanitizeMsgpackValue(value) {
|
|
3904
|
+
function sanitizeMsgpackValue(value, depth, state) {
|
|
3905
|
+
state.count += 1;
|
|
3906
|
+
if (state.count > MAX_SANITIZE_NODES3) {
|
|
3907
|
+
throw new Error(`MessagePack payload exceeds max node count of ${MAX_SANITIZE_NODES3}.`);
|
|
3908
|
+
}
|
|
3909
|
+
if (depth > MAX_SANITIZE_DEPTH3) {
|
|
3910
|
+
throw new Error(`MessagePack payload exceeds max depth of ${MAX_SANITIZE_DEPTH3}.`);
|
|
3911
|
+
}
|
|
3237
3912
|
if (Array.isArray(value)) {
|
|
3238
|
-
return value.map((entry) => sanitizeMsgpackValue(entry));
|
|
3913
|
+
return value.map((entry) => sanitizeMsgpackValue(entry, depth + 1, state));
|
|
3239
3914
|
}
|
|
3240
3915
|
if (!isPlainObject2(value)) {
|
|
3241
3916
|
return value;
|
|
3242
3917
|
}
|
|
3243
3918
|
const sanitized = {};
|
|
3244
3919
|
for (const [key, entry] of Object.entries(value)) {
|
|
3245
|
-
if (
|
|
3920
|
+
if (DANGEROUS_KEYS2.has(key)) {
|
|
3246
3921
|
continue;
|
|
3247
3922
|
}
|
|
3248
|
-
sanitized[key] = sanitizeMsgpackValue(entry);
|
|
3923
|
+
sanitized[key] = sanitizeMsgpackValue(entry, depth + 1, state);
|
|
3249
3924
|
}
|
|
3250
3925
|
return sanitized;
|
|
3251
3926
|
}
|