layercache 1.3.0 → 1.3.1
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 -13
- 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 +1 -1
- package/packages/nestjs/dist/index.cjs +57 -11
- package/packages/nestjs/dist/index.d.cts +2 -0
- package/packages/nestjs/dist/index.d.ts +2 -0
- package/packages/nestjs/dist/index.js +57 -11
package/dist/index.d.ts
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.js';
|
|
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.js';
|
|
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
|
|
package/dist/index.js
CHANGED
|
@@ -457,15 +457,14 @@ function createInstanceId() {
|
|
|
457
457
|
if (globalThis.crypto?.randomUUID) {
|
|
458
458
|
return globalThis.crypto.randomUUID();
|
|
459
459
|
}
|
|
460
|
-
const bytes = new Uint8Array(16);
|
|
461
460
|
if (globalThis.crypto?.getRandomValues) {
|
|
461
|
+
const bytes = new Uint8Array(16);
|
|
462
462
|
globalThis.crypto.getRandomValues(bytes);
|
|
463
|
-
|
|
464
|
-
for (let i = 0; i < bytes.length; i += 1) {
|
|
465
|
-
bytes[i] = Math.floor(Math.random() * 256);
|
|
466
|
-
}
|
|
463
|
+
return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
467
464
|
}
|
|
468
|
-
|
|
465
|
+
throw new Error(
|
|
466
|
+
"layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
|
|
467
|
+
);
|
|
469
468
|
}
|
|
470
469
|
|
|
471
470
|
// src/internal/CacheStackGeneration.ts
|
|
@@ -1286,7 +1285,8 @@ var CircuitBreakerManager = class {
|
|
|
1286
1285
|
}
|
|
1287
1286
|
const remainingMs = state.openUntil - now;
|
|
1288
1287
|
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
1289
|
-
|
|
1288
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
1289
|
+
throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
|
|
1290
1290
|
}
|
|
1291
1291
|
recordFailure(key, options) {
|
|
1292
1292
|
if (!options) {
|
|
@@ -1802,7 +1802,14 @@ var JsonSerializer = class {
|
|
|
1802
1802
|
}
|
|
1803
1803
|
deserialize(payload) {
|
|
1804
1804
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
1805
|
-
|
|
1805
|
+
let parsed;
|
|
1806
|
+
try {
|
|
1807
|
+
parsed = JSON.parse(normalized);
|
|
1808
|
+
} catch (error) {
|
|
1809
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1810
|
+
throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
|
|
1811
|
+
}
|
|
1812
|
+
return sanitizeStructuredData(parsed, {
|
|
1806
1813
|
label: "JSON payload",
|
|
1807
1814
|
maxDepth: 200,
|
|
1808
1815
|
maxNodes: 1e4
|
|
@@ -1813,6 +1820,12 @@ var JsonSerializer = class {
|
|
|
1813
1820
|
// src/stampede/StampedeGuard.ts
|
|
1814
1821
|
var StampedeGuard = class {
|
|
1815
1822
|
inFlight = /* @__PURE__ */ new Map();
|
|
1823
|
+
maxInFlight;
|
|
1824
|
+
entryTimeoutMs;
|
|
1825
|
+
constructor(options = {}) {
|
|
1826
|
+
this.maxInFlight = options.maxInFlight ?? 1e4;
|
|
1827
|
+
this.entryTimeoutMs = options.entryTimeoutMs;
|
|
1828
|
+
}
|
|
1816
1829
|
async execute(key, task) {
|
|
1817
1830
|
const existing = this.inFlight.get(key);
|
|
1818
1831
|
if (existing) {
|
|
@@ -1823,8 +1836,15 @@ var StampedeGuard = class {
|
|
|
1823
1836
|
this.releaseEntry(key, existing);
|
|
1824
1837
|
}
|
|
1825
1838
|
}
|
|
1839
|
+
if (this.inFlight.size >= this.maxInFlight) {
|
|
1840
|
+
throw new Error(
|
|
1841
|
+
`StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
const taskPromise = Promise.resolve().then(task);
|
|
1845
|
+
const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
|
|
1826
1846
|
const entry = {
|
|
1827
|
-
promise:
|
|
1847
|
+
promise: guardedPromise,
|
|
1828
1848
|
references: 1
|
|
1829
1849
|
};
|
|
1830
1850
|
this.inFlight.set(key, entry);
|
|
@@ -1834,6 +1854,27 @@ var StampedeGuard = class {
|
|
|
1834
1854
|
this.releaseEntry(key, entry);
|
|
1835
1855
|
}
|
|
1836
1856
|
}
|
|
1857
|
+
withTimeout(key, promise, timeoutMs) {
|
|
1858
|
+
return new Promise((resolve2, reject) => {
|
|
1859
|
+
const timer = setTimeout(() => {
|
|
1860
|
+
reject(
|
|
1861
|
+
new Error(
|
|
1862
|
+
`StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
|
|
1863
|
+
)
|
|
1864
|
+
);
|
|
1865
|
+
}, timeoutMs);
|
|
1866
|
+
promise.then(
|
|
1867
|
+
(value) => {
|
|
1868
|
+
clearTimeout(timer);
|
|
1869
|
+
resolve2(value);
|
|
1870
|
+
},
|
|
1871
|
+
(error) => {
|
|
1872
|
+
clearTimeout(timer);
|
|
1873
|
+
reject(error);
|
|
1874
|
+
}
|
|
1875
|
+
);
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1837
1878
|
releaseEntry(key, entry) {
|
|
1838
1879
|
entry.references -= 1;
|
|
1839
1880
|
const current = this.inFlight.get(key);
|
|
@@ -1899,6 +1940,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
1899
1940
|
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
1900
1941
|
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
1901
1942
|
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
1943
|
+
this.stampedeGuard = new StampedeGuard({
|
|
1944
|
+
maxInFlight: options.stampedeMaxInFlight,
|
|
1945
|
+
entryTimeoutMs: options.stampedeEntryTimeoutMs
|
|
1946
|
+
});
|
|
1902
1947
|
this.currentGeneration = options.generation;
|
|
1903
1948
|
if (options.publishSetInvalidation !== void 0) {
|
|
1904
1949
|
console.warn(
|
|
@@ -1977,7 +2022,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1977
2022
|
}
|
|
1978
2023
|
layers;
|
|
1979
2024
|
options;
|
|
1980
|
-
stampedeGuard
|
|
2025
|
+
stampedeGuard;
|
|
1981
2026
|
metricsCollector = new MetricsCollector();
|
|
1982
2027
|
instanceId = createInstanceId();
|
|
1983
2028
|
startup;
|
|
@@ -2215,7 +2260,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
2215
2260
|
return promise;
|
|
2216
2261
|
}
|
|
2217
2262
|
if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
|
|
2218
|
-
|
|
2263
|
+
const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
|
|
2264
|
+
throw new Error(`mget received conflicting entries for key "${displayKey}".`);
|
|
2219
2265
|
}
|
|
2220
2266
|
return existing.promise;
|
|
2221
2267
|
})
|
|
@@ -3267,6 +3313,11 @@ var RedisInvalidationBus = class {
|
|
|
3267
3313
|
|
|
3268
3314
|
// src/http/createCacheStatsHandler.ts
|
|
3269
3315
|
function createCacheStatsHandler(cache, options = {}) {
|
|
3316
|
+
if (options.allowPublicAccess === true) {
|
|
3317
|
+
console.warn(
|
|
3318
|
+
"[layercache] WARNING: Stats endpoint is publicly accessible without authentication. Set allowPublicAccess: false (or provide an authorize callback) before deploying to production."
|
|
3319
|
+
);
|
|
3320
|
+
}
|
|
3270
3321
|
return async (request, response) => {
|
|
3271
3322
|
response.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
3272
3323
|
response.setHeader?.("cache-control", "no-store");
|
|
@@ -3309,6 +3360,11 @@ function createCachedMethodDecorator(options) {
|
|
|
3309
3360
|
|
|
3310
3361
|
// src/integrations/fastify.ts
|
|
3311
3362
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
3363
|
+
if (options.exposeStatsRoute === true && options.allowPublicStatsRoute === true) {
|
|
3364
|
+
console.warn(
|
|
3365
|
+
"[layercache] WARNING: Cache stats route is publicly accessible without authentication. Set allowPublicStatsRoute: false (or provide an authorizeStatsRoute callback) before deploying to production."
|
|
3366
|
+
);
|
|
3367
|
+
}
|
|
3312
3368
|
return async (fastify) => {
|
|
3313
3369
|
fastify.decorate("cache", cache);
|
|
3314
3370
|
if (options.exposeStatsRoute === true && fastify.get) {
|
|
@@ -3525,7 +3581,11 @@ var RedisLayer = class {
|
|
|
3525
3581
|
return unwrapStoredValue(payload);
|
|
3526
3582
|
}
|
|
3527
3583
|
async getEntry(key) {
|
|
3528
|
-
|
|
3584
|
+
this.validateKey(key);
|
|
3585
|
+
const payload = await this.runCommand(
|
|
3586
|
+
`get(${this.displayKey(key)})`,
|
|
3587
|
+
() => this.client.getBuffer(this.withPrefix(key))
|
|
3588
|
+
);
|
|
3529
3589
|
if (payload === null) {
|
|
3530
3590
|
return null;
|
|
3531
3591
|
}
|
|
@@ -3535,6 +3595,9 @@ var RedisLayer = class {
|
|
|
3535
3595
|
if (keys.length === 0) {
|
|
3536
3596
|
return [];
|
|
3537
3597
|
}
|
|
3598
|
+
for (const key of keys) {
|
|
3599
|
+
this.validateKey(key);
|
|
3600
|
+
}
|
|
3538
3601
|
const pipeline = this.client.pipeline();
|
|
3539
3602
|
for (const key of keys) {
|
|
3540
3603
|
pipeline.getBuffer(this.withPrefix(key));
|
|
@@ -3557,6 +3620,9 @@ var RedisLayer = class {
|
|
|
3557
3620
|
if (entries.length === 0) {
|
|
3558
3621
|
return;
|
|
3559
3622
|
}
|
|
3623
|
+
for (const entry of entries) {
|
|
3624
|
+
this.validateKey(entry.key);
|
|
3625
|
+
}
|
|
3560
3626
|
const pipeline = this.client.pipeline();
|
|
3561
3627
|
for (const entry of entries) {
|
|
3562
3628
|
const serialized = this.primarySerializer().serialize(entry.value);
|
|
@@ -3571,33 +3637,43 @@ var RedisLayer = class {
|
|
|
3571
3637
|
await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
|
|
3572
3638
|
}
|
|
3573
3639
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3640
|
+
this.validateKey(key);
|
|
3574
3641
|
const serialized = this.primarySerializer().serialize(value);
|
|
3575
3642
|
const payload = await this.encodePayload(serialized);
|
|
3576
3643
|
const normalizedKey = this.withPrefix(key);
|
|
3577
3644
|
if (ttl && ttl > 0) {
|
|
3578
|
-
await this.runCommand(
|
|
3645
|
+
await this.runCommand(
|
|
3646
|
+
`set(${this.displayKey(key)})`,
|
|
3647
|
+
() => this.client.set(normalizedKey, payload, "EX", ttl)
|
|
3648
|
+
);
|
|
3579
3649
|
return;
|
|
3580
3650
|
}
|
|
3581
|
-
await this.runCommand(`set(
|
|
3651
|
+
await this.runCommand(`set(${this.displayKey(key)})`, () => this.client.set(normalizedKey, payload));
|
|
3582
3652
|
}
|
|
3583
3653
|
async delete(key) {
|
|
3584
|
-
|
|
3654
|
+
this.validateKey(key);
|
|
3655
|
+
await this.runCommand(`delete(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
|
|
3585
3656
|
}
|
|
3586
3657
|
async deleteMany(keys) {
|
|
3587
3658
|
if (keys.length === 0) {
|
|
3588
3659
|
return;
|
|
3589
3660
|
}
|
|
3661
|
+
for (const key of keys) {
|
|
3662
|
+
this.validateKey(key);
|
|
3663
|
+
}
|
|
3590
3664
|
await this.runCommand(
|
|
3591
3665
|
`deleteMany(${keys.length})`,
|
|
3592
3666
|
() => this.client.del(...keys.map((key) => this.withPrefix(key)))
|
|
3593
3667
|
);
|
|
3594
3668
|
}
|
|
3595
3669
|
async has(key) {
|
|
3596
|
-
|
|
3670
|
+
this.validateKey(key);
|
|
3671
|
+
const exists = await this.runCommand(`has(${this.displayKey(key)})`, () => this.client.exists(this.withPrefix(key)));
|
|
3597
3672
|
return exists > 0;
|
|
3598
3673
|
}
|
|
3599
3674
|
async ttl(key) {
|
|
3600
|
-
|
|
3675
|
+
this.validateKey(key);
|
|
3676
|
+
const remaining = await this.runCommand(`ttl(${this.displayKey(key)})`, () => this.client.ttl(this.withPrefix(key)));
|
|
3601
3677
|
if (remaining < 0) {
|
|
3602
3678
|
return null;
|
|
3603
3679
|
}
|
|
@@ -3697,6 +3773,23 @@ var RedisLayer = class {
|
|
|
3697
3773
|
withPrefix(key) {
|
|
3698
3774
|
return `${this.prefix}${key}`;
|
|
3699
3775
|
}
|
|
3776
|
+
validateKey(key) {
|
|
3777
|
+
if (key.length === 0) {
|
|
3778
|
+
throw new Error("RedisLayer: key must not be empty.");
|
|
3779
|
+
}
|
|
3780
|
+
if (key.length > 1024) {
|
|
3781
|
+
throw new Error(`RedisLayer: key length must be at most 1 024 characters (got ${key.length}).`);
|
|
3782
|
+
}
|
|
3783
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
3784
|
+
throw new Error("RedisLayer: key contains unsupported control characters.");
|
|
3785
|
+
}
|
|
3786
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
3787
|
+
throw new Error("RedisLayer: key contains unsupported surrogate code points.");
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
displayKey(key) {
|
|
3791
|
+
return key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
3792
|
+
}
|
|
3700
3793
|
async deserializeOrDelete(key, payload) {
|
|
3701
3794
|
let decodedPayload;
|
|
3702
3795
|
try {
|
|
@@ -3720,23 +3813,30 @@ var RedisLayer = class {
|
|
|
3720
3813
|
}
|
|
3721
3814
|
async deleteCorruptedKey(key) {
|
|
3722
3815
|
try {
|
|
3723
|
-
await this.runCommand(`deleteCorrupted(
|
|
3816
|
+
await this.runCommand(`deleteCorrupted(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
|
|
3724
3817
|
} catch (deleteError) {
|
|
3725
|
-
|
|
3818
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
3819
|
+
console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${displayKey}"`, deleteError);
|
|
3726
3820
|
}
|
|
3727
3821
|
}
|
|
3728
3822
|
async rewriteWithPrimarySerializer(key, value) {
|
|
3729
3823
|
const serialized = this.primarySerializer().serialize(value);
|
|
3730
3824
|
const payload = await this.encodePayload(serialized);
|
|
3731
|
-
const ttl = await this.runCommand(
|
|
3825
|
+
const ttl = await this.runCommand(
|
|
3826
|
+
`rewrite-ttl(${this.displayKey(key)})`,
|
|
3827
|
+
() => this.client.ttl(this.withPrefix(key))
|
|
3828
|
+
);
|
|
3732
3829
|
if (ttl > 0) {
|
|
3733
3830
|
await this.runCommand(
|
|
3734
|
-
`rewrite-set(
|
|
3831
|
+
`rewrite-set(${this.displayKey(key)})`,
|
|
3735
3832
|
() => this.client.set(this.withPrefix(key), payload, "EX", ttl)
|
|
3736
3833
|
);
|
|
3737
3834
|
return;
|
|
3738
3835
|
}
|
|
3739
|
-
await this.runCommand(
|
|
3836
|
+
await this.runCommand(
|
|
3837
|
+
`rewrite-set(${this.displayKey(key)})`,
|
|
3838
|
+
() => this.client.set(this.withPrefix(key), payload)
|
|
3839
|
+
);
|
|
3740
3840
|
}
|
|
3741
3841
|
primarySerializer() {
|
|
3742
3842
|
const serializer = this.serializers[0];
|
|
@@ -3864,9 +3964,131 @@ var RedisLayer = class {
|
|
|
3864
3964
|
};
|
|
3865
3965
|
|
|
3866
3966
|
// src/layers/DiskLayer.ts
|
|
3867
|
-
import { createHash, randomBytes as
|
|
3967
|
+
import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
|
|
3868
3968
|
import { promises as fs2 } from "fs";
|
|
3869
3969
|
import { join, resolve } from "path";
|
|
3970
|
+
|
|
3971
|
+
// src/internal/PayloadProtection.ts
|
|
3972
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
3973
|
+
var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
|
|
3974
|
+
var MAGIC_SIGNED = Buffer.from("LCS1:");
|
|
3975
|
+
var ALGORITHM = "aes-256-gcm";
|
|
3976
|
+
var IV_LENGTH = 12;
|
|
3977
|
+
var AUTH_TAG_LENGTH = 16;
|
|
3978
|
+
var HMAC_LENGTH = 32;
|
|
3979
|
+
var PayloadProtection = class {
|
|
3980
|
+
encryptionKey;
|
|
3981
|
+
signingKey;
|
|
3982
|
+
constructor(options) {
|
|
3983
|
+
if (options.encryptionKey) {
|
|
3984
|
+
const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
|
|
3985
|
+
this.encryptionKey = createHash("sha256").update(raw).digest();
|
|
3986
|
+
}
|
|
3987
|
+
if (options.signingKey && !options.encryptionKey) {
|
|
3988
|
+
const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
|
|
3989
|
+
this.signingKey = createHash("sha256").update(raw).digest();
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
/** Returns `true` when any protection (encryption or signing) is configured. */
|
|
3993
|
+
get isEnabled() {
|
|
3994
|
+
return this.encryptionKey !== void 0 || this.signingKey !== void 0;
|
|
3995
|
+
}
|
|
3996
|
+
/**
|
|
3997
|
+
* Applies the configured protection (encryption or signing) to a payload.
|
|
3998
|
+
* Returns the input unchanged when no protection is configured.
|
|
3999
|
+
*/
|
|
4000
|
+
protect(payload) {
|
|
4001
|
+
if (this.encryptionKey) {
|
|
4002
|
+
return this.encrypt(payload, this.encryptionKey);
|
|
4003
|
+
}
|
|
4004
|
+
if (this.signingKey) {
|
|
4005
|
+
return this.sign(payload, this.signingKey);
|
|
4006
|
+
}
|
|
4007
|
+
return payload;
|
|
4008
|
+
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Removes the protection layer from a payload.
|
|
4011
|
+
*
|
|
4012
|
+
* - Protected payloads are decrypted/verified using the configured keys.
|
|
4013
|
+
* - Legacy unprotected payloads pass through unchanged when **no** protection
|
|
4014
|
+
* is configured.
|
|
4015
|
+
* - If protection **is** configured but the payload is not protected, the
|
|
4016
|
+
* payload is treated as a legacy entry. Callers can handle this case by
|
|
4017
|
+
* checking `isEnabled` separately.
|
|
4018
|
+
*/
|
|
4019
|
+
unprotect(payload) {
|
|
4020
|
+
if (this.startsWith(payload, MAGIC_ENCRYPTED)) {
|
|
4021
|
+
if (!this.encryptionKey) {
|
|
4022
|
+
throw new PayloadProtectionError("Encrypted payload but no encryptionKey configured.");
|
|
4023
|
+
}
|
|
4024
|
+
return this.decrypt(payload, this.encryptionKey);
|
|
4025
|
+
}
|
|
4026
|
+
if (this.startsWith(payload, MAGIC_SIGNED)) {
|
|
4027
|
+
if (!this.signingKey) {
|
|
4028
|
+
throw new PayloadProtectionError("Signed payload but no signingKey configured.");
|
|
4029
|
+
}
|
|
4030
|
+
return this.verify(payload, this.signingKey);
|
|
4031
|
+
}
|
|
4032
|
+
return payload;
|
|
4033
|
+
}
|
|
4034
|
+
// ── Encryption (AES-256-GCM) ──────────────────────────────────────────
|
|
4035
|
+
encrypt(plaintext, key) {
|
|
4036
|
+
const iv = randomBytes2(IV_LENGTH);
|
|
4037
|
+
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
4038
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
4039
|
+
const authTag = cipher.getAuthTag();
|
|
4040
|
+
return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
|
|
4041
|
+
}
|
|
4042
|
+
decrypt(payload, key) {
|
|
4043
|
+
const headerEnd = MAGIC_ENCRYPTED.length;
|
|
4044
|
+
const iv = payload.subarray(headerEnd, headerEnd + IV_LENGTH);
|
|
4045
|
+
const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
4046
|
+
const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
4047
|
+
try {
|
|
4048
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
|
4049
|
+
authTagLength: AUTH_TAG_LENGTH
|
|
4050
|
+
});
|
|
4051
|
+
decipher.setAuthTag(authTag);
|
|
4052
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
4053
|
+
} catch {
|
|
4054
|
+
throw new PayloadProtectionError(
|
|
4055
|
+
"Decryption failed. The data may have been tampered with or the encryptionKey is incorrect."
|
|
4056
|
+
);
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
// ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
|
|
4060
|
+
sign(payload, key) {
|
|
4061
|
+
const hmac = createHmac("sha256", key).update(payload).digest();
|
|
4062
|
+
return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
|
|
4063
|
+
}
|
|
4064
|
+
verify(payload, key) {
|
|
4065
|
+
const headerEnd = MAGIC_SIGNED.length;
|
|
4066
|
+
const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
|
|
4067
|
+
const data = payload.subarray(headerEnd + HMAC_LENGTH);
|
|
4068
|
+
const expectedHmac = createHmac("sha256", key).update(data).digest();
|
|
4069
|
+
if (receivedHmac.length !== HMAC_LENGTH || !timingSafeEqual(receivedHmac, expectedHmac)) {
|
|
4070
|
+
throw new PayloadProtectionError(
|
|
4071
|
+
"HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
|
|
4072
|
+
);
|
|
4073
|
+
}
|
|
4074
|
+
return data;
|
|
4075
|
+
}
|
|
4076
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
4077
|
+
startsWith(buffer, prefix) {
|
|
4078
|
+
if (buffer.length < prefix.length) {
|
|
4079
|
+
return false;
|
|
4080
|
+
}
|
|
4081
|
+
return buffer.subarray(0, prefix.length).equals(prefix);
|
|
4082
|
+
}
|
|
4083
|
+
};
|
|
4084
|
+
var PayloadProtectionError = class extends Error {
|
|
4085
|
+
constructor(message) {
|
|
4086
|
+
super(message);
|
|
4087
|
+
this.name = "PayloadProtectionError";
|
|
4088
|
+
}
|
|
4089
|
+
};
|
|
4090
|
+
|
|
4091
|
+
// src/layers/DiskLayer.ts
|
|
3870
4092
|
var FILE_SCAN_CONCURRENCY = 32;
|
|
3871
4093
|
var DiskLayer = class {
|
|
3872
4094
|
name;
|
|
@@ -3876,6 +4098,7 @@ var DiskLayer = class {
|
|
|
3876
4098
|
serializer;
|
|
3877
4099
|
maxFiles;
|
|
3878
4100
|
maxEntryBytes;
|
|
4101
|
+
protection;
|
|
3879
4102
|
writeQueue = Promise.resolve();
|
|
3880
4103
|
constructor(options) {
|
|
3881
4104
|
this.directory = this.resolveDirectory(options.directory);
|
|
@@ -3884,6 +4107,10 @@ var DiskLayer = class {
|
|
|
3884
4107
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
3885
4108
|
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
3886
4109
|
this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
|
|
4110
|
+
this.protection = new PayloadProtection({
|
|
4111
|
+
encryptionKey: options.encryptionKey,
|
|
4112
|
+
signingKey: options.signingKey
|
|
4113
|
+
});
|
|
3887
4114
|
}
|
|
3888
4115
|
async get(key) {
|
|
3889
4116
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -3916,10 +4143,12 @@ var DiskLayer = class {
|
|
|
3916
4143
|
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
3917
4144
|
};
|
|
3918
4145
|
const payload = this.serializer.serialize(entry);
|
|
4146
|
+
const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
|
|
4147
|
+
const protectedPayload = this.protection.protect(raw);
|
|
3919
4148
|
const targetPath = this.keyToPath(key);
|
|
3920
|
-
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${
|
|
4149
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes3(8).toString("hex")}.tmp`;
|
|
3921
4150
|
try {
|
|
3922
|
-
await fs2.writeFile(tempPath,
|
|
4151
|
+
await fs2.writeFile(tempPath, protectedPayload);
|
|
3923
4152
|
await fs2.rename(tempPath, targetPath);
|
|
3924
4153
|
} catch (error) {
|
|
3925
4154
|
await this.safeDelete(tempPath);
|
|
@@ -4017,7 +4246,7 @@ var DiskLayer = class {
|
|
|
4017
4246
|
async dispose() {
|
|
4018
4247
|
}
|
|
4019
4248
|
keyToPath(key) {
|
|
4020
|
-
const hash =
|
|
4249
|
+
const hash = createHash2("sha256").update(key).digest("hex");
|
|
4021
4250
|
return join(this.directory, `${hash}.lc`);
|
|
4022
4251
|
}
|
|
4023
4252
|
resolveDirectory(directory) {
|
|
@@ -4031,10 +4260,13 @@ var DiskLayer = class {
|
|
|
4031
4260
|
}
|
|
4032
4261
|
normalizeMaxFiles(maxFiles) {
|
|
4033
4262
|
if (maxFiles === void 0) {
|
|
4263
|
+
return 5e4;
|
|
4264
|
+
}
|
|
4265
|
+
if (maxFiles === Number.POSITIVE_INFINITY) {
|
|
4034
4266
|
return void 0;
|
|
4035
4267
|
}
|
|
4036
4268
|
if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
|
|
4037
|
-
throw new Error("DiskLayer.maxFiles must be a positive integer.");
|
|
4269
|
+
throw new Error("DiskLayer.maxFiles must be a positive integer or Infinity.");
|
|
4038
4270
|
}
|
|
4039
4271
|
return maxFiles;
|
|
4040
4272
|
}
|
|
@@ -4146,7 +4378,8 @@ var DiskLayer = class {
|
|
|
4146
4378
|
);
|
|
4147
4379
|
}
|
|
4148
4380
|
deserializeEntry(raw) {
|
|
4149
|
-
const
|
|
4381
|
+
const unprotected = this.protection.unprotect(raw);
|
|
4382
|
+
const entry = this.serializer.deserialize(unprotected);
|
|
4150
4383
|
if (!isDiskEntry(entry)) {
|
|
4151
4384
|
throw new Error("Invalid disk cache entry.");
|
|
4152
4385
|
}
|
|
@@ -4268,7 +4501,10 @@ var MemcachedLayer = class {
|
|
|
4268
4501
|
validateKey(key) {
|
|
4269
4502
|
const fullKey = this.withPrefix(key);
|
|
4270
4503
|
if (Buffer.byteLength(fullKey, "utf8") > 250) {
|
|
4271
|
-
|
|
4504
|
+
const displayKey = fullKey.slice(0, 64);
|
|
4505
|
+
throw new Error(
|
|
4506
|
+
`MemcachedLayer: key exceeds 250-byte Memcached limit: "${displayKey}${fullKey.length > 64 ? "..." : ""}"`
|
|
4507
|
+
);
|
|
4272
4508
|
}
|
|
4273
4509
|
if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
|
|
4274
4510
|
throw new Error(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layercache",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Production-ready multi-layer caching for Node.js. Stack memory + Redis + disk behind one API with stampede prevention, tag invalidation, stale serving, and full observability.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cache",
|