layercache 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,11 +1,22 @@
1
1
  import {
2
- RedisTagIndex
3
- } from "./chunk-BQLL6IM5.js";
2
+ RedisTagIndex,
3
+ validateAdaptiveTtlOptions,
4
+ validateCacheKey,
5
+ validateCircuitBreakerOptions,
6
+ validateLayerNumberOption,
7
+ validateNonNegativeNumber,
8
+ validatePattern,
9
+ validatePositiveNumber,
10
+ validateRateLimitOptions,
11
+ validateTag,
12
+ validateTags,
13
+ validateTtlPolicy
14
+ } from "./chunk-BORDQ3LA.js";
4
15
  import {
5
16
  MemoryLayer,
6
17
  TagIndex,
7
18
  createHonoCacheMiddleware
8
- } from "./chunk-GJBKCFE6.js";
19
+ } from "./chunk-5RCAX2BQ.js";
9
20
  import {
10
21
  PatternMatcher,
11
22
  createStoredValueEnvelope,
@@ -715,6 +726,7 @@ var CacheStackLayerWriter = class {
715
726
  };
716
727
 
717
728
  // src/internal/CacheStackMaintenance.ts
729
+ var MAX_KEY_EPOCHS = 5e4;
718
730
  var CacheStackMaintenance = class {
719
731
  keyEpochs = /* @__PURE__ */ new Map();
720
732
  writeBehindQueue = [];
@@ -758,6 +770,7 @@ var CacheStackMaintenance = class {
758
770
  for (const key of keys) {
759
771
  this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1);
760
772
  }
773
+ this.pruneKeyEpochsIfNeeded();
761
774
  }
762
775
  isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch) {
763
776
  if (expectedClearEpoch !== void 0 && expectedClearEpoch !== this.clearEpoch) {
@@ -810,6 +823,16 @@ var CacheStackMaintenance = class {
810
823
  async waitForGenerationCleanup() {
811
824
  await this.generationCleanupPromise;
812
825
  }
826
+ pruneKeyEpochsIfNeeded() {
827
+ if (this.keyEpochs.size <= MAX_KEY_EPOCHS) {
828
+ return;
829
+ }
830
+ const sorted = [...this.keyEpochs.entries()].sort((a, b) => a[1] - b[1]);
831
+ const toDelete = Math.ceil(sorted.length * 0.1);
832
+ for (let i = 0; i < toDelete; i++) {
833
+ this.keyEpochs.delete(sorted[i][0]);
834
+ }
835
+ }
813
836
  };
814
837
 
815
838
  // src/internal/CacheStackRuntimePolicy.ts
@@ -849,16 +872,16 @@ function planFreshReadPolicies({
849
872
  }
850
873
 
851
874
  // src/internal/CacheStackSnapshotManager.ts
852
- import { randomBytes } from "crypto";
853
875
  import { constants, promises as fs } from "fs";
854
- import path from "path";
855
876
 
856
877
  // src/internal/CacheSnapshotFile.ts
857
- function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path2) {
858
- const relative = path2.relative(realBaseDir, candidatePath);
859
- return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path2.isAbsolute(relative));
878
+ import { randomBytes } from "crypto";
879
+ import { rename, unlink } from "fs/promises";
880
+ function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path) {
881
+ const relative = path.relative(realBaseDir, candidatePath);
882
+ return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path.isAbsolute(relative));
860
883
  }
