layercache 1.2.2 → 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 +27 -8
- package/dist/{chunk-ZMDB5KOK.js → chunk-7V7XAB74.js} +24 -1
- package/dist/{chunk-46UH7LNM.js → chunk-KOYGHLVP.js} +142 -18
- package/dist/{chunk-IXCMHVHP.js → chunk-QHWG7QS5.js} +1 -1
- package/dist/cli.cjs +37 -3
- package/dist/cli.js +15 -4
- package/dist/{edge-DLpdQN0W.d.ts → edge-B_rUqDy6.d.cts} +34 -1
- package/dist/{edge-DLpdQN0W.d.cts → edge-B_rUqDy6.d.ts} +34 -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 +591 -98
- package/dist/index.d.cts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +429 -84
- package/package.json +4 -4
- package/packages/nestjs/dist/index.cjs +492 -43
- package/packages/nestjs/dist/index.d.cts +25 -0
- package/packages/nestjs/dist/index.d.ts +25 -0
- package/packages/nestjs/dist/index.js +492 -43
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,8 +360,9 @@ var CircuitBreakerManager = class {
|
|
|
360
360
|
|
|
361
361
|
// src/internal/FetchRateLimiter.ts
|
|
362
362
|
var FetchRateLimiter = class {
|
|
363
|
-
queue = [];
|
|
364
363
|
buckets = /* @__PURE__ */ new Map();
|
|
364
|
+
queuesByBucket = /* @__PURE__ */ new Map();
|
|
365
|
+
pendingBuckets = /* @__PURE__ */ new Set();
|
|
365
366
|
fetcherBuckets = /* @__PURE__ */ new WeakMap();
|
|
366
367
|
nextFetcherBucketId = 0;
|
|
367
368
|
drainTimer;
|
|
@@ -374,13 +375,17 @@ var FetchRateLimiter = class {
|
|
|
374
375
|
return task();
|
|
375
376
|
}
|
|
376
377
|
return new Promise((resolve2, reject) => {
|
|
377
|
-
this.
|
|
378
|
-
|
|
378
|
+
const bucketKey = this.resolveBucketKey(normalized, context);
|
|
379
|
+
const queue = this.queuesByBucket.get(bucketKey) ?? [];
|
|
380
|
+
queue.push({
|
|
381
|
+
bucketKey,
|
|
379
382
|
options: normalized,
|
|
380
383
|
task,
|
|
381
384
|
resolve: resolve2,
|
|
382
385
|
reject
|
|
383
386
|
});
|
|
387
|
+
this.queuesByBucket.set(bucketKey, queue);
|
|
388
|
+
this.pendingBuckets.add(bucketKey);
|
|
384
389
|
this.drain();
|
|
385
390
|
});
|
|
386
391
|
}
|
|
@@ -423,22 +428,30 @@ var FetchRateLimiter = class {
|
|
|
423
428
|
clearTimeout(this.drainTimer);
|
|
424
429
|
this.drainTimer = void 0;
|
|
425
430
|
}
|
|
426
|
-
while (this.
|
|
427
|
-
let
|
|
431
|
+
while (this.pendingBuckets.size > 0) {
|
|
432
|
+
let nextBucketKey;
|
|
428
433
|
let nextWaitMs = Number.POSITIVE_INFINITY;
|
|
429
|
-
for (
|
|
430
|
-
const
|
|
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];
|
|
431
442
|
if (!next2) {
|
|
443
|
+
this.pendingBuckets.delete(bucketKey);
|
|
444
|
+
this.queuesByBucket.delete(bucketKey);
|
|
432
445
|
continue;
|
|
433
446
|
}
|
|
434
|
-
const waitMs = this.waitTime(
|
|
447
|
+
const waitMs = this.waitTime(bucketKey, next2.options);
|
|
435
448
|
if (waitMs <= 0) {
|
|
436
|
-
|
|
449
|
+
nextBucketKey = bucketKey;
|
|
437
450
|
break;
|
|
438
451
|
}
|
|
439
452
|
nextWaitMs = Math.min(nextWaitMs, waitMs);
|
|
440
453
|
}
|
|
441
|
-
if (
|
|
454
|
+
if (!nextBucketKey) {
|
|
442
455
|
if (Number.isFinite(nextWaitMs)) {
|
|
443
456
|
this.drainTimer = setTimeout(() => {
|
|
444
457
|
this.drainTimer = void 0;
|
|
@@ -448,15 +461,32 @@ var FetchRateLimiter = class {
|
|
|
448
461
|
}
|
|
449
462
|
return;
|
|
450
463
|
}
|
|
451
|
-
const
|
|
464
|
+
const queue = this.queuesByBucket.get(nextBucketKey);
|
|
465
|
+
const next = queue?.shift();
|
|
452
466
|
if (!next) {
|
|
453
|
-
|
|
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);
|
|
454
474
|
}
|
|
455
475
|
const bucket = this.bucketState(next.bucketKey);
|
|
476
|
+
if (bucket.cleanupTimer) {
|
|
477
|
+
clearTimeout(bucket.cleanupTimer);
|
|
478
|
+
bucket.cleanupTimer = void 0;
|
|
479
|
+
}
|
|
456
480
|
bucket.active += 1;
|
|
457
|
-
|
|
481
|
+
if (next.options.intervalMs && next.options.maxPerInterval) {
|
|
482
|
+
bucket.startedAt.push(Date.now());
|
|
483
|
+
}
|
|
458
484
|
void next.task().then(next.resolve, next.reject).finally(() => {
|
|
459
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);
|
|
460
490
|
this.drain();
|
|
461
491
|
});
|
|
462
492
|
}
|
|
@@ -498,6 +528,31 @@ var FetchRateLimiter = class {
|
|
|
498
528
|
this.buckets.set(bucketKey, bucket);
|
|
499
529
|
return bucket;
|
|
500
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);
|
|
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?.();
|
|
555
|
+
}
|
|
501
556
|
};
|
|
502
557
|
|
|
503
558
|
// src/internal/MetricsCollector.ts
|
|
@@ -686,6 +741,41 @@ var TtlResolver = class {
|
|
|
686
741
|
}
|
|
687
742
|
};
|
|
688
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
|
+
|
|
689
779
|
// src/stampede/StampedeGuard.ts
|
|
690
780
|
import { Mutex as Mutex2 } from "async-mutex";
|
|
691
781
|
var StampedeGuard = class {
|
|
@@ -696,7 +786,8 @@ var StampedeGuard = class {
|
|
|
696
786
|
return await entry.mutex.runExclusive(task);
|
|
697
787
|
} finally {
|
|
698
788
|
entry.references -= 1;
|
|
699
|
-
|
|
789
|
+
const current = this.mutexes.get(key);
|
|
790
|
+
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
700
791
|
this.mutexes.delete(key);
|
|
701
792
|
}
|
|
702
793
|
}
|
|
@@ -726,8 +817,10 @@ var CacheMissError = class extends Error {
|
|
|
726
817
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
727
818
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
728
819
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
820
|
+
var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
|
|
729
821
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
730
822
|
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
823
|
+
var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
731
824
|
var DebugLogger = class {
|
|
732
825
|
enabled;
|
|
733
826
|
constructor(enabled) {
|
|
@@ -774,6 +867,21 @@ var CacheStack = class extends EventEmitter {
|
|
|
774
867
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
775
868
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
776
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
|
+
}
|
|
777
885
|
this.initializeWriteBehind(options.writeBehind);
|
|
778
886
|
this.startup = this.initialize();
|
|
779
887
|
}
|
|
@@ -787,6 +895,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
787
895
|
logger;
|
|
788
896
|
tagIndex;
|
|
789
897
|
fetchRateLimiter = new FetchRateLimiter();
|
|
898
|
+
snapshotSerializer = new JsonSerializer();
|
|
790
899
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
791
900
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
792
901
|
ttlResolver;
|
|
@@ -795,6 +904,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
795
904
|
writeBehindQueue = [];
|
|
796
905
|
writeBehindTimer;
|
|
797
906
|
writeBehindFlushPromise;
|
|
907
|
+
generationCleanupPromise;
|
|
798
908
|
isDisconnecting = false;
|
|
799
909
|
disconnectPromise;
|
|
800
910
|
/**
|
|
@@ -807,6 +917,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
807
917
|
const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
|
|
808
918
|
this.validateWriteOptions(options);
|
|
809
919
|
await this.awaitStartup("get");
|
|
920
|
+
return this.getPrepared(normalizedKey, fetcher, options);
|
|
921
|
+
}
|
|
922
|
+
async getPrepared(normalizedKey, fetcher, options) {
|
|
810
923
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
811
924
|
if (hit.found) {
|
|
812
925
|
this.ttlResolver.recordAccess(normalizedKey);
|
|
@@ -884,6 +997,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
884
997
|
return true;
|
|
885
998
|
}
|
|
886
999
|
} catch {
|
|
1000
|
+
await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
|
|
887
1001
|
}
|
|
888
1002
|
} else {
|
|
889
1003
|
try {
|
|
@@ -891,7 +1005,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
891
1005
|
if (value !== null) {
|
|
892
1006
|
return true;
|
|
893
1007
|
}
|
|
894
|
-
} catch {
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
await this.reportRecoverableLayerFailure(layer, "has", error);
|
|
895
1010
|
}
|
|
896
1011
|
}
|
|
897
1012
|
}
|
|
@@ -983,13 +1098,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
983
1098
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
984
1099
|
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
985
1100
|
if (!canFastPath) {
|
|
1101
|
+
await this.awaitStartup("mget");
|
|
986
1102
|
const pendingReads = /* @__PURE__ */ new Map();
|
|
987
1103
|
return Promise.all(
|
|
988
1104
|
normalizedEntries.map((entry) => {
|
|
989
1105
|
const optionsSignature = this.serializeOptions(entry.options);
|
|
990
1106
|
const existing = pendingReads.get(entry.key);
|
|
991
1107
|
if (!existing) {
|
|
992
|
-
const promise = this.
|
|
1108
|
+
const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
|
|
993
1109
|
pendingReads.set(entry.key, {
|
|
994
1110
|
promise,
|
|
995
1111
|
fetch: entry.fetch,
|
|
@@ -1128,14 +1244,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1128
1244
|
}
|
|
1129
1245
|
async invalidateByPattern(pattern) {
|
|
1130
1246
|
await this.awaitStartup("invalidateByPattern");
|
|
1131
|
-
const keys = await this.
|
|
1247
|
+
const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
|
|
1132
1248
|
await this.deleteKeys(keys);
|
|
1133
1249
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1134
1250
|
}
|
|
1135
1251
|
async invalidateByPrefix(prefix) {
|
|
1136
1252
|
await this.awaitStartup("invalidateByPrefix");
|
|
1137
1253
|
const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
|
|
1138
|
-
const keys =
|
|
1254
|
+
const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
|
|
1139
1255
|
await this.deleteKeys(keys);
|
|
1140
1256
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1141
1257
|
}
|
|
@@ -1185,9 +1301,18 @@ var CacheStack = class extends EventEmitter {
|
|
|
1185
1301
|
})
|
|
1186
1302
|
);
|
|
1187
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
|
+
*/
|
|
1188
1309
|
bumpGeneration(nextGeneration) {
|
|
1189
1310
|
const current = this.currentGeneration ?? 0;
|
|
1311
|
+
const previousGeneration = this.currentGeneration;
|
|
1190
1312
|
this.currentGeneration = nextGeneration ?? current + 1;
|
|
1313
|
+
if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
|
|
1314
|
+
this.scheduleGenerationCleanup(previousGeneration);
|
|
1315
|
+
}
|
|
1191
1316
|
return this.currentGeneration;
|
|
1192
1317
|
}
|
|
1193
1318
|
/**
|
|
@@ -1271,27 +1396,28 @@ var CacheStack = class extends EventEmitter {
|
|
|
1271
1396
|
this.assertActive("persistToFile");
|
|
1272
1397
|
const snapshot = await this.exportState();
|
|
1273
1398
|
const { promises: fs2 } = await import("fs");
|
|
1274
|
-
await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
1399
|
+
await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
|
|
1275
1400
|
}
|
|
1276
1401
|
async restoreFromFile(filePath) {
|
|
1277
1402
|
this.assertActive("restoreFromFile");
|
|
1278
1403
|
const { promises: fs2 } = await import("fs");
|
|
1279
|
-
const raw = await fs2.readFile(filePath, "utf8");
|
|
1404
|
+
const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
|
|
1280
1405
|
let parsed;
|
|
1281
1406
|
try {
|
|
1282
|
-
parsed = JSON.parse(raw
|
|
1283
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1284
|
-
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1285
|
-
}
|
|
1286
|
-
return value;
|
|
1287
|
-
});
|
|
1407
|
+
parsed = JSON.parse(raw);
|
|
1288
1408
|
} catch (cause) {
|
|
1289
1409
|
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
1290
1410
|
}
|
|
1291
1411
|
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1292
1412
|
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
1293
1413
|
}
|
|
1294
|
-
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
|
+
);
|
|
1295
1421
|
}
|
|
1296
1422
|
async disconnect() {
|
|
1297
1423
|
if (!this.disconnectPromise) {
|
|
@@ -1300,6 +1426,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1300
1426
|
await this.startup;
|
|
1301
1427
|
await this.unsubscribeInvalidation?.();
|
|
1302
1428
|
await this.flushWriteBehindQueue();
|
|
1429
|
+
await this.generationCleanupPromise;
|
|
1303
1430
|
await Promise.allSettled([...this.backgroundRefreshes.values()]);
|
|
1304
1431
|
if (this.writeBehindTimer) {
|
|
1305
1432
|
clearInterval(this.writeBehindTimer);
|
|
@@ -1383,8 +1510,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1383
1510
|
await this.storeEntry(key, "empty", null, options);
|
|
1384
1511
|
return null;
|
|
1385
1512
|
}
|
|
1386
|
-
if (options?.shouldCache
|
|
1387
|
-
|
|
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
|
+
}
|
|
1388
1521
|
}
|
|
1389
1522
|
await this.storeEntry(key, "value", fetched, options);
|
|
1390
1523
|
return fetched;
|
|
@@ -1611,7 +1744,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1611
1744
|
const refresh = (async () => {
|
|
1612
1745
|
this.metricsCollector.increment("refreshes");
|
|
1613
1746
|
try {
|
|
1614
|
-
await this.
|
|
1747
|
+
await this.runBackgroundRefresh(key, fetcher, options);
|
|
1615
1748
|
} catch (error) {
|
|
1616
1749
|
this.metricsCollector.increment("refreshErrors");
|
|
1617
1750
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
@@ -1621,6 +1754,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
1621
1754
|
})();
|
|
1622
1755
|
this.backgroundRefreshes.set(key, refresh);
|
|
1623
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
|
+
}
|
|
1624
1767
|
resolveSingleFlightOptions() {
|
|
1625
1768
|
return {
|
|
1626
1769
|
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
@@ -1688,8 +1831,120 @@ var CacheStack = class extends EventEmitter {
|
|
|
1688
1831
|
sleep(ms) {
|
|
1689
1832
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1690
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
|
+
}
|
|
1863
|
+
}
|
|
1691
1864
|
shouldBroadcastL1Invalidation() {
|
|
1692
|
-
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
|
+
}
|
|
1693
1948
|
}
|
|
1694
1949
|
initializeWriteBehind(options) {
|
|
1695
1950
|
if (this.options.writeStrategy !== "write-behind") {
|
|
@@ -1727,7 +1982,17 @@ var CacheStack = class extends EventEmitter {
|
|
|
1727
1982
|
const batchSize = this.options.writeBehind?.batchSize ?? 100;
|
|
1728
1983
|
const batch = this.writeBehindQueue.splice(0, batchSize);
|
|
1729
1984
|
this.writeBehindFlushPromise = (async () => {
|
|
1730
|
-
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
|
+
}
|
|
1731
1996
|
})();
|
|
1732
1997
|
await this.writeBehindFlushPromise;
|
|
1733
1998
|
this.writeBehindFlushPromise = void 0;
|
|
@@ -1832,9 +2097,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
1832
2097
|
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
1833
2098
|
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
1834
2099
|
this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
|
|
2100
|
+
this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
|
|
1835
2101
|
this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
|
|
1836
2102
|
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
1837
2103
|
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
2104
|
+
if (typeof this.options.generationCleanup === "object") {
|
|
2105
|
+
this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
|
|
2106
|
+
}
|
|
1838
2107
|
if (this.options.generation !== void 0) {
|
|
1839
2108
|
this.validateNonNegativeNumber("generation", this.options.generation);
|
|
1840
2109
|
}
|
|
@@ -1906,6 +2175,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
1906
2175
|
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
1907
2176
|
throw new Error("Cache key contains unsupported control characters.");
|
|
1908
2177
|
}
|
|
2178
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
2179
|
+
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
2180
|
+
}
|
|
1909
2181
|
return key;
|
|
1910
2182
|
}
|
|
1911
2183
|
validateTtlPolicy(name, policy) {
|
|
@@ -1983,6 +2255,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1983
2255
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1984
2256
|
return null;
|
|
1985
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
|
+
}
|
|
1986
2266
|
isGracefulDegradationEnabled() {
|
|
1987
2267
|
return Boolean(this.options.gracefulDegradation);
|
|
1988
2268
|
}
|
|
@@ -2006,10 +2286,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
2006
2286
|
}
|
|
2007
2287
|
}
|
|
2008
2288
|
serializeKeyPart(value) {
|
|
2009
|
-
if (typeof value === "string"
|
|
2010
|
-
return
|
|
2289
|
+
if (typeof value === "string") {
|
|
2290
|
+
return `s:${value}`;
|
|
2291
|
+
}
|
|
2292
|
+
if (typeof value === "number") {
|
|
2293
|
+
return `n:${value}`;
|
|
2011
2294
|
}
|
|
2012
|
-
|
|
2295
|
+
if (typeof value === "boolean") {
|
|
2296
|
+
return `b:${value}`;
|
|
2297
|
+
}
|
|
2298
|
+
return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
|
|
2013
2299
|
}
|
|
2014
2300
|
isCacheSnapshotEntries(value) {
|
|
2015
2301
|
return Array.isArray(value) && value.every((entry) => {
|
|
@@ -2017,15 +2303,39 @@ var CacheStack = class extends EventEmitter {
|
|
|
2017
2303
|
return false;
|
|
2018
2304
|
}
|
|
2019
2305
|
const candidate = entry;
|
|
2020
|
-
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);
|
|
2021
2307
|
});
|
|
2022
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
|
+
}
|
|
2023
2330
|
normalizeForSerialization(value) {
|
|
2024
2331
|
if (Array.isArray(value)) {
|
|
2025
2332
|
return value.map((entry) => this.normalizeForSerialization(entry));
|
|
2026
2333
|
}
|
|
2027
2334
|
if (value && typeof value === "object") {
|
|
2028
2335
|
return Object.keys(value).sort().reduce((normalized, key) => {
|
|
2336
|
+
if (DANGEROUS_OBJECT_KEYS.has(key)) {
|
|
2337
|
+
return normalized;
|
|
2338
|
+
}
|
|
2029
2339
|
normalized[key] = this.normalizeForSerialization(value[key]);
|
|
2030
2340
|
return normalized;
|
|
2031
2341
|
}, {});
|
|
@@ -2152,7 +2462,7 @@ function createCachedMethodDecorator(options) {
|
|
|
2152
2462
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
2153
2463
|
return async (fastify) => {
|
|
2154
2464
|
fastify.decorate("cache", cache);
|
|
2155
|
-
if (options.exposeStatsRoute
|
|
2465
|
+
if (options.exposeStatsRoute === true && fastify.get) {
|
|
2156
2466
|
fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
|
|
2157
2467
|
}
|
|
2158
2468
|
};
|
|
@@ -2168,7 +2478,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2168
2478
|
next();
|
|
2169
2479
|
return;
|
|
2170
2480
|
}
|
|
2171
|
-
const
|
|
2481
|
+
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
2482
|
+
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
|
|
2172
2483
|
const cached = await cache.get(key, void 0, options);
|
|
2173
2484
|
if (cached !== null) {
|
|
2174
2485
|
res.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
@@ -2184,7 +2495,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2184
2495
|
if (originalJson) {
|
|
2185
2496
|
res.json = (body) => {
|
|
2186
2497
|
res.setHeader?.("x-cache", "MISS");
|
|
2187
|
-
|
|
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
|
+
});
|
|
2188
2504
|
return originalJson(body);
|
|
2189
2505
|
};
|
|
2190
2506
|
}
|
|
@@ -2194,6 +2510,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2194
2510
|
}
|
|
2195
2511
|
};
|
|
2196
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
|
+
}
|
|
2197
2522
|
|
|
2198
2523
|
// src/integrations/graphql.ts
|
|
2199
2524
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
@@ -2294,39 +2619,6 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
|
|
|
2294
2619
|
// src/layers/RedisLayer.ts
|
|
2295
2620
|
import { promisify } from "util";
|
|
2296
2621
|
import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
|
|
2297
|
-
|
|
2298
|
-
// src/serialization/JsonSerializer.ts
|
|
2299
|
-
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2300
|
-
var JsonSerializer = class {
|
|
2301
|
-
serialize(value) {
|
|
2302
|
-
return JSON.stringify(value);
|
|
2303
|
-
}
|
|
2304
|
-
deserialize(payload) {
|
|
2305
|
-
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2306
|
-
return sanitizeJsonValue(JSON.parse(normalized));
|
|
2307
|
-
}
|
|
2308
|
-
};
|
|
2309
|
-
function sanitizeJsonValue(value) {
|
|
2310
|
-
if (Array.isArray(value)) {
|
|
2311
|
-
return value.map((entry) => sanitizeJsonValue(entry));
|
|
2312
|
-
}
|
|
2313
|
-
if (!isPlainObject(value)) {
|
|
2314
|
-
return value;
|
|
2315
|
-
}
|
|
2316
|
-
const sanitized = {};
|
|
2317
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
2318
|
-
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
2319
|
-
continue;
|
|
2320
|
-
}
|
|
2321
|
-
sanitized[key] = sanitizeJsonValue(entry);
|
|
2322
|
-
}
|
|
2323
|
-
return sanitized;
|
|
2324
|
-
}
|
|
2325
|
-
function isPlainObject(value) {
|
|
2326
|
-
return Object.prototype.toString.call(value) === "[object Object]";
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
// src/layers/RedisLayer.ts
|
|
2330
2622
|
var BATCH_DELETE_SIZE = 500;
|
|
2331
2623
|
var gzipAsync = promisify(gzip);
|
|
2332
2624
|
var gunzipAsync = promisify(gunzip);
|
|
@@ -2343,6 +2635,7 @@ var RedisLayer = class {
|
|
|
2343
2635
|
scanCount;
|
|
2344
2636
|
compression;
|
|
2345
2637
|
compressionThreshold;
|
|
2638
|
+
decompressionMaxBytes;
|
|
2346
2639
|
disconnectOnDispose;
|
|
2347
2640
|
constructor(options) {
|
|
2348
2641
|
this.client = options.client;
|
|
@@ -2354,6 +2647,7 @@ var RedisLayer = class {
|
|
|
2354
2647
|
this.scanCount = options.scanCount ?? 100;
|
|
2355
2648
|
this.compression = options.compression;
|
|
2356
2649
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
2650
|
+
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
2357
2651
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
2358
2652
|
}
|
|
2359
2653
|
async get(key) {
|
|
@@ -2553,16 +2847,29 @@ var RedisLayer = class {
|
|
|
2553
2847
|
}
|
|
2554
2848
|
/**
|
|
2555
2849
|
* Decompresses the payload asynchronously if a compression header is present.
|
|
2850
|
+
* Enforces a maximum decompressed size to prevent decompression bomb attacks.
|
|
2556
2851
|
*/
|
|
2557
2852
|
async decodePayload(payload) {
|
|
2558
2853
|
if (!Buffer.isBuffer(payload)) {
|
|
2559
2854
|
return payload;
|
|
2560
2855
|
}
|
|
2561
2856
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
2562
|
-
|
|
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;
|
|
2563
2864
|
}
|
|
2564
2865
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
2565
|
-
|
|
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;
|
|
2566
2873
|
}
|
|
2567
2874
|
return payload;
|
|
2568
2875
|
}
|
|
@@ -2622,8 +2929,13 @@ var DiskLayer = class {
|
|
|
2622
2929
|
const payload = this.serializer.serialize(entry);
|
|
2623
2930
|
const targetPath = this.keyToPath(key);
|
|
2624
2931
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
2625
|
-
|
|
2626
|
-
|
|
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
|
+
}
|
|
2627
2939
|
if (this.maxFiles !== void 0) {
|
|
2628
2940
|
await this.enforceMaxFiles();
|
|
2629
2941
|
}
|
|
@@ -2633,9 +2945,7 @@ var DiskLayer = class {
|
|
|
2633
2945
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2634
2946
|
}
|
|
2635
2947
|
async setMany(entries) {
|
|
2636
|
-
|
|
2637
|
-
await this.set(entry.key, entry.value, entry.ttl);
|
|
2638
|
-
}
|
|
2948
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
2639
2949
|
}
|
|
2640
2950
|
async has(key) {
|
|
2641
2951
|
const value = await this.getEntry(key);
|
|
@@ -2839,6 +3149,7 @@ var MemcachedLayer = class {
|
|
|
2839
3149
|
return unwrapStoredValue(await this.getEntry(key));
|
|
2840
3150
|
}
|
|
2841
3151
|
async getEntry(key) {
|
|
3152
|
+
this.validateKey(key);
|
|
2842
3153
|
const result = await this.client.get(this.withPrefix(key));
|
|
2843
3154
|
if (!result || result.value === null) {
|
|
2844
3155
|
return null;
|
|
@@ -2853,16 +3164,19 @@ var MemcachedLayer = class {
|
|
|
2853
3164
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2854
3165
|
}
|
|
2855
3166
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3167
|
+
this.validateKey(key);
|
|
2856
3168
|
const payload = this.serializer.serialize(value);
|
|
2857
3169
|
await this.client.set(this.withPrefix(key), payload, {
|
|
2858
3170
|
expires: ttl && ttl > 0 ? ttl : void 0
|
|
2859
3171
|
});
|
|
2860
3172
|
}
|
|
2861
3173
|
async has(key) {
|
|
3174
|
+
this.validateKey(key);
|
|
2862
3175
|
const result = await this.client.get(this.withPrefix(key));
|
|
2863
3176
|
return result !== null && result.value !== null;
|
|
2864
3177
|
}
|
|
2865
3178
|
async delete(key) {
|
|
3179
|
+
this.validateKey(key);
|
|
2866
3180
|
await this.client.delete(this.withPrefix(key));
|
|
2867
3181
|
}
|
|
2868
3182
|
async deleteMany(keys) {
|
|
@@ -2876,19 +3190,50 @@ var MemcachedLayer = class {
|
|
|
2876
3190
|
withPrefix(key) {
|
|
2877
3191
|
return `${this.keyPrefix}${key}`;
|
|
2878
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
|
+
}
|
|
2879
3204
|
};
|
|
2880
3205
|
|
|
2881
3206
|
// src/serialization/MsgpackSerializer.ts
|
|
2882
3207
|
import { decode, encode } from "@msgpack/msgpack";
|
|
3208
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2883
3209
|
var MsgpackSerializer = class {
|
|
2884
3210
|
serialize(value) {
|
|
2885
3211
|
return Buffer.from(encode(value));
|
|
2886
3212
|
}
|
|
2887
3213
|
deserialize(payload) {
|
|
2888
3214
|
const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
2889
|
-
return decode(normalized);
|
|
3215
|
+
return sanitizeMsgpackValue(decode(normalized));
|
|
2890
3216
|
}
|
|
2891
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
|
+
}
|
|
2892
3237
|
|
|
2893
3238
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
2894
3239
|
import { randomUUID } from "crypto";
|
|
@@ -3017,7 +3362,7 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
3017
3362
|
};
|
|
3018
3363
|
}
|
|
3019
3364
|
function sanitizeLabel(value) {
|
|
3020
|
-
return value.replace(/["\\\n]/g, "_");
|
|
3365
|
+
return value.replace(/["\\\n\r]/g, "_");
|
|
3021
3366
|
}
|
|
3022
3367
|
export {
|
|
3023
3368
|
CacheMissError,
|