layercache 1.2.1 → 1.2.3
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/README.md +66 -11
- package/dist/{chunk-ZMDB5KOK.js → chunk-7V7XAB74.js} +24 -1
- package/dist/{chunk-46UH7LNM.js → chunk-KOYGHLVP.js} +142 -18
- package/dist/{chunk-GF47Y3XR.js → chunk-QHWG7QS5.js} +56 -25
- package/dist/cli.cjs +92 -27
- package/dist/cli.js +15 -4
- package/dist/{edge-C1sBhTfv.d.ts → edge-B_rUqDy6.d.cts} +39 -1
- package/dist/{edge-C1sBhTfv.d.cts → edge-B_rUqDy6.d.ts} +39 -1
- package/dist/edge.cjs +165 -17
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +2 -2
- package/dist/index.cjs +798 -127
- package/dist/index.d.cts +18 -3
- package/dist/index.d.ts +18 -3
- package/dist/index.js +582 -90
- package/package.json +5 -5
- package/packages/nestjs/dist/index.cjs +582 -61
- package/packages/nestjs/dist/index.d.cts +30 -0
- package/packages/nestjs/dist/index.d.ts +30 -0
- package/packages/nestjs/dist/index.js +582 -61
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RedisTagIndex
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-QHWG7QS5.js";
|
|
4
4
|
import {
|
|
5
5
|
MemoryLayer,
|
|
6
6
|
TagIndex,
|
|
7
7
|
createHonoCacheMiddleware
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-KOYGHLVP.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-7V7XAB74.js";
|
|
19
19
|
|
|
20
20
|
// src/CacheStack.ts
|
|
21
21
|
import { EventEmitter } from "events";
|
|
@@ -360,11 +360,13 @@ var CircuitBreakerManager = class {
|
|
|
360
360
|
|
|
361
361
|
// src/internal/FetchRateLimiter.ts
|
|
362
362
|
var FetchRateLimiter = class {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
363
|
+
buckets = /* @__PURE__ */ new Map();
|
|
364
|
+
queuesByBucket = /* @__PURE__ */ new Map();
|
|
365
|
+
pendingBuckets = /* @__PURE__ */ new Set();
|
|
366
|
+
fetcherBuckets = /* @__PURE__ */ new WeakMap();
|
|
367
|
+
nextFetcherBucketId = 0;
|
|
366
368
|
drainTimer;
|
|
367
|
-
async schedule(options, task) {
|
|
369
|
+
async schedule(options, context, task) {
|
|
368
370
|
if (!options) {
|
|
369
371
|
return task();
|
|
370
372
|
}
|
|
@@ -372,8 +374,18 @@ var FetchRateLimiter = class {
|
|
|
372
374
|
if (!normalized) {
|
|
373
375
|
return task();
|
|
374
376
|
}
|
|
375
|
-
return new Promise((
|
|
376
|
-
this.
|
|
377
|
+
return new Promise((resolve2, reject) => {
|
|
378
|
+
const bucketKey = this.resolveBucketKey(normalized, context);
|
|
379
|
+
const queue = this.queuesByBucket.get(bucketKey) ?? [];
|
|
380
|
+
queue.push({
|
|
381
|
+
bucketKey,
|
|
382
|
+
options: normalized,
|
|
383
|
+
task,
|
|
384
|
+
resolve: resolve2,
|
|
385
|
+
reject
|
|
386
|
+
});
|
|
387
|
+
this.queuesByBucket.set(bucketKey, queue);
|
|
388
|
+
this.pendingBuckets.add(bucketKey);
|
|
377
389
|
this.drain();
|
|
378
390
|
});
|
|
379
391
|
}
|
|
@@ -387,63 +399,159 @@ var FetchRateLimiter = class {
|
|
|
387
399
|
return {
|
|
388
400
|
maxConcurrent,
|
|
389
401
|
intervalMs,
|
|
390
|
-
maxPerInterval
|
|
402
|
+
maxPerInterval,
|
|
403
|
+
scope: options.scope ?? "global",
|
|
404
|
+
bucketKey: options.bucketKey
|
|
391
405
|
};
|
|
392
406
|
}
|
|
407
|
+
resolveBucketKey(options, context) {
|
|
408
|
+
if (options.bucketKey) {
|
|
409
|
+
return `custom:${options.bucketKey}`;
|
|
410
|
+
}
|
|
411
|
+
if (options.scope === "key") {
|
|
412
|
+
return `key:${context.key}`;
|
|
413
|
+
}
|
|
414
|
+
if (options.scope === "fetcher") {
|
|
415
|
+
const existing = this.fetcherBuckets.get(context.fetcher);
|
|
416
|
+
if (existing) {
|
|
417
|
+
return existing;
|
|
418
|
+
}
|
|
419
|
+
const bucket = `fetcher:${this.nextFetcherBucketId}`;
|
|
420
|
+
this.nextFetcherBucketId += 1;
|
|
421
|
+
this.fetcherBuckets.set(context.fetcher, bucket);
|
|
422
|
+
return bucket;
|
|
423
|
+
}
|
|
424
|
+
return "global";
|
|
425
|
+
}
|
|
393
426
|
drain() {
|
|
394
427
|
if (this.drainTimer) {
|
|
395
428
|
clearTimeout(this.drainTimer);
|
|
396
429
|
this.drainTimer = void 0;
|
|
397
430
|
}
|
|
398
|
-
while (this.
|
|
399
|
-
|
|
400
|
-
|
|
431
|
+
while (this.pendingBuckets.size > 0) {
|
|
432
|
+
let nextBucketKey;
|
|
433
|
+
let nextWaitMs = Number.POSITIVE_INFINITY;
|
|
434
|
+
for (const bucketKey of this.pendingBuckets) {
|
|
435
|
+
const queue2 = this.queuesByBucket.get(bucketKey);
|
|
436
|
+
if (!queue2 || queue2.length === 0) {
|
|
437
|
+
this.pendingBuckets.delete(bucketKey);
|
|
438
|
+
this.queuesByBucket.delete(bucketKey);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const next2 = queue2[0];
|
|
442
|
+
if (!next2) {
|
|
443
|
+
this.pendingBuckets.delete(bucketKey);
|
|
444
|
+
this.queuesByBucket.delete(bucketKey);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const waitMs = this.waitTime(bucketKey, next2.options);
|
|
448
|
+
if (waitMs <= 0) {
|
|
449
|
+
nextBucketKey = bucketKey;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
nextWaitMs = Math.min(nextWaitMs, waitMs);
|
|
453
|
+
}
|
|
454
|
+
if (!nextBucketKey) {
|
|
455
|
+
if (Number.isFinite(nextWaitMs)) {
|
|
456
|
+
this.drainTimer = setTimeout(() => {
|
|
457
|
+
this.drainTimer = void 0;
|
|
458
|
+
this.drain();
|
|
459
|
+
}, nextWaitMs);
|
|
460
|
+
this.drainTimer.unref?.();
|
|
461
|
+
}
|
|
401
462
|
return;
|
|
402
463
|
}
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
464
|
+
const queue = this.queuesByBucket.get(nextBucketKey);
|
|
465
|
+
const next = queue?.shift();
|
|
466
|
+
if (!next) {
|
|
467
|
+
this.pendingBuckets.delete(nextBucketKey);
|
|
468
|
+
this.queuesByBucket.delete(nextBucketKey);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!queue || queue.length === 0) {
|
|
472
|
+
this.pendingBuckets.delete(nextBucketKey);
|
|
473
|
+
this.queuesByBucket.delete(nextBucketKey);
|
|
474
|
+
}
|
|
475
|
+
const bucket = this.bucketState(next.bucketKey);
|
|
476
|
+
if (bucket.cleanupTimer) {
|
|
477
|
+
clearTimeout(bucket.cleanupTimer);
|
|
478
|
+
bucket.cleanupTimer = void 0;
|
|
479
|
+
}
|
|
480
|
+
bucket.active += 1;
|
|
481
|
+
if (next.options.intervalMs && next.options.maxPerInterval) {
|
|
482
|
+
bucket.startedAt.push(Date.now());
|
|
411
483
|
}
|
|
412
|
-
this.queue.shift();
|
|
413
|
-
this.active += 1;
|
|
414
|
-
this.startedAt.push(Date.now());
|
|
415
484
|
void next.task().then(next.resolve, next.reject).finally(() => {
|
|
416
|
-
|
|
485
|
+
bucket.active -= 1;
|
|
486
|
+
if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
|
|
487
|
+
this.pendingBuckets.add(next.bucketKey);
|
|
488
|
+
}
|
|
489
|
+
this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
|
|
417
490
|
this.drain();
|
|
418
491
|
});
|
|
419
492
|
}
|
|
420
493
|
}
|
|
421
|
-
waitTime(options) {
|
|
494
|
+
waitTime(bucketKey, options) {
|
|
495
|
+
const bucket = this.bucketState(bucketKey);
|
|
422
496
|
const now = Date.now();
|
|
423
|
-
if (options.maxConcurrent &&
|
|
497
|
+
if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
|
|
424
498
|
return 1;
|
|
425
499
|
}
|
|
426
500
|
if (!options.intervalMs || !options.maxPerInterval) {
|
|
427
501
|
return 0;
|
|
428
502
|
}
|
|
429
|
-
this.prune(now, options.intervalMs);
|
|
430
|
-
if (
|
|
503
|
+
this.prune(bucket, now, options.intervalMs);
|
|
504
|
+
if (bucket.startedAt.length < options.maxPerInterval) {
|
|
431
505
|
return 0;
|
|
432
506
|
}
|
|
433
|
-
const oldest =
|
|
507
|
+
const oldest = bucket.startedAt[0];
|
|
434
508
|
if (!oldest) {
|
|
435
509
|
return 0;
|
|
436
510
|
}
|
|
437
511
|
return Math.max(1, options.intervalMs - (now - oldest));
|
|
438
512
|
}
|
|
439
|
-
prune(now, intervalMs) {
|
|
440
|
-
while (
|
|
441
|
-
const startedAt =
|
|
513
|
+
prune(bucket, now, intervalMs) {
|
|
514
|
+
while (bucket.startedAt.length > 0) {
|
|
515
|
+
const startedAt = bucket.startedAt[0];
|
|
442
516
|
if (startedAt === void 0 || now - startedAt < intervalMs) {
|
|
443
517
|
break;
|
|
444
518
|
}
|
|
445
|
-
|
|
519
|
+
bucket.startedAt.shift();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
bucketState(bucketKey) {
|
|
523
|
+
const existing = this.buckets.get(bucketKey);
|
|
524
|
+
if (existing) {
|
|
525
|
+
return existing;
|
|
526
|
+
}
|
|
527
|
+
const bucket = { active: 0, startedAt: [] };
|
|
528
|
+
this.buckets.set(bucketKey, bucket);
|
|
529
|
+
return bucket;
|
|
530
|
+
}
|
|
531
|
+
cleanupBucket(bucketKey, bucket, intervalMs) {
|
|
532
|
+
const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
|
|
533
|
+
if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
|
|
534
|
+
this.buckets.delete(bucketKey);
|
|
535
|
+
this.queuesByBucket.delete(bucketKey);
|
|
536
|
+
this.pendingBuckets.delete(bucketKey);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (!intervalMs || bucket.active > 0 || queued > 0) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (bucket.cleanupTimer) {
|
|
543
|
+
clearTimeout(bucket.cleanupTimer);
|
|
446
544
|
}
|
|
545
|
+
bucket.cleanupTimer = setTimeout(() => {
|
|
546
|
+
bucket.cleanupTimer = void 0;
|
|
547
|
+
this.prune(bucket, Date.now(), intervalMs);
|
|
548
|
+
if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
|
|
549
|
+
this.buckets.delete(bucketKey);
|
|
550
|
+
this.queuesByBucket.delete(bucketKey);
|
|
551
|
+
this.pendingBuckets.delete(bucketKey);
|
|
552
|
+
}
|
|
553
|
+
}, intervalMs);
|
|
554
|
+
bucket.cleanupTimer.unref?.();
|
|
447
555
|
}
|
|
448
556
|
};
|
|
449
557
|
|
|
@@ -633,6 +741,41 @@ var TtlResolver = class {
|
|
|
633
741
|
}
|
|
634
742
|
};
|
|
635
743
|
|
|
744
|
+
// src/serialization/JsonSerializer.ts
|
|
745
|
+
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
746
|
+
var JsonSerializer = class {
|
|
747
|
+
serialize(value) {
|
|
748
|
+
return JSON.stringify(value);
|
|
749
|
+
}
|
|
750
|
+
deserialize(payload) {
|
|
751
|
+
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
752
|
+
return sanitizeJsonValue(JSON.parse(normalized), 0);
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
var MAX_SANITIZE_DEPTH = 200;
|
|
756
|
+
function sanitizeJsonValue(value, depth) {
|
|
757
|
+
if (depth > MAX_SANITIZE_DEPTH) {
|
|
758
|
+
return value;
|
|
759
|
+
}
|
|
760
|
+
if (Array.isArray(value)) {
|
|
761
|
+
return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
|
|
762
|
+
}
|
|
763
|
+
if (!isPlainObject(value)) {
|
|
764
|
+
return value;
|
|
765
|
+
}
|
|
766
|
+
const sanitized = {};
|
|
767
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
768
|
+
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
sanitized[key] = sanitizeJsonValue(entry, depth + 1);
|
|
772
|
+
}
|
|
773
|
+
return sanitized;
|
|
774
|
+
}
|
|
775
|
+
function isPlainObject(value) {
|
|
776
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
777
|
+
}
|
|
778
|
+
|
|
636
779
|
// src/stampede/StampedeGuard.ts
|
|
637
780
|
import { Mutex as Mutex2 } from "async-mutex";
|
|
638
781
|
var StampedeGuard = class {
|
|
@@ -643,7 +786,8 @@ var StampedeGuard = class {
|
|
|
643
786
|
return await entry.mutex.runExclusive(task);
|
|
644
787
|
} finally {
|
|
645
788
|
entry.references -= 1;
|
|
646
|
-
|
|
789
|
+
const current = this.mutexes.get(key);
|
|
790
|
+
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
647
791
|
this.mutexes.delete(key);
|
|
648
792
|
}
|
|
649
793
|
}
|
|
@@ -673,8 +817,10 @@ var CacheMissError = class extends Error {
|
|
|
673
817
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
674
818
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
675
819
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
820
|
+
var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
|
|
676
821
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
677
822
|
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
823
|
+
var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
678
824
|
var DebugLogger = class {
|
|
679
825
|
enabled;
|
|
680
826
|
constructor(enabled) {
|
|
@@ -721,6 +867,21 @@ var CacheStack = class extends EventEmitter {
|
|
|
721
867
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
722
868
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
723
869
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
870
|
+
if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
|
|
871
|
+
this.logger.warn?.(
|
|
872
|
+
"Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
|
|
876
|
+
this.logger.warn?.(
|
|
877
|
+
"Using the default in-memory TagIndex with a shared cache layer that does not implement keys() can leave invalidateByPattern() and invalidateByPrefix() incomplete after restarts. Use RedisTagIndex or implement keys() on the shared layer."
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
|
|
881
|
+
this.logger.warn?.(
|
|
882
|
+
"broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
|
|
883
|
+
);
|
|
884
|
+
}
|
|
724
885
|
this.initializeWriteBehind(options.writeBehind);
|
|
725
886
|
this.startup = this.initialize();
|
|
726
887
|
}
|
|
@@ -734,6 +895,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
734
895
|
logger;
|
|
735
896
|
tagIndex;
|
|
736
897
|
fetchRateLimiter = new FetchRateLimiter();
|
|
898
|
+
snapshotSerializer = new JsonSerializer();
|
|
737
899
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
738
900
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
739
901
|
ttlResolver;
|
|
@@ -742,6 +904,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
742
904
|
writeBehindQueue = [];
|
|
743
905
|
writeBehindTimer;
|
|
744
906
|
writeBehindFlushPromise;
|
|
907
|
+
generationCleanupPromise;
|
|
745
908
|
isDisconnecting = false;
|
|
746
909
|
disconnectPromise;
|
|
747
910
|
/**
|
|
@@ -754,6 +917,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
754
917
|
const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
|
|
755
918
|
this.validateWriteOptions(options);
|
|
756
919
|
await this.awaitStartup("get");
|
|
920
|
+
return this.getPrepared(normalizedKey, fetcher, options);
|
|
921
|
+
}
|
|
922
|
+
async getPrepared(normalizedKey, fetcher, options) {
|
|
757
923
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
758
924
|
if (hit.found) {
|
|
759
925
|
this.ttlResolver.recordAccess(normalizedKey);
|
|
@@ -831,6 +997,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
831
997
|
return true;
|
|
832
998
|
}
|
|
833
999
|
} catch {
|
|
1000
|
+
await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
|
|
834
1001
|
}
|
|
835
1002
|
} else {
|
|
836
1003
|
try {
|
|
@@ -838,7 +1005,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
838
1005
|
if (value !== null) {
|
|
839
1006
|
return true;
|
|
840
1007
|
}
|
|
841
|
-
} catch {
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
await this.reportRecoverableLayerFailure(layer, "has", error);
|
|
842
1010
|
}
|
|
843
1011
|
}
|
|
844
1012
|
}
|
|
@@ -930,13 +1098,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
930
1098
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
931
1099
|
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
932
1100
|
if (!canFastPath) {
|
|
1101
|
+
await this.awaitStartup("mget");
|
|
933
1102
|
const pendingReads = /* @__PURE__ */ new Map();
|
|
934
1103
|
return Promise.all(
|
|
935
1104
|
normalizedEntries.map((entry) => {
|
|
936
1105
|
const optionsSignature = this.serializeOptions(entry.options);
|
|
937
1106
|
const existing = pendingReads.get(entry.key);
|
|
938
1107
|
if (!existing) {
|
|
939
|
-
const promise = this.
|
|
1108
|
+
const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
|
|
940
1109
|
pendingReads.set(entry.key, {
|
|
941
1110
|
promise,
|
|
942
1111
|
fetch: entry.fetch,
|
|
@@ -1075,14 +1244,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1075
1244
|
}
|
|
1076
1245
|
async invalidateByPattern(pattern) {
|
|
1077
1246
|
await this.awaitStartup("invalidateByPattern");
|
|
1078
|
-
const keys = await this.
|
|
1247
|
+
const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
|
|
1079
1248
|
await this.deleteKeys(keys);
|
|
1080
1249
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1081
1250
|
}
|
|
1082
1251
|
async invalidateByPrefix(prefix) {
|
|
1083
1252
|
await this.awaitStartup("invalidateByPrefix");
|
|
1084
1253
|
const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
|
|
1085
|
-
const keys =
|
|
1254
|
+
const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
|
|
1086
1255
|
await this.deleteKeys(keys);
|
|
1087
1256
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1088
1257
|
}
|
|
@@ -1132,9 +1301,18 @@ var CacheStack = class extends EventEmitter {
|
|
|
1132
1301
|
})
|
|
1133
1302
|
);
|
|
1134
1303
|
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Rotates the active generation prefix used for all future cache keys.
|
|
1306
|
+
* Previous-generation keys remain in the underlying layers until they expire,
|
|
1307
|
+
* unless `generationCleanup` is enabled to prune them in the background.
|
|
1308
|
+
*/
|
|
1135
1309
|
bumpGeneration(nextGeneration) {
|
|
1136
1310
|
const current = this.currentGeneration ?? 0;
|
|
1311
|
+
const previousGeneration = this.currentGeneration;
|
|
1137
1312
|
this.currentGeneration = nextGeneration ?? current + 1;
|
|
1313
|
+
if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
|
|
1314
|
+
this.scheduleGenerationCleanup(previousGeneration);
|
|
1315
|
+
}
|
|
1138
1316
|
return this.currentGeneration;
|
|
1139
1317
|
}
|
|
1140
1318
|
/**
|
|
@@ -1218,27 +1396,28 @@ var CacheStack = class extends EventEmitter {
|
|
|
1218
1396
|
this.assertActive("persistToFile");
|
|
1219
1397
|
const snapshot = await this.exportState();
|
|
1220
1398
|
const { promises: fs2 } = await import("fs");
|
|
1221
|
-
await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
1399
|
+
await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
|
|
1222
1400
|
}
|
|
1223
1401
|
async restoreFromFile(filePath) {
|
|
1224
1402
|
this.assertActive("restoreFromFile");
|
|
1225
1403
|
const { promises: fs2 } = await import("fs");
|
|
1226
|
-
const raw = await fs2.readFile(filePath, "utf8");
|
|
1404
|
+
const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
|
|
1227
1405
|
let parsed;
|
|
1228
1406
|
try {
|
|
1229
|
-
parsed = JSON.parse(raw
|
|
1230
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1231
|
-
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1232
|
-
}
|
|
1233
|
-
return value;
|
|
1234
|
-
});
|
|
1407
|
+
parsed = JSON.parse(raw);
|
|
1235
1408
|
} catch (cause) {
|
|
1236
1409
|
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
1237
1410
|
}
|
|
1238
1411
|
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1239
1412
|
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
1240
1413
|
}
|
|
1241
|
-
await this.importState(
|
|
1414
|
+
await this.importState(
|
|
1415
|
+
parsed.map((entry) => ({
|
|
1416
|
+
key: entry.key,
|
|
1417
|
+
value: this.sanitizeSnapshotValue(entry.value),
|
|
1418
|
+
ttl: entry.ttl
|
|
1419
|
+
}))
|
|
1420
|
+
);
|
|
1242
1421
|
}
|
|
1243
1422
|
async disconnect() {
|
|
1244
1423
|
if (!this.disconnectPromise) {
|
|
@@ -1247,6 +1426,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1247
1426
|
await this.startup;
|
|
1248
1427
|
await this.unsubscribeInvalidation?.();
|
|
1249
1428
|
await this.flushWriteBehindQueue();
|
|
1429
|
+
await this.generationCleanupPromise;
|
|
1250
1430
|
await Promise.allSettled([...this.backgroundRefreshes.values()]);
|
|
1251
1431
|
if (this.writeBehindTimer) {
|
|
1252
1432
|
clearInterval(this.writeBehindTimer);
|
|
@@ -1314,6 +1494,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1314
1494
|
try {
|
|
1315
1495
|
fetched = await this.fetchRateLimiter.schedule(
|
|
1316
1496
|
options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
|
|
1497
|
+
{ key, fetcher },
|
|
1317
1498
|
fetcher
|
|
1318
1499
|
);
|
|
1319
1500
|
this.circuitBreakerManager.recordSuccess(key);
|
|
@@ -1329,8 +1510,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1329
1510
|
await this.storeEntry(key, "empty", null, options);
|
|
1330
1511
|
return null;
|
|
1331
1512
|
}
|
|
1332
|
-
if (options?.shouldCache
|
|
1333
|
-
|
|
1513
|
+
if (options?.shouldCache) {
|
|
1514
|
+
try {
|
|
1515
|
+
if (!options.shouldCache(fetched)) {
|
|
1516
|
+
return fetched;
|
|
1517
|
+
}
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
|
|
1520
|
+
}
|
|
1334
1521
|
}
|
|
1335
1522
|
await this.storeEntry(key, "value", fetched, options);
|
|
1336
1523
|
return fetched;
|
|
@@ -1557,7 +1744,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1557
1744
|
const refresh = (async () => {
|
|
1558
1745
|
this.metricsCollector.increment("refreshes");
|
|
1559
1746
|
try {
|
|
1560
|
-
await this.
|
|
1747
|
+
await this.runBackgroundRefresh(key, fetcher, options);
|
|
1561
1748
|
} catch (error) {
|
|
1562
1749
|
this.metricsCollector.increment("refreshErrors");
|
|
1563
1750
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
@@ -1567,11 +1754,22 @@ var CacheStack = class extends EventEmitter {
|
|
|
1567
1754
|
})();
|
|
1568
1755
|
this.backgroundRefreshes.set(key, refresh);
|
|
1569
1756
|
}
|
|
1757
|
+
async runBackgroundRefresh(key, fetcher, options) {
|
|
1758
|
+
const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
|
|
1759
|
+
await this.fetchWithGuards(
|
|
1760
|
+
key,
|
|
1761
|
+
() => this.withTimeout(fetcher(), timeoutMs, () => {
|
|
1762
|
+
return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
|
|
1763
|
+
}),
|
|
1764
|
+
options
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1570
1767
|
resolveSingleFlightOptions() {
|
|
1571
1768
|
return {
|
|
1572
1769
|
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
1573
1770
|
waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
|
|
1574
|
-
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
|
|
1771
|
+
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
|
|
1772
|
+
renewIntervalMs: this.options.singleFlightRenewIntervalMs
|
|
1575
1773
|
};
|
|
1576
1774
|
}
|
|
1577
1775
|
async deleteKeys(keys) {
|
|
@@ -1631,10 +1829,122 @@ var CacheStack = class extends EventEmitter {
|
|
|
1631
1829
|
return String(error);
|
|
1632
1830
|
}
|
|
1633
1831
|
sleep(ms) {
|
|
1634
|
-
return new Promise((
|
|
1832
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1833
|
+
}
|
|
1834
|
+
async withTimeout(promise, timeoutMs, onTimeout) {
|
|
1835
|
+
if (timeoutMs <= 0) {
|
|
1836
|
+
return promise;
|
|
1837
|
+
}
|
|
1838
|
+
let timer;
|
|
1839
|
+
const observedPromise = promise.then(
|
|
1840
|
+
(value) => ({ kind: "value", value }),
|
|
1841
|
+
(error) => ({ kind: "error", error })
|
|
1842
|
+
);
|
|
1843
|
+
try {
|
|
1844
|
+
const result = await Promise.race([
|
|
1845
|
+
observedPromise,
|
|
1846
|
+
new Promise((_, reject) => {
|
|
1847
|
+
timer = setTimeout(() => reject(onTimeout()), timeoutMs);
|
|
1848
|
+
timer.unref?.();
|
|
1849
|
+
})
|
|
1850
|
+
]);
|
|
1851
|
+
if (result && typeof result === "object" && "kind" in result) {
|
|
1852
|
+
if (result.kind === "error") {
|
|
1853
|
+
throw result.error;
|
|
1854
|
+
}
|
|
1855
|
+
return result.value;
|
|
1856
|
+
}
|
|
1857
|
+
return result;
|
|
1858
|
+
} finally {
|
|
1859
|
+
if (timer) {
|
|
1860
|
+
clearTimeout(timer);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1635
1863
|
}
|
|
1636
1864
|
shouldBroadcastL1Invalidation() {
|
|
1637
|
-
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ??
|
|
1865
|
+
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
|
|
1866
|
+
}
|
|
1867
|
+
async collectKeysWithPrefix(prefix) {
|
|
1868
|
+
const matches = new Set(
|
|
1869
|
+
this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
|
|
1870
|
+
);
|
|
1871
|
+
await Promise.all(
|
|
1872
|
+
this.layers.map(async (layer) => {
|
|
1873
|
+
if (!layer.keys || this.shouldSkipLayer(layer)) {
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
try {
|
|
1877
|
+
const keys = await layer.keys();
|
|
1878
|
+
for (const key of keys) {
|
|
1879
|
+
if (key.startsWith(prefix)) {
|
|
1880
|
+
matches.add(key);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
|
|
1885
|
+
}
|
|
1886
|
+
})
|
|
1887
|
+
);
|
|
1888
|
+
return [...matches];
|
|
1889
|
+
}
|
|
1890
|
+
async collectKeysMatchingPattern(pattern) {
|
|
1891
|
+
const matches = new Set(await this.tagIndex.matchPattern(pattern));
|
|
1892
|
+
await Promise.all(
|
|
1893
|
+
this.layers.map(async (layer) => {
|
|
1894
|
+
if (!layer.keys || this.shouldSkipLayer(layer)) {
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
try {
|
|
1898
|
+
const keys = await layer.keys();
|
|
1899
|
+
for (const key of keys) {
|
|
1900
|
+
if (PatternMatcher.matches(pattern, key)) {
|
|
1901
|
+
matches.add(key);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
|
|
1906
|
+
}
|
|
1907
|
+
})
|
|
1908
|
+
);
|
|
1909
|
+
return [...matches];
|
|
1910
|
+
}
|
|
1911
|
+
shouldCleanupGenerations() {
|
|
1912
|
+
return Boolean(this.options.generationCleanup);
|
|
1913
|
+
}
|
|
1914
|
+
generationCleanupBatchSize() {
|
|
1915
|
+
const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
|
|
1916
|
+
return configured ?? 500;
|
|
1917
|
+
}
|
|
1918
|
+
scheduleGenerationCleanup(generation) {
|
|
1919
|
+
const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
|
|
1920
|
+
this.logger.warn?.("generation-cleanup-error", {
|
|
1921
|
+
generation,
|
|
1922
|
+
error: this.formatError(error)
|
|
1923
|
+
});
|
|
1924
|
+
});
|
|
1925
|
+
this.generationCleanupPromise = task.finally(() => {
|
|
1926
|
+
if (this.generationCleanupPromise === task) {
|
|
1927
|
+
this.generationCleanupPromise = void 0;
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
async cleanupGeneration(generation) {
|
|
1932
|
+
const prefix = `v${generation}:`;
|
|
1933
|
+
const keys = await this.collectKeysWithPrefix(prefix);
|
|
1934
|
+
if (keys.length === 0) {
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
const batchSize = this.generationCleanupBatchSize();
|
|
1938
|
+
for (let index = 0; index < keys.length; index += batchSize) {
|
|
1939
|
+
const batch = keys.slice(index, index + batchSize);
|
|
1940
|
+
await this.deleteKeys(batch);
|
|
1941
|
+
await this.publishInvalidation({
|
|
1942
|
+
scope: "keys",
|
|
1943
|
+
keys: batch,
|
|
1944
|
+
sourceId: this.instanceId,
|
|
1945
|
+
operation: "invalidate"
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1638
1948
|
}
|
|
1639
1949
|
initializeWriteBehind(options) {
|
|
1640
1950
|
if (this.options.writeStrategy !== "write-behind") {
|
|
@@ -1672,7 +1982,17 @@ var CacheStack = class extends EventEmitter {
|
|
|
1672
1982
|
const batchSize = this.options.writeBehind?.batchSize ?? 100;
|
|
1673
1983
|
const batch = this.writeBehindQueue.splice(0, batchSize);
|
|
1674
1984
|
this.writeBehindFlushPromise = (async () => {
|
|
1675
|
-
await Promise.allSettled(batch.map((operation) => operation()));
|
|
1985
|
+
const results = await Promise.allSettled(batch.map((operation) => operation()));
|
|
1986
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
1987
|
+
if (failures.length > 0) {
|
|
1988
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
1989
|
+
this.logger.error?.("write-behind-flush-failure", {
|
|
1990
|
+
failed: failures.length,
|
|
1991
|
+
total: batch.length,
|
|
1992
|
+
errors: failures.map((failure) => this.formatError(failure.reason))
|
|
1993
|
+
});
|
|
1994
|
+
this.emitError("write-behind", { failed: failures.length, total: batch.length });
|
|
1995
|
+
}
|
|
1676
1996
|
})();
|
|
1677
1997
|
await this.writeBehindFlushPromise;
|
|
1678
1998
|
this.writeBehindFlushPromise = void 0;
|
|
@@ -1776,8 +2096,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1776
2096
|
this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
|
|
1777
2097
|
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
1778
2098
|
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
2099
|
+
this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
|
|
2100
|
+
this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
|
|
2101
|
+
this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
|
|
1779
2102
|
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
1780
2103
|
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
2104
|
+
if (typeof this.options.generationCleanup === "object") {
|
|
2105
|
+
this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
|
|
2106
|
+
}
|
|
1781
2107
|
if (this.options.generation !== void 0) {
|
|
1782
2108
|
this.validateNonNegativeNumber("generation", this.options.generation);
|
|
1783
2109
|
}
|
|
@@ -1795,6 +2121,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1795
2121
|
this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
|
|
1796
2122
|
this.validateAdaptiveTtlOptions(options.adaptiveTtl);
|
|
1797
2123
|
this.validateCircuitBreakerOptions(options.circuitBreaker);
|
|
2124
|
+
this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
|
|
1798
2125
|
}
|
|
1799
2126
|
validateLayerNumberOption(name, value) {
|
|
1800
2127
|
if (value === void 0) {
|
|
@@ -1819,6 +2146,20 @@ var CacheStack = class extends EventEmitter {
|
|
|
1819
2146
|
throw new Error(`${name} must be a positive finite number.`);
|
|
1820
2147
|
}
|
|
1821
2148
|
}
|
|
2149
|
+
validateRateLimitOptions(name, options) {
|
|
2150
|
+
if (!options) {
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
2154
|
+
this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
2155
|
+
this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
2156
|
+
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
2157
|
+
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
2158
|
+
}
|
|
2159
|
+
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
2160
|
+
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
1822
2163
|
validateNonNegativeNumber(name, value) {
|
|
1823
2164
|
if (!Number.isFinite(value) || value < 0) {
|
|
1824
2165
|
throw new Error(`${name} must be a non-negative finite number.`);
|
|
@@ -1834,6 +2175,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
1834
2175
|
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
1835
2176
|
throw new Error("Cache key contains unsupported control characters.");
|
|
1836
2177
|
}
|
|
2178
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
2179
|
+
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
2180
|
+
}
|
|
1837
2181
|
return key;
|
|
1838
2182
|
}
|
|
1839
2183
|
validateTtlPolicy(name, policy) {
|
|
@@ -1911,6 +2255,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1911
2255
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1912
2256
|
return null;
|
|
1913
2257
|
}
|
|
2258
|
+
async reportRecoverableLayerFailure(layer, operation, error) {
|
|
2259
|
+
if (this.isGracefulDegradationEnabled()) {
|
|
2260
|
+
await this.handleLayerFailure(layer, operation, error);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
|
|
2264
|
+
this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
|
|
2265
|
+
}
|
|
1914
2266
|
isGracefulDegradationEnabled() {
|
|
1915
2267
|
return Boolean(this.options.gracefulDegradation);
|
|
1916
2268
|
}
|
|
@@ -1934,10 +2286,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
1934
2286
|
}
|
|
1935
2287
|
}
|
|
1936
2288
|
serializeKeyPart(value) {
|
|
1937
|
-
if (typeof value === "string"
|
|
1938
|
-
return
|
|
2289
|
+
if (typeof value === "string") {
|
|
2290
|
+
return `s:${value}`;
|
|
2291
|
+
}
|
|
2292
|
+
if (typeof value === "number") {
|
|
2293
|
+
return `n:${value}`;
|
|
2294
|
+
}
|
|
2295
|
+
if (typeof value === "boolean") {
|
|
2296
|
+
return `b:${value}`;
|
|
1939
2297
|
}
|
|
1940
|
-
return JSON.stringify(this.normalizeForSerialization(value))
|
|
2298
|
+
return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
|
|
1941
2299
|
}
|
|
1942
2300
|
isCacheSnapshotEntries(value) {
|
|
1943
2301
|
return Array.isArray(value) && value.every((entry) => {
|
|
@@ -1945,15 +2303,39 @@ var CacheStack = class extends EventEmitter {
|
|
|
1945
2303
|
return false;
|
|
1946
2304
|
}
|
|
1947
2305
|
const candidate = entry;
|
|
1948
|
-
return typeof candidate.key === "string";
|
|
2306
|
+
return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
|
|
1949
2307
|
});
|
|
1950
2308
|
}
|
|
2309
|
+
sanitizeSnapshotValue(value) {
|
|
2310
|
+
return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
|
|
2311
|
+
}
|
|
2312
|
+
async validateSnapshotFilePath(filePath) {
|
|
2313
|
+
if (filePath.length === 0) {
|
|
2314
|
+
throw new Error("filePath must not be empty.");
|
|
2315
|
+
}
|
|
2316
|
+
if (filePath.includes("\0")) {
|
|
2317
|
+
throw new Error("filePath must not contain null bytes.");
|
|
2318
|
+
}
|
|
2319
|
+
const path = await import("path");
|
|
2320
|
+
const resolved = path.resolve(filePath);
|
|
2321
|
+
const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
|
|
2322
|
+
if (baseDir !== false) {
|
|
2323
|
+
const relative = path.relative(baseDir, resolved);
|
|
2324
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
2325
|
+
throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
return resolved;
|
|
2329
|
+
}
|
|
1951
2330
|
normalizeForSerialization(value) {
|
|
1952
2331
|
if (Array.isArray(value)) {
|
|
1953
2332
|
return value.map((entry) => this.normalizeForSerialization(entry));
|
|
1954
2333
|
}
|
|
1955
2334
|
if (value && typeof value === "object") {
|
|
1956
2335
|
return Object.keys(value).sort().reduce((normalized, key) => {
|
|
2336
|
+
if (DANGEROUS_OBJECT_KEYS.has(key)) {
|
|
2337
|
+
return normalized;
|
|
2338
|
+
}
|
|
1957
2339
|
normalized[key] = this.normalizeForSerialization(value[key]);
|
|
1958
2340
|
return normalized;
|
|
1959
2341
|
}, {});
|
|
@@ -2080,7 +2462,7 @@ function createCachedMethodDecorator(options) {
|
|
|
2080
2462
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
2081
2463
|
return async (fastify) => {
|
|
2082
2464
|
fastify.decorate("cache", cache);
|
|
2083
|
-
if (options.exposeStatsRoute
|
|
2465
|
+
if (options.exposeStatsRoute === true && fastify.get) {
|
|
2084
2466
|
fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
|
|
2085
2467
|
}
|
|
2086
2468
|
};
|
|
@@ -2096,7 +2478,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2096
2478
|
next();
|
|
2097
2479
|
return;
|
|
2098
2480
|
}
|
|
2099
|
-
const
|
|
2481
|
+
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
2482
|
+
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
|
|
2100
2483
|
const cached = await cache.get(key, void 0, options);
|
|
2101
2484
|
if (cached !== null) {
|
|
2102
2485
|
res.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
@@ -2112,7 +2495,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2112
2495
|
if (originalJson) {
|
|
2113
2496
|
res.json = (body) => {
|
|
2114
2497
|
res.setHeader?.("x-cache", "MISS");
|
|
2115
|
-
|
|
2498
|
+
cache.set(key, body, options).catch((err) => {
|
|
2499
|
+
cache.emit("error", {
|
|
2500
|
+
operation: "set",
|
|
2501
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2502
|
+
});
|
|
2503
|
+
});
|
|
2116
2504
|
return originalJson(body);
|
|
2117
2505
|
};
|
|
2118
2506
|
}
|
|
@@ -2122,6 +2510,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2122
2510
|
}
|
|
2123
2511
|
};
|
|
2124
2512
|
}
|
|
2513
|
+
function normalizeUrl(url) {
|
|
2514
|
+
try {
|
|
2515
|
+
const parsed = new URL(url, "http://localhost");
|
|
2516
|
+
parsed.searchParams.sort();
|
|
2517
|
+
return decodeURIComponent(parsed.pathname) + parsed.search;
|
|
2518
|
+
} catch {
|
|
2519
|
+
return url;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2125
2522
|
|
|
2126
2523
|
// src/integrations/graphql.ts
|
|
2127
2524
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
@@ -2222,19 +2619,6 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
|
|
|
2222
2619
|
// src/layers/RedisLayer.ts
|
|
2223
2620
|
import { promisify } from "util";
|
|
2224
2621
|
import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
|
|
2225
|
-
|
|
2226
|
-
// src/serialization/JsonSerializer.ts
|
|
2227
|
-
var JsonSerializer = class {
|
|
2228
|
-
serialize(value) {
|
|
2229
|
-
return JSON.stringify(value);
|
|
2230
|
-
}
|
|
2231
|
-
deserialize(payload) {
|
|
2232
|
-
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2233
|
-
return JSON.parse(normalized);
|
|
2234
|
-
}
|
|
2235
|
-
};
|
|
2236
|
-
|
|
2237
|
-
// src/layers/RedisLayer.ts
|
|
2238
2622
|
var BATCH_DELETE_SIZE = 500;
|
|
2239
2623
|
var gzipAsync = promisify(gzip);
|
|
2240
2624
|
var gunzipAsync = promisify(gunzip);
|
|
@@ -2251,6 +2635,7 @@ var RedisLayer = class {
|
|
|
2251
2635
|
scanCount;
|
|
2252
2636
|
compression;
|
|
2253
2637
|
compressionThreshold;
|
|
2638
|
+
decompressionMaxBytes;
|
|
2254
2639
|
disconnectOnDispose;
|
|
2255
2640
|
constructor(options) {
|
|
2256
2641
|
this.client = options.client;
|
|
@@ -2262,6 +2647,7 @@ var RedisLayer = class {
|
|
|
2262
2647
|
this.scanCount = options.scanCount ?? 100;
|
|
2263
2648
|
this.compression = options.compression;
|
|
2264
2649
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
2650
|
+
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
2265
2651
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
2266
2652
|
}
|
|
2267
2653
|
async get(key) {
|
|
@@ -2461,16 +2847,29 @@ var RedisLayer = class {
|
|
|
2461
2847
|
}
|
|
2462
2848
|
/**
|
|
2463
2849
|
* Decompresses the payload asynchronously if a compression header is present.
|
|
2850
|
+
* Enforces a maximum decompressed size to prevent decompression bomb attacks.
|
|
2464
2851
|
*/
|
|
2465
2852
|
async decodePayload(payload) {
|
|
2466
2853
|
if (!Buffer.isBuffer(payload)) {
|
|
2467
2854
|
return payload;
|
|
2468
2855
|
}
|
|
2469
2856
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
2470
|
-
|
|
2857
|
+
const decompressed = await gunzipAsync(payload.subarray(10));
|
|
2858
|
+
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
2859
|
+
throw new Error(
|
|
2860
|
+
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
2861
|
+
);
|
|
2862
|
+
}
|
|
2863
|
+
return decompressed;
|
|
2471
2864
|
}
|
|
2472
2865
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
2473
|
-
|
|
2866
|
+
const decompressed = await brotliDecompressAsync(payload.subarray(12));
|
|
2867
|
+
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
2868
|
+
throw new Error(
|
|
2869
|
+
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
2870
|
+
);
|
|
2871
|
+
}
|
|
2872
|
+
return decompressed;
|
|
2474
2873
|
}
|
|
2475
2874
|
return payload;
|
|
2476
2875
|
}
|
|
@@ -2479,7 +2878,7 @@ var RedisLayer = class {
|
|
|
2479
2878
|
// src/layers/DiskLayer.ts
|
|
2480
2879
|
import { createHash } from "crypto";
|
|
2481
2880
|
import { promises as fs } from "fs";
|
|
2482
|
-
import { join } from "path";
|
|
2881
|
+
import { join, resolve } from "path";
|
|
2483
2882
|
var DiskLayer = class {
|
|
2484
2883
|
name;
|
|
2485
2884
|
defaultTtl;
|
|
@@ -2489,11 +2888,11 @@ var DiskLayer = class {
|
|
|
2489
2888
|
maxFiles;
|
|
2490
2889
|
writeQueue = Promise.resolve();
|
|
2491
2890
|
constructor(options) {
|
|
2492
|
-
this.directory = options.directory;
|
|
2891
|
+
this.directory = this.resolveDirectory(options.directory);
|
|
2493
2892
|
this.defaultTtl = options.ttl;
|
|
2494
2893
|
this.name = options.name ?? "disk";
|
|
2495
2894
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2496
|
-
this.maxFiles = options.maxFiles;
|
|
2895
|
+
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
2497
2896
|
}
|
|
2498
2897
|
async get(key) {
|
|
2499
2898
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -2508,7 +2907,7 @@ var DiskLayer = class {
|
|
|
2508
2907
|
}
|
|
2509
2908
|
let entry;
|
|
2510
2909
|
try {
|
|
2511
|
-
entry = this.
|
|
2910
|
+
entry = this.deserializeEntry(raw);
|
|
2512
2911
|
} catch {
|
|
2513
2912
|
await this.safeDelete(filePath);
|
|
2514
2913
|
return null;
|
|
@@ -2530,8 +2929,13 @@ var DiskLayer = class {
|
|
|
2530
2929
|
const payload = this.serializer.serialize(entry);
|
|
2531
2930
|
const targetPath = this.keyToPath(key);
|
|
2532
2931
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
2533
|
-
|
|
2534
|
-
|
|
2932
|
+
try {
|
|
2933
|
+
await fs.writeFile(tempPath, payload);
|
|
2934
|
+
await fs.rename(tempPath, targetPath);
|
|
2935
|
+
} catch (error) {
|
|
2936
|
+
await this.safeDelete(tempPath);
|
|
2937
|
+
throw error;
|
|
2938
|
+
}
|
|
2535
2939
|
if (this.maxFiles !== void 0) {
|
|
2536
2940
|
await this.enforceMaxFiles();
|
|
2537
2941
|
}
|
|
@@ -2541,9 +2945,7 @@ var DiskLayer = class {
|
|
|
2541
2945
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2542
2946
|
}
|
|
2543
2947
|
async setMany(entries) {
|
|
2544
|
-
|
|
2545
|
-
await this.set(entry.key, entry.value, entry.ttl);
|
|
2546
|
-
}
|
|
2948
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
2547
2949
|
}
|
|
2548
2950
|
async has(key) {
|
|
2549
2951
|
const value = await this.getEntry(key);
|
|
@@ -2559,8 +2961,9 @@ var DiskLayer = class {
|
|
|
2559
2961
|
}
|
|
2560
2962
|
let entry;
|
|
2561
2963
|
try {
|
|
2562
|
-
entry = this.
|
|
2964
|
+
entry = this.deserializeEntry(raw);
|
|
2563
2965
|
} catch {
|
|
2966
|
+
await this.safeDelete(filePath);
|
|
2564
2967
|
return null;
|
|
2565
2968
|
}
|
|
2566
2969
|
if (entry.expiresAt === null) {
|
|
@@ -2617,7 +3020,7 @@ var DiskLayer = class {
|
|
|
2617
3020
|
}
|
|
2618
3021
|
let entry;
|
|
2619
3022
|
try {
|
|
2620
|
-
entry = this.
|
|
3023
|
+
entry = this.deserializeEntry(raw);
|
|
2621
3024
|
} catch {
|
|
2622
3025
|
await this.safeDelete(filePath);
|
|
2623
3026
|
return;
|
|
@@ -2649,6 +3052,31 @@ var DiskLayer = class {
|
|
|
2649
3052
|
const hash = createHash("sha256").update(key).digest("hex");
|
|
2650
3053
|
return join(this.directory, `${hash}.lc`);
|
|
2651
3054
|
}
|
|
3055
|
+
resolveDirectory(directory) {
|
|
3056
|
+
if (typeof directory !== "string" || directory.trim().length === 0) {
|
|
3057
|
+
throw new Error("DiskLayer.directory must be a non-empty path.");
|
|
3058
|
+
}
|
|
3059
|
+
if (directory.includes("\0")) {
|
|
3060
|
+
throw new Error("DiskLayer.directory must not contain null bytes.");
|
|
3061
|
+
}
|
|
3062
|
+
return resolve(directory);
|
|
3063
|
+
}
|
|
3064
|
+
normalizeMaxFiles(maxFiles) {
|
|
3065
|
+
if (maxFiles === void 0) {
|
|
3066
|
+
return void 0;
|
|
3067
|
+
}
|
|
3068
|
+
if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
|
|
3069
|
+
throw new Error("DiskLayer.maxFiles must be a positive integer.");
|
|
3070
|
+
}
|
|
3071
|
+
return maxFiles;
|
|
3072
|
+
}
|
|
3073
|
+
deserializeEntry(raw) {
|
|
3074
|
+
const entry = this.serializer.deserialize(raw);
|
|
3075
|
+
if (!isDiskEntry(entry)) {
|
|
3076
|
+
throw new Error("Invalid disk cache entry.");
|
|
3077
|
+
}
|
|
3078
|
+
return entry;
|
|
3079
|
+
}
|
|
2652
3080
|
async safeDelete(filePath) {
|
|
2653
3081
|
try {
|
|
2654
3082
|
await fs.unlink(filePath);
|
|
@@ -2693,6 +3121,14 @@ var DiskLayer = class {
|
|
|
2693
3121
|
await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
|
|
2694
3122
|
}
|
|
2695
3123
|
};
|
|
3124
|
+
function isDiskEntry(value) {
|
|
3125
|
+
if (!value || typeof value !== "object") {
|
|
3126
|
+
return false;
|
|
3127
|
+
}
|
|
3128
|
+
const candidate = value;
|
|
3129
|
+
const validExpiry = candidate.expiresAt === null || typeof candidate.expiresAt === "number";
|
|
3130
|
+
return typeof candidate.key === "string" && validExpiry && "value" in candidate;
|
|
3131
|
+
}
|
|
2696
3132
|
|
|
2697
3133
|
// src/layers/MemcachedLayer.ts
|
|
2698
3134
|
var MemcachedLayer = class {
|
|
@@ -2713,6 +3149,7 @@ var MemcachedLayer = class {
|
|
|
2713
3149
|
return unwrapStoredValue(await this.getEntry(key));
|
|
2714
3150
|
}
|
|
2715
3151
|
async getEntry(key) {
|
|
3152
|
+
this.validateKey(key);
|
|
2716
3153
|
const result = await this.client.get(this.withPrefix(key));
|
|
2717
3154
|
if (!result || result.value === null) {
|
|
2718
3155
|
return null;
|
|
@@ -2727,16 +3164,19 @@ var MemcachedLayer = class {
|
|
|
2727
3164
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2728
3165
|
}
|
|
2729
3166
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3167
|
+
this.validateKey(key);
|
|
2730
3168
|
const payload = this.serializer.serialize(value);
|
|
2731
3169
|
await this.client.set(this.withPrefix(key), payload, {
|
|
2732
3170
|
expires: ttl && ttl > 0 ? ttl : void 0
|
|
2733
3171
|
});
|
|
2734
3172
|
}
|
|
2735
3173
|
async has(key) {
|
|
3174
|
+
this.validateKey(key);
|
|
2736
3175
|
const result = await this.client.get(this.withPrefix(key));
|
|
2737
3176
|
return result !== null && result.value !== null;
|
|
2738
3177
|
}
|
|
2739
3178
|
async delete(key) {
|
|
3179
|
+
this.validateKey(key);
|
|
2740
3180
|
await this.client.delete(this.withPrefix(key));
|
|
2741
3181
|
}
|
|
2742
3182
|
async deleteMany(keys) {
|
|
@@ -2750,19 +3190,50 @@ var MemcachedLayer = class {
|
|
|
2750
3190
|
withPrefix(key) {
|
|
2751
3191
|
return `${this.keyPrefix}${key}`;
|
|
2752
3192
|
}
|
|
3193
|
+
validateKey(key) {
|
|
3194
|
+
const fullKey = this.withPrefix(key);
|
|
3195
|
+
if (Buffer.byteLength(fullKey, "utf8") > 250) {
|
|
3196
|
+
throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
|
|
3197
|
+
}
|
|
3198
|
+
if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
|
|
3199
|
+
throw new Error(
|
|
3200
|
+
"MemcachedLayer: key contains invalid characters (whitespace or control characters are not allowed)."
|
|
3201
|
+
);
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
2753
3204
|
};
|
|
2754
3205
|
|
|
2755
3206
|
// src/serialization/MsgpackSerializer.ts
|
|
2756
3207
|
import { decode, encode } from "@msgpack/msgpack";
|
|
3208
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2757
3209
|
var MsgpackSerializer = class {
|
|
2758
3210
|
serialize(value) {
|
|
2759
3211
|
return Buffer.from(encode(value));
|
|
2760
3212
|
}
|
|
2761
3213
|
deserialize(payload) {
|
|
2762
3214
|
const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
2763
|
-
return decode(normalized);
|
|
3215
|
+
return sanitizeMsgpackValue(decode(normalized));
|
|
2764
3216
|
}
|
|
2765
3217
|
};
|
|
3218
|
+
function sanitizeMsgpackValue(value) {
|
|
3219
|
+
if (Array.isArray(value)) {
|
|
3220
|
+
return value.map((entry) => sanitizeMsgpackValue(entry));
|
|
3221
|
+
}
|
|
3222
|
+
if (!isPlainObject2(value)) {
|
|
3223
|
+
return value;
|
|
3224
|
+
}
|
|
3225
|
+
const sanitized = {};
|
|
3226
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
3227
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
sanitized[key] = sanitizeMsgpackValue(entry);
|
|
3231
|
+
}
|
|
3232
|
+
return sanitized;
|
|
3233
|
+
}
|
|
3234
|
+
function isPlainObject2(value) {
|
|
3235
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
3236
|
+
}
|
|
2766
3237
|
|
|
2767
3238
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
2768
3239
|
import { randomUUID } from "crypto";
|
|
@@ -2772,6 +3243,12 @@ if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
|
2772
3243
|
end
|
|
2773
3244
|
return 0
|
|
2774
3245
|
`;
|
|
3246
|
+
var RENEW_SCRIPT = `
|
|
3247
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
3248
|
+
return redis.call("pexpire", KEYS[1], ARGV[2])
|
|
3249
|
+
end
|
|
3250
|
+
return 0
|
|
3251
|
+
`;
|
|
2775
3252
|
var RedisSingleFlightCoordinator = class {
|
|
2776
3253
|
client;
|
|
2777
3254
|
prefix;
|
|
@@ -2784,14 +3261,29 @@ var RedisSingleFlightCoordinator = class {
|
|
|
2784
3261
|
const token = randomUUID();
|
|
2785
3262
|
const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
|
|
2786
3263
|
if (acquired === "OK") {
|
|
3264
|
+
const renewTimer = this.startLeaseRenewal(lockKey, token, options);
|
|
2787
3265
|
try {
|
|
2788
3266
|
return await worker();
|
|
2789
3267
|
} finally {
|
|
3268
|
+
if (renewTimer) {
|
|
3269
|
+
clearInterval(renewTimer);
|
|
3270
|
+
}
|
|
2790
3271
|
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
2791
3272
|
}
|
|
2792
3273
|
}
|
|
2793
3274
|
return waiter();
|
|
2794
3275
|
}
|
|
3276
|
+
startLeaseRenewal(lockKey, token, options) {
|
|
3277
|
+
const renewIntervalMs = options.renewIntervalMs ?? Math.max(100, Math.floor(options.leaseMs / 2));
|
|
3278
|
+
if (renewIntervalMs <= 0 || renewIntervalMs >= options.leaseMs) {
|
|
3279
|
+
return void 0;
|
|
3280
|
+
}
|
|
3281
|
+
const timer = setInterval(() => {
|
|
3282
|
+
void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
|
|
3283
|
+
}, renewIntervalMs);
|
|
3284
|
+
timer.unref?.();
|
|
3285
|
+
return timer;
|
|
3286
|
+
}
|
|
2795
3287
|
};
|
|
2796
3288
|
|
|
2797
3289
|
// src/metrics/PrometheusExporter.ts
|
|
@@ -2870,7 +3362,7 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
2870
3362
|
};
|
|
2871
3363
|
}
|
|
2872
3364
|
function sanitizeLabel(value) {
|
|
2873
|
-
return value.replace(/["\\\n]/g, "_");
|
|
3365
|
+
return value.replace(/["\\\n\r]/g, "_");
|
|
2874
3366
|
}
|
|
2875
3367
|
export {
|
|
2876
3368
|
CacheMissError,
|