861
- async function findExistingAncestor(directory, fs3, path2) {
884
+ async function findExistingAncestor(directory, fs3, path) {
862
885
  let current = directory;
863
886
  while (true) {
864
887
  try {
@@ -869,7 +892,7 @@ async function findExistingAncestor(directory, fs3, path2) {
869
892
  throw error;
870
893
  }
871
894
  }
872
- const parent = path2.dirname(current);
895
+ const parent = path.dirname(current);
873
896
  if (parent === current) {
874
897
  return current;
875
898
  }
@@ -884,36 +907,36 @@ async function validateSnapshotFilePath(filePath, mode, snapshotBaseDir, cwd = p
884
907
  throw new Error("filePath must not contain null bytes.");
885
908
  }
886
909
  const { promises: fs3 } = await import("fs");
887
- const path2 = await import("path");
888
- const resolved = path2.resolve(filePath);
889
- const baseDir = snapshotBaseDir === false ? false : path2.resolve(snapshotBaseDir ?? cwd);
910
+ const path = await import("path");
911
+ const resolved = path.resolve(filePath);
912
+ const baseDir = snapshotBaseDir === false ? false : path.resolve(snapshotBaseDir ?? cwd);
890
913
  if (baseDir === false) {
891
914
  return resolved;
892
915
  }
893
916
  await fs3.mkdir(baseDir, { recursive: true });
894
917
  const realBaseDir = await fs3.realpath(baseDir);
895
- if (!isWithinSnapshotBase(realBaseDir, resolved, path2.sep, path2)) {
918
+ if (!isWithinSnapshotBase(realBaseDir, resolved, path.sep, path)) {
896
919
  throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
897
920
  }
898
921
  if (mode === "read") {
899
922
  const realTarget = await fs3.realpath(resolved);
900
- if (!isWithinSnapshotBase(realBaseDir, realTarget, path2.sep, path2)) {
923
+ if (!isWithinSnapshotBase(realBaseDir, realTarget, path.sep, path)) {
901
924
  throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
902
925
  }
903
926
  return realTarget;
904
927
  }
905
- const parentDir = path2.dirname(resolved);
906
- const existingAncestor = await findExistingAncestor(parentDir, fs3, path2);
928
+ const parentDir = path.dirname(resolved);
929
+ const existingAncestor = await findExistingAncestor(parentDir, fs3, path);
907
930
  const realExistingAncestor = await fs3.realpath(existingAncestor);
908
- if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path2.sep, path2)) {
931
+ if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path.sep, path)) {
909
932
  throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
910
933
  }
911
934
  await fs3.mkdir(parentDir, { recursive: true });
912
935
  const realParentDir = await fs3.realpath(parentDir);
913
- if (!isWithinSnapshotBase(realBaseDir, realParentDir, path2.sep, path2)) {
936
+ if (!isWithinSnapshotBase(realBaseDir, realParentDir, path.sep, path)) {
914
937
  throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
915
938
  }
916
- const targetPath = path2.join(realParentDir, path2.basename(resolved));
939
+ const targetPath = path.join(realParentDir, path.basename(resolved));
917
940
  try {
918
941
  const existing = await fs3.lstat(targetPath);
919
942
  if (existing.isSymbolicLink()) {
@@ -948,6 +971,17 @@ async function readUtf8HandleWithLimit(handle, byteLimit) {
948
971
  }
949
972
  return Buffer.concat(chunks).toString("utf8");
950
973
  }
974
+ function atomicWriteTempPath(targetPath) {
975
+ return `${targetPath}.tmp-${randomBytes(8).toString("hex")}`;
976
+ }
977
+ async function commitAtomicWrite(tempPath, targetPath) {
978
+ try {
979
+ await rename(tempPath, targetPath);
980
+ } catch (error) {
981
+ await unlink(tempPath).catch(() => void 0);
982
+ throw error;
983
+ }
984
+ }
951
985
 
952
986
  // src/internal/StructuredDataSanitizer.ts
953
987
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
@@ -1026,10 +1060,7 @@ var CacheStackSnapshotManager = class {
1026
1060
  }
1027
1061
  async persistToFile(filePath, snapshotBaseDir, maxEntries) {
1028
1062
  const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
1029
- const tempPath = path.join(
1030
- path.dirname(targetPath),
1031
- `.layercache-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`
1032
- );
1063
+ const tempPath = atomicWriteTempPath(targetPath);
1033
1064
  let handle;
1034
1065
  try {
1035
1066
  handle = await fs.open(tempPath, "wx");
@@ -1044,7 +1075,7 @@ var CacheStackSnapshotManager = class {
1044
1075
  await openedHandle.writeFile(wroteAny ? "\n]" : "]", "utf8");
1045
1076
  await openedHandle.close();
1046
1077
  handle = void 0;
1047
- await fs.rename(tempPath, targetPath);
1078
+ await commitAtomicWrite(tempPath, targetPath);
1048
1079
  } catch (error) {
1049
1080
  await handle?.close().catch(() => void 0);
1050
1081
  await fs.unlink(tempPath).catch(() => void 0);
@@ -1138,130 +1169,6 @@ var CacheStackSnapshotManager = class {
1138
1169
  }
1139
1170
  };
1140
1171
 
1141
- // src/internal/CacheStackValidation.ts
1142
- var MAX_CACHE_KEY_LENGTH = 1024;
1143
- var MAX_PATTERN_LENGTH = 1024;
1144
- var MAX_TAGS_PER_OPERATION = 128;
1145
- function validatePositiveNumber(name, value) {
1146
- if (value === void 0) {
1147
- return;
1148
- }
1149
- if (!Number.isFinite(value) || value <= 0) {
1150
- throw new Error(`${name} must be a positive finite number.`);
1151
- }
1152
- }
1153
- function validateNonNegativeNumber(name, value) {
1154
- if (!Number.isFinite(value) || value < 0) {
1155
- throw new Error(`${name} must be a non-negative finite number.`);
1156
- }
1157
- }
1158
- function validateLayerNumberOption(name, value) {
1159
- if (value === void 0) {
1160
- return;
1161
- }
1162
- if (typeof value === "number") {
1163
- validateNonNegativeNumber(name, value);
1164
- return;
1165
- }
1166
- for (const [layerName, layerValue] of Object.entries(value)) {
1167
- if (layerValue === void 0) {
1168
- continue;
1169
- }
1170
- validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
1171
- }
1172
- }
1173
- function validateRateLimitOptions(name, options) {
1174
- if (!options) {
1175
- return;
1176
- }
1177
- validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
1178
- validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
1179
- validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
1180
- if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
1181
- throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
1182
- }
1183
- if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
1184
- throw new Error(`${name}.bucketKey must not be empty.`);
1185
- }
1186
- }
1187
- function validateCacheKey(key) {
1188
- if (key.length === 0) {
1189
- throw new Error("Cache key must not be empty.");
1190
- }
1191
- if (key.length > MAX_CACHE_KEY_LENGTH) {
1192
- throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
1193
- }
1194
- if (/[\u0000-\u001F\u007F]/.test(key)) {
1195
- throw new Error("Cache key contains unsupported control characters.");
1196
- }
1197
- if (/[\uD800-\uDFFF]/.test(key)) {
1198
- throw new Error("Cache key contains unsupported surrogate code points.");
1199
- }
1200
- return key;
1201
- }
1202
- function validateTag(tag) {
1203
- if (tag.length === 0) {
1204
- throw new Error("Cache tag must not be empty.");
1205
- }
1206
- if (tag.length > MAX_CACHE_KEY_LENGTH) {
1207
- throw new Error(`Cache tag length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
1208
- }
1209
- if (/[\u0000-\u001F\u007F]/.test(tag)) {
1210
- throw new Error("Cache tag contains unsupported control characters.");
1211
- }
1212
- if (/[\uD800-\uDFFF]/.test(tag)) {
1213
- throw new Error("Cache tag contains unsupported surrogate code points.");
1214
- }
1215
- return tag;
1216
- }
1217
- function validateTags(tags) {
1218
- if (!tags) {
1219
- return;
1220
- }
1221
- if (tags.length > MAX_TAGS_PER_OPERATION) {
1222
- throw new Error(`options.tags must contain at most ${MAX_TAGS_PER_OPERATION} tags.`);
1223
- }
1224
- for (const tag of tags) {
1225
- validateTag(tag);
1226
- }
1227
- }
1228
- function validatePattern(pattern) {
1229
- if (pattern.length === 0) {
1230
- throw new Error("Pattern must not be empty.");
1231
- }
1232
- if (pattern.length > MAX_PATTERN_LENGTH) {
1233
- throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
1234
- }
1235
- if (/[\u0000-\u001F\u007F]/.test(pattern)) {
1236
- throw new Error("Pattern contains unsupported control characters.");
1237
- }
1238
- }
1239
- function validateTtlPolicy(name, policy) {
1240
- if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
1241
- return;
1242
- }
1243
- if ("alignTo" in policy) {
1244
- validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
1245
- return;
1246
- }
1247
- throw new Error(`${name} is invalid.`);
1248
- }
1249
- function validateAdaptiveTtlOptions(options) {
1250
- if (!options || options === true) {
1251
- return;
1252
- }
1253
- validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
1254
- validateLayerNumberOption("adaptiveTtl.step", options.step);
1255
- validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
1256
- }
1257
- function validateCircuitBreakerOptions(options) {
1258
- if (!options) {
1259
- return;
1260
- }
1261
- validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
1262
- validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
1263
- }
1264
-
1265
1172
  // src/internal/CircuitBreakerManager.ts
1266
1173
  var CircuitBreakerManager = class {
1267
1174
  breakers = /* @__PURE__ */ new Map();
@@ -1359,6 +1266,7 @@ var CircuitBreakerManager = class {
1359
1266
 
1360
1267
  // src/internal/FetchRateLimiter.ts
1361
1268
  var MAX_BUCKETS = 1e4;
1269
+ var MAX_QUEUE_PER_BUCKET = 1e4;
1362
1270
  var FetchRateLimiter = class {
1363
1271
  buckets = /* @__PURE__ */ new Map();
1364
1272
  queuesByBucket = /* @__PURE__ */ new Map();
@@ -1367,6 +1275,7 @@ var FetchRateLimiter = class {
1367
1275
  nextFetcherBucketId = 0;
1368
1276
  drainTimer;
1369
1277
  isDisposed = false;
1278
+ rateLimitBypasses = 0;
1370
1279
  async schedule(options, context, task) {
1371
1280
  if (this.isDisposed) {
1372
1281
  throw new Error("FetchRateLimiter has been disposed.");
@@ -1381,6 +1290,11 @@ var FetchRateLimiter = class {
1381
1290
  return new Promise((resolve2, reject) => {
1382
1291
  const bucketKey = this.resolveBucketKey(normalized, context);
1383
1292
  const queue = this.queuesByBucket.get(bucketKey) ?? [];
1293
+ if (queue.length >= MAX_QUEUE_PER_BUCKET) {
1294
+ this.rateLimitBypasses += 1;
1295
+ task().then(resolve2, reject);
1296
+ return;
1297
+ }
1384
1298
  queue.push({
1385
1299
  bucketKey,
1386
1300
  options: normalized,
@@ -1685,7 +1599,13 @@ var MetricsCollector = class {
1685
1599
  };
1686
1600
 
1687
1601
  // src/internal/TtlResolver.ts
1602
+ import { randomBytes as randomBytes2 } from "crypto";
1688
1603
  var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
1604
+ var secureRandom = {
1605
+ value() {
1606
+ return randomBytes2(4).readUInt32BE(0) / 4294967296;
1607
+ }
1608
+ };
1689
1609
  var TtlResolver = class {
1690
1610
  accessProfiles = /* @__PURE__ */ new Map();
1691
1611
  maxProfileEntries;
@@ -1748,7 +1668,7 @@ var TtlResolver = class {
1748
1668
  if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
1749
1669
  return ttl;
1750
1670
  }
1751
- const delta = (Math.random() * 2 - 1) * jitter;
1671
+ const delta = (secureRandom.value() * 2 - 1) * jitter;
1752
1672
  return Math.max(1, Math.round(ttl + delta));
1753
1673
  }
1754
1674
  resolvePolicyTtl(key, value, policy) {
@@ -2864,7 +2784,7 @@ var CacheStack = class extends EventEmitter {
2864
2784
  } catch (error) {
2865
2785
  if (this.backgroundRefreshAbort.get(key)) return;
2866
2786
  this.metricsCollector.increment("refreshErrors");
2867
- this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
2787
+ this.logger.warn?.("background-refresh-error", { key, error: this.formatError(error) });
2868
2788
  } finally {
2869
2789
  this.backgroundRefreshes.delete(key);
2870
2790
  this.backgroundRefreshAbort.delete(key);
@@ -3162,7 +3082,12 @@ var CacheStack = class extends EventEmitter {
3162
3082
  }
3163
3083
  }
3164
3084
  shouldSkipLayer(layer) {
3165
- return shouldSkipLayer(this.layerDegradedUntil.get(layer.name));
3085
+ const degradedUntil = this.layerDegradedUntil.get(layer.name);
3086
+ const skip = shouldSkipLayer(degradedUntil);
3087
+ if (!skip && degradedUntil !== void 0) {
3088
+ this.layerDegradedUntil.delete(layer.name);
3089
+ }
3090
+ return skip;
3166
3091
  }
3167
3092
  async handleLayerFailure(layer, operation, error) {
3168
3093
  const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation);
@@ -3964,12 +3889,12 @@ var RedisLayer = class {
3964
3889
  };
3965
3890
 
3966
3891
  // src/layers/DiskLayer.ts
3967
- import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
3892
+ import { createHash as createHash2, randomBytes as randomBytes4 } from "crypto";
3968
3893
  import { promises as fs2 } from "fs";
3969
3894
  import { join, resolve } from "path";
3970
3895
 
3971
3896
  // src/internal/PayloadProtection.ts
3972
- import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
3897
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes as randomBytes3, timingSafeEqual } from "crypto";
3973
3898
  var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
3974
3899
  var MAGIC_SIGNED = Buffer.from("LCS1:");
3975
3900
  var ALGORITHM = "aes-256-gcm";
@@ -4033,7 +3958,7 @@ var PayloadProtection = class {
4033
3958
  }
4034
3959
  // ── Encryption (AES-256-GCM) ──────────────────────────────────────────
4035
3960
  encrypt(plaintext, key) {
4036
- const iv = randomBytes2(IV_LENGTH);
3961
+ const iv = randomBytes3(IV_LENGTH);
4037
3962
  const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
4038
3963
  const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
4039
3964
  const authTag = cipher.getAuthTag();
@@ -4146,7 +4071,7 @@ var DiskLayer = class {
4146
4071
  const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
4147
4072
  const protectedPayload = this.protection.protect(raw);
4148
4073
  const targetPath = this.keyToPath(key);
4149
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes3(8).toString("hex")}.tmp`;
4074
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes4(8).toString("hex")}.tmp`;
4150
4075
  try {
4151
4076
  await fs2.writeFile(tempPath, protectedPayload);
4152
4077
  await fs2.rename(tempPath, targetPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
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",
@@ -18,7 +18,6 @@
18
18
  "nodejs",
19
19
  "express",
20
20
  "fastify",
21
- "nestjs",
22
21
  "hono",
23
22
  "trpc",
24
23
  "graphql",
@@ -54,20 +53,15 @@
54
53
  },
55
54
  "files": [
56
55
  "dist",
57
- "packages/nestjs/dist",
58
56
  "examples",
59
57
  "benchmarks"
60
58
  ],
61
59
  "engines": {
62
60
  "node": ">=20"
63
61
  },
64
- "workspaces": [
65
- "packages/nestjs"
66
- ],
67
62
  "scripts": {
68
63
  "build": "tsup src/index.ts src/cli.ts src/edge.ts --format esm,cjs --dts",
69
- "build:nestjs": "npm --workspace @cachestack/nestjs run build",
70
- "build:all": "npm run build && npm run build:nestjs",
64
+ "build:all": "npm run build",
71
65
  "test": "vitest run",
72
66
  "test:coverage": "vitest run --coverage",
73
67
  "test:watch": "vitest",
@@ -96,16 +90,12 @@
96
90
  },
97
91
  "devDependencies": {
98
92
  "@biomejs/biome": "^1.9.4",
99
- "@nestjs/common": "^11.1.0",
100
- "@nestjs/core": "^11.1.0",
101
93
  "@types/autocannon": "^7.12.7",
102
94
  "@types/node": "^22.15.2",
103
95
  "@vitest/coverage-v8": "^4.1.2",
104
96
  "autocannon": "^8.0.0",
105
97
  "ioredis": "^5.6.1",
106
98
  "ioredis-mock": "^8.13.0",
107
- "reflect-metadata": "^0.2.2",
108
- "rxjs": "^7.8.1",
109
99
  "tsup": "^8.5.0",
110
100
  "tsx": "^4.19.3",
111
101
  "typescript": "^5.8.3",
@@ -1,15 +0,0 @@
1
- import { Module } from '@nestjs/common'
2
- import Redis from 'ioredis'
3
- import { CacheStackModule } from '../../packages/nestjs/src'
4
- import { MemoryLayer, RedisLayer } from '../../src'
5
-
6
- const redis = new Redis(process.env.REDIS_URL)
7
-
8
- @Module({
9
- imports: [
10
- CacheStackModule.forRoot({
11
- layers: [new MemoryLayer({ ttl: 20 }), new RedisLayer({ client: redis, ttl: 300 })]
12
- })
13
- ]
14
- })
15
- export class AppModule {}