layercache 1.3.0 → 1.3.2
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 +2 -52
- package/dist/cli.cjs +23 -1
- package/dist/cli.js +23 -1
- package/dist/{edge-BXWTKlI1.d.cts → edge-CUHTP9Bc.d.cts} +2 -0
- package/dist/{edge-BXWTKlI1.d.ts → edge-CUHTP9Bc.d.ts} +2 -0
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/index.cjs +267 -31
- package/dist/index.d.cts +48 -3
- package/dist/index.d.ts +48 -3
- package/dist/index.js +265 -29
- package/package.json +2 -12
- package/examples/nestjs-module/app.module.ts +0 -15
- package/packages/nestjs/dist/index.cjs +0 -3906
- package/packages/nestjs/dist/index.d.cts +0 -627
- package/packages/nestjs/dist/index.d.ts +0 -627
- package/packages/nestjs/dist/index.js +0 -3869
package/dist/index.cjs
CHANGED
|
@@ -545,15 +545,14 @@ function createInstanceId() {
|
|
|
545
545
|
if (globalThis.crypto?.randomUUID) {
|
|
546
546
|
return globalThis.crypto.randomUUID();
|
|
547
547
|
}
|
|
548
|
-
const bytes = new Uint8Array(16);
|
|
549
548
|
if (globalThis.crypto?.getRandomValues) {
|
|
549
|
+
const bytes = new Uint8Array(16);
|
|
550
550
|
globalThis.crypto.getRandomValues(bytes);
|
|
551
|
-
|
|
552
|
-
for (let i = 0; i < bytes.length; i += 1) {
|
|
553
|
-
bytes[i] = Math.floor(Math.random() * 256);
|
|
554
|
-
}
|
|
551
|
+
return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
555
552
|
}
|
|
556
|
-
|
|
553
|
+
throw new Error(
|
|
554
|
+
"layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
|
|
555
|
+
);
|
|
557
556
|
}
|
|
558
557
|
|
|
559
558
|
// src/internal/CacheStackGeneration.ts
|
|
@@ -1532,7 +1531,8 @@ var CircuitBreakerManager = class {
|
|
|
1532
1531
|
}
|
|
1533
1532
|
const remainingMs = state.openUntil - now;
|
|
1534
1533
|
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
1535
|
-
|
|
1534
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
1535
|
+
throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
|
|
1536
1536
|
}
|
|
1537
1537
|
recordFailure(key, options) {
|
|
1538
1538
|
if (!options) {
|
|
@@ -2297,7 +2297,14 @@ var JsonSerializer = class {
|
|
|
2297
2297
|
}
|
|
2298
2298
|
deserialize(payload) {
|
|
2299
2299
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2300
|
-
|
|
2300
|
+
let parsed;
|
|
2301
|
+
try {
|
|
2302
|
+
parsed = JSON.parse(normalized);
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2305
|
+
throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
|
|
2306
|
+
}
|
|
2307
|
+
return sanitizeStructuredData(parsed, {
|
|
2301
2308
|
label: "JSON payload",
|
|
2302
2309
|
maxDepth: 200,
|
|
2303
2310
|
maxNodes: 1e4
|
|
@@ -2308,6 +2315,12 @@ var JsonSerializer = class {
|
|
|
2308
2315
|
// src/stampede/StampedeGuard.ts
|
|
2309
2316
|
var StampedeGuard = class {
|
|
2310
2317
|
inFlight = /* @__PURE__ */ new Map();
|
|
2318
|
+
maxInFlight;
|
|
2319
|
+
entryTimeoutMs;
|
|
2320
|
+
constructor(options = {}) {
|
|
2321
|
+
this.maxInFlight = options.maxInFlight ?? 1e4;
|
|
2322
|
+
this.entryTimeoutMs = options.entryTimeoutMs;
|
|
2323
|
+
}
|
|
2311
2324
|
async execute(key, task) {
|
|
2312
2325
|
const existing = this.inFlight.get(key);
|
|
2313
2326
|
if (existing) {
|
|
@@ -2318,8 +2331,15 @@ var StampedeGuard = class {
|
|
|
2318
2331
|
this.releaseEntry(key, existing);
|
|
2319
2332
|
}
|
|
2320
2333
|
}
|
|
2334
|
+
if (this.inFlight.size >= this.maxInFlight) {
|
|
2335
|
+
throw new Error(
|
|
2336
|
+
`StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
const taskPromise = Promise.resolve().then(task);
|
|
2340
|
+
const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
|
|
2321
2341
|
const entry = {
|
|
2322
|
-
promise:
|
|
2342
|
+
promise: guardedPromise,
|
|
2323
2343
|
references: 1
|
|
2324
2344
|
};
|
|
2325
2345
|
this.inFlight.set(key, entry);
|
|
@@ -2329,6 +2349,27 @@ var StampedeGuard = class {
|
|
|
2329
2349
|
this.releaseEntry(key, entry);
|
|
2330
2350
|
}
|
|
2331
2351
|
}
|
|
2352
|
+
withTimeout(key, promise, timeoutMs) {
|
|
2353
|
+
return new Promise((resolve2, reject) => {
|
|
2354
|
+
const timer = setTimeout(() => {
|
|
2355
|
+
reject(
|
|
2356
|
+
new Error(
|
|
2357
|
+
`StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
|
|
2358
|
+
)
|
|
2359
|
+
);
|
|
2360
|
+
}, timeoutMs);
|
|
2361
|
+
promise.then(
|
|
2362
|
+
(value) => {
|
|
2363
|
+
clearTimeout(timer);
|
|
2364
|
+
resolve2(value);
|
|
2365
|
+
},
|
|
2366
|
+
(error) => {
|
|
2367
|
+
clearTimeout(timer);
|
|
2368
|
+
reject(error);
|
|
2369
|
+
}
|
|
2370
|
+
);
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2332
2373
|
releaseEntry(key, entry) {
|
|
2333
2374
|
entry.references -= 1;
|
|
2334
2375
|
const current = this.inFlight.get(key);
|
|
@@ -2394,6 +2435,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2394
2435
|
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
2395
2436
|
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
2396
2437
|
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
2438
|
+
this.stampedeGuard = new StampedeGuard({
|
|
2439
|
+
maxInFlight: options.stampedeMaxInFlight,
|
|
2440
|
+
entryTimeoutMs: options.stampedeEntryTimeoutMs
|
|
2441
|
+
});
|
|
2397
2442
|
this.currentGeneration = options.generation;
|
|
2398
2443
|
if (options.publishSetInvalidation !== void 0) {
|
|
2399
2444
|
console.warn(
|
|
@@ -2472,7 +2517,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2472
2517
|
}
|
|
2473
2518
|
layers;
|
|
2474
2519
|
options;
|
|
2475
|
-
stampedeGuard
|
|
2520
|
+
stampedeGuard;
|
|
2476
2521
|
metricsCollector = new MetricsCollector();
|
|
2477
2522
|
instanceId = createInstanceId();
|
|
2478
2523
|
startup;
|
|
@@ -2710,7 +2755,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2710
2755
|
return promise;
|
|
2711
2756
|
}
|
|
2712
2757
|
if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
|
|
2713
|
-
|
|
2758
|
+
const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
|
|
2759
|
+
throw new Error(`mget received conflicting entries for key "${displayKey}".`);
|
|
2714
2760
|
}
|
|
2715
2761
|
return existing.promise;
|
|
2716
2762
|
})
|
|
@@ -3942,6 +3988,11 @@ function simpleHash(value) {
|
|
|
3942
3988
|
|
|
3943
3989
|
// src/http/createCacheStatsHandler.ts
|
|
3944
3990
|
function createCacheStatsHandler(cache, options = {}) {
|
|
3991
|
+
if (options.allowPublicAccess === true) {
|
|
3992
|
+
console.warn(
|
|
3993
|
+
"[layercache] WARNING: Stats endpoint is publicly accessible without authentication. Set allowPublicAccess: false (or provide an authorize callback) before deploying to production."
|
|
3994
|
+
);
|
|
3995
|
+
}
|
|
3945
3996
|
return async (request, response) => {
|
|
3946
3997
|
response.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
3947
3998
|
response.setHeader?.("cache-control", "no-store");
|
|
@@ -3984,6 +4035,11 @@ function createCachedMethodDecorator(options) {
|
|
|
3984
4035
|
|
|
3985
4036
|
// src/integrations/fastify.ts
|
|
3986
4037
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
4038
|
+
if (options.exposeStatsRoute === true && options.allowPublicStatsRoute === true) {
|
|
4039
|
+
console.warn(
|
|
4040
|
+
"[layercache] WARNING: Cache stats route is publicly accessible without authentication. Set allowPublicStatsRoute: false (or provide an authorizeStatsRoute callback) before deploying to production."
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
3987
4043
|
return async (fastify) => {
|
|
3988
4044
|
fastify.decorate("cache", cache);
|
|
3989
4045
|
if (options.exposeStatsRoute === true && fastify.get) {
|
|
@@ -4433,7 +4489,11 @@ var RedisLayer = class {
|
|
|
4433
4489
|
return unwrapStoredValue(payload);
|
|
4434
4490
|
}
|
|
4435
4491
|
async getEntry(key) {
|
|
4436
|
-
|
|
4492
|
+
this.validateKey(key);
|
|
4493
|
+
const payload = await this.runCommand(
|
|
4494
|
+
`get(${this.displayKey(key)})`,
|
|
4495
|
+
() => this.client.getBuffer(this.withPrefix(key))
|
|
4496
|
+
);
|
|
4437
4497
|
if (payload === null) {
|
|
4438
4498
|
return null;
|
|
4439
4499
|
}
|
|
@@ -4443,6 +4503,9 @@ var RedisLayer = class {
|
|
|
4443
4503
|
if (keys.length === 0) {
|
|
4444
4504
|
return [];
|
|
4445
4505
|
}
|
|
4506
|
+
for (const key of keys) {
|
|
4507
|
+
this.validateKey(key);
|
|
4508
|
+
}
|
|
4446
4509
|
const pipeline = this.client.pipeline();
|
|
4447
4510
|
for (const key of keys) {
|
|
4448
4511
|
pipeline.getBuffer(this.withPrefix(key));
|
|
@@ -4465,6 +4528,9 @@ var RedisLayer = class {
|
|
|
4465
4528
|
if (entries.length === 0) {
|
|
4466
4529
|
return;
|
|
4467
4530
|
}
|
|
4531
|
+
for (const entry of entries) {
|
|
4532
|
+
this.validateKey(entry.key);
|
|
4533
|
+
}
|
|
4468
4534
|
const pipeline = this.client.pipeline();
|
|
4469
4535
|
for (const entry of entries) {
|
|
4470
4536
|
const serialized = this.primarySerializer().serialize(entry.value);
|
|
@@ -4479,33 +4545,43 @@ var RedisLayer = class {
|
|
|
4479
4545
|
await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
|
|
4480
4546
|
}
|
|
4481
4547
|
async set(key, value, ttl = this.defaultTtl) {
|
|
4548
|
+
this.validateKey(key);
|
|
4482
4549
|
const serialized = this.primarySerializer().serialize(value);
|
|
4483
4550
|
const payload = await this.encodePayload(serialized);
|
|
4484
4551
|
const normalizedKey = this.withPrefix(key);
|
|
4485
4552
|
if (ttl && ttl > 0) {
|
|
4486
|
-
await this.runCommand(
|
|
4553
|
+
await this.runCommand(
|
|
4554
|
+
`set(${this.displayKey(key)})`,
|
|
4555
|
+
() => this.client.set(normalizedKey, payload, "EX", ttl)
|
|
4556
|
+
);
|
|
4487
4557
|
return;
|
|
4488
4558
|
}
|
|
4489
|
-
await this.runCommand(`set(
|
|
4559
|
+
await this.runCommand(`set(${this.displayKey(key)})`, () => this.client.set(normalizedKey, payload));
|
|
4490
4560
|
}
|
|
4491
4561
|
async delete(key) {
|
|
4492
|
-
|
|
4562
|
+
this.validateKey(key);
|
|
4563
|
+
await this.runCommand(`delete(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
|
|
4493
4564
|
}
|
|
4494
4565
|
async deleteMany(keys) {
|
|
4495
4566
|
if (keys.length === 0) {
|
|
4496
4567
|
return;
|
|
4497
4568
|
}
|
|
4569
|
+
for (const key of keys) {
|
|
4570
|
+
this.validateKey(key);
|
|
4571
|
+
}
|
|
4498
4572
|
await this.runCommand(
|
|
4499
4573
|
`deleteMany(${keys.length})`,
|
|
4500
4574
|
() => this.client.del(...keys.map((key) => this.withPrefix(key)))
|
|
4501
4575
|
);
|
|
4502
4576
|
}
|
|
4503
4577
|
async has(key) {
|
|
4504
|
-
|
|
4578
|
+
this.validateKey(key);
|
|
4579
|
+
const exists = await this.runCommand(`has(${this.displayKey(key)})`, () => this.client.exists(this.withPrefix(key)));
|
|
4505
4580
|
return exists > 0;
|
|
4506
4581
|
}
|
|
4507
4582
|
async ttl(key) {
|
|
4508
|
-
|
|
4583
|
+
this.validateKey(key);
|
|
4584
|
+
const remaining = await this.runCommand(`ttl(${this.displayKey(key)})`, () => this.client.ttl(this.withPrefix(key)));
|
|
4509
4585
|
if (remaining < 0) {
|
|
4510
4586
|
return null;
|
|
4511
4587
|
}
|
|
@@ -4605,6 +4681,23 @@ var RedisLayer = class {
|
|
|
4605
4681
|
withPrefix(key) {
|
|
4606
4682
|
return `${this.prefix}${key}`;
|
|
4607
4683
|
}
|
|
4684
|
+
validateKey(key) {
|
|
4685
|
+
if (key.length === 0) {
|
|
4686
|
+
throw new Error("RedisLayer: key must not be empty.");
|
|
4687
|
+
}
|
|
4688
|
+
if (key.length > 1024) {
|
|
4689
|
+
throw new Error(`RedisLayer: key length must be at most 1 024 characters (got ${key.length}).`);
|
|
4690
|
+
}
|
|
4691
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
4692
|
+
throw new Error("RedisLayer: key contains unsupported control characters.");
|
|
4693
|
+
}
|
|
4694
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
4695
|
+
throw new Error("RedisLayer: key contains unsupported surrogate code points.");
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
displayKey(key) {
|
|
4699
|
+
return key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
4700
|
+
}
|
|
4608
4701
|
async deserializeOrDelete(key, payload) {
|
|
4609
4702
|
let decodedPayload;
|
|
4610
4703
|
try {
|
|
@@ -4628,23 +4721,30 @@ var RedisLayer = class {
|
|
|
4628
4721
|
}
|
|
4629
4722
|
async deleteCorruptedKey(key) {
|
|
4630
4723
|
try {
|
|
4631
|
-
await this.runCommand(`deleteCorrupted(
|
|
4724
|
+
await this.runCommand(`deleteCorrupted(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
|
|
4632
4725
|
} catch (deleteError) {
|
|
4633
|
-
|
|
4726
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
4727
|
+
console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${displayKey}"`, deleteError);
|
|
4634
4728
|
}
|
|
4635
4729
|
}
|
|
4636
4730
|
async rewriteWithPrimarySerializer(key, value) {
|
|
4637
4731
|
const serialized = this.primarySerializer().serialize(value);
|
|
4638
4732
|
const payload = await this.encodePayload(serialized);
|
|
4639
|
-
const ttl = await this.runCommand(
|
|
4733
|
+
const ttl = await this.runCommand(
|
|
4734
|
+
`rewrite-ttl(${this.displayKey(key)})`,
|
|
4735
|
+
() => this.client.ttl(this.withPrefix(key))
|
|
4736
|
+
);
|
|
4640
4737
|
if (ttl > 0) {
|
|
4641
4738
|
await this.runCommand(
|
|
4642
|
-
`rewrite-set(
|
|
4739
|
+
`rewrite-set(${this.displayKey(key)})`,
|
|
4643
4740
|
() => this.client.set(this.withPrefix(key), payload, "EX", ttl)
|
|
4644
4741
|
);
|
|
4645
4742
|
return;
|
|
4646
4743
|
}
|
|
4647
|
-
await this.runCommand(
|
|
4744
|
+
await this.runCommand(
|
|
4745
|
+
`rewrite-set(${this.displayKey(key)})`,
|
|
4746
|
+
() => this.client.set(this.withPrefix(key), payload)
|
|
4747
|
+
);
|
|
4648
4748
|
}
|
|
4649
4749
|
primarySerializer() {
|
|
4650
4750
|
const serializer = this.serializers[0];
|
|
@@ -4772,9 +4872,131 @@ var RedisLayer = class {
|
|
|
4772
4872
|
};
|
|
4773
4873
|
|
|
4774
4874
|
// src/layers/DiskLayer.ts
|
|
4775
|
-
var
|
|
4875
|
+
var import_node_crypto3 = require("crypto");
|
|
4776
4876
|
var import_node_fs2 = require("fs");
|
|
4777
4877
|
var import_node_path2 = require("path");
|
|
4878
|
+
|
|
4879
|
+
// src/internal/PayloadProtection.ts
|
|
4880
|
+
var import_node_crypto2 = require("crypto");
|
|
4881
|
+
var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
|
|
4882
|
+
var MAGIC_SIGNED = Buffer.from("LCS1:");
|
|
4883
|
+
var ALGORITHM = "aes-256-gcm";
|
|
4884
|
+
var IV_LENGTH = 12;
|
|
4885
|
+
var AUTH_TAG_LENGTH = 16;
|
|
4886
|
+
var HMAC_LENGTH = 32;
|
|
4887
|
+
var PayloadProtection = class {
|
|
4888
|
+
encryptionKey;
|
|
4889
|
+
signingKey;
|
|
4890
|
+
constructor(options) {
|
|
4891
|
+
if (options.encryptionKey) {
|
|
4892
|
+
const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
|
|
4893
|
+
this.encryptionKey = (0, import_node_crypto2.createHash)("sha256").update(raw).digest();
|
|
4894
|
+
}
|
|
4895
|
+
if (options.signingKey && !options.encryptionKey) {
|
|
4896
|
+
const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
|
|
4897
|
+
this.signingKey = (0, import_node_crypto2.createHash)("sha256").update(raw).digest();
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
/** Returns `true` when any protection (encryption or signing) is configured. */
|
|
4901
|
+
get isEnabled() {
|
|
4902
|
+
return this.encryptionKey !== void 0 || this.signingKey !== void 0;
|
|
4903
|
+
}
|
|
4904
|
+
/**
|
|
4905
|
+
* Applies the configured protection (encryption or signing) to a payload.
|
|
4906
|
+
* Returns the input unchanged when no protection is configured.
|
|
4907
|
+
*/
|
|
4908
|
+
protect(payload) {
|
|
4909
|
+
if (this.encryptionKey) {
|
|
4910
|
+
return this.encrypt(payload, this.encryptionKey);
|
|
4911
|
+
}
|
|
4912
|
+
if (this.signingKey) {
|
|
4913
|
+
return this.sign(payload, this.signingKey);
|
|
4914
|
+
}
|
|
4915
|
+
return payload;
|
|
4916
|
+
}
|
|
4917
|
+
/**
|
|
4918
|
+
* Removes the protection layer from a payload.
|
|
4919
|
+
*
|
|
4920
|
+
* - Protected payloads are decrypted/verified using the configured keys.
|
|
4921
|
+
* - Legacy unprotected payloads pass through unchanged when **no** protection
|
|
4922
|
+
* is configured.
|
|
4923
|
+
* - If protection **is** configured but the payload is not protected, the
|
|
4924
|
+
* payload is treated as a legacy entry. Callers can handle this case by
|
|
4925
|
+
* checking `isEnabled` separately.
|
|
4926
|
+
*/
|
|
4927
|
+
unprotect(payload) {
|
|
4928
|
+
if (this.startsWith(payload, MAGIC_ENCRYPTED)) {
|
|
4929
|
+
if (!this.encryptionKey) {
|
|
4930
|
+
throw new PayloadProtectionError("Encrypted payload but no encryptionKey configured.");
|
|
4931
|
+
}
|
|
4932
|
+
return this.decrypt(payload, this.encryptionKey);
|
|
4933
|
+
}
|
|
4934
|
+
if (this.startsWith(payload, MAGIC_SIGNED)) {
|
|
4935
|
+
if (!this.signingKey) {
|
|
4936
|
+
throw new PayloadProtectionError("Signed payload but no signingKey configured.");
|
|
4937
|
+
}
|
|
4938
|
+
return this.verify(payload, this.signingKey);
|
|
4939
|
+
}
|
|
4940
|
+
return payload;
|
|
4941
|
+
}
|
|
4942
|
+
// ── Encryption (AES-256-GCM) ──────────────────────────────────────────
|
|
4943
|
+
encrypt(plaintext, key) {
|
|
4944
|
+
const iv = (0, import_node_crypto2.randomBytes)(IV_LENGTH);
|
|
4945
|
+
const cipher = (0, import_node_crypto2.createCipheriv)(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
4946
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
4947
|
+
const authTag = cipher.getAuthTag();
|
|
4948
|
+
return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
|
|
4949
|
+
}
|
|
4950
|
+
decrypt(payload, key) {
|
|
4951
|
+
const headerEnd = MAGIC_ENCRYPTED.length;
|
|
4952
|
+
const iv = payload.subarray(headerEnd, headerEnd + IV_LENGTH);
|
|
4953
|
+
const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
4954
|
+
const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
4955
|
+
try {
|
|
4956
|
+
const decipher = (0, import_node_crypto2.createDecipheriv)(ALGORITHM, key, iv, {
|
|
4957
|
+
authTagLength: AUTH_TAG_LENGTH
|
|
4958
|
+
});
|
|
4959
|
+
decipher.setAuthTag(authTag);
|
|
4960
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
4961
|
+
} catch {
|
|
4962
|
+
throw new PayloadProtectionError(
|
|
4963
|
+
"Decryption failed. The data may have been tampered with or the encryptionKey is incorrect."
|
|
4964
|
+
);
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
// ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
|
|
4968
|
+
sign(payload, key) {
|
|
4969
|
+
const hmac = (0, import_node_crypto2.createHmac)("sha256", key).update(payload).digest();
|
|
4970
|
+
return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
|
|
4971
|
+
}
|
|
4972
|
+
verify(payload, key) {
|
|
4973
|
+
const headerEnd = MAGIC_SIGNED.length;
|
|
4974
|
+
const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
|
|
4975
|
+
const data = payload.subarray(headerEnd + HMAC_LENGTH);
|
|
4976
|
+
const expectedHmac = (0, import_node_crypto2.createHmac)("sha256", key).update(data).digest();
|
|
4977
|
+
if (receivedHmac.length !== HMAC_LENGTH || !(0, import_node_crypto2.timingSafeEqual)(receivedHmac, expectedHmac)) {
|
|
4978
|
+
throw new PayloadProtectionError(
|
|
4979
|
+
"HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
|
|
4980
|
+
);
|
|
4981
|
+
}
|
|
4982
|
+
return data;
|
|
4983
|
+
}
|
|
4984
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
4985
|
+
startsWith(buffer, prefix) {
|
|
4986
|
+
if (buffer.length < prefix.length) {
|
|
4987
|
+
return false;
|
|
4988
|
+
}
|
|
4989
|
+
return buffer.subarray(0, prefix.length).equals(prefix);
|
|
4990
|
+
}
|
|
4991
|
+
};
|
|
4992
|
+
var PayloadProtectionError = class extends Error {
|
|
4993
|
+
constructor(message) {
|
|
4994
|
+
super(message);
|
|
4995
|
+
this.name = "PayloadProtectionError";
|
|
4996
|
+
}
|
|
4997
|
+
};
|
|
4998
|
+
|
|
4999
|
+
// src/layers/DiskLayer.ts
|
|
4778
5000
|
var FILE_SCAN_CONCURRENCY = 32;
|
|
4779
5001
|
var DiskLayer = class {
|
|
4780
5002
|
name;
|
|
@@ -4784,6 +5006,7 @@ var DiskLayer = class {
|
|
|
4784
5006
|
serializer;
|
|
4785
5007
|
maxFiles;
|
|
4786
5008
|
maxEntryBytes;
|
|
5009
|
+
protection;
|
|
4787
5010
|
writeQueue = Promise.resolve();
|
|
4788
5011
|
constructor(options) {
|
|
4789
5012
|
this.directory = this.resolveDirectory(options.directory);
|
|
@@ -4792,6 +5015,10 @@ var DiskLayer = class {
|
|
|
4792
5015
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
4793
5016
|
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
4794
5017
|
this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
|
|
5018
|
+
this.protection = new PayloadProtection({
|
|
5019
|
+
encryptionKey: options.encryptionKey,
|
|
5020
|
+
signingKey: options.signingKey
|
|
5021
|
+
});
|
|
4795
5022
|
}
|
|
4796
5023
|
async get(key) {
|
|
4797
5024
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -4824,10 +5051,12 @@ var DiskLayer = class {
|
|
|
4824
5051
|
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
4825
5052
|
};
|
|
4826
5053
|
const payload = this.serializer.serialize(entry);
|
|
5054
|
+
const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
|
|
5055
|
+
const protectedPayload = this.protection.protect(raw);
|
|
4827
5056
|
const targetPath = this.keyToPath(key);
|
|
4828
|
-
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0,
|
|
5057
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0, import_node_crypto3.randomBytes)(8).toString("hex")}.tmp`;
|
|
4829
5058
|
try {
|
|
4830
|
-
await import_node_fs2.promises.writeFile(tempPath,
|
|
5059
|
+
await import_node_fs2.promises.writeFile(tempPath, protectedPayload);
|
|
4831
5060
|
await import_node_fs2.promises.rename(tempPath, targetPath);
|
|
4832
5061
|
} catch (error) {
|
|
4833
5062
|
await this.safeDelete(tempPath);
|
|
@@ -4925,7 +5154,7 @@ var DiskLayer = class {
|
|
|
4925
5154
|
async dispose() {
|
|
4926
5155
|
}
|
|
4927
5156
|
keyToPath(key) {
|
|
4928
|
-
const hash = (0,
|
|
5157
|
+
const hash = (0, import_node_crypto3.createHash)("sha256").update(key).digest("hex");
|
|
4929
5158
|
return (0, import_node_path2.join)(this.directory, `${hash}.lc`);
|
|
4930
5159
|
}
|
|
4931
5160
|
resolveDirectory(directory) {
|
|
@@ -4939,10 +5168,13 @@ var DiskLayer = class {
|
|
|
4939
5168
|
}
|
|
4940
5169
|
normalizeMaxFiles(maxFiles) {
|
|
4941
5170
|
if (maxFiles === void 0) {
|
|
5171
|
+
return 5e4;
|
|
5172
|
+
}
|
|
5173
|
+
if (maxFiles === Number.POSITIVE_INFINITY) {
|
|
4942
5174
|
return void 0;
|
|
4943
5175
|
}
|
|
4944
5176
|
if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
|
|
4945
|
-
throw new Error("DiskLayer.maxFiles must be a positive integer.");
|
|
5177
|
+
throw new Error("DiskLayer.maxFiles must be a positive integer or Infinity.");
|
|
4946
5178
|
}
|
|
4947
5179
|
return maxFiles;
|
|
4948
5180
|
}
|
|
@@ -5054,7 +5286,8 @@ var DiskLayer = class {
|
|
|
5054
5286
|
);
|
|
5055
5287
|
}
|
|
5056
5288
|
deserializeEntry(raw) {
|
|
5057
|
-
const
|
|
5289
|
+
const unprotected = this.protection.unprotect(raw);
|
|
5290
|
+
const entry = this.serializer.deserialize(unprotected);
|
|
5058
5291
|
if (!isDiskEntry(entry)) {
|
|
5059
5292
|
throw new Error("Invalid disk cache entry.");
|
|
5060
5293
|
}
|
|
@@ -5176,7 +5409,10 @@ var MemcachedLayer = class {
|
|
|
5176
5409
|
validateKey(key) {
|
|
5177
5410
|
const fullKey = this.withPrefix(key);
|
|
5178
5411
|
if (Buffer.byteLength(fullKey, "utf8") > 250) {
|
|
5179
|
-
|
|
5412
|
+
const displayKey = fullKey.slice(0, 64);
|
|
5413
|
+
throw new Error(
|
|
5414
|
+
`MemcachedLayer: key exceeds 250-byte Memcached limit: "${displayKey}${fullKey.length > 64 ? "..." : ""}"`
|
|
5415
|
+
);
|
|
5180
5416
|
}
|
|
5181
5417
|
if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
|
|
5182
5418
|
throw new Error(
|
|
@@ -5203,7 +5439,7 @@ var MsgpackSerializer = class {
|
|
|
5203
5439
|
};
|
|
5204
5440
|
|
|
5205
5441
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
5206
|
-
var
|
|
5442
|
+
var import_node_crypto4 = require("crypto");
|
|
5207
5443
|
var RELEASE_SCRIPT = `
|
|
5208
5444
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
5209
5445
|
return redis.call("del", KEYS[1])
|
|
@@ -5227,7 +5463,7 @@ var RedisSingleFlightCoordinator = class {
|
|
|
5227
5463
|
}
|
|
5228
5464
|
async execute(key, options, worker, waiter) {
|
|
5229
5465
|
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
5230
|
-
const token = (0,
|
|
5466
|
+
const token = (0, import_node_crypto4.randomUUID)();
|
|
5231
5467
|
const acquired = await this.runCommand(
|
|
5232
5468
|
`acquire("${key}")`,
|
|
5233
5469
|
() => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-
|
|
2
|
-
export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-
|
|
1
|
+
import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-CUHTP9Bc.cjs';
|
|
2
|
+
export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-CUHTP9Bc.cjs';
|
|
3
3
|
import Redis from 'ioredis';
|
|
4
4
|
import 'node:events';
|
|
5
5
|
|
|
@@ -63,6 +63,11 @@ declare class RedisTagIndex implements CacheTagIndex {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
interface CacheStatsHandlerOptions {
|
|
66
|
+
/**
|
|
67
|
+
* @deprecated Exposing cache stats without authentication is a security risk.
|
|
68
|
+
* Provide an `authorize` callback instead. This option will be removed in a
|
|
69
|
+
* future release.
|
|
70
|
+
*/
|
|
66
71
|
allowPublicAccess?: boolean;
|
|
67
72
|
authorize?: (request: unknown) => boolean | Promise<boolean>;
|
|
68
73
|
unauthorizedStatusCode?: number;
|
|
@@ -91,6 +96,11 @@ interface FastifyLikeReply {
|
|
|
91
96
|
interface FastifyLayercachePluginOptions {
|
|
92
97
|
exposeStatsRoute?: boolean;
|
|
93
98
|
statsPath?: string;
|
|
99
|
+
/**
|
|
100
|
+
* @deprecated Exposing cache stats without authentication is a security risk.
|
|
101
|
+
* Provide an `authorizeStatsRoute` callback instead. This option will be
|
|
102
|
+
* removed in a future release.
|
|
103
|
+
*/
|
|
94
104
|
allowPublicStatsRoute?: boolean;
|
|
95
105
|
authorizeStatsRoute?: (request: unknown) => boolean | Promise<boolean>;
|
|
96
106
|
unauthorizedStatusCode?: number;
|
|
@@ -246,6 +256,8 @@ declare class RedisLayer implements CacheLayer {
|
|
|
246
256
|
forEachKey(visitor: (key: string) => void | Promise<void>): Promise<void>;
|
|
247
257
|
private scanKeys;
|
|
248
258
|
private withPrefix;
|
|
259
|
+
private validateKey;
|
|
260
|
+
private displayKey;
|
|
249
261
|
private deserializeOrDelete;
|
|
250
262
|
private deleteCorruptedKey;
|
|
251
263
|
private rewriteWithPrimarySerializer;
|
|
@@ -274,7 +286,8 @@ interface DiskLayerOptions {
|
|
|
274
286
|
/**
|
|
275
287
|
* Maximum number of cache files to store on disk. When exceeded, the oldest
|
|
276
288
|
* entries (by file mtime) are evicted to keep the directory bounded.
|
|
277
|
-
* Defaults to
|
|
289
|
+
* Defaults to 50 000. Set to `Infinity` to disable the limit (not recommended
|
|
290
|
+
* in production — unbounded growth will eventually exhaust disk space).
|
|
278
291
|
*/
|
|
279
292
|
maxFiles?: number;
|
|
280
293
|
/**
|
|
@@ -283,6 +296,19 @@ interface DiskLayerOptions {
|
|
|
283
296
|
* Set to `false` to disable the limit.
|
|
284
297
|
*/
|
|
285
298
|
maxEntryBytes?: number | false;
|
|
299
|
+
/**
|
|
300
|
+
* Encrypt cached data at rest using AES-256-GCM. Accepts a string or Buffer.
|
|
301
|
+
* The key material is hashed with SHA-256 to derive the actual cipher key.
|
|
302
|
+
* Encryption also provides authenticated integrity — a separate signingKey
|
|
303
|
+
* is unnecessary when encryption is enabled.
|
|
304
|
+
*/
|
|
305
|
+
encryptionKey?: string | Buffer;
|
|
306
|
+
/**
|
|
307
|
+
* Sign cached data at rest using HMAC-SHA256 for integrity verification.
|
|
308
|
+
* Accepts a string or Buffer. Ignored when `encryptionKey` is also provided
|
|
309
|
+
* (AES-GCM already provides integrity).
|
|
310
|
+
*/
|
|
311
|
+
signingKey?: string | Buffer;
|
|
286
312
|
}
|
|
287
313
|
/**
|
|
288
314
|
* A file-system backed cache layer.
|
|
@@ -303,6 +329,7 @@ declare class DiskLayer implements CacheLayer {
|
|
|
303
329
|
private readonly serializer;
|
|
304
330
|
private readonly maxFiles;
|
|
305
331
|
private readonly maxEntryBytes;
|
|
332
|
+
private readonly protection;
|
|
306
333
|
private writeQueue;
|
|
307
334
|
constructor(options: DiskLayerOptions);
|
|
308
335
|
get<T>(key: string): Promise<T | null>;
|
|
@@ -429,9 +456,27 @@ declare class RedisSingleFlightCoordinator implements CacheSingleFlightCoordinat
|
|
|
429
456
|
private runCommand;
|
|
430
457
|
}
|
|
431
458
|
|
|
459
|
+
interface StampedeGuardOptions {
|
|
460
|
+
/**
|
|
461
|
+
* Maximum number of concurrent in-flight keys. When exceeded, new `execute`
|
|
462
|
+
* calls for keys that are not already in-flight will throw immediately.
|
|
463
|
+
* Defaults to 10 000.
|
|
464
|
+
*/
|
|
465
|
+
maxInFlight?: number;
|
|
466
|
+
/**
|
|
467
|
+
* Maximum milliseconds to wait for a single in-flight task before rejecting
|
|
468
|
+
* with a timeout error. When a timeout fires the entry is released so
|
|
469
|
+
* subsequent callers can retry. Defaults to no timeout.
|
|
470
|
+
*/
|
|
471
|
+
entryTimeoutMs?: number;
|
|
472
|
+
}
|
|
432
473
|
declare class StampedeGuard {
|
|
433
474
|
private readonly inFlight;
|
|
475
|
+
private readonly maxInFlight;
|
|
476
|
+
private readonly entryTimeoutMs;
|
|
477
|
+
constructor(options?: StampedeGuardOptions);
|
|
434
478
|
execute<T>(key: string, task: () => Promise<T>): Promise<T>;
|
|
479
|
+
private withTimeout;
|
|
435
480
|
private releaseEntry;
|
|
436
481
|
}
|
|
437
482
|
|