layercache 1.2.5 → 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 -912
- package/dist/{chunk-7V7XAB74.js → chunk-4PPBOOXT.js} +37 -3
- package/dist/{chunk-QHWG7QS5.js → chunk-BQLL6IM5.js} +47 -1
- package/dist/{chunk-JC26W3KK.js → chunk-GJBKCFE6.js} +38 -3
- package/dist/cli.cjs +83 -3
- package/dist/cli.js +2 -2
- package/dist/{edge-P07GCO2Y.d.ts → edge-DLstcDMn.d.cts} +32 -14
- package/dist/{edge-P07GCO2Y.d.cts → edge-DLstcDMn.d.ts} +32 -14
- package/dist/edge.cjs +74 -5
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +2 -2
- package/dist/index.cjs +1070 -352
- package/dist/index.d.cts +42 -4
- package/dist/index.d.ts +42 -4
- package/dist/index.js +950 -347
- package/package.json +29 -3
- package/packages/nestjs/dist/index.cjs +722 -272
- package/packages/nestjs/dist/index.d.cts +23 -13
- package/packages/nestjs/dist/index.d.ts +23 -13
- package/packages/nestjs/dist/index.js +722 -272
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
|
);
|
|
@@ -142,6 +158,24 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
142
158
|
qualify(key) {
|
|
143
159
|
return `${this.prefix}:${key}`;
|
|
144
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
|
+
}
|
|
145
179
|
async trackMetrics(operation) {
|
|
146
180
|
return this.getMetricsMutex().runExclusive(async () => {
|
|
147
181
|
const before = this.cache.getMetrics();
|
|
@@ -276,6 +310,9 @@ function validateNamespaceKey(key) {
|
|
|
276
310
|
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
277
311
|
throw new Error("Namespace prefix contains unsupported control characters.");
|
|
278
312
|
}
|
|
313
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
314
|
+
throw new Error("Namespace prefix contains unsupported surrogate code points.");
|
|
315
|
+
}
|
|
279
316
|
}
|
|
280
317
|
|
|
281
318
|
// src/internal/CacheKeyDiscovery.ts
|
|
@@ -284,21 +321,41 @@ var CacheKeyDiscovery = class {
|
|
|
284
321
|
this.options = options;
|
|
285
322
|
}
|
|
286
323
|
options;
|
|
287
|
-
async collectKeysWithPrefix(prefix) {
|
|
324
|
+
async collectKeysWithPrefix(prefix, maxMatches = false) {
|
|
288
325
|
const { tagIndex } = this.options;
|
|
289
|
-
const matches = new Set(
|
|
290
|
-
|
|
291
|
-
|
|
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
|
+
}
|
|
292
339
|
await Promise.all(
|
|
293
340
|
this.options.layers.map(async (layer) => {
|
|
294
|
-
if (!layer.keys || this.options.shouldSkipLayer(layer)) {
|
|
341
|
+
if (!layer.keys && !layer.forEachKey || this.options.shouldSkipLayer(layer)) {
|
|
295
342
|
return;
|
|
296
343
|
}
|
|
297
344
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
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 ?? []) {
|
|
300
356
|
if (key.startsWith(prefix)) {
|
|
301
357
|
matches.add(key);
|
|
358
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
302
359
|
}
|
|
303
360
|
}
|
|
304
361
|
} catch (error) {
|
|
@@ -308,18 +365,39 @@ var CacheKeyDiscovery = class {
|
|
|
308
365
|
);
|
|
309
366
|
return [...matches];
|
|
310
367
|
}
|
|
311
|
-
async collectKeysMatchingPattern(pattern) {
|
|
312
|
-
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
|
+
}
|
|
313
381
|
await Promise.all(
|
|
314
382
|
this.options.layers.map(async (layer) => {
|
|
315
|
-
if (!layer.keys || this.options.shouldSkipLayer(layer)) {
|
|
383
|
+
if (!layer.keys && !layer.forEachKey || this.options.shouldSkipLayer(layer)) {
|
|
316
384
|
return;
|
|
317
385
|
}
|
|
318
386
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
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 ?? []) {
|
|
321
398
|
if (PatternMatcher.matches(pattern, key)) {
|
|
322
399
|
matches.add(key);
|
|
400
|
+
this.assertWithinMatchLimit(matches, maxMatches);
|
|
323
401
|
}
|
|
324
402
|
}
|
|
325
403
|
} catch (error) {
|
|
@@ -329,8 +407,280 @@ var CacheKeyDiscovery = class {
|
|
|
329
407
|
);
|
|
330
408
|
return [...matches];
|
|
331
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
|
+
}
|
|
332
415
|
};
|
|
333
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
|
+
|
|
334
684
|
// src/internal/CircuitBreakerManager.ts
|
|
335
685
|
var CircuitBreakerManager = class {
|
|
336
686
|
breakers = /* @__PURE__ */ new Map();
|
|
@@ -825,22 +1175,27 @@ var TtlResolver = class {
|
|
|
825
1175
|
|
|
826
1176
|
// src/serialization/JsonSerializer.ts
|
|
827
1177
|
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
1178
|
+
var MAX_SANITIZE_NODES = 1e4;
|
|
828
1179
|
var JsonSerializer = class {
|
|
829
1180
|
serialize(value) {
|
|
830
1181
|
return JSON.stringify(value);
|
|
831
1182
|
}
|
|
832
1183
|
deserialize(payload) {
|
|
833
1184
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
834
|
-
return sanitizeJsonValue(JSON.parse(normalized), 0);
|
|
1185
|
+
return sanitizeJsonValue(JSON.parse(normalized), 0, { count: 0 });
|
|
835
1186
|
}
|
|
836
1187
|
};
|
|
837
1188
|
var MAX_SANITIZE_DEPTH = 200;
|
|
838
|
-
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
|
+
}
|
|
839
1194
|
if (depth > MAX_SANITIZE_DEPTH) {
|
|
840
|
-
|
|
1195
|
+
throw new Error(`JSON payload exceeds max depth of ${MAX_SANITIZE_DEPTH}.`);
|
|
841
1196
|
}
|
|
842
1197
|
if (Array.isArray(value)) {
|
|
843
|
-
return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
|
|
1198
|
+
return value.map((entry) => sanitizeJsonValue(entry, depth + 1, state));
|
|
844
1199
|
}
|
|
845
1200
|
if (!isPlainObject(value)) {
|
|
846
1201
|
return value;
|
|
@@ -850,7 +1205,7 @@ function sanitizeJsonValue(value, depth) {
|
|
|
850
1205
|
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
851
1206
|
continue;
|
|
852
1207
|
}
|
|
853
|
-
sanitized[key] = sanitizeJsonValue(entry, depth + 1);
|
|
1208
|
+
sanitized[key] = sanitizeJsonValue(entry, depth + 1, state);
|
|
854
1209
|
}
|
|
855
1210
|
return sanitized;
|
|
856
1211
|
}
|
|
@@ -900,10 +1255,11 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
|
900
1255
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
901
1256
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
902
1257
|
var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
|
|
903
|
-
var
|
|
904
|
-
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;
|
|
905
1262
|
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
906
|
-
var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
907
1263
|
var DebugLogger = class {
|
|
908
1264
|
enabled;
|
|
909
1265
|
constructor(enabled) {
|
|
@@ -990,6 +1346,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
990
1346
|
snapshotSerializer = new JsonSerializer();
|
|
991
1347
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
992
1348
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
1349
|
+
keyEpochs = /* @__PURE__ */ new Map();
|
|
993
1350
|
ttlResolver;
|
|
994
1351
|
circuitBreakerManager;
|
|
995
1352
|
currentGeneration;
|
|
@@ -997,6 +1354,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
997
1354
|
writeBehindTimer;
|
|
998
1355
|
writeBehindFlushPromise;
|
|
999
1356
|
generationCleanupPromise;
|
|
1357
|
+
clearEpoch = 0;
|
|
1000
1358
|
isDisconnecting = false;
|
|
1001
1359
|
disconnectPromise;
|
|
1002
1360
|
/**
|
|
@@ -1006,7 +1364,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1006
1364
|
* and no `fetcher` is provided.
|
|
1007
1365
|
*/
|
|
1008
1366
|
async get(key, fetcher, options) {
|
|
1009
|
-
const normalizedKey = this.qualifyKey(
|
|
1367
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1010
1368
|
this.validateWriteOptions(options);
|
|
1011
1369
|
await this.awaitStartup("get");
|
|
1012
1370
|
return this.getPrepared(normalizedKey, fetcher, options);
|
|
@@ -1076,7 +1434,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1076
1434
|
* Returns true if the given key exists and is not expired in any layer.
|
|
1077
1435
|
*/
|
|
1078
1436
|
async has(key) {
|
|
1079
|
-
const normalizedKey = this.qualifyKey(
|
|
1437
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1080
1438
|
await this.awaitStartup("has");
|
|
1081
1439
|
for (const layer of this.layers) {
|
|
1082
1440
|
if (this.shouldSkipLayer(layer)) {
|
|
@@ -1109,7 +1467,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1109
1467
|
* that has it, or null if the key is not found / has no TTL.
|
|
1110
1468
|
*/
|
|
1111
1469
|
async ttl(key) {
|
|
1112
|
-
const normalizedKey = this.qualifyKey(
|
|
1470
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1113
1471
|
await this.awaitStartup("ttl");
|
|
1114
1472
|
for (const layer of this.layers) {
|
|
1115
1473
|
if (this.shouldSkipLayer(layer)) {
|
|
@@ -1131,7 +1489,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1131
1489
|
* Stores a value in all cache layers. Overwrites any existing value.
|
|
1132
1490
|
*/
|
|
1133
1491
|
async set(key, value, options) {
|
|
1134
|
-
const normalizedKey = this.qualifyKey(
|
|
1492
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1135
1493
|
this.validateWriteOptions(options);
|
|
1136
1494
|
await this.awaitStartup("set");
|
|
1137
1495
|
await this.storeEntry(normalizedKey, "value", value, options);
|
|
@@ -1140,7 +1498,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1140
1498
|
* Deletes the key from all layers and publishes an invalidation message.
|
|
1141
1499
|
*/
|
|
1142
1500
|
async delete(key) {
|
|
1143
|
-
const normalizedKey = this.qualifyKey(
|
|
1501
|
+
const normalizedKey = this.qualifyKey(validateCacheKey(key));
|
|
1144
1502
|
await this.awaitStartup("delete");
|
|
1145
1503
|
await this.deleteKeys([normalizedKey]);
|
|
1146
1504
|
await this.publishInvalidation({
|
|
@@ -1152,6 +1510,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1152
1510
|
}
|
|
1153
1511
|
async clear() {
|
|
1154
1512
|
await this.awaitStartup("clear");
|
|
1513
|
+
this.beginClearEpoch();
|
|
1155
1514
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
1156
1515
|
await this.tagIndex.clear();
|
|
1157
1516
|
this.ttlResolver.clearProfiles();
|
|
@@ -1168,7 +1527,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1168
1527
|
return;
|
|
1169
1528
|
}
|
|
1170
1529
|
await this.awaitStartup("mdelete");
|
|
1171
|
-
const normalizedKeys = keys.map((k) =>
|
|
1530
|
+
const normalizedKeys = keys.map((k) => validateCacheKey(k));
|
|
1172
1531
|
const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key));
|
|
1173
1532
|
await this.deleteKeys(cacheKeys);
|
|
1174
1533
|
await this.publishInvalidation({
|
|
@@ -1185,7 +1544,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1185
1544
|
}
|
|
1186
1545
|
const normalizedEntries = entries.map((entry) => ({
|
|
1187
1546
|
...entry,
|
|
1188
|
-
key: this.qualifyKey(
|
|
1547
|
+
key: this.qualifyKey(validateCacheKey(entry.key))
|
|
1189
1548
|
}));
|
|
1190
1549
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
1191
1550
|
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
@@ -1194,7 +1553,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1194
1553
|
const pendingReads = /* @__PURE__ */ new Map();
|
|
1195
1554
|
return Promise.all(
|
|
1196
1555
|
normalizedEntries.map((entry) => {
|
|
1197
|
-
const optionsSignature =
|
|
1556
|
+
const optionsSignature = serializeOptions(entry.options);
|
|
1198
1557
|
const existing = pendingReads.get(entry.key);
|
|
1199
1558
|
if (!existing) {
|
|
1200
1559
|
const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
|
|
@@ -1263,7 +1622,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1263
1622
|
this.assertActive("mset");
|
|
1264
1623
|
const normalizedEntries = entries.map((entry) => ({
|
|
1265
1624
|
...entry,
|
|
1266
|
-
key: this.qualifyKey(
|
|
1625
|
+
key: this.qualifyKey(validateCacheKey(entry.key))
|
|
1267
1626
|
}));
|
|
1268
1627
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
1269
1628
|
await this.awaitStartup("mset");
|
|
@@ -1306,7 +1665,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1306
1665
|
*/
|
|
1307
1666
|
wrap(prefix, fetcher, options = {}) {
|
|
1308
1667
|
return (...args) => {
|
|
1309
|
-
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) =>
|
|
1668
|
+
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => serializeKeyPart(argument)).join(":");
|
|
1310
1669
|
const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
|
|
1311
1670
|
return this.get(key, () => fetcher(...args), options);
|
|
1312
1671
|
};
|
|
@@ -1316,11 +1675,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
1316
1675
|
* `prefix:`. Useful for multi-tenant or module-level isolation.
|
|
1317
1676
|
*/
|
|
1318
1677
|
namespace(prefix) {
|
|
1678
|
+
validateNamespaceKey(prefix);
|
|
1319
1679
|
return new CacheNamespace(this, prefix);
|
|
1320
1680
|
}
|
|
1321
1681
|
async invalidateByTag(tag) {
|
|
1682
|
+
validateTag(tag);
|
|
1322
1683
|
await this.awaitStartup("invalidateByTag");
|
|
1323
|
-
const keys = await this.
|
|
1684
|
+
const keys = await this.collectKeysForTag(tag);
|
|
1324
1685
|
await this.deleteKeys(keys);
|
|
1325
1686
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1326
1687
|
}
|
|
@@ -1328,23 +1689,28 @@ var CacheStack = class extends EventEmitter {
|
|
|
1328
1689
|
if (tags.length === 0) {
|
|
1329
1690
|
return;
|
|
1330
1691
|
}
|
|
1692
|
+
validateTags(tags);
|
|
1331
1693
|
await this.awaitStartup("invalidateByTags");
|
|
1332
|
-
const keysByTag = await Promise.all(tags.map((tag) => this.
|
|
1694
|
+
const keysByTag = await Promise.all(tags.map((tag) => this.collectKeysForTag(tag)));
|
|
1333
1695
|
const keys = mode === "all" ? this.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())];
|
|
1696
|
+
this.assertWithinInvalidationKeyLimit(keys.length);
|
|
1334
1697
|
await this.deleteKeys(keys);
|
|
1335
1698
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1336
1699
|
}
|
|
1337
1700
|
async invalidateByPattern(pattern) {
|
|
1338
|
-
|
|
1701
|
+
validatePattern(pattern);
|
|
1339
1702
|
await this.awaitStartup("invalidateByPattern");
|
|
1340
|
-
const keys = await this.keyDiscovery.collectKeysMatchingPattern(
|
|
1703
|
+
const keys = await this.keyDiscovery.collectKeysMatchingPattern(
|
|
1704
|
+
this.qualifyPattern(pattern),
|
|
1705
|
+
this.invalidationMaxKeys()
|
|
1706
|
+
);
|
|
1341
1707
|
await this.deleteKeys(keys);
|
|
1342
1708
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1343
1709
|
}
|
|
1344
1710
|
async invalidateByPrefix(prefix) {
|
|
1345
1711
|
await this.awaitStartup("invalidateByPrefix");
|
|
1346
|
-
const qualifiedPrefix = this.qualifyKey(
|
|
1347
|
-
const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
|
|
1712
|
+
const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix));
|
|
1713
|
+
const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys());
|
|
1348
1714
|
await this.deleteKeys(keys);
|
|
1349
1715
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1350
1716
|
}
|
|
@@ -1414,7 +1780,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1414
1780
|
* Returns `null` if the key does not exist in any layer.
|
|
1415
1781
|
*/
|
|
1416
1782
|
async inspect(key) {
|
|
1417
|
-
const userKey =
|
|
1783
|
+
const userKey = validateCacheKey(key);
|
|
1418
1784
|
const normalizedKey = this.qualifyKey(userKey);
|
|
1419
1785
|
await this.awaitStartup("inspect");
|
|
1420
1786
|
const foundInLayers = [];
|
|
@@ -1451,50 +1817,79 @@ var CacheStack = class extends EventEmitter {
|
|
|
1451
1817
|
}
|
|
1452
1818
|
async exportState() {
|
|
1453
1819
|
await this.awaitStartup("exportState");
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
const keys = await layer.keys();
|
|
1460
|
-
for (const key of keys) {
|
|
1461
|
-
const exportedKey = this.stripQualifiedKey(key);
|
|
1462
|
-
if (exported.has(exportedKey)) {
|
|
1463
|
-
continue;
|
|
1464
|
-
}
|
|
1465
|
-
const stored = await this.readLayerEntry(layer, key);
|
|
1466
|
-
if (stored === null) {
|
|
1467
|
-
continue;
|
|
1468
|
-
}
|
|
1469
|
-
exported.set(exportedKey, {
|
|
1470
|
-
key: exportedKey,
|
|
1471
|
-
value: stored,
|
|
1472
|
-
ttl: remainingStoredTtlSeconds(stored)
|
|
1473
|
-
});
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
return [...exported.values()];
|
|
1820
|
+
const entries = [];
|
|
1821
|
+
await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
|
|
1822
|
+
entries.push(entry);
|
|
1823
|
+
});
|
|
1824
|
+
return entries;
|
|
1477
1825
|
}
|
|
1478
1826
|
async importState(entries) {
|
|
1479
1827
|
await this.awaitStartup("importState");
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
+
}
|
|
1487
1842
|
}
|
|
1488
1843
|
async persistToFile(filePath) {
|
|
1489
1844
|
this.assertActive("persistToFile");
|
|
1490
|
-
const snapshot = await this.exportState();
|
|
1491
1845
|
const { promises: fs2 } = await import("fs");
|
|
1492
|
-
|
|
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
|
+
}
|
|
1493
1872
|
}
|
|
1494
1873
|
async restoreFromFile(filePath) {
|
|
1495
1874
|
this.assertActive("restoreFromFile");
|
|
1496
|
-
const { promises: fs2 } = await import("fs");
|
|
1497
|
-
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
|
+
}
|
|
1498
1893
|
let parsed;
|
|
1499
1894
|
try {
|
|
1500
1895
|
parsed = JSON.parse(raw);
|
|
@@ -1538,14 +1933,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1538
1933
|
await this.handleInvalidationMessage(message);
|
|
1539
1934
|
});
|
|
1540
1935
|
}
|
|
1541
|
-
async fetchWithGuards(key, fetcher, options) {
|
|
1936
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
1542
1937
|
const fetchTask = async () => {
|
|
1543
1938
|
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
1544
1939
|
if (secondHit.found) {
|
|
1545
1940
|
this.metricsCollector.increment("hits");
|
|
1546
1941
|
return secondHit.value;
|
|
1547
1942
|
}
|
|
1548
|
-
return this.fetchAndPopulate(key, fetcher, options);
|
|
1943
|
+
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
1549
1944
|
};
|
|
1550
1945
|
const singleFlightTask = async () => {
|
|
1551
1946
|
if (!this.options.singleFlightCoordinator) {
|
|
@@ -1555,7 +1950,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1555
1950
|
key,
|
|
1556
1951
|
this.resolveSingleFlightOptions(),
|
|
1557
1952
|
fetchTask,
|
|
1558
|
-
() => this.waitForFreshValue(key, fetcher, options)
|
|
1953
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
1559
1954
|
);
|
|
1560
1955
|
};
|
|
1561
1956
|
if (this.options.stampedePrevention === false) {
|
|
@@ -1563,7 +1958,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1563
1958
|
}
|
|
1564
1959
|
return this.stampedeGuard.execute(key, singleFlightTask);
|
|
1565
1960
|
}
|
|
1566
|
-
async waitForFreshValue(key, fetcher, options) {
|
|
1961
|
+
async waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
1567
1962
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
1568
1963
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
1569
1964
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -1577,9 +1972,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
1577
1972
|
}
|
|
1578
1973
|
await this.sleep(pollIntervalMs);
|
|
1579
1974
|
}
|
|
1580
|
-
return this.fetchAndPopulate(key, fetcher, options);
|
|
1975
|
+
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
1581
1976
|
}
|
|
1582
|
-
async fetchAndPopulate(key, fetcher, options) {
|
|
1977
|
+
async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
1583
1978
|
this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
1584
1979
|
this.metricsCollector.increment("fetches");
|
|
1585
1980
|
const fetchStart = Date.now();
|
|
@@ -1600,6 +1995,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
1600
1995
|
if (!this.shouldNegativeCache(options)) {
|
|
1601
1996
|
return null;
|
|
1602
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
|
+
}
|
|
1603
2008
|
await this.storeEntry(key, "empty", null, options);
|
|
1604
2009
|
return null;
|
|
1605
2010
|
}
|
|
@@ -1612,11 +2017,26 @@ var CacheStack = class extends EventEmitter {
|
|
|
1612
2017
|
this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
|
|
1613
2018
|
}
|
|
1614
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
|
+
}
|
|
1615
2030
|
await this.storeEntry(key, "value", fetched, options);
|
|
1616
2031
|
return fetched;
|
|
1617
2032
|
}
|
|
1618
2033
|
async storeEntry(key, kind, value, options) {
|
|
2034
|
+
const clearEpoch = this.clearEpoch;
|
|
2035
|
+
const keyEpoch = this.currentKeyEpoch(key);
|
|
1619
2036
|
await this.writeAcrossLayers(key, kind, value, options);
|
|
2037
|
+
if (this.isWriteOutdated(key, clearEpoch, keyEpoch)) {
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
1620
2040
|
if (options?.tags) {
|
|
1621
2041
|
await this.tagIndex.track(key, options.tags);
|
|
1622
2042
|
} else {
|
|
@@ -1631,6 +2051,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
1631
2051
|
}
|
|
1632
2052
|
async writeBatch(entries) {
|
|
1633
2053
|
const now = Date.now();
|
|
2054
|
+
const clearEpoch = this.clearEpoch;
|
|
2055
|
+
const entryEpochs = new Map(entries.map((entry) => [entry.key, this.currentKeyEpoch(entry.key)]));
|
|
1634
2056
|
const entriesByLayer = /* @__PURE__ */ new Map();
|
|
1635
2057
|
const immediateOperations = [];
|
|
1636
2058
|
const deferredOperations = [];
|
|
@@ -1647,12 +2069,21 @@ var CacheStack = class extends EventEmitter {
|
|
|
1647
2069
|
}
|
|
1648
2070
|
for (const [layer, layerEntries] of entriesByLayer.entries()) {
|
|
1649
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
|
+
}
|
|
1650
2081
|
try {
|
|
1651
2082
|
if (layer.setMany) {
|
|
1652
|
-
await layer.setMany(
|
|
2083
|
+
await layer.setMany(activeEntries);
|
|
1653
2084
|
return;
|
|
1654
2085
|
}
|
|
1655
|
-
await Promise.all(
|
|
2086
|
+
await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
1656
2087
|
} catch (error) {
|
|
1657
2088
|
await this.handleLayerFailure(layer, "write", error);
|
|
1658
2089
|
}
|
|
@@ -1665,7 +2096,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
1665
2096
|
}
|
|
1666
2097
|
await this.executeLayerOperations(immediateOperations, { key: "batch", action: "mset" });
|
|
1667
2098
|
await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)));
|
|
2099
|
+
if (clearEpoch !== this.clearEpoch) {
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
1668
2102
|
for (const entry of entries) {
|
|
2103
|
+
if (this.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
1669
2106
|
if (entry.options?.tags) {
|
|
1670
2107
|
await this.tagIndex.track(entry.key, entry.options.tags);
|
|
1671
2108
|
} else {
|
|
@@ -1767,10 +2204,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
1767
2204
|
}
|
|
1768
2205
|
async writeAcrossLayers(key, kind, value, options) {
|
|
1769
2206
|
const now = Date.now();
|
|
2207
|
+
const clearEpoch = this.clearEpoch;
|
|
2208
|
+
const keyEpoch = this.currentKeyEpoch(key);
|
|
1770
2209
|
const immediateOperations = [];
|
|
1771
2210
|
const deferredOperations = [];
|
|
1772
2211
|
for (const layer of this.layers) {
|
|
1773
2212
|
const operation = async () => {
|
|
2213
|
+
if (this.isWriteOutdated(key, clearEpoch, keyEpoch)) {
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
1774
2216
|
if (this.shouldSkipLayer(layer)) {
|
|
1775
2217
|
return;
|
|
1776
2218
|
}
|
|
@@ -1834,10 +2276,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
1834
2276
|
if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
|
|
1835
2277
|
return;
|
|
1836
2278
|
}
|
|
2279
|
+
const clearEpoch = this.clearEpoch;
|
|
2280
|
+
const keyEpoch = this.currentKeyEpoch(key);
|
|
1837
2281
|
const refresh = (async () => {
|
|
1838
2282
|
this.metricsCollector.increment("refreshes");
|
|
1839
2283
|
try {
|
|
1840
|
-
await this.runBackgroundRefresh(key, fetcher, options);
|
|
2284
|
+
await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
|
|
1841
2285
|
} catch (error) {
|
|
1842
2286
|
this.metricsCollector.increment("refreshErrors");
|
|
1843
2287
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
@@ -1847,14 +2291,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
1847
2291
|
})();
|
|
1848
2292
|
this.backgroundRefreshes.set(key, refresh);
|
|
1849
2293
|
}
|
|
1850
|
-
async runBackgroundRefresh(key, fetcher, options) {
|
|
2294
|
+
async runBackgroundRefresh(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
1851
2295
|
const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
|
|
1852
2296
|
await this.fetchWithGuards(
|
|
1853
2297
|
key,
|
|
1854
2298
|
() => this.withTimeout(fetcher(), timeoutMs, () => {
|
|
1855
2299
|
return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
|
|
1856
2300
|
}),
|
|
1857
|
-
options
|
|
2301
|
+
options,
|
|
2302
|
+
expectedClearEpoch,
|
|
2303
|
+
expectedKeyEpoch
|
|
1858
2304
|
);
|
|
1859
2305
|
}
|
|
1860
2306
|
resolveSingleFlightOptions() {
|
|
@@ -1869,6 +2315,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1869
2315
|
if (keys.length === 0) {
|
|
1870
2316
|
return;
|
|
1871
2317
|
}
|
|
2318
|
+
this.bumpKeyEpochs(keys);
|
|
1872
2319
|
await this.deleteKeysFromLayers(this.layers, keys);
|
|
1873
2320
|
for (const key of keys) {
|
|
1874
2321
|
await this.tagIndex.remove(key);
|
|
@@ -1891,21 +2338,22 @@ var CacheStack = class extends EventEmitter {
|
|
|
1891
2338
|
return;
|
|
1892
2339
|
}
|
|
1893
2340
|
const localLayers = this.layers.filter((layer) => layer.isLocal);
|
|
1894
|
-
if (localLayers.length === 0) {
|
|
1895
|
-
return;
|
|
1896
|
-
}
|
|
1897
2341
|
if (message.scope === "clear") {
|
|
2342
|
+
this.beginClearEpoch();
|
|
1898
2343
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
1899
2344
|
await this.tagIndex.clear();
|
|
1900
2345
|
this.ttlResolver.clearProfiles();
|
|
2346
|
+
this.circuitBreakerManager.clear();
|
|
1901
2347
|
return;
|
|
1902
2348
|
}
|
|
1903
2349
|
const keys = message.keys ?? [];
|
|
2350
|
+
this.bumpKeyEpochs(keys);
|
|
1904
2351
|
await this.deleteKeysFromLayers(localLayers, keys);
|
|
1905
2352
|
if (message.operation !== "write") {
|
|
1906
2353
|
for (const key of keys) {
|
|
1907
2354
|
await this.tagIndex.remove(key);
|
|
1908
2355
|
this.ttlResolver.deleteProfile(key);
|
|
2356
|
+
this.circuitBreakerManager.delete(key);
|
|
1909
2357
|
}
|
|
1910
2358
|
}
|
|
1911
2359
|
}
|
|
@@ -2011,6 +2459,28 @@ var CacheStack = class extends EventEmitter {
|
|
|
2011
2459
|
shouldWriteBehind(layer) {
|
|
2012
2460
|
return this.options.writeStrategy === "write-behind" && !layer.isLocal;
|
|
2013
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
|
+
}
|
|
2014
2484
|
async enqueueWriteBehind(operation) {
|
|
2015
2485
|
this.writeBehindQueue.push(operation);
|
|
2016
2486
|
const batchSize = this.options.writeBehind?.batchSize ?? 100;
|
|
@@ -2137,118 +2607,50 @@ var CacheStack = class extends EventEmitter {
|
|
|
2137
2607
|
if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
|
|
2138
2608
|
throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
|
|
2139
2609
|
}
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
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);
|
|
2153
2632
|
if (typeof this.options.generationCleanup === "object") {
|
|
2154
|
-
|
|
2633
|
+
validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
|
|
2155
2634
|
}
|
|
2156
2635
|
if (this.options.generation !== void 0) {
|
|
2157
|
-
|
|
2636
|
+
validateNonNegativeNumber("generation", this.options.generation);
|
|
2158
2637
|
}
|
|
2159
2638
|
}
|
|
2160
2639
|
validateWriteOptions(options) {
|
|
2161
2640
|
if (!options) {
|
|
2162
2641
|
return;
|
|
2163
2642
|
}
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
validateLayerNumberOption(name, value) {
|
|
2176
|
-
if (value === void 0) {
|
|
2177
|
-
return;
|
|
2178
|
-
}
|
|
2179
|
-
if (typeof value === "number") {
|
|
2180
|
-
this.validateNonNegativeNumber(name, value);
|
|
2181
|
-
return;
|
|
2182
|
-
}
|
|
2183
|
-
for (const [layerName, layerValue] of Object.entries(value)) {
|
|
2184
|
-
if (layerValue === void 0) {
|
|
2185
|
-
continue;
|
|
2186
|
-
}
|
|
2187
|
-
this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
|
-
validatePositiveNumber(name, value) {
|
|
2191
|
-
if (value === void 0) {
|
|
2192
|
-
return;
|
|
2193
|
-
}
|
|
2194
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
2195
|
-
throw new Error(`${name} must be a positive finite number.`);
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2198
|
-
validateRateLimitOptions(name, options) {
|
|
2199
|
-
if (!options) {
|
|
2200
|
-
return;
|
|
2201
|
-
}
|
|
2202
|
-
this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
2203
|
-
this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
2204
|
-
this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
2205
|
-
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
2206
|
-
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
2207
|
-
}
|
|
2208
|
-
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
2209
|
-
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
validateNonNegativeNumber(name, value) {
|
|
2213
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
2214
|
-
throw new Error(`${name} must be a non-negative finite number.`);
|
|
2215
|
-
}
|
|
2216
|
-
}
|
|
2217
|
-
validateCacheKey(key) {
|
|
2218
|
-
if (key.length === 0) {
|
|
2219
|
-
throw new Error("Cache key must not be empty.");
|
|
2220
|
-
}
|
|
2221
|
-
if (key.length > MAX_CACHE_KEY_LENGTH) {
|
|
2222
|
-
throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
2223
|
-
}
|
|
2224
|
-
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
2225
|
-
throw new Error("Cache key contains unsupported control characters.");
|
|
2226
|
-
}
|
|
2227
|
-
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
2228
|
-
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
2229
|
-
}
|
|
2230
|
-
return key;
|
|
2231
|
-
}
|
|
2232
|
-
validatePattern(pattern) {
|
|
2233
|
-
if (pattern.length === 0) {
|
|
2234
|
-
throw new Error("Pattern must not be empty.");
|
|
2235
|
-
}
|
|
2236
|
-
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
2237
|
-
throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
|
|
2238
|
-
}
|
|
2239
|
-
if (/[\u0000-\u001F\u007F]/.test(pattern)) {
|
|
2240
|
-
throw new Error("Pattern contains unsupported control characters.");
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
|
-
validateTtlPolicy(name, policy) {
|
|
2244
|
-
if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
|
|
2245
|
-
return;
|
|
2246
|
-
}
|
|
2247
|
-
if ("alignTo" in policy) {
|
|
2248
|
-
this.validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
|
|
2249
|
-
return;
|
|
2250
|
-
}
|
|
2251
|
-
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);
|
|
2252
2654
|
}
|
|
2253
2655
|
assertActive(operation) {
|
|
2254
2656
|
if (this.isDisconnecting) {
|
|
@@ -2260,24 +2662,6 @@ var CacheStack = class extends EventEmitter {
|
|
|
2260
2662
|
await this.startup;
|
|
2261
2663
|
this.assertActive(operation);
|
|
2262
2664
|
}
|
|
2263
|
-
serializeOptions(options) {
|
|
2264
|
-
return JSON.stringify(this.normalizeForSerialization(options) ?? null);
|
|
2265
|
-
}
|
|
2266
|
-
validateAdaptiveTtlOptions(options) {
|
|
2267
|
-
if (!options || options === true) {
|
|
2268
|
-
return;
|
|
2269
|
-
}
|
|
2270
|
-
this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
|
|
2271
|
-
this.validateLayerNumberOption("adaptiveTtl.step", options.step);
|
|
2272
|
-
this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
|
|
2273
|
-
}
|
|
2274
|
-
validateCircuitBreakerOptions(options) {
|
|
2275
|
-
if (!options) {
|
|
2276
|
-
return;
|
|
2277
|
-
}
|
|
2278
|
-
this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
|
|
2279
|
-
this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
|
|
2280
|
-
}
|
|
2281
2665
|
async applyFreshReadPolicies(key, hit, options, fetcher) {
|
|
2282
2666
|
const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
|
|
2283
2667
|
const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
|
|
@@ -2345,18 +2729,6 @@ var CacheStack = class extends EventEmitter {
|
|
|
2345
2729
|
this.emit("error", { operation, ...context });
|
|
2346
2730
|
}
|
|
2347
2731
|
}
|
|
2348
|
-
serializeKeyPart(value) {
|
|
2349
|
-
if (typeof value === "string") {
|
|
2350
|
-
return `s:${value}`;
|
|
2351
|
-
}
|
|
2352
|
-
if (typeof value === "number") {
|
|
2353
|
-
return `n:${value}`;
|
|
2354
|
-
}
|
|
2355
|
-
if (typeof value === "boolean") {
|
|
2356
|
-
return `b:${value}`;
|
|
2357
|
-
}
|
|
2358
|
-
return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
|
|
2359
|
-
}
|
|
2360
2732
|
isCacheSnapshotEntries(value) {
|
|
2361
2733
|
return Array.isArray(value) && value.every((entry) => {
|
|
2362
2734
|
if (!entry || typeof entry !== "object") {
|
|
@@ -2369,54 +2741,72 @@ var CacheStack = class extends EventEmitter {
|
|
|
2369
2741
|
sanitizeSnapshotValue(value) {
|
|
2370
2742
|
return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
|
|
2371
2743
|
}
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
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];
|
|
2378
2761
|
}
|
|
2379
|
-
const
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
if (baseDir !== false) {
|
|
2383
|
-
const relative = path.relative(baseDir, resolved);
|
|
2384
|
-
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
2385
|
-
throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
|
|
2386
|
-
}
|
|
2762
|
+
for (const key of await this.tagIndex.keysForTag(tag)) {
|
|
2763
|
+
keys.add(key);
|
|
2764
|
+
this.assertWithinInvalidationKeyLimit(keys.size);
|
|
2387
2765
|
}
|
|
2388
|
-
return
|
|
2766
|
+
return [...keys];
|
|
2389
2767
|
}
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
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}).`);
|
|
2393
2772
|
}
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
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;
|
|
2398
2784
|
}
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
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
|
+
}
|
|
2402
2807
|
}
|
|
2403
|
-
return value;
|
|
2404
2808
|
}
|
|
2405
2809
|
};
|
|
2406
|
-
function createInstanceId() {
|
|
2407
|
-
if (globalThis.crypto?.randomUUID) {
|
|
2408
|
-
return globalThis.crypto.randomUUID();
|
|
2409
|
-
}
|
|
2410
|
-
const bytes = new Uint8Array(16);
|
|
2411
|
-
if (globalThis.crypto?.getRandomValues) {
|
|
2412
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
2413
|
-
} else {
|
|
2414
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
2415
|
-
bytes[i] = Math.floor(Math.random() * 256);
|
|
2416
|
-
}
|
|
2417
|
-
}
|
|
2418
|
-
return `layercache-${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
2419
|
-
}
|
|
2420
2810
|
|
|
2421
2811
|
// src/invalidation/RedisInvalidationBus.ts
|
|
2422
2812
|
var RedisInvalidationBus = class {
|
|
@@ -2495,15 +2885,24 @@ var RedisInvalidationBus = class {
|
|
|
2495
2885
|
}
|
|
2496
2886
|
};
|
|
2497
2887
|
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2498
|
-
|
|
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
|
+
}
|
|
2499
2898
|
if (Array.isArray(value)) {
|
|
2500
|
-
return value.map(sanitizeJsonValue2);
|
|
2899
|
+
return value.map((entry) => sanitizeJsonValue2(entry, depth + 1, state));
|
|
2501
2900
|
}
|
|
2502
2901
|
if (value && typeof value === "object") {
|
|
2503
2902
|
const result = /* @__PURE__ */ Object.create(null);
|
|
2504
2903
|
for (const key of Object.keys(value)) {
|
|
2505
2904
|
if (!DANGEROUS_KEYS.has(key)) {
|
|
2506
|
-
result[key] = sanitizeJsonValue2(value[key]);
|
|
2905
|
+
result[key] = sanitizeJsonValue2(value[key], depth + 1, state);
|
|
2507
2906
|
}
|
|
2508
2907
|
}
|
|
2509
2908
|
return result;
|
|
@@ -2512,12 +2911,18 @@ function sanitizeJsonValue2(value) {
|
|
|
2512
2911
|
}
|
|
2513
2912
|
|
|
2514
2913
|
// src/http/createCacheStatsHandler.ts
|
|
2515
|
-
function createCacheStatsHandler(cache) {
|
|
2516
|
-
return async (
|
|
2517
|
-
response.statusCode = 200;
|
|
2914
|
+
function createCacheStatsHandler(cache, options = {}) {
|
|
2915
|
+
return async (request, response) => {
|
|
2518
2916
|
response.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
2519
2917
|
response.setHeader?.("cache-control", "no-store");
|
|
2520
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;
|
|
2521
2926
|
response.end(JSON.stringify(cache.getStats(), null, 2));
|
|
2522
2927
|
};
|
|
2523
2928
|
}
|
|
@@ -2552,7 +2957,26 @@ function createFastifyLayercachePlugin(cache, options = {}) {
|
|
|
2552
2957
|
return async (fastify) => {
|
|
2553
2958
|
fastify.decorate("cache", cache);
|
|
2554
2959
|
if (options.exposeStatsRoute === true && fastify.get) {
|
|
2555
|
-
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
|
+
});
|
|
2556
2980
|
}
|
|
2557
2981
|
};
|
|
2558
2982
|
}
|
|
@@ -2567,6 +2991,10 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2567
2991
|
next();
|
|
2568
2992
|
return;
|
|
2569
2993
|
}
|
|
2994
|
+
if (!options.keyResolver && options.allowPrivateCaching !== true) {
|
|
2995
|
+
next();
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2570
2998
|
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
2571
2999
|
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
|
|
2572
3000
|
const cached = await cache.get(key, void 0, options);
|
|
@@ -2611,6 +3039,11 @@ function normalizeUrl(url) {
|
|
|
2611
3039
|
|
|
2612
3040
|
// src/integrations/graphql.ts
|
|
2613
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
|
+
}
|
|
2614
3047
|
const wrapped = cache.wrap(prefix, resolver, {
|
|
2615
3048
|
...options,
|
|
2616
3049
|
keyResolver: options.keyResolver
|
|
@@ -2682,6 +3115,11 @@ function instrument(name, tracer, method, attributes) {
|
|
|
2682
3115
|
|
|
2683
3116
|
// src/integrations/trpc.ts
|
|
2684
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
|
+
}
|
|
2685
3123
|
return async (context) => {
|
|
2686
3124
|
const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
|
|
2687
3125
|
let didFetch = false;
|
|
@@ -2706,13 +3144,12 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
|
|
|
2706
3144
|
}
|
|
2707
3145
|
|
|
2708
3146
|
// src/layers/RedisLayer.ts
|
|
3147
|
+
import { Readable } from "stream";
|
|
2709
3148
|
import { promisify } from "util";
|
|
2710
|
-
import { brotliCompress,
|
|
3149
|
+
import { brotliCompress, createBrotliDecompress, createGunzip, gzip } from "zlib";
|
|
2711
3150
|
var BATCH_DELETE_SIZE = 500;
|
|
2712
3151
|
var gzipAsync = promisify(gzip);
|
|
2713
|
-
var gunzipAsync = promisify(gunzip);
|
|
2714
3152
|
var brotliCompressAsync = promisify(brotliCompress);
|
|
2715
|
-
var brotliDecompressAsync = promisify(brotliDecompress);
|
|
2716
3153
|
var RedisLayer = class {
|
|
2717
3154
|
name;
|
|
2718
3155
|
defaultTtl;
|
|
@@ -2820,8 +3257,18 @@ var RedisLayer = class {
|
|
|
2820
3257
|
return remaining;
|
|
2821
3258
|
}
|
|
2822
3259
|
async size() {
|
|
2823
|
-
|
|
2824
|
-
|
|
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;
|
|
2825
3272
|
}
|
|
2826
3273
|
async ping() {
|
|
2827
3274
|
try {
|
|
@@ -2867,6 +3314,17 @@ var RedisLayer = class {
|
|
|
2867
3314
|
}
|
|
2868
3315
|
return keys.map((key) => key.slice(this.prefix.length));
|
|
2869
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
|
+
}
|
|
2870
3328
|
async scanKeys(pattern) {
|
|
2871
3329
|
const matches = [];
|
|
2872
3330
|
let cursor = "0";
|
|
@@ -2881,7 +3339,13 @@ var RedisLayer = class {
|
|
|
2881
3339
|
return `${this.prefix}${key}`;
|
|
2882
3340
|
}
|
|
2883
3341
|
async deserializeOrDelete(key, payload) {
|
|
2884
|
-
|
|
3342
|
+
let decodedPayload;
|
|
3343
|
+
try {
|
|
3344
|
+
decodedPayload = await this.decodePayload(payload);
|
|
3345
|
+
} catch {
|
|
3346
|
+
await this.deleteCorruptedKey(key);
|
|
3347
|
+
return null;
|
|
3348
|
+
}
|
|
2885
3349
|
for (const serializer of this.serializers) {
|
|
2886
3350
|
try {
|
|
2887
3351
|
const value = serializer.deserialize(decodedPayload);
|
|
@@ -2892,12 +3356,15 @@ var RedisLayer = class {
|
|
|
2892
3356
|
} catch {
|
|
2893
3357
|
}
|
|
2894
3358
|
}
|
|
3359
|
+
await this.deleteCorruptedKey(key);
|
|
3360
|
+
return null;
|
|
3361
|
+
}
|
|
3362
|
+
async deleteCorruptedKey(key) {
|
|
2895
3363
|
try {
|
|
2896
3364
|
await this.client.del(this.withPrefix(key));
|
|
2897
3365
|
} catch (deleteError) {
|
|
2898
3366
|
console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
|
|
2899
3367
|
}
|
|
2900
|
-
return null;
|
|
2901
3368
|
}
|
|
2902
3369
|
async rewriteWithPrimarySerializer(key, value) {
|
|
2903
3370
|
const serialized = this.primarySerializer().serialize(value);
|
|
@@ -2944,31 +3411,72 @@ var RedisLayer = class {
|
|
|
2944
3411
|
return payload;
|
|
2945
3412
|
}
|
|
2946
3413
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
2947
|
-
|
|
2948
|
-
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
2949
|
-
throw new Error(
|
|
2950
|
-
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
2951
|
-
);
|
|
2952
|
-
}
|
|
2953
|
-
return decompressed;
|
|
3414
|
+
return this.decompressWithLimit(createGunzip(), payload.subarray(10));
|
|
2954
3415
|
}
|
|
2955
3416
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
2956
|
-
|
|
2957
|
-
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
2958
|
-
throw new Error(
|
|
2959
|
-
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
2960
|
-
);
|
|
2961
|
-
}
|
|
2962
|
-
return decompressed;
|
|
3417
|
+
return this.decompressWithLimit(createBrotliDecompress(), payload.subarray(12));
|
|
2963
3418
|
}
|
|
2964
3419
|
return payload;
|
|
2965
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
|
+
}
|
|
2966
3473
|
};
|
|
2967
3474
|
|
|
2968
3475
|
// src/layers/DiskLayer.ts
|
|
2969
3476
|
import { createHash } from "crypto";
|
|
2970
3477
|
import { promises as fs } from "fs";
|
|
2971
3478
|
import { join, resolve } from "path";
|
|
3479
|
+
var FILE_SCAN_CONCURRENCY = 32;
|
|
2972
3480
|
var DiskLayer = class {
|
|
2973
3481
|
name;
|
|
2974
3482
|
defaultTtl;
|
|
@@ -2976,6 +3484,7 @@ var DiskLayer = class {
|
|
|
2976
3484
|
directory;
|
|
2977
3485
|
serializer;
|
|
2978
3486
|
maxFiles;
|
|
3487
|
+
maxEntryBytes;
|
|
2979
3488
|
writeQueue = Promise.resolve();
|
|
2980
3489
|
constructor(options) {
|
|
2981
3490
|
this.directory = this.resolveDirectory(options.directory);
|
|
@@ -2983,16 +3492,15 @@ var DiskLayer = class {
|
|
|
2983
3492
|
this.name = options.name ?? "disk";
|
|
2984
3493
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2985
3494
|
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
3495
|
+
this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
|
|
2986
3496
|
}
|
|
2987
3497
|
async get(key) {
|
|
2988
3498
|
return unwrapStoredValue(await this.getEntry(key));
|
|
2989
3499
|
}
|
|
2990
3500
|
async getEntry(key) {
|
|
2991
3501
|
const filePath = this.keyToPath(key);
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
raw = await fs.readFile(filePath);
|
|
2995
|
-
} catch {
|
|
3502
|
+
const raw = await this.readEntryFile(filePath);
|
|
3503
|
+
if (raw === null) {
|
|
2996
3504
|
return null;
|
|
2997
3505
|
}
|
|
2998
3506
|
let entry;
|
|
@@ -3043,10 +3551,8 @@ var DiskLayer = class {
|
|
|
3043
3551
|
}
|
|
3044
3552
|
async ttl(key) {
|
|
3045
3553
|
const filePath = this.keyToPath(key);
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
raw = await fs.readFile(filePath);
|
|
3049
|
-
} catch {
|
|
3554
|
+
const raw = await this.readEntryFile(filePath);
|
|
3555
|
+
if (raw === null) {
|
|
3050
3556
|
return null;
|
|
3051
3557
|
}
|
|
3052
3558
|
let entry;
|
|
@@ -3070,7 +3576,7 @@ var DiskLayer = class {
|
|
|
3070
3576
|
}
|
|
3071
3577
|
async deleteMany(keys) {
|
|
3072
3578
|
await this.enqueueWrite(async () => {
|
|
3073
|
-
await
|
|
3579
|
+
await this.deletePathsWithConcurrency(keys.map((key) => this.keyToPath(key)));
|
|
3074
3580
|
});
|
|
3075
3581
|
}
|
|
3076
3582
|
async clear() {
|
|
@@ -3081,8 +3587,8 @@ var DiskLayer = class {
|
|
|
3081
3587
|
} catch {
|
|
3082
3588
|
return;
|
|
3083
3589
|
}
|
|
3084
|
-
await
|
|
3085
|
-
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))
|
|
3086
3592
|
);
|
|
3087
3593
|
});
|
|
3088
3594
|
}
|
|
@@ -3091,42 +3597,23 @@ var DiskLayer = class {
|
|
|
3091
3597
|
* Expired entries are skipped and cleaned up during the scan.
|
|
3092
3598
|
*/
|
|
3093
3599
|
async keys() {
|
|
3094
|
-
let entries;
|
|
3095
|
-
try {
|
|
3096
|
-
entries = await fs.readdir(this.directory);
|
|
3097
|
-
} catch {
|
|
3098
|
-
return [];
|
|
3099
|
-
}
|
|
3100
|
-
const lcFiles = entries.filter((name) => name.endsWith(".lc"));
|
|
3101
3600
|
const keys = [];
|
|
3102
|
-
await
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
let raw;
|
|
3106
|
-
try {
|
|
3107
|
-
raw = await fs.readFile(filePath);
|
|
3108
|
-
} catch {
|
|
3109
|
-
return;
|
|
3110
|
-
}
|
|
3111
|
-
let entry;
|
|
3112
|
-
try {
|
|
3113
|
-
entry = this.deserializeEntry(raw);
|
|
3114
|
-
} catch {
|
|
3115
|
-
await this.safeDelete(filePath);
|
|
3116
|
-
return;
|
|
3117
|
-
}
|
|
3118
|
-
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
3119
|
-
await this.safeDelete(filePath);
|
|
3120
|
-
return;
|
|
3121
|
-
}
|
|
3122
|
-
keys.push(entry.key);
|
|
3123
|
-
})
|
|
3124
|
-
);
|
|
3601
|
+
await this.scanEntries(async (entry) => {
|
|
3602
|
+
keys.push(entry.key);
|
|
3603
|
+
});
|
|
3125
3604
|
return keys;
|
|
3126
3605
|
}
|
|
3606
|
+
async forEachKey(visitor) {
|
|
3607
|
+
await this.scanEntries(async (entry) => {
|
|
3608
|
+
await visitor(entry.key);
|
|
3609
|
+
});
|
|
3610
|
+
}
|
|
3127
3611
|
async size() {
|
|
3128
|
-
|
|
3129
|
-
|
|
3612
|
+
let count = 0;
|
|
3613
|
+
await this.scanEntries(async () => {
|
|
3614
|
+
count += 1;
|
|
3615
|
+
});
|
|
3616
|
+
return count;
|
|
3130
3617
|
}
|
|
3131
3618
|
async ping() {
|
|
3132
3619
|
try {
|
|
@@ -3160,6 +3647,113 @@ var DiskLayer = class {
|
|
|
3160
3647
|
}
|
|
3161
3648
|
return maxFiles;
|
|
3162
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
|
+
}
|
|
3163
3757
|
deserializeEntry(raw) {
|
|
3164
3758
|
const entry = this.serializer.deserialize(raw);
|
|
3165
3759
|
if (!isDiskEntry(entry)) {
|
|
@@ -3296,18 +3890,27 @@ var MemcachedLayer = class {
|
|
|
3296
3890
|
// src/serialization/MsgpackSerializer.ts
|
|
3297
3891
|
import { decode, encode } from "@msgpack/msgpack";
|
|
3298
3892
|
var DANGEROUS_KEYS2 = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
3893
|
+
var MAX_SANITIZE_DEPTH3 = 64;
|
|
3894
|
+
var MAX_SANITIZE_NODES3 = 1e4;
|
|
3299
3895
|
var MsgpackSerializer = class {
|
|
3300
3896
|
serialize(value) {
|
|
3301
3897
|
return Buffer.from(encode(value));
|
|
3302
3898
|
}
|
|
3303
3899
|
deserialize(payload) {
|
|
3304
3900
|
const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
3305
|
-
return sanitizeMsgpackValue(decode(normalized));
|
|
3901
|
+
return sanitizeMsgpackValue(decode(normalized), 0, { count: 0 });
|
|
3306
3902
|
}
|
|
3307
3903
|
};
|
|
3308
|
-
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
|
+
}
|
|
3309
3912
|
if (Array.isArray(value)) {
|
|
3310
|
-
return value.map((entry) => sanitizeMsgpackValue(entry));
|
|
3913
|
+
return value.map((entry) => sanitizeMsgpackValue(entry, depth + 1, state));
|
|
3311
3914
|
}
|
|
3312
3915
|
if (!isPlainObject2(value)) {
|
|
3313
3916
|
return value;
|
|
@@ -3317,7 +3920,7 @@ function sanitizeMsgpackValue(value) {
|
|
|
3317
3920
|
if (DANGEROUS_KEYS2.has(key)) {
|
|
3318
3921
|
continue;
|
|
3319
3922
|
}
|
|
3320
|
-
sanitized[key] = sanitizeMsgpackValue(entry);
|
|
3923
|
+
sanitized[key] = sanitizeMsgpackValue(entry, depth + 1, state);
|
|
3321
3924
|
}
|
|
3322
3925
|
return sanitized;
|
|
3323
3926
|
}
|