layercache 2.1.0 → 3.1.0
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 +29 -9
- package/dist/{chunk-6X7NV5BG.js → chunk-L6L7QXYF.js} +95 -14
- package/dist/{chunk-IVX6ABFX.js → chunk-XMUT66SH.js} +116 -90
- package/dist/cli.cjs +153 -25
- package/dist/cli.js +69 -13
- package/dist/{edge-BCU8D-Yd.d.cts → edge-LBUuZAdr.d.cts} +61 -2
- package/dist/{edge-BCU8D-Yd.d.ts → edge-LBUuZAdr.d.ts} +61 -2
- package/dist/edge.cjs +114 -90
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +1 -1
- package/dist/index.cjs +578 -220
- package/dist/index.d.cts +55 -3
- package/dist/index.d.ts +55 -3
- package/dist/index.js +366 -113
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -39,6 +39,7 @@ __export(index_exports, {
|
|
|
39
39
|
MemoryLayer: () => MemoryLayer,
|
|
40
40
|
MsgpackSerializer: () => MsgpackSerializer,
|
|
41
41
|
PatternMatcher: () => PatternMatcher,
|
|
42
|
+
RedisGenerationStore: () => RedisGenerationStore,
|
|
42
43
|
RedisInvalidationBus: () => RedisInvalidationBus,
|
|
43
44
|
RedisLayer: () => RedisLayer,
|
|
44
45
|
RedisSingleFlightCoordinator: () => RedisSingleFlightCoordinator,
|
|
@@ -97,39 +98,6 @@ function cloneNamespaceMetrics(metrics) {
|
|
|
97
98
|
)
|
|
98
99
|
};
|
|
99
100
|
}
|
|
100
|
-
function diffNamespaceMetrics(before, after) {
|
|
101
|
-
const latencyByLayer = Object.fromEntries(
|
|
102
|
-
Object.entries(after.latencyByLayer).map(([layer, value]) => [
|
|
103
|
-
layer,
|
|
104
|
-
{
|
|
105
|
-
avgMs: value.avgMs,
|
|
106
|
-
maxMs: value.maxMs,
|
|
107
|
-
count: Math.max(0, value.count - (before.latencyByLayer[layer]?.count ?? 0))
|
|
108
|
-
}
|
|
109
|
-
])
|
|
110
|
-
);
|
|
111
|
-
return {
|
|
112
|
-
hits: after.hits - before.hits,
|
|
113
|
-
misses: after.misses - before.misses,
|
|
114
|
-
fetches: after.fetches - before.fetches,
|
|
115
|
-
sets: after.sets - before.sets,
|
|
116
|
-
deletes: after.deletes - before.deletes,
|
|
117
|
-
backfills: after.backfills - before.backfills,
|
|
118
|
-
invalidations: after.invalidations - before.invalidations,
|
|
119
|
-
staleHits: after.staleHits - before.staleHits,
|
|
120
|
-
refreshes: after.refreshes - before.refreshes,
|
|
121
|
-
refreshErrors: after.refreshErrors - before.refreshErrors,
|
|
122
|
-
writeFailures: after.writeFailures - before.writeFailures,
|
|
123
|
-
singleFlightWaits: after.singleFlightWaits - before.singleFlightWaits,
|
|
124
|
-
negativeCacheHits: after.negativeCacheHits - before.negativeCacheHits,
|
|
125
|
-
circuitBreakerTrips: after.circuitBreakerTrips - before.circuitBreakerTrips,
|
|
126
|
-
degradedOperations: after.degradedOperations - before.degradedOperations,
|
|
127
|
-
hitsByLayer: diffMetricMap(before.hitsByLayer, after.hitsByLayer),
|
|
128
|
-
missesByLayer: diffMetricMap(before.missesByLayer, after.missesByLayer),
|
|
129
|
-
latencyByLayer,
|
|
130
|
-
resetAt: after.resetAt
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
101
|
function addNamespaceMetrics(base, delta) {
|
|
134
102
|
return {
|
|
135
103
|
hits: base.hits + delta.hits,
|
|
@@ -165,14 +133,6 @@ function computeNamespaceHitRate(metrics) {
|
|
|
165
133
|
}
|
|
166
134
|
return { overall, byLayer };
|
|
167
135
|
}
|
|
168
|
-
function diffMetricMap(before, after) {
|
|
169
|
-
const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
170
|
-
const result = {};
|
|
171
|
-
for (const key of keys) {
|
|
172
|
-
result[key] = (after[key] ?? 0) - (before[key] ?? 0);
|
|
173
|
-
}
|
|
174
|
-
return result;
|
|
175
|
-
}
|
|
176
136
|
function addMetricMap(base, delta) {
|
|
177
137
|
const keys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(delta)]);
|
|
178
138
|
const result = {};
|
|
@@ -209,6 +169,20 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
209
169
|
async getOrSet(key, fetcher, options) {
|
|
210
170
|
return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
|
|
211
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Returns a namespaced cache entry, or `null` on miss.
|
|
174
|
+
* Unlike `get()`, this distinguishes a stored `null` value from an absent key.
|
|
175
|
+
*/
|
|
176
|
+
async getEntry(key) {
|
|
177
|
+
const entry = await this.trackMetrics(() => this.cache.getEntry(this.qualify(key)));
|
|
178
|
+
if (entry === null) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
...entry,
|
|
183
|
+
key
|
|
184
|
+
};
|
|
185
|
+
}
|
|
212
186
|
/**
|
|
213
187
|
* Like `get()`, but throws `CacheMissError` instead of returning `null`.
|
|
214
188
|
*/
|
|
@@ -443,13 +417,24 @@ var CacheNamespace = class _CacheNamespace {
|
|
|
443
417
|
};
|
|
444
418
|
}
|
|
445
419
|
async trackMetrics(operation) {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
420
|
+
let result;
|
|
421
|
+
let metrics;
|
|
422
|
+
try {
|
|
423
|
+
;
|
|
424
|
+
({ result, metrics } = await this.cache.captureMetrics(operation));
|
|
425
|
+
} catch (error) {
|
|
426
|
+
const capturedMetrics = error.metrics;
|
|
427
|
+
if (capturedMetrics) {
|
|
428
|
+
await this.getMetricsMutex().runExclusive(() => {
|
|
429
|
+
this.metrics = addNamespaceMetrics(this.metrics, capturedMetrics);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
await this.getMetricsMutex().runExclusive(() => {
|
|
435
|
+
this.metrics = addNamespaceMetrics(this.metrics, metrics);
|
|
452
436
|
});
|
|
437
|
+
return result;
|
|
453
438
|
}
|
|
454
439
|
getMetricsMutex() {
|
|
455
440
|
const existing = _CacheNamespace.metricsMutexes.get(this.cache);
|
|
@@ -1157,7 +1142,9 @@ var CacheStackMaintenance = class {
|
|
|
1157
1142
|
}
|
|
1158
1143
|
bumpKeyEpochs(keys) {
|
|
1159
1144
|
for (const key of keys) {
|
|
1160
|
-
|
|
1145
|
+
const nextEpoch = this.currentKeyEpoch(key) + 1;
|
|
1146
|
+
this.keyEpochs.delete(key);
|
|
1147
|
+
this.keyEpochs.set(key, nextEpoch);
|
|
1161
1148
|
}
|
|
1162
1149
|
this.pruneKeyEpochsIfNeeded();
|
|
1163
1150
|
}
|
|
@@ -1216,10 +1203,13 @@ var CacheStackMaintenance = class {
|
|
|
1216
1203
|
if (this.keyEpochs.size <= MAX_KEY_EPOCHS) {
|
|
1217
1204
|
return;
|
|
1218
1205
|
}
|
|
1219
|
-
const
|
|
1220
|
-
const toDelete = Math.ceil(sorted.length * 0.1);
|
|
1206
|
+
const toDelete = Math.ceil(this.keyEpochs.size * 0.1);
|
|
1221
1207
|
for (let i = 0; i < toDelete; i++) {
|
|
1222
|
-
this.keyEpochs.
|
|
1208
|
+
const oldestKey = this.keyEpochs.keys().next().value;
|
|
1209
|
+
if (oldestKey === void 0) {
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
this.keyEpochs.delete(oldestKey);
|
|
1223
1213
|
}
|
|
1224
1214
|
}
|
|
1225
1215
|
};
|
|
@@ -1265,6 +1255,9 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
|
1265
1255
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
1266
1256
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
1267
1257
|
var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
|
|
1258
|
+
var SINGLE_FLIGHT_BACKOFF_FACTOR = 2;
|
|
1259
|
+
var SINGLE_FLIGHT_BACKOFF_JITTER = 0.2;
|
|
1260
|
+
var SINGLE_FLIGHT_MAX_POLL_MS = 1e3;
|
|
1268
1261
|
var CacheStackReader = class {
|
|
1269
1262
|
constructor(options) {
|
|
1270
1263
|
this.options = options;
|
|
@@ -1353,22 +1346,28 @@ var CacheStackReader = class {
|
|
|
1353
1346
|
if (upToIndex < 0) {
|
|
1354
1347
|
return;
|
|
1355
1348
|
}
|
|
1349
|
+
const operations = [];
|
|
1356
1350
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
1357
1351
|
const layer = this.options.layers[index];
|
|
1358
1352
|
if (!layer || this.options.shouldSkipLayer(layer)) {
|
|
1359
1353
|
continue;
|
|
1360
1354
|
}
|
|
1361
1355
|
const ttl = remainingStoredTtlMs(stored) ?? this.options.resolveLayerMs(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1356
|
+
operations.push(
|
|
1357
|
+
(async () => {
|
|
1358
|
+
try {
|
|
1359
|
+
await layer.set(key, stored, ttl);
|
|
1360
|
+
} catch (error) {
|
|
1361
|
+
await this.options.handleLayerFailure(layer, "backfill", error);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
this.options.metricsCollector.increment("backfills");
|
|
1365
|
+
this.options.logger.debug?.("backfill", { key, layer: layer.name });
|
|
1366
|
+
this.options.emit("backfill", { key, layer: layer.name });
|
|
1367
|
+
})()
|
|
1368
|
+
);
|
|
1371
1369
|
}
|
|
1370
|
+
await Promise.all(operations);
|
|
1372
1371
|
}
|
|
1373
1372
|
abortAllRefreshes() {
|
|
1374
1373
|
for (const key of this.backgroundRefreshAbort.keys()) {
|
|
@@ -1482,6 +1481,7 @@ var CacheStackReader = class {
|
|
|
1482
1481
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
1483
1482
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
1484
1483
|
const deadline = Date.now() + timeoutMs;
|
|
1484
|
+
let nextPollMs = pollIntervalMs;
|
|
1485
1485
|
this.options.metricsCollector.increment("singleFlightWaits");
|
|
1486
1486
|
this.options.emit("stampede-dedupe", { key });
|
|
1487
1487
|
while (Date.now() < deadline) {
|
|
@@ -1490,16 +1490,36 @@ var CacheStackReader = class {
|
|
|
1490
1490
|
this.options.metricsCollector.increment("hits");
|
|
1491
1491
|
return hit.value;
|
|
1492
1492
|
}
|
|
1493
|
-
|
|
1493
|
+
const remainingMs = deadline - Date.now();
|
|
1494
|
+
if (remainingMs <= 0) {
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
const delayMs = Math.min(this.jitterSingleFlightPoll(nextPollMs), remainingMs);
|
|
1498
|
+
await this.options.sleep(delayMs);
|
|
1499
|
+
nextPollMs = Math.min(nextPollMs * SINGLE_FLIGHT_BACKOFF_FACTOR, SINGLE_FLIGHT_MAX_POLL_MS, timeoutMs);
|
|
1500
|
+
}
|
|
1501
|
+
if (!this.options.singleFlightCoordinator) {
|
|
1502
|
+
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext);
|
|
1494
1503
|
}
|
|
1495
|
-
return this.
|
|
1504
|
+
return this.options.singleFlightCoordinator.execute(
|
|
1505
|
+
key,
|
|
1506
|
+
this.resolveSingleFlightOptions(),
|
|
1507
|
+
() => this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext),
|
|
1508
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
jitterSingleFlightPoll(delayMs) {
|
|
1512
|
+
const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER;
|
|
1513
|
+
return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2));
|
|
1496
1514
|
}
|
|
1497
1515
|
async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext = {
|
|
1498
1516
|
key,
|
|
1499
1517
|
currentValue: void 0,
|
|
1500
1518
|
state: "miss"
|
|
1501
1519
|
}) {
|
|
1502
|
-
|
|
1520
|
+
const circuitBreakerOptions = options?.circuitBreaker ?? this.options.circuitBreaker;
|
|
1521
|
+
const breakerKey = this.resolveCircuitBreakerKey(key, circuitBreakerOptions);
|
|
1522
|
+
this.options.circuitBreakerManager.assertClosed(breakerKey, circuitBreakerOptions);
|
|
1503
1523
|
this.options.metricsCollector.increment("fetches");
|
|
1504
1524
|
const fetchStart = Date.now();
|
|
1505
1525
|
let fetched;
|
|
@@ -1509,13 +1529,13 @@ var CacheStackReader = class {
|
|
|
1509
1529
|
{ key, fetcher },
|
|
1510
1530
|
() => fetcher(fetcherContext)
|
|
1511
1531
|
);
|
|
1512
|
-
this.options.circuitBreakerManager.recordSuccess(
|
|
1532
|
+
this.options.circuitBreakerManager.recordSuccess(breakerKey);
|
|
1513
1533
|
this.options.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
|
|
1514
1534
|
} catch (error) {
|
|
1515
|
-
this.options.recordCircuitFailure(key,
|
|
1535
|
+
this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error);
|
|
1516
1536
|
throw error;
|
|
1517
1537
|
}
|
|
1518
|
-
if (fetched ===
|
|
1538
|
+
if (fetched === void 0 || fetched === null && !this.shouldCacheNullValues(options)) {
|
|
1519
1539
|
if (!this.shouldNegativeCache(options)) {
|
|
1520
1540
|
return null;
|
|
1521
1541
|
}
|
|
@@ -1557,6 +1577,18 @@ var CacheStackReader = class {
|
|
|
1557
1577
|
await this.options.storeEntry(key, "value", fetched, options);
|
|
1558
1578
|
return fetched;
|
|
1559
1579
|
}
|
|
1580
|
+
resolveCircuitBreakerKey(key, options) {
|
|
1581
|
+
if (!options) {
|
|
1582
|
+
return `key:${key}`;
|
|
1583
|
+
}
|
|
1584
|
+
if (options.breakerKey) {
|
|
1585
|
+
return `custom:${options.breakerKey}`;
|
|
1586
|
+
}
|
|
1587
|
+
if (options.scope === "shared") {
|
|
1588
|
+
return "scope:shared";
|
|
1589
|
+
}
|
|
1590
|
+
return `key:${key}`;
|
|
1591
|
+
}
|
|
1560
1592
|
runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext) {
|
|
1561
1593
|
this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext);
|
|
1562
1594
|
}
|
|
@@ -1657,6 +1689,9 @@ var CacheStackReader = class {
|
|
|
1657
1689
|
shouldNegativeCache(options) {
|
|
1658
1690
|
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
1659
1691
|
}
|
|
1692
|
+
shouldCacheNullValues(options) {
|
|
1693
|
+
return options?.cacheNullValues ?? this.options.cacheNullValues ?? false;
|
|
1694
|
+
}
|
|
1660
1695
|
isNegativeStoredValue(stored) {
|
|
1661
1696
|
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|
|
1662
1697
|
}
|
|
@@ -1999,9 +2034,12 @@ function validateRateLimitOptions(name, options) {
|
|
|
1999
2034
|
validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
2000
2035
|
validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
2001
2036
|
validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
2002
|
-
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
2037
|
+
if (options.scope !== void 0 && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
2003
2038
|
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
2004
2039
|
}
|
|
2040
|
+
if (options.queueOverflow !== void 0 && !["reject", "bypass"].includes(options.queueOverflow)) {
|
|
2041
|
+
throw new Error(`${name}.queueOverflow must be one of "reject" or "bypass".`);
|
|
2042
|
+
}
|
|
2005
2043
|
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
2006
2044
|
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
2007
2045
|
}
|
|
@@ -2082,6 +2120,12 @@ function validateCircuitBreakerOptions(options) {
|
|
|
2082
2120
|
}
|
|
2083
2121
|
validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
|
|
2084
2122
|
validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
|
|
2123
|
+
if (options.scope !== void 0 && !["key", "shared"].includes(options.scope)) {
|
|
2124
|
+
throw new Error('circuitBreaker.scope must be one of "key" or "shared".');
|
|
2125
|
+
}
|
|
2126
|
+
if (options.breakerKey !== void 0 && options.breakerKey.length === 0) {
|
|
2127
|
+
throw new Error("circuitBreaker.breakerKey must not be empty.");
|
|
2128
|
+
}
|
|
2085
2129
|
}
|
|
2086
2130
|
function validateContextEntryOptions(name, options) {
|
|
2087
2131
|
if (!options) {
|
|
@@ -2195,6 +2239,7 @@ var CircuitBreakerManager = class {
|
|
|
2195
2239
|
// src/internal/FetchRateLimiter.ts
|
|
2196
2240
|
var MAX_BUCKETS = 1e4;
|
|
2197
2241
|
var MAX_QUEUE_PER_BUCKET = 1e4;
|
|
2242
|
+
var DEFAULT_QUEUE_OVERFLOW_POLICY = "reject";
|
|
2198
2243
|
var FetchRateLimiter = class {
|
|
2199
2244
|
buckets = /* @__PURE__ */ new Map();
|
|
2200
2245
|
queuesByBucket = /* @__PURE__ */ new Map();
|
|
@@ -2219,8 +2264,12 @@ var FetchRateLimiter = class {
|
|
|
2219
2264
|
const bucketKey = this.resolveBucketKey(normalized, context);
|
|
2220
2265
|
const queue = this.queuesByBucket.get(bucketKey) ?? [];
|
|
2221
2266
|
if (queue.length >= MAX_QUEUE_PER_BUCKET) {
|
|
2222
|
-
|
|
2223
|
-
|
|
2267
|
+
if ((normalized.queueOverflow ?? DEFAULT_QUEUE_OVERFLOW_POLICY) === "bypass") {
|
|
2268
|
+
this.rateLimitBypasses += 1;
|
|
2269
|
+
task().then(resolve2, reject);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
reject(new Error(`FetchRateLimiter queue overflow for bucket "${bucketKey}".`));
|
|
2224
2273
|
return;
|
|
2225
2274
|
}
|
|
2226
2275
|
queue.push({
|
|
@@ -2268,7 +2317,8 @@ var FetchRateLimiter = class {
|
|
|
2268
2317
|
intervalMs,
|
|
2269
2318
|
maxPerInterval,
|
|
2270
2319
|
scope: options.scope ?? "global",
|
|
2271
|
-
bucketKey: options.bucketKey
|
|
2320
|
+
bucketKey: options.bucketKey,
|
|
2321
|
+
queueOverflow: options.queueOverflow
|
|
2272
2322
|
};
|
|
2273
2323
|
}
|
|
2274
2324
|
resolveBucketKey(options, context) {
|
|
@@ -2453,7 +2503,9 @@ var FetchRateLimiter = class {
|
|
|
2453
2503
|
};
|
|
2454
2504
|
|
|
2455
2505
|
// src/internal/MetricsCollector.ts
|
|
2506
|
+
var import_node_async_hooks = require("async_hooks");
|
|
2456
2507
|
var MetricsCollector = class {
|
|
2508
|
+
captures = new import_node_async_hooks.AsyncLocalStorage();
|
|
2457
2509
|
data = this.empty();
|
|
2458
2510
|
get snapshot() {
|
|
2459
2511
|
return {
|
|
@@ -2466,18 +2518,46 @@ var MetricsCollector = class {
|
|
|
2466
2518
|
increment(field, amount = 1) {
|
|
2467
2519
|
;
|
|
2468
2520
|
this.data[field] += amount;
|
|
2521
|
+
for (const capture of this.captures.getStore() ?? []) {
|
|
2522
|
+
;
|
|
2523
|
+
capture[field] += amount;
|
|
2524
|
+
}
|
|
2469
2525
|
}
|
|
2470
2526
|
incrementLayer(map, layerName) {
|
|
2471
2527
|
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
2528
|
+
for (const capture of this.captures.getStore() ?? []) {
|
|
2529
|
+
capture[map][layerName] = (capture[map][layerName] ?? 0) + 1;
|
|
2530
|
+
}
|
|
2472
2531
|
}
|
|
2473
2532
|
/**
|
|
2474
2533
|
* Records a read latency sample for the given layer.
|
|
2475
2534
|
* Maintains a rolling average and max using Welford's online algorithm.
|
|
2476
2535
|
*/
|
|
2477
2536
|
recordLatency(layerName, durationMs) {
|
|
2478
|
-
|
|
2537
|
+
this.recordLatencySample(this.data, layerName, durationMs);
|
|
2538
|
+
for (const capture of this.captures.getStore() ?? []) {
|
|
2539
|
+
this.recordLatencySample(capture, layerName, durationMs);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
async capture(operation) {
|
|
2543
|
+
const metrics = this.empty();
|
|
2544
|
+
const activeCaptures = this.captures.getStore();
|
|
2545
|
+
const captures = activeCaptures ? [...activeCaptures, metrics] : [metrics];
|
|
2546
|
+
try {
|
|
2547
|
+
const result = await this.captures.run(captures, operation);
|
|
2548
|
+
return { result, metrics };
|
|
2549
|
+
} catch (error) {
|
|
2550
|
+
if ((typeof error === "object" || typeof error === "function") && error !== null) {
|
|
2551
|
+
;
|
|
2552
|
+
error.metrics = metrics;
|
|
2553
|
+
}
|
|
2554
|
+
throw error;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
recordLatencySample(metrics, layerName, durationMs) {
|
|
2558
|
+
const existing = metrics.latencyByLayer[layerName];
|
|
2479
2559
|
if (!existing) {
|
|
2480
|
-
|
|
2560
|
+
metrics.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
|
|
2481
2561
|
return;
|
|
2482
2562
|
}
|
|
2483
2563
|
existing.count += 1;
|
|
@@ -2544,6 +2624,7 @@ var TtlResolver = class {
|
|
|
2544
2624
|
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
2545
2625
|
profile.hits += 1;
|
|
2546
2626
|
profile.lastAccessAt = Date.now();
|
|
2627
|
+
this.accessProfiles.delete(key);
|
|
2547
2628
|
this.accessProfiles.set(key, profile);
|
|
2548
2629
|
this.pruneIfNeeded();
|
|
2549
2630
|
}
|
|
@@ -2633,41 +2714,45 @@ var TtlResolver = class {
|
|
|
2633
2714
|
return;
|
|
2634
2715
|
}
|
|
2635
2716
|
const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
this.accessProfiles.delete(entry[0]);
|
|
2717
|
+
for (let i = 0; i < toRemove; i++) {
|
|
2718
|
+
const oldestKey = this.accessProfiles.keys().next().value;
|
|
2719
|
+
if (oldestKey === void 0) {
|
|
2720
|
+
break;
|
|
2641
2721
|
}
|
|
2722
|
+
this.accessProfiles.delete(oldestKey);
|
|
2642
2723
|
}
|
|
2643
2724
|
}
|
|
2644
2725
|
};
|
|
2645
2726
|
|
|
2646
2727
|
// src/invalidation/TagIndex.ts
|
|
2647
|
-
var
|
|
2728
|
+
var DEFAULT_TOUCH_REFRESH_INTERVAL_MS = 1e3;
|
|
2648
2729
|
var TagIndex = class {
|
|
2649
2730
|
tagToKeys = /* @__PURE__ */ new Map();
|
|
2650
2731
|
keyToTags = /* @__PURE__ */ new Map();
|
|
2651
2732
|
knownKeys = /* @__PURE__ */ new Map();
|
|
2652
2733
|
maxKnownKeys;
|
|
2734
|
+
touchRefreshIntervalMs;
|
|
2653
2735
|
nextNodeId = 1;
|
|
2654
2736
|
root = this.createTrieNode();
|
|
2655
2737
|
constructor(options = {}) {
|
|
2656
2738
|
this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
|
|
2739
|
+
this.touchRefreshIntervalMs = options.touchRefreshIntervalMs ?? DEFAULT_TOUCH_REFRESH_INTERVAL_MS;
|
|
2657
2740
|
}
|
|
2658
2741
|
/**
|
|
2659
2742
|
* Records a key as known without changing tag assignments.
|
|
2660
2743
|
*/
|
|
2661
2744
|
async touch(key) {
|
|
2662
|
-
this.insertKnownKey(key)
|
|
2663
|
-
|
|
2745
|
+
if (this.insertKnownKey(key)) {
|
|
2746
|
+
this.pruneKnownKeysIfNeeded();
|
|
2747
|
+
}
|
|
2664
2748
|
}
|
|
2665
2749
|
/**
|
|
2666
2750
|
* Replaces the tags associated with a key and records the key as known.
|
|
2667
2751
|
*/
|
|
2668
2752
|
async track(key, tags) {
|
|
2669
|
-
this.insertKnownKey(key)
|
|
2670
|
-
|
|
2753
|
+
if (this.insertKnownKey(key)) {
|
|
2754
|
+
this.pruneKnownKeysIfNeeded();
|
|
2755
|
+
}
|
|
2671
2756
|
if (tags.length === 0) {
|
|
2672
2757
|
return;
|
|
2673
2758
|
}
|
|
@@ -2737,9 +2822,14 @@ var TagIndex = class {
|
|
|
2737
2822
|
* Returns known keys matching a wildcard pattern.
|
|
2738
2823
|
*/
|
|
2739
2824
|
async matchPattern(pattern) {
|
|
2740
|
-
const
|
|
2741
|
-
|
|
2742
|
-
|
|
2825
|
+
const literalPrefix = this.literalPrefix(pattern);
|
|
2826
|
+
const node = this.findNode(literalPrefix);
|
|
2827
|
+
if (!node) {
|
|
2828
|
+
return [];
|
|
2829
|
+
}
|
|
2830
|
+
const candidates = [];
|
|
2831
|
+
this.collectFromNode(node, literalPrefix, candidates);
|
|
2832
|
+
return candidates.filter((key) => PatternMatcher.matches(pattern, key));
|
|
2743
2833
|
}
|
|
2744
2834
|
/**
|
|
2745
2835
|
* Visits known keys matching a wildcard pattern.
|
|
@@ -2769,10 +2859,18 @@ var TagIndex = class {
|
|
|
2769
2859
|
};
|
|
2770
2860
|
}
|
|
2771
2861
|
insertKnownKey(key) {
|
|
2772
|
-
const
|
|
2773
|
-
|
|
2862
|
+
const previousTouch = this.knownKeys.get(key);
|
|
2863
|
+
const isNew = previousTouch === void 0;
|
|
2864
|
+
const now = Date.now();
|
|
2865
|
+
if (!isNew && now - previousTouch < this.touchRefreshIntervalMs) {
|
|
2866
|
+
return false;
|
|
2867
|
+
}
|
|
2774
2868
|
if (!isNew) {
|
|
2775
|
-
|
|
2869
|
+
this.knownKeys.delete(key);
|
|
2870
|
+
}
|
|
2871
|
+
this.knownKeys.set(key, now);
|
|
2872
|
+
if (!isNew) {
|
|
2873
|
+
return true;
|
|
2776
2874
|
}
|
|
2777
2875
|
let node = this.root;
|
|
2778
2876
|
for (const character of key) {
|
|
@@ -2784,6 +2882,7 @@ var TagIndex = class {
|
|
|
2784
2882
|
node = child;
|
|
2785
2883
|
}
|
|
2786
2884
|
node.terminal = true;
|
|
2885
|
+
return true;
|
|
2787
2886
|
}
|
|
2788
2887
|
findNode(prefix) {
|
|
2789
2888
|
let node = this.root;
|
|
@@ -2796,85 +2895,52 @@ var TagIndex = class {
|
|
|
2796
2895
|
return node;
|
|
2797
2896
|
}
|
|
2798
2897
|
collectFromNode(node, prefix, matches) {
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2898
|
+
const stack = [{ node, prefix }];
|
|
2899
|
+
while (stack.length > 0) {
|
|
2900
|
+
const current = stack.pop();
|
|
2901
|
+
if (!current) {
|
|
2902
|
+
continue;
|
|
2903
|
+
}
|
|
2904
|
+
if (current.node.terminal) {
|
|
2905
|
+
matches.push(current.prefix);
|
|
2906
|
+
}
|
|
2907
|
+
const children = [...current.node.children].reverse();
|
|
2908
|
+
for (const [character, child] of children) {
|
|
2909
|
+
stack.push({ node: child, prefix: `${current.prefix}${character}` });
|
|
2910
|
+
}
|
|
2804
2911
|
}
|
|
2805
2912
|
}
|
|
2806
2913
|
async visitFromNode(node, prefix, visitor) {
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
}
|
|
2813
|
-
}
|
|
2814
|
-
collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited, depth) {
|
|
2815
|
-
if (depth > MAX_PATTERN_RECURSION_DEPTH) {
|
|
2816
|
-
return;
|
|
2817
|
-
}
|
|
2818
|
-
const stateKey = `${node.id}:${patternIndex}`;
|
|
2819
|
-
if (visited.has(stateKey)) {
|
|
2820
|
-
return;
|
|
2821
|
-
}
|
|
2822
|
-
visited.add(stateKey);
|
|
2823
|
-
if (patternIndex === pattern.length) {
|
|
2824
|
-
if (node.terminal) {
|
|
2825
|
-
matches.add(prefix);
|
|
2914
|
+
const stack = [{ node, prefix }];
|
|
2915
|
+
while (stack.length > 0) {
|
|
2916
|
+
const current = stack.pop();
|
|
2917
|
+
if (!current) {
|
|
2918
|
+
continue;
|
|
2826
2919
|
}
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
const patternChar = pattern[patternIndex];
|
|
2830
|
-
if (patternChar === void 0) {
|
|
2831
|
-
return;
|
|
2832
|
-
}
|
|
2833
|
-
if (patternChar === "*") {
|
|
2834
|
-
this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited, depth + 1);
|
|
2835
|
-
for (const [character, child2] of node.children) {
|
|
2836
|
-
this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited, depth + 1);
|
|
2920
|
+
if (current.node.terminal) {
|
|
2921
|
+
await visitor(current.prefix);
|
|
2837
2922
|
}
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
for (const [character, child2] of node.children) {
|
|
2842
|
-
this.collectPatternMatches(
|
|
2843
|
-
child2,
|
|
2844
|
-
`${prefix}${character}`,
|
|
2845
|
-
pattern,
|
|
2846
|
-
patternIndex + 1,
|
|
2847
|
-
matches,
|
|
2848
|
-
visited,
|
|
2849
|
-
depth + 1
|
|
2850
|
-
);
|
|
2923
|
+
const children = [...current.node.children].reverse();
|
|
2924
|
+
for (const [character, child] of children) {
|
|
2925
|
+
stack.push({ node: child, prefix: `${current.prefix}${character}` });
|
|
2851
2926
|
}
|
|
2852
|
-
return;
|
|
2853
|
-
}
|
|
2854
|
-
const child = node.children.get(patternChar);
|
|
2855
|
-
if (child) {
|
|
2856
|
-
this.collectPatternMatches(
|
|
2857
|
-
child,
|
|
2858
|
-
`${prefix}${patternChar}`,
|
|
2859
|
-
pattern,
|
|
2860
|
-
patternIndex + 1,
|
|
2861
|
-
matches,
|
|
2862
|
-
visited,
|
|
2863
|
-
depth + 1
|
|
2864
|
-
);
|
|
2865
2927
|
}
|
|
2866
2928
|
}
|
|
2929
|
+
literalPrefix(pattern) {
|
|
2930
|
+
const wildcardIndex = pattern.search(/[*?]/);
|
|
2931
|
+
return wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex);
|
|
2932
|
+
}
|
|
2867
2933
|
pruneKnownKeysIfNeeded() {
|
|
2868
2934
|
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
2869
2935
|
return;
|
|
2870
2936
|
}
|
|
2871
|
-
const sorted = [...this.knownKeys.entries()].sort((a, b) => a[1] - b[1]);
|
|
2872
2937
|
const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
|
|
2873
|
-
for (let i = 0; i < toRemove
|
|
2874
|
-
const
|
|
2875
|
-
if (
|
|
2876
|
-
|
|
2938
|
+
for (let i = 0; i < toRemove; i += 1) {
|
|
2939
|
+
const oldestKey = this.knownKeys.keys().next().value;
|
|
2940
|
+
if (oldestKey === void 0) {
|
|
2941
|
+
break;
|
|
2877
2942
|
}
|
|
2943
|
+
this.removeKnownKey(oldestKey);
|
|
2878
2944
|
}
|
|
2879
2945
|
}
|
|
2880
2946
|
removeKey(key) {
|
|
@@ -3171,7 +3237,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3171
3237
|
emitError: (operation, context) => this.emitError(operation, context),
|
|
3172
3238
|
formatError: (error) => this.formatError(error),
|
|
3173
3239
|
storeEntry: (key, kind, value, options2) => this.storeEntry(key, kind, value, options2),
|
|
3174
|
-
recordCircuitFailure: (key, options2, error) => this.recordCircuitFailure(key, options2, error),
|
|
3240
|
+
recordCircuitFailure: (key, breakerKey, options2, error) => this.recordCircuitFailure(key, breakerKey, options2, error),
|
|
3175
3241
|
resolveLayerMs: (layerName, override, globalDefault, fallback) => this.resolveLayerMs(layerName, override, globalDefault, fallback),
|
|
3176
3242
|
sleep: (ms) => this.sleep(ms),
|
|
3177
3243
|
withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
|
|
@@ -3186,6 +3252,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3186
3252
|
singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
|
|
3187
3253
|
backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
|
|
3188
3254
|
negativeCaching: options.negativeCaching,
|
|
3255
|
+
cacheNullValues: options.cacheNullValues,
|
|
3189
3256
|
refreshAhead: options.refreshAhead,
|
|
3190
3257
|
circuitBreaker: options.circuitBreaker,
|
|
3191
3258
|
fetcherRateLimit: options.fetcherRateLimit
|
|
@@ -3238,6 +3305,64 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3238
3305
|
async getOrSet(key, fetcher, options) {
|
|
3239
3306
|
return this.get(key, fetcher, options);
|
|
3240
3307
|
}
|
|
3308
|
+
/**
|
|
3309
|
+
* Returns a discriminated cache entry, or `null` on miss.
|
|
3310
|
+
* Unlike `get()`, this distinguishes a stored `null` value from an absent key.
|
|
3311
|
+
*/
|
|
3312
|
+
async getEntry(key) {
|
|
3313
|
+
return this.observeOperation("layercache.get_entry", { "layercache.key": String(key ?? "") }, async () => {
|
|
3314
|
+
const userKey = validateCacheKey(key);
|
|
3315
|
+
const normalizedKey = this.qualifyKey(userKey);
|
|
3316
|
+
await this.awaitStartup("getEntry");
|
|
3317
|
+
let sawRetainableValue = false;
|
|
3318
|
+
for (let index = 0; index < this.layers.length; index += 1) {
|
|
3319
|
+
const layer = this.layers[index];
|
|
3320
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
3321
|
+
continue;
|
|
3322
|
+
}
|
|
3323
|
+
const readStart = performance.now();
|
|
3324
|
+
const stored = await this.readLayerEntry(layer, normalizedKey);
|
|
3325
|
+
this.metricsCollector.recordLatency(layer.name, performance.now() - readStart);
|
|
3326
|
+
if (stored === null) {
|
|
3327
|
+
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
const resolved = resolveStoredValue(stored);
|
|
3331
|
+
if (resolved.state === "expired") {
|
|
3332
|
+
await layer.delete(normalizedKey);
|
|
3333
|
+
continue;
|
|
3334
|
+
}
|
|
3335
|
+
sawRetainableValue = true;
|
|
3336
|
+
await this.tagIndex.touch(normalizedKey);
|
|
3337
|
+
await this.reader.backfill(normalizedKey, stored, index - 1);
|
|
3338
|
+
this.metricsCollector.increment("hits");
|
|
3339
|
+
if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
|
|
3340
|
+
this.metricsCollector.increment("staleHits");
|
|
3341
|
+
}
|
|
3342
|
+
this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
|
|
3343
|
+
this.logger.debug?.("hit", { key: normalizedKey, layer: layer.name, state: resolved.state });
|
|
3344
|
+
this.emit("hit", {
|
|
3345
|
+
key: normalizedKey,
|
|
3346
|
+
layer: layer.name,
|
|
3347
|
+
state: resolved.state
|
|
3348
|
+
});
|
|
3349
|
+
return {
|
|
3350
|
+
key: userKey,
|
|
3351
|
+
value: resolved.value,
|
|
3352
|
+
kind: resolved.envelope?.kind ?? "value",
|
|
3353
|
+
state: resolved.state,
|
|
3354
|
+
layer: layer.name
|
|
3355
|
+
};
|
|
3356
|
+
}
|
|
3357
|
+
if (!sawRetainableValue) {
|
|
3358
|
+
await this.tagIndex.remove(normalizedKey);
|
|
3359
|
+
}
|
|
3360
|
+
this.metricsCollector.increment("misses");
|
|
3361
|
+
this.logger.debug?.("miss", { key: normalizedKey, mode: "getEntry" });
|
|
3362
|
+
this.emit("miss", { key: normalizedKey, mode: "getEntry" });
|
|
3363
|
+
return null;
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3241
3366
|
/**
|
|
3242
3367
|
* Like `get()`, but throws `CacheMissError` instead of returning `null`.
|
|
3243
3368
|
* Useful when the value is expected to exist or the fetcher is expected to
|
|
@@ -3702,6 +3827,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3702
3827
|
getMetrics() {
|
|
3703
3828
|
return this.metricsCollector.snapshot;
|
|
3704
3829
|
}
|
|
3830
|
+
/**
|
|
3831
|
+
* Runs an operation while collecting only the metrics emitted by its async context.
|
|
3832
|
+
* Used by namespaces so metrics tracking does not serialize the operation itself.
|
|
3833
|
+
*/
|
|
3834
|
+
async captureMetrics(operation) {
|
|
3835
|
+
return this.metricsCollector.capture(operation);
|
|
3836
|
+
}
|
|
3705
3837
|
/**
|
|
3706
3838
|
* Returns metrics plus layer degradation state and active background refresh count.
|
|
3707
3839
|
*/
|
|
@@ -3775,6 +3907,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3775
3907
|
}
|
|
3776
3908
|
return this.currentGeneration;
|
|
3777
3909
|
}
|
|
3910
|
+
/**
|
|
3911
|
+
* Returns the active generation prefix number used for future cache keys.
|
|
3912
|
+
*/
|
|
3913
|
+
getGeneration() {
|
|
3914
|
+
return this.currentGeneration;
|
|
3915
|
+
}
|
|
3778
3916
|
/**
|
|
3779
3917
|
* Returns detailed metadata about a single cache key: which layers contain it,
|
|
3780
3918
|
* remaining fresh/stale/error TTLs, and associated tags.
|
|
@@ -3999,7 +4137,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3999
4137
|
for (const key of keys) {
|
|
4000
4138
|
await this.tagIndex.remove(key);
|
|
4001
4139
|
this.ttlResolver.deleteProfile(key);
|
|
4002
|
-
this.circuitBreakerManager.delete(key);
|
|
4140
|
+
this.circuitBreakerManager.delete(`key:${key}`);
|
|
4003
4141
|
}
|
|
4004
4142
|
this.metricsCollector.increment("deletes", keys.length);
|
|
4005
4143
|
this.metricsCollector.increment("invalidations");
|
|
@@ -4018,7 +4156,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
4018
4156
|
}
|
|
4019
4157
|
await this.tagIndex.remove(key);
|
|
4020
4158
|
this.ttlResolver.deleteProfile(key);
|
|
4021
|
-
this.circuitBreakerManager.delete(key);
|
|
4159
|
+
this.circuitBreakerManager.delete(`key:${key}`);
|
|
4022
4160
|
}
|
|
4023
4161
|
this.metricsCollector.increment("invalidations");
|
|
4024
4162
|
this.logger.debug?.("expire", { keys });
|
|
@@ -4060,7 +4198,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
4060
4198
|
for (const key of keys) {
|
|
4061
4199
|
await this.tagIndex.remove(key);
|
|
4062
4200
|
this.ttlResolver.deleteProfile(key);
|
|
4063
|
-
this.circuitBreakerManager.delete(key);
|
|
4201
|
+
this.circuitBreakerManager.delete(`key:${key}`);
|
|
4064
4202
|
}
|
|
4065
4203
|
}
|
|
4066
4204
|
}
|
|
@@ -4305,15 +4443,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
4305
4443
|
isGracefulDegradationEnabled() {
|
|
4306
4444
|
return Boolean(this.options.gracefulDegradation);
|
|
4307
4445
|
}
|
|
4308
|
-
recordCircuitFailure(key, options, error) {
|
|
4446
|
+
recordCircuitFailure(key, breakerKey, options, error) {
|
|
4309
4447
|
if (!options) {
|
|
4310
4448
|
return;
|
|
4311
4449
|
}
|
|
4312
|
-
this.circuitBreakerManager.recordFailure(
|
|
4313
|
-
if (this.circuitBreakerManager.isOpen(
|
|
4450
|
+
this.circuitBreakerManager.recordFailure(breakerKey, options);
|
|
4451
|
+
if (this.circuitBreakerManager.isOpen(breakerKey)) {
|
|
4314
4452
|
this.metricsCollector.increment("circuitBreakerTrips");
|
|
4315
4453
|
}
|
|
4316
|
-
this.emitError("fetch", { key, error: this.formatError(error) });
|
|
4454
|
+
this.emitError("fetch", { key, breakerKey, error: this.formatError(error) });
|
|
4317
4455
|
}
|
|
4318
4456
|
emitError(operation, context) {
|
|
4319
4457
|
this.logger.error?.(operation, context);
|
|
@@ -4332,12 +4470,65 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
4332
4470
|
}
|
|
4333
4471
|
};
|
|
4334
4472
|
|
|
4473
|
+
// src/generation/RedisGenerationStore.ts
|
|
4474
|
+
var DEFAULT_GENERATION_KEY = "layercache:generation";
|
|
4475
|
+
var RedisGenerationStore = class {
|
|
4476
|
+
client;
|
|
4477
|
+
key;
|
|
4478
|
+
constructor(options) {
|
|
4479
|
+
this.client = options.client;
|
|
4480
|
+
this.key = options.key ?? DEFAULT_GENERATION_KEY;
|
|
4481
|
+
}
|
|
4482
|
+
async get() {
|
|
4483
|
+
const stored = await this.client.get(this.key);
|
|
4484
|
+
if (stored === null) {
|
|
4485
|
+
return void 0;
|
|
4486
|
+
}
|
|
4487
|
+
return this.parseGeneration(stored);
|
|
4488
|
+
}
|
|
4489
|
+
async getOrInitialize(initialGeneration = 0) {
|
|
4490
|
+
this.assertGeneration(initialGeneration);
|
|
4491
|
+
await this.client.set(this.key, String(initialGeneration), "NX");
|
|
4492
|
+
const generation = await this.get();
|
|
4493
|
+
if (generation === void 0) {
|
|
4494
|
+
throw new Error(`RedisGenerationStore failed to initialize generation key "${this.key}".`);
|
|
4495
|
+
}
|
|
4496
|
+
return generation;
|
|
4497
|
+
}
|
|
4498
|
+
async set(generation) {
|
|
4499
|
+
this.assertGeneration(generation);
|
|
4500
|
+
await this.client.set(this.key, String(generation));
|
|
4501
|
+
}
|
|
4502
|
+
async bump() {
|
|
4503
|
+
const generation = await this.client.incr(this.key);
|
|
4504
|
+
this.assertGeneration(generation);
|
|
4505
|
+
return generation;
|
|
4506
|
+
}
|
|
4507
|
+
parseGeneration(value) {
|
|
4508
|
+
const generation = Number.parseInt(value, 10);
|
|
4509
|
+
if (String(generation) !== value || !this.isGeneration(generation)) {
|
|
4510
|
+
throw new Error(`RedisGenerationStore found invalid persisted generation value for key "${this.key}".`);
|
|
4511
|
+
}
|
|
4512
|
+
return generation;
|
|
4513
|
+
}
|
|
4514
|
+
assertGeneration(value) {
|
|
4515
|
+
if (!this.isGeneration(value)) {
|
|
4516
|
+
throw new Error("RedisGenerationStore generation must be a non-negative safe integer.");
|
|
4517
|
+
}
|
|
4518
|
+
}
|
|
4519
|
+
isGeneration(value) {
|
|
4520
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
4521
|
+
}
|
|
4522
|
+
};
|
|
4523
|
+
|
|
4335
4524
|
// src/invalidation/RedisInvalidationBus.ts
|
|
4525
|
+
var import_node_crypto3 = require("crypto");
|
|
4336
4526
|
var RedisInvalidationBus = class {
|
|
4337
4527
|
channel;
|
|
4338
4528
|
publisher;
|
|
4339
4529
|
subscriber;
|
|
4340
4530
|
logger;
|
|
4531
|
+
signingKey;
|
|
4341
4532
|
handlers = /* @__PURE__ */ new Set();
|
|
4342
4533
|
sharedListener;
|
|
4343
4534
|
subscribePromise;
|
|
@@ -4346,6 +4537,7 @@ var RedisInvalidationBus = class {
|
|
|
4346
4537
|
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
4347
4538
|
this.channel = options.channel ?? "layercache:invalidation";
|
|
4348
4539
|
this.logger = options.logger;
|
|
4540
|
+
this.signingKey = options.signingSecret ? normalizeSigningSecret(options.signingSecret) : void 0;
|
|
4349
4541
|
}
|
|
4350
4542
|
/**
|
|
4351
4543
|
* Subscribes to invalidation messages and returns an unsubscribe function.
|
|
@@ -4385,7 +4577,7 @@ var RedisInvalidationBus = class {
|
|
|
4385
4577
|
* Publishes an invalidation message to other subscribers.
|
|
4386
4578
|
*/
|
|
4387
4579
|
async publish(message) {
|
|
4388
|
-
await this.publisher.publish(this.channel, JSON.stringify(message));
|
|
4580
|
+
await this.publisher.publish(this.channel, JSON.stringify(this.signingKey ? this.signMessage(message) : message));
|
|
4389
4581
|
}
|
|
4390
4582
|
async dispatchToHandlers(payload) {
|
|
4391
4583
|
let message;
|
|
@@ -4396,10 +4588,11 @@ var RedisInvalidationBus = class {
|
|
|
4396
4588
|
maxNodes: 1e4,
|
|
4397
4589
|
createObject: () => /* @__PURE__ */ Object.create(null)
|
|
4398
4590
|
});
|
|
4399
|
-
|
|
4591
|
+
const candidate = this.signingKey ? this.verifySignedEnvelope(parsed) : parsed;
|
|
4592
|
+
if (!this.isInvalidationMessage(candidate)) {
|
|
4400
4593
|
throw new Error("Invalid invalidation payload shape.");
|
|
4401
4594
|
}
|
|
4402
|
-
message =
|
|
4595
|
+
message = candidate;
|
|
4403
4596
|
} catch (error) {
|
|
4404
4597
|
this.reportError("invalid invalidation payload", error);
|
|
4405
4598
|
return;
|
|
@@ -4424,6 +4617,34 @@ var RedisInvalidationBus = class {
|
|
|
4424
4617
|
const validKeys = candidate.keys === void 0 || Array.isArray(candidate.keys) && candidate.keys.every((key) => typeof key === "string");
|
|
4425
4618
|
return validScope && typeof candidate.sourceId === "string" && candidate.sourceId.length > 0 && validOperation && validKeys;
|
|
4426
4619
|
}
|
|
4620
|
+
signMessage(message) {
|
|
4621
|
+
const payload = JSON.stringify(message);
|
|
4622
|
+
return {
|
|
4623
|
+
payload: message,
|
|
4624
|
+
signature: this.createSignature(payload)
|
|
4625
|
+
};
|
|
4626
|
+
}
|
|
4627
|
+
verifySignedEnvelope(value) {
|
|
4628
|
+
if (!value || typeof value !== "object") {
|
|
4629
|
+
throw new Error("Signed invalidation envelope must be an object.");
|
|
4630
|
+
}
|
|
4631
|
+
const envelope = value;
|
|
4632
|
+
if (!envelope.payload || typeof envelope.payload !== "object" || typeof envelope.signature !== "string") {
|
|
4633
|
+
throw new Error("Signed invalidation envelope is missing payload or signature.");
|
|
4634
|
+
}
|
|
4635
|
+
const payload = JSON.stringify(envelope.payload);
|
|
4636
|
+
const expected = this.createSignature(payload);
|
|
4637
|
+
if (!isEqualSignature(envelope.signature, expected)) {
|
|
4638
|
+
throw new Error("Invalid invalidation message signature.");
|
|
4639
|
+
}
|
|
4640
|
+
return envelope.payload;
|
|
4641
|
+
}
|
|
4642
|
+
createSignature(payload) {
|
|
4643
|
+
if (!this.signingKey) {
|
|
4644
|
+
throw new Error("RedisInvalidationBus signing key is not configured.");
|
|
4645
|
+
}
|
|
4646
|
+
return (0, import_node_crypto3.createHmac)("sha256", this.signingKey).update(payload).digest("hex");
|
|
4647
|
+
}
|
|
4427
4648
|
reportError(message, error) {
|
|
4428
4649
|
if (this.logger?.error) {
|
|
4429
4650
|
this.logger.error(message, { error });
|
|
@@ -4432,18 +4653,31 @@ var RedisInvalidationBus = class {
|
|
|
4432
4653
|
console.error(`[layercache] ${message}`, error);
|
|
4433
4654
|
}
|
|
4434
4655
|
};
|
|
4656
|
+
function normalizeSigningSecret(secret) {
|
|
4657
|
+
const raw = Buffer.isBuffer(secret) ? secret : Buffer.from(secret, "utf8");
|
|
4658
|
+
return (0, import_node_crypto3.createHash)("sha256").update(raw).digest();
|
|
4659
|
+
}
|
|
4660
|
+
function isEqualSignature(actual, expected) {
|
|
4661
|
+
const actualBuffer = Buffer.from(actual, "hex");
|
|
4662
|
+
const expectedBuffer = Buffer.from(expected, "hex");
|
|
4663
|
+
return actualBuffer.length === expectedBuffer.length && (0, import_node_crypto3.timingSafeEqual)(actualBuffer, expectedBuffer);
|
|
4664
|
+
}
|
|
4435
4665
|
|
|
4436
4666
|
// src/invalidation/RedisTagIndex.ts
|
|
4667
|
+
var DEFAULT_KNOWN_KEYS_SHARDS = 16;
|
|
4437
4668
|
var RedisTagIndex = class {
|
|
4438
4669
|
client;
|
|
4439
4670
|
prefix;
|
|
4440
4671
|
scanCount;
|
|
4441
4672
|
knownKeysShards;
|
|
4673
|
+
logger;
|
|
4674
|
+
warnedLegacyKnownKeys = false;
|
|
4442
4675
|
constructor(options) {
|
|
4443
4676
|
this.client = options.client;
|
|
4444
4677
|
this.prefix = options.prefix ?? "layercache:tag-index";
|
|
4445
4678
|
this.scanCount = options.scanCount ?? 100;
|
|
4446
4679
|
this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
|
|
4680
|
+
this.logger = options.logger;
|
|
4447
4681
|
}
|
|
4448
4682
|
/**
|
|
4449
4683
|
* Records a key as known without changing tag assignments.
|
|
@@ -4479,6 +4713,9 @@ var RedisTagIndex = class {
|
|
|
4479
4713
|
const existingTags = await this.client.smembers(keyTagsKey);
|
|
4480
4714
|
const pipeline = this.client.pipeline();
|
|
4481
4715
|
pipeline.srem(this.knownKeysKeyFor(key), key);
|
|
4716
|
+
if (this.knownKeysShards > 1) {
|
|
4717
|
+
pipeline.srem(this.legacyKnownKeysKey(), key);
|
|
4718
|
+
}
|
|
4482
4719
|
pipeline.del(keyTagsKey);
|
|
4483
4720
|
for (const tag of existingTags) {
|
|
4484
4721
|
pipeline.srem(this.tagKeysKey(tag), key);
|
|
@@ -4509,28 +4746,34 @@ var RedisTagIndex = class {
|
|
|
4509
4746
|
* Returns known keys that start with a prefix.
|
|
4510
4747
|
*/
|
|
4511
4748
|
async keysForPrefix(prefix) {
|
|
4512
|
-
const matches =
|
|
4513
|
-
for (const knownKeysKey of this.
|
|
4749
|
+
const matches = /* @__PURE__ */ new Set();
|
|
4750
|
+
for (const knownKeysKey of await this.knownKeysKeysForRead()) {
|
|
4514
4751
|
let cursor = "0";
|
|
4515
4752
|
do {
|
|
4516
4753
|
const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
|
|
4517
4754
|
cursor = nextCursor;
|
|
4518
|
-
|
|
4755
|
+
for (const key of keys) {
|
|
4756
|
+
if (key.startsWith(prefix)) {
|
|
4757
|
+
matches.add(key);
|
|
4758
|
+
}
|
|
4759
|
+
}
|
|
4519
4760
|
} while (cursor !== "0");
|
|
4520
4761
|
}
|
|
4521
|
-
return matches;
|
|
4762
|
+
return [...matches];
|
|
4522
4763
|
}
|
|
4523
4764
|
/**
|
|
4524
4765
|
* Visits known keys that start with a prefix.
|
|
4525
4766
|
*/
|
|
4526
4767
|
async forEachKeyForPrefix(prefix, visitor) {
|
|
4527
|
-
|
|
4768
|
+
const visited = /* @__PURE__ */ new Set();
|
|
4769
|
+
for (const knownKeysKey of await this.knownKeysKeysForRead()) {
|
|
4528
4770
|
let cursor = "0";
|
|
4529
4771
|
do {
|
|
4530
4772
|
const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
|
|
4531
4773
|
cursor = nextCursor;
|
|
4532
4774
|
for (const key of keys) {
|
|
4533
|
-
if (key.startsWith(prefix)) {
|
|
4775
|
+
if (key.startsWith(prefix) && !visited.has(key)) {
|
|
4776
|
+
visited.add(key);
|
|
4534
4777
|
await visitor(key);
|
|
4535
4778
|
}
|
|
4536
4779
|
}
|
|
@@ -4547,8 +4790,8 @@ var RedisTagIndex = class {
|
|
|
4547
4790
|
* Returns known keys matching a wildcard pattern.
|
|
4548
4791
|
*/
|
|
4549
4792
|
async matchPattern(pattern) {
|
|
4550
|
-
const matches =
|
|
4551
|
-
for (const knownKeysKey of this.
|
|
4793
|
+
const matches = /* @__PURE__ */ new Set();
|
|
4794
|
+
for (const knownKeysKey of await this.knownKeysKeysForRead()) {
|
|
4552
4795
|
let cursor = "0";
|
|
4553
4796
|
do {
|
|
4554
4797
|
const [nextCursor, keys] = await this.client.sscan(
|
|
@@ -4560,16 +4803,21 @@ var RedisTagIndex = class {
|
|
|
4560
4803
|
this.scanCount
|
|
4561
4804
|
);
|
|
4562
4805
|
cursor = nextCursor;
|
|
4563
|
-
|
|
4806
|
+
for (const key of keys) {
|
|
4807
|
+
if (PatternMatcher.matches(pattern, key)) {
|
|
4808
|
+
matches.add(key);
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4564
4811
|
} while (cursor !== "0");
|
|
4565
4812
|
}
|
|
4566
|
-
return matches;
|
|
4813
|
+
return [...matches];
|
|
4567
4814
|
}
|
|
4568
4815
|
/**
|
|
4569
4816
|
* Visits known keys matching a wildcard pattern.
|
|
4570
4817
|
*/
|
|
4571
4818
|
async forEachKeyMatchingPattern(pattern, visitor) {
|
|
4572
|
-
|
|
4819
|
+
const visited = /* @__PURE__ */ new Set();
|
|
4820
|
+
for (const knownKeysKey of await this.knownKeysKeysForRead()) {
|
|
4573
4821
|
let cursor = "0";
|
|
4574
4822
|
do {
|
|
4575
4823
|
const [nextCursor, keys] = await this.client.sscan(
|
|
@@ -4582,7 +4830,8 @@ var RedisTagIndex = class {
|
|
|
4582
4830
|
);
|
|
4583
4831
|
cursor = nextCursor;
|
|
4584
4832
|
for (const key of keys) {
|
|
4585
|
-
if (PatternMatcher.matches(pattern, key)) {
|
|
4833
|
+
if (PatternMatcher.matches(pattern, key) && !visited.has(key)) {
|
|
4834
|
+
visited.add(key);
|
|
4586
4835
|
await visitor(key);
|
|
4587
4836
|
}
|
|
4588
4837
|
}
|
|
@@ -4599,6 +4848,31 @@ var RedisTagIndex = class {
|
|
|
4599
4848
|
}
|
|
4600
4849
|
await this.client.del(...indexKeys);
|
|
4601
4850
|
}
|
|
4851
|
+
async migrateLegacyKnownKeys() {
|
|
4852
|
+
if (this.knownKeysShards === 1) {
|
|
4853
|
+
return { migratedKeys: 0 };
|
|
4854
|
+
}
|
|
4855
|
+
const legacyKey = this.legacyKnownKeysKey();
|
|
4856
|
+
let cursor = "0";
|
|
4857
|
+
let migratedKeys = 0;
|
|
4858
|
+
do {
|
|
4859
|
+
const [nextCursor, keys] = await this.client.sscan(legacyKey, cursor, "COUNT", this.scanCount);
|
|
4860
|
+
cursor = nextCursor;
|
|
4861
|
+
if (keys.length === 0) {
|
|
4862
|
+
continue;
|
|
4863
|
+
}
|
|
4864
|
+
const pipeline = this.client.pipeline();
|
|
4865
|
+
for (const key of keys) {
|
|
4866
|
+
pipeline.sadd(this.knownKeysKeyFor(key), key);
|
|
4867
|
+
}
|
|
4868
|
+
await pipeline.exec();
|
|
4869
|
+
migratedKeys += keys.length;
|
|
4870
|
+
} while (cursor !== "0");
|
|
4871
|
+
if (migratedKeys > 0) {
|
|
4872
|
+
await this.client.del(legacyKey);
|
|
4873
|
+
}
|
|
4874
|
+
return { migratedKeys };
|
|
4875
|
+
}
|
|
4602
4876
|
async scanIndexKeys() {
|
|
4603
4877
|
const matches = [];
|
|
4604
4878
|
let cursor = "0";
|
|
@@ -4616,12 +4890,40 @@ var RedisTagIndex = class {
|
|
|
4616
4890
|
}
|
|
4617
4891
|
return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
|
|
4618
4892
|
}
|
|
4893
|
+
async knownKeysKeysForRead() {
|
|
4894
|
+
if (this.knownKeysShards === 1) {
|
|
4895
|
+
return [this.legacyKnownKeysKey()];
|
|
4896
|
+
}
|
|
4897
|
+
const shardedKeys = this.knownKeysKeys();
|
|
4898
|
+
const legacyKey = this.legacyKnownKeysKey();
|
|
4899
|
+
const legacyExists = await this.client.exists(legacyKey) > 0;
|
|
4900
|
+
if (!legacyExists) {
|
|
4901
|
+
return shardedKeys;
|
|
4902
|
+
}
|
|
4903
|
+
this.warnLegacyKnownKeys(legacyKey);
|
|
4904
|
+
return [legacyKey, ...shardedKeys];
|
|
4905
|
+
}
|
|
4619
4906
|
knownKeysKeys() {
|
|
4620
4907
|
if (this.knownKeysShards === 1) {
|
|
4621
4908
|
return [`${this.prefix}:keys`];
|
|
4622
4909
|
}
|
|
4623
4910
|
return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
|
|
4624
4911
|
}
|
|
4912
|
+
legacyKnownKeysKey() {
|
|
4913
|
+
return `${this.prefix}:keys`;
|
|
4914
|
+
}
|
|
4915
|
+
warnLegacyKnownKeys(legacyKey) {
|
|
4916
|
+
if (this.warnedLegacyKnownKeys) {
|
|
4917
|
+
return;
|
|
4918
|
+
}
|
|
4919
|
+
this.warnedLegacyKnownKeys = true;
|
|
4920
|
+
const message = "RedisTagIndex detected a legacy RedisTagIndex known-key set. Run `layercache migrate-tag-index` to migrate keys into the sharded layout.";
|
|
4921
|
+
if (this.logger?.warn) {
|
|
4922
|
+
this.logger.warn(message, { legacyKey, knownKeysShards: this.knownKeysShards });
|
|
4923
|
+
return;
|
|
4924
|
+
}
|
|
4925
|
+
console.warn(`[layercache] ${message}`, { legacyKey, knownKeysShards: this.knownKeysShards });
|
|
4926
|
+
}
|
|
4625
4927
|
keyTagsKey(key) {
|
|
4626
4928
|
return `${this.prefix}:key:${encodeURIComponent(key)}`;
|
|
4627
4929
|
}
|
|
@@ -4631,7 +4933,7 @@ var RedisTagIndex = class {
|
|
|
4631
4933
|
};
|
|
4632
4934
|
function normalizeKnownKeysShards(value) {
|
|
4633
4935
|
if (value === void 0) {
|
|
4634
|
-
return
|
|
4936
|
+
return DEFAULT_KNOWN_KEYS_SHARDS;
|
|
4635
4937
|
}
|
|
4636
4938
|
if (!Number.isInteger(value) || value <= 0) {
|
|
4637
4939
|
throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
|
|
@@ -4727,6 +5029,41 @@ function createFastifyLayercachePlugin(cache, options = {}) {
|
|
|
4727
5029
|
};
|
|
4728
5030
|
}
|
|
4729
5031
|
|
|
5032
|
+
// src/integrations/httpCacheKeys.ts
|
|
5033
|
+
var SENSITIVE_QUERY_PARAMETERS = /* @__PURE__ */ new Set([
|
|
5034
|
+
"access_token",
|
|
5035
|
+
"api_key",
|
|
5036
|
+
"apikey",
|
|
5037
|
+
"auth",
|
|
5038
|
+
"authorization",
|
|
5039
|
+
"code",
|
|
5040
|
+
"credentials",
|
|
5041
|
+
"id_token",
|
|
5042
|
+
"jwt",
|
|
5043
|
+
"password",
|
|
5044
|
+
"private_key",
|
|
5045
|
+
"refresh_token",
|
|
5046
|
+
"secret",
|
|
5047
|
+
"session",
|
|
5048
|
+
"sessionid",
|
|
5049
|
+
"session_id",
|
|
5050
|
+
"token"
|
|
5051
|
+
]);
|
|
5052
|
+
function normalizeHttpCacheUrl(url) {
|
|
5053
|
+
try {
|
|
5054
|
+
const parsed = new URL(url, "http://localhost");
|
|
5055
|
+
for (const name of [...parsed.searchParams.keys()]) {
|
|
5056
|
+
if (SENSITIVE_QUERY_PARAMETERS.has(name.toLowerCase())) {
|
|
5057
|
+
parsed.searchParams.delete(name);
|
|
5058
|
+
}
|
|
5059
|
+
}
|
|
5060
|
+
parsed.searchParams.sort();
|
|
5061
|
+
return parsed.pathname + parsed.search;
|
|
5062
|
+
} catch {
|
|
5063
|
+
return url;
|
|
5064
|
+
}
|
|
5065
|
+
}
|
|
5066
|
+
|
|
4730
5067
|
// src/integrations/express.ts
|
|
4731
5068
|
function createExpressCacheMiddleware(cache, options = {}) {
|
|
4732
5069
|
const allowedMethods = new Set((options.methods ?? ["GET"]).map((m) => m.toUpperCase()));
|
|
@@ -4742,7 +5079,7 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
4742
5079
|
return;
|
|
4743
5080
|
}
|
|
4744
5081
|
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
4745
|
-
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${
|
|
5082
|
+
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeHttpCacheUrl(rawUrl)}`;
|
|
4746
5083
|
const cached = await cache.get(key, void 0, options);
|
|
4747
5084
|
if (cached !== null) {
|
|
4748
5085
|
res.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
@@ -4758,12 +5095,14 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
4758
5095
|
if (originalJson) {
|
|
4759
5096
|
res.json = (body) => {
|
|
4760
5097
|
res.setHeader?.("x-cache", "MISS");
|
|
4761
|
-
|
|
4762
|
-
cache.
|
|
4763
|
-
|
|
4764
|
-
|
|
5098
|
+
if (isSuccessfulStatus(res.statusCode)) {
|
|
5099
|
+
cache.set(key, body, options).catch((err) => {
|
|
5100
|
+
cache.emit("error", {
|
|
5101
|
+
operation: "set",
|
|
5102
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5103
|
+
});
|
|
4765
5104
|
});
|
|
4766
|
-
}
|
|
5105
|
+
}
|
|
4767
5106
|
return originalJson(body);
|
|
4768
5107
|
};
|
|
4769
5108
|
}
|
|
@@ -4773,14 +5112,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
4773
5112
|
}
|
|
4774
5113
|
};
|
|
4775
5114
|
}
|
|
4776
|
-
function
|
|
4777
|
-
|
|
4778
|
-
const parsed = new URL(url, "http://localhost");
|
|
4779
|
-
parsed.searchParams.sort();
|
|
4780
|
-
return parsed.pathname + parsed.search;
|
|
4781
|
-
} catch {
|
|
4782
|
-
return url;
|
|
4783
|
-
}
|
|
5115
|
+
function isSuccessfulStatus(statusCode) {
|
|
5116
|
+
return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
|
|
4784
5117
|
}
|
|
4785
5118
|
|
|
4786
5119
|
// src/integrations/graphql.ts
|
|
@@ -4811,35 +5144,39 @@ function createHonoCacheMiddleware(cache, options = {}) {
|
|
|
4811
5144
|
return;
|
|
4812
5145
|
}
|
|
4813
5146
|
const rawPath = context.req.path ?? context.req.url ?? "/";
|
|
4814
|
-
const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${
|
|
5147
|
+
const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeHttpCacheUrl(rawPath)}`;
|
|
4815
5148
|
const cached = await cache.get(key, void 0, options);
|
|
4816
5149
|
if (cached !== null) {
|
|
4817
5150
|
context.header?.("x-cache", "HIT");
|
|
4818
5151
|
context.header?.("content-type", "application/json; charset=utf-8");
|
|
4819
5152
|
return context.json(cached);
|
|
4820
5153
|
}
|
|
5154
|
+
let currentStatus;
|
|
5155
|
+
const originalStatus = context.status?.bind(context);
|
|
5156
|
+
if (originalStatus) {
|
|
5157
|
+
context.status = (status) => {
|
|
5158
|
+
currentStatus = status;
|
|
5159
|
+
return originalStatus(status);
|
|
5160
|
+
};
|
|
5161
|
+
}
|
|
4821
5162
|
const originalJson = context.json.bind(context);
|
|
4822
5163
|
context.json = (body, status) => {
|
|
4823
5164
|
context.header?.("x-cache", "MISS");
|
|
4824
|
-
|
|
4825
|
-
cache.
|
|
4826
|
-
|
|
4827
|
-
|
|
5165
|
+
if (isSuccessfulStatus2(status ?? currentStatus)) {
|
|
5166
|
+
cache.set(key, body, options).catch((err) => {
|
|
5167
|
+
cache.emit("error", {
|
|
5168
|
+
operation: "set",
|
|
5169
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5170
|
+
});
|
|
4828
5171
|
});
|
|
4829
|
-
}
|
|
5172
|
+
}
|
|
4830
5173
|
return originalJson(body, status);
|
|
4831
5174
|
};
|
|
4832
5175
|
await next();
|
|
4833
5176
|
};
|
|
4834
5177
|
}
|
|
4835
|
-
function
|
|
4836
|
-
|
|
4837
|
-
const parsed = new URL(url, "http://localhost");
|
|
4838
|
-
parsed.searchParams.sort();
|
|
4839
|
-
return parsed.pathname + parsed.search;
|
|
4840
|
-
} catch {
|
|
4841
|
-
return url;
|
|
4842
|
-
}
|
|
5178
|
+
function isSuccessfulStatus2(statusCode) {
|
|
5179
|
+
return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
|
|
4843
5180
|
}
|
|
4844
5181
|
|
|
4845
5182
|
// src/integrations/opentelemetry.ts
|
|
@@ -5634,12 +5971,12 @@ var RedisLayer = class {
|
|
|
5634
5971
|
};
|
|
5635
5972
|
|
|
5636
5973
|
// src/layers/DiskLayer.ts
|
|
5637
|
-
var
|
|
5974
|
+
var import_node_crypto5 = require("crypto");
|
|
5638
5975
|
var import_node_fs2 = require("fs");
|
|
5639
5976
|
var import_node_path = require("path");
|
|
5640
5977
|
|
|
5641
5978
|
// src/internal/PayloadProtection.ts
|
|
5642
|
-
var
|
|
5979
|
+
var import_node_crypto4 = require("crypto");
|
|
5643
5980
|
var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
|
|
5644
5981
|
var MAGIC_SIGNED = Buffer.from("LCS1:");
|
|
5645
5982
|
var ALGORITHM = "aes-256-gcm";
|
|
@@ -5652,11 +5989,11 @@ var PayloadProtection = class {
|
|
|
5652
5989
|
constructor(options) {
|
|
5653
5990
|
if (options.encryptionKey) {
|
|
5654
5991
|
const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
|
|
5655
|
-
this.encryptionKey = (0,
|
|
5992
|
+
this.encryptionKey = (0, import_node_crypto4.createHash)("sha256").update(raw).digest();
|
|
5656
5993
|
}
|
|
5657
5994
|
if (options.signingKey && !options.encryptionKey) {
|
|
5658
5995
|
const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
|
|
5659
|
-
this.signingKey = (0,
|
|
5996
|
+
this.signingKey = (0, import_node_crypto4.createHash)("sha256").update(raw).digest();
|
|
5660
5997
|
}
|
|
5661
5998
|
}
|
|
5662
5999
|
/** Returns `true` when any protection (encryption or signing) is configured. */
|
|
@@ -5703,8 +6040,8 @@ var PayloadProtection = class {
|
|
|
5703
6040
|
}
|
|
5704
6041
|
// ── Encryption (AES-256-GCM) ──────────────────────────────────────────
|
|
5705
6042
|
encrypt(plaintext, key) {
|
|
5706
|
-
const iv = (0,
|
|
5707
|
-
const cipher = (0,
|
|
6043
|
+
const iv = (0, import_node_crypto4.randomBytes)(IV_LENGTH);
|
|
6044
|
+
const cipher = (0, import_node_crypto4.createCipheriv)(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
5708
6045
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
5709
6046
|
const authTag = cipher.getAuthTag();
|
|
5710
6047
|
return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
|
|
@@ -5715,7 +6052,7 @@ var PayloadProtection = class {
|
|
|
5715
6052
|
const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
5716
6053
|
const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
5717
6054
|
try {
|
|
5718
|
-
const decipher = (0,
|
|
6055
|
+
const decipher = (0, import_node_crypto4.createDecipheriv)(ALGORITHM, key, iv, {
|
|
5719
6056
|
authTagLength: AUTH_TAG_LENGTH
|
|
5720
6057
|
});
|
|
5721
6058
|
decipher.setAuthTag(authTag);
|
|
@@ -5728,15 +6065,15 @@ var PayloadProtection = class {
|
|
|
5728
6065
|
}
|
|
5729
6066
|
// ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
|
|
5730
6067
|
sign(payload, key) {
|
|
5731
|
-
const hmac = (0,
|
|
6068
|
+
const hmac = (0, import_node_crypto4.createHmac)("sha256", key).update(payload).digest();
|
|
5732
6069
|
return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
|
|
5733
6070
|
}
|
|
5734
6071
|
verify(payload, key) {
|
|
5735
6072
|
const headerEnd = MAGIC_SIGNED.length;
|
|
5736
6073
|
const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
|
|
5737
6074
|
const data = payload.subarray(headerEnd + HMAC_LENGTH);
|
|
5738
|
-
const expectedHmac = (0,
|
|
5739
|
-
if (receivedHmac.length !== HMAC_LENGTH || !(0,
|
|
6075
|
+
const expectedHmac = (0, import_node_crypto4.createHmac)("sha256", key).update(data).digest();
|
|
6076
|
+
if (receivedHmac.length !== HMAC_LENGTH || !(0, import_node_crypto4.timingSafeEqual)(receivedHmac, expectedHmac)) {
|
|
5740
6077
|
throw new PayloadProtectionError(
|
|
5741
6078
|
"HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
|
|
5742
6079
|
);
|
|
@@ -5760,6 +6097,7 @@ var PayloadProtectionError = class extends Error {
|
|
|
5760
6097
|
|
|
5761
6098
|
// src/layers/DiskLayer.ts
|
|
5762
6099
|
var FILE_SCAN_CONCURRENCY = 32;
|
|
6100
|
+
var DEFAULT_MAX_WRITE_QUEUE_DEPTH = 1e4;
|
|
5763
6101
|
var DiskLayer = class {
|
|
5764
6102
|
name;
|
|
5765
6103
|
defaultTtl;
|
|
@@ -5768,8 +6106,10 @@ var DiskLayer = class {
|
|
|
5768
6106
|
serializer;
|
|
5769
6107
|
maxFiles;
|
|
5770
6108
|
maxEntryBytes;
|
|
6109
|
+
maxWriteQueueDepth;
|
|
5771
6110
|
protection;
|
|
5772
6111
|
writeQueue = Promise.resolve();
|
|
6112
|
+
writeQueueDepth = 0;
|
|
5773
6113
|
/**
|
|
5774
6114
|
* Creates a disk-backed cache layer.
|
|
5775
6115
|
*/
|
|
@@ -5780,6 +6120,7 @@ var DiskLayer = class {
|
|
|
5780
6120
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
5781
6121
|
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
5782
6122
|
this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
|
|
6123
|
+
this.maxWriteQueueDepth = this.normalizeMaxWriteQueueDepth(options.maxWriteQueueDepth);
|
|
5783
6124
|
this.protection = new PayloadProtection({
|
|
5784
6125
|
encryptionKey: options.encryptionKey,
|
|
5785
6126
|
signingKey: options.signingKey
|
|
@@ -5828,7 +6169,7 @@ var DiskLayer = class {
|
|
|
5828
6169
|
const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
|
|
5829
6170
|
const protectedPayload = this.protection.protect(raw);
|
|
5830
6171
|
const targetPath = this.keyToPath(key);
|
|
5831
|
-
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0,
|
|
6172
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0, import_node_crypto5.randomBytes)(8).toString("hex")}.tmp`;
|
|
5832
6173
|
try {
|
|
5833
6174
|
await import_node_fs2.promises.writeFile(tempPath, protectedPayload);
|
|
5834
6175
|
await import_node_fs2.promises.rename(tempPath, targetPath);
|
|
@@ -5961,7 +6302,7 @@ var DiskLayer = class {
|
|
|
5961
6302
|
async dispose() {
|
|
5962
6303
|
}
|
|
5963
6304
|
keyToPath(key) {
|
|
5964
|
-
const hash = (0,
|
|
6305
|
+
const hash = (0, import_node_crypto5.createHash)("sha256").update(key).digest("hex");
|
|
5965
6306
|
return (0, import_node_path.join)(this.directory, `${hash}.lc`);
|
|
5966
6307
|
}
|
|
5967
6308
|
resolveDirectory(directory) {
|
|
@@ -5995,6 +6336,16 @@ var DiskLayer = class {
|
|
|
5995
6336
|
}
|
|
5996
6337
|
return normalized;
|
|
5997
6338
|
}
|
|
6339
|
+
normalizeMaxWriteQueueDepth(maxWriteQueueDepth) {
|
|
6340
|
+
if (maxWriteQueueDepth === false) {
|
|
6341
|
+
return false;
|
|
6342
|
+
}
|
|
6343
|
+
const normalized = maxWriteQueueDepth ?? DEFAULT_MAX_WRITE_QUEUE_DEPTH;
|
|
6344
|
+
if (!Number.isInteger(normalized) || normalized <= 0) {
|
|
6345
|
+
throw new Error("DiskLayer.maxWriteQueueDepth must be a positive integer or false.");
|
|
6346
|
+
}
|
|
6347
|
+
return normalized;
|
|
6348
|
+
}
|
|
5998
6349
|
async readEntryFile(filePath) {
|
|
5999
6350
|
let handle;
|
|
6000
6351
|
try {
|
|
@@ -6107,7 +6458,13 @@ var DiskLayer = class {
|
|
|
6107
6458
|
}
|
|
6108
6459
|
}
|
|
6109
6460
|
enqueueWrite(operation) {
|
|
6110
|
-
|
|
6461
|
+
if (this.maxWriteQueueDepth !== false && this.writeQueueDepth >= this.maxWriteQueueDepth) {
|
|
6462
|
+
return Promise.reject(new Error(`DiskLayer write queue limit (${this.maxWriteQueueDepth}) exceeded.`));
|
|
6463
|
+
}
|
|
6464
|
+
this.writeQueueDepth += 1;
|
|
6465
|
+
const next = this.writeQueue.then(operation, operation).finally(() => {
|
|
6466
|
+
this.writeQueueDepth -= 1;
|
|
6467
|
+
});
|
|
6111
6468
|
this.writeQueue = next.catch(() => void 0);
|
|
6112
6469
|
return next;
|
|
6113
6470
|
}
|
|
@@ -6279,7 +6636,7 @@ var MsgpackSerializer = class {
|
|
|
6279
6636
|
};
|
|
6280
6637
|
|
|
6281
6638
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
6282
|
-
var
|
|
6639
|
+
var import_node_crypto6 = require("crypto");
|
|
6283
6640
|
var RELEASE_SCRIPT = `
|
|
6284
6641
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
6285
6642
|
return redis.call("del", KEYS[1])
|
|
@@ -6307,7 +6664,7 @@ var RedisSingleFlightCoordinator = class {
|
|
|
6307
6664
|
*/
|
|
6308
6665
|
async execute(key, options, worker, waiter) {
|
|
6309
6666
|
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
6310
|
-
const token = (0,
|
|
6667
|
+
const token = (0, import_node_crypto6.randomUUID)();
|
|
6311
6668
|
const acquired = await this.runCommand(
|
|
6312
6669
|
`acquire("${key}")`,
|
|
6313
6670
|
() => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
|
|
@@ -6461,6 +6818,7 @@ function sanitizeLabel(value) {
|
|
|
6461
6818
|
MemoryLayer,
|
|
6462
6819
|
MsgpackSerializer,
|
|
6463
6820
|
PatternMatcher,
|
|
6821
|
+
RedisGenerationStore,
|
|
6464
6822
|
RedisInvalidationBus,
|
|
6465
6823
|
RedisLayer,
|
|
6466
6824
|
RedisSingleFlightCoordinator,
|