@leejpsd/nextjs-cache-handler 0.1.1 → 0.3.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +131 -30
  3. package/dist/cache-components/index.cjs +297 -52
  4. package/dist/cache-components/index.cjs.map +1 -1
  5. package/dist/cache-components/index.d.cts +1 -1
  6. package/dist/cache-components/index.d.ts +1 -1
  7. package/dist/cache-components/index.js +289 -52
  8. package/dist/cache-components/index.js.map +1 -1
  9. package/dist/incremental/index.cjs +231 -41
  10. package/dist/incremental/index.cjs.map +1 -1
  11. package/dist/incremental/index.d.cts +42 -3
  12. package/dist/incremental/index.d.ts +42 -3
  13. package/dist/incremental/index.js +223 -41
  14. package/dist/incremental/index.js.map +1 -1
  15. package/dist/index.cjs +375 -76
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +367 -76
  20. package/dist/index.js.map +1 -1
  21. package/dist/ops/index.d.cts +1 -1
  22. package/dist/ops/index.d.ts +1 -1
  23. package/dist/otel/index.cjs +72 -0
  24. package/dist/otel/index.cjs.map +1 -0
  25. package/dist/otel/index.d.cts +72 -0
  26. package/dist/otel/index.d.ts +72 -0
  27. package/dist/otel/index.js +64 -0
  28. package/dist/otel/index.js.map +1 -0
  29. package/dist/shared/client/adapter-ioredis.cjs +49 -10
  30. package/dist/shared/client/adapter-ioredis.cjs.map +1 -1
  31. package/dist/shared/client/adapter-ioredis.d.cts +5 -2
  32. package/dist/shared/client/adapter-ioredis.d.ts +5 -2
  33. package/dist/shared/client/adapter-ioredis.js +43 -11
  34. package/dist/shared/client/adapter-ioredis.js.map +1 -1
  35. package/dist/shared/client/adapter-redis.cjs +22 -7
  36. package/dist/shared/client/adapter-redis.cjs.map +1 -1
  37. package/dist/shared/client/adapter-redis.d.cts +1 -1
  38. package/dist/shared/client/adapter-redis.d.ts +1 -1
  39. package/dist/shared/client/adapter-redis.js +16 -7
  40. package/dist/shared/client/adapter-redis.js.map +1 -1
  41. package/dist/{types-CgriT7My.d.cts → types-Bl-rkLKX.d.cts} +59 -1
  42. package/dist/{types-CgriT7My.d.ts → types-Bl-rkLKX.d.ts} +59 -1
  43. package/package.json +23 -4
package/dist/index.cjs CHANGED
@@ -1,15 +1,17 @@
1
1
  'use strict';
2
2
 
3
+ var module$1 = require('module');
4
+ var path = require('path');
5
+ var util = require('util');
6
+ var zlib = require('zlib');
3
7
  var crypto = require('crypto');
4
8
 
9
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
10
+
11
+ var path__default = /*#__PURE__*/_interopDefault(path);
12
+
5
13
  var __defProp = Object.defineProperty;
6
14
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
8
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
9
- }) : x)(function(x) {
10
- if (typeof require !== "undefined") return require.apply(this, arguments);
11
- throw Error('Dynamic require of "' + x + '" is not supported');
12
- });
13
15
  var __esm = (fn, res) => function __init() {
14
16
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
15
17
  };
@@ -18,6 +20,32 @@ var __export = (target, all) => {
18
20
  __defProp(target, name, { get: all[name], enumerable: true });
19
21
  };
20
22
 
23
+ // node_modules/tsup/assets/cjs_shims.js
24
+ var getImportMetaUrl, importMetaUrl;
25
+ var init_cjs_shims = __esm({
26
+ "node_modules/tsup/assets/cjs_shims.js"() {
27
+ getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
28
+ importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
+ }
30
+ });
31
+ function nodeRequire(id) {
32
+ try {
33
+ return packageRequire(id);
34
+ } catch {
35
+ return cwdRequire(id);
36
+ }
37
+ }
38
+ var packageRequire, cwdRequire;
39
+ var init_node_require = __esm({
40
+ "src/shared/node-require.ts"() {
41
+ init_cjs_shims();
42
+ packageRequire = module$1.createRequire(importMetaUrl);
43
+ cwdRequire = module$1.createRequire(
44
+ path__default.default.join(process.cwd(), "__resolve-anchor__.js")
45
+ );
46
+ }
47
+ });
48
+
21
49
  // src/shared/client/adapter-redis.ts
22
50
  var adapter_redis_exports = {};
23
51
  __export(adapter_redis_exports, {
@@ -28,7 +56,7 @@ function adaptRedisV5(client) {
28
56
  return client;
29
57
  }
30
58
  function createRedisV5Client(config) {
31
- const { createClient } = __require("redis");
59
+ const { createClient } = nodeRequire("redis");
32
60
  const useTls = config.tls ?? config.url.startsWith("rediss://");
33
61
  const opts = {
34
62
  url: config.url,
@@ -43,6 +71,8 @@ function createRedisV5Client(config) {
43
71
  }
44
72
  var init_adapter_redis = __esm({
45
73
  "src/shared/client/adapter-redis.ts"() {
74
+ init_cjs_shims();
75
+ init_node_require();
46
76
  }
47
77
  });
48
78
 
@@ -52,7 +82,8 @@ __export(adapter_ioredis_exports, {
52
82
  adaptCluster: () => adaptCluster,
53
83
  adaptIoredis: () => adaptIoredis,
54
84
  createIoredisClient: () => createIoredisClient,
55
- createIoredisCluster: () => createIoredisCluster
85
+ createIoredisCluster: () => createIoredisCluster,
86
+ createIoredisSentinel: () => createIoredisSentinel
56
87
  });
57
88
  function adapt(client, isClusterClient = false) {
58
89
  return {
@@ -135,6 +166,9 @@ function adapt(client, isClusterClient = false) {
135
166
  on: (event, listener) => {
136
167
  client.on(event, listener);
137
168
  return client;
169
+ },
170
+ dispose: () => {
171
+ client.disconnect();
138
172
  }
139
173
  };
140
174
  }
@@ -145,18 +179,36 @@ function adaptCluster(cluster) {
145
179
  return adapt(cluster, true);
146
180
  }
147
181
  function createIoredisClient(config) {
148
- const IORedis = __require("ioredis");
182
+ const IORedis = nodeRequire("ioredis");
149
183
  const Ctor = IORedis.default ?? IORedis;
150
184
  return new Ctor(config.url, {
151
185
  password: config.password,
152
186
  connectTimeout: config.connectTimeout ?? 1500,
153
187
  tls: config.tls ? {} : void 0,
154
188
  lazyConnect: true,
155
- maxRetriesPerRequest: 1
189
+ maxRetriesPerRequest: 1,
190
+ // Batch same-tick commands into one RTT (matches redis@5's default
191
+ // behavior) — makes Promise.all fan-outs a single pipeline.
192
+ enableAutoPipelining: true
193
+ });
194
+ }
195
+ function createIoredisSentinel(config) {
196
+ const IORedis = nodeRequire("ioredis");
197
+ const Ctor = IORedis.default ?? IORedis;
198
+ return new Ctor({
199
+ sentinels: config.sentinels,
200
+ name: config.name,
201
+ password: config.password,
202
+ sentinelPassword: config.sentinelPassword,
203
+ connectTimeout: config.connectTimeout ?? 1500,
204
+ tls: config.tls ? {} : void 0,
205
+ lazyConnect: true,
206
+ maxRetriesPerRequest: 1,
207
+ enableAutoPipelining: true
156
208
  });
157
209
  }
158
210
  function createIoredisCluster(config) {
159
- const IORedis = __require("ioredis");
211
+ const IORedis = nodeRequire("ioredis");
160
212
  const ClusterCtor = IORedis.Cluster ?? IORedis.default.Cluster;
161
213
  return new ClusterCtor(config.nodes, {
162
214
  redisOptions: {
@@ -164,15 +216,29 @@ function createIoredisCluster(config) {
164
216
  tls: config.tls ? {} : void 0
165
217
  },
166
218
  enableReadyCheck: true,
167
- lazyConnect: true
219
+ lazyConnect: true,
220
+ // Same-slot commands issued in the same tick share one pipeline.
221
+ enableAutoPipelining: true
168
222
  });
169
223
  }
170
224
  var init_adapter_ioredis = __esm({
171
225
  "src/shared/client/adapter-ioredis.ts"() {
226
+ init_cjs_shims();
227
+ init_node_require();
172
228
  }
173
229
  });
174
230
 
231
+ // src/index.ts
232
+ init_cjs_shims();
233
+
234
+ // src/cache-components/index.ts
235
+ init_cjs_shims();
236
+
237
+ // src/cache-components/handler.ts
238
+ init_cjs_shims();
239
+
175
240
  // src/shared/abort.ts
241
+ init_cjs_shims();
176
242
  var CacheTimeoutError = class extends Error {
177
243
  constructor(opName, ms) {
178
244
  super(`[@leejpsd/nextjs-cache-handler] ${opName} timed out after ${ms}ms`);
@@ -204,7 +270,41 @@ async function withAbortSignal(opName, ms, fn) {
204
270
  }
205
271
  }
206
272
 
273
+ // src/shared/compress.ts
274
+ init_cjs_shims();
275
+ var gzipAsync = util.promisify(zlib.gzip);
276
+ var gunzipAsync = util.promisify(zlib.gunzip);
277
+ var brotliCompressAsync = util.promisify(zlib.brotliCompress);
278
+ var brotliDecompressAsync = util.promisify(zlib.brotliDecompress);
279
+ var GZIP_MARKER = "__ncgz__:";
280
+ var BROTLI_MARKER = "__ncbr__:";
281
+ var MIN_COMPRESS_BYTES = 1024;
282
+ var BROTLI_OPTS = {
283
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 }
284
+ };
285
+ async function compressValue(value, algo) {
286
+ if (!algo || Buffer.byteLength(value) < MIN_COMPRESS_BYTES) return value;
287
+ const input = Buffer.from(value);
288
+ if (algo === "gzip") {
289
+ return GZIP_MARKER + (await gzipAsync(input)).toString("base64");
290
+ }
291
+ return BROTLI_MARKER + (await brotliCompressAsync(input, BROTLI_OPTS)).toString("base64");
292
+ }
293
+ async function decompressValue(value) {
294
+ if (value === null) return null;
295
+ if (value.startsWith(GZIP_MARKER)) {
296
+ const buf = Buffer.from(value.slice(GZIP_MARKER.length), "base64");
297
+ return (await gunzipAsync(buf)).toString("utf8");
298
+ }
299
+ if (value.startsWith(BROTLI_MARKER)) {
300
+ const buf = Buffer.from(value.slice(BROTLI_MARKER.length), "base64");
301
+ return (await brotliDecompressAsync(buf)).toString("utf8");
302
+ }
303
+ return value;
304
+ }
305
+
207
306
  // src/shared/client/index.ts
307
+ init_cjs_shims();
208
308
  async function buildClient(source) {
209
309
  if (typeof source === "function") {
210
310
  return await source();
@@ -222,6 +322,10 @@ async function buildClient(source) {
222
322
  const { adaptCluster: adaptCluster2, createIoredisCluster: createIoredisCluster2 } = await Promise.resolve().then(() => (init_adapter_ioredis(), adapter_ioredis_exports));
223
323
  return adaptCluster2(createIoredisCluster2(source));
224
324
  }
325
+ case "sentinel": {
326
+ const { adaptIoredis: adaptIoredis2, createIoredisSentinel: createIoredisSentinel2 } = await Promise.resolve().then(() => (init_adapter_ioredis(), adapter_ioredis_exports));
327
+ return adaptIoredis2(createIoredisSentinel2(source));
328
+ }
225
329
  default: {
226
330
  const exhaustive = source;
227
331
  throw new Error(
@@ -231,28 +335,45 @@ async function buildClient(source) {
231
335
  }
232
336
  }
233
337
  var ConnectionManager = class {
234
- constructor(source, onError) {
338
+ constructor(source, onError, retry = {}) {
235
339
  this.source = source;
236
340
  this.onError = onError;
341
+ this.baseCooldownMs = retry.baseCooldownMs ?? 1e3;
342
+ this.maxCooldownMs = retry.maxCooldownMs ?? 3e4;
237
343
  }
238
344
  source;
239
345
  onError;
240
346
  client = null;
241
347
  connecting = null;
242
- failed = false;
348
+ consecutiveFailures = 0;
349
+ lastFailureAt = 0;
350
+ baseCooldownMs;
351
+ maxCooldownMs;
352
+ cooldownMs() {
353
+ if (this.consecutiveFailures === 0) return 0;
354
+ return Math.min(
355
+ this.maxCooldownMs,
356
+ this.baseCooldownMs * 2 ** (this.consecutiveFailures - 1)
357
+ );
358
+ }
243
359
  async getOrConnect() {
244
360
  if (this.client?.isOpen) return this.client;
245
- if (this.failed) return null;
246
361
  if (this.connecting) return this.connecting;
362
+ if (Date.now() - this.lastFailureAt < this.cooldownMs()) return null;
363
+ const stale = this.client;
364
+ this.client = null;
247
365
  this.connecting = (async () => {
366
+ disposeQuietly(stale);
248
367
  try {
249
368
  const c = await buildClient(this.source);
250
369
  c.on("error", (err) => this.onError(err));
251
370
  if (!c.isOpen) await c.connect();
252
371
  this.client = c;
372
+ this.consecutiveFailures = 0;
253
373
  return c;
254
374
  } catch (err) {
255
- this.failed = true;
375
+ this.consecutiveFailures += 1;
376
+ this.lastFailureAt = Date.now();
256
377
  this.onError(err);
257
378
  return null;
258
379
  } finally {
@@ -262,13 +383,24 @@ var ConnectionManager = class {
262
383
  return this.connecting;
263
384
  }
264
385
  reset() {
386
+ disposeQuietly(this.client);
265
387
  this.client = null;
266
388
  this.connecting = null;
267
- this.failed = false;
389
+ this.consecutiveFailures = 0;
390
+ this.lastFailureAt = 0;
268
391
  }
269
392
  };
393
+ function disposeQuietly(client) {
394
+ if (!client) return;
395
+ const c = client;
396
+ try {
397
+ (c.dispose ?? c.destroy ?? c.disconnect)?.call(c);
398
+ } catch {
399
+ }
400
+ }
270
401
 
271
402
  // src/shared/logger.ts
403
+ init_cjs_shims();
272
404
  var defaultLogger = {
273
405
  debug: () => {
274
406
  },
@@ -284,6 +416,9 @@ var defaultLogger = {
284
416
  }
285
417
  };
286
418
 
419
+ // src/shared/lua/index.ts
420
+ init_cjs_shims();
421
+
287
422
  // src/shared/lua/set-with-tags.lua
288
423
  var set_with_tags_default = `-- set-with-tags.lua
289
424
  --
@@ -387,13 +522,19 @@ async function execLuaScript(client, name, keys, args) {
387
522
  }
388
523
 
389
524
  // src/shared/memory-fallback.ts
525
+ init_cjs_shims();
390
526
  var MAX_TIMER_MS = 2147483647;
391
527
  function clampTimerMs(ms) {
392
528
  return Math.min(Math.max(0, ms), MAX_TIMER_MS);
393
529
  }
530
+ var DEFAULT_MAX_ENTRIES = 1e3;
394
531
  var MemoryStore = class {
395
532
  kv = /* @__PURE__ */ new Map();
396
533
  timers = /* @__PURE__ */ new Map();
534
+ maxEntries;
535
+ constructor(maxEntries = DEFAULT_MAX_ENTRIES) {
536
+ this.maxEntries = Math.max(1, maxEntries);
537
+ }
397
538
  set(key, value, ttlSeconds) {
398
539
  this.delete(key);
399
540
  const expiresAt = Date.now() + ttlSeconds * 1e3;
@@ -403,6 +544,10 @@ var MemoryStore = class {
403
544
  timer.unref();
404
545
  }
405
546
  this.timers.set(key, timer);
547
+ while (this.kv.size > this.maxEntries) {
548
+ const oldest = this.kv.keys().next().value;
549
+ this.delete(oldest);
550
+ }
406
551
  }
407
552
  get(key) {
408
553
  const entry = this.kv.get(key);
@@ -411,6 +556,8 @@ var MemoryStore = class {
411
556
  this.delete(key);
412
557
  return null;
413
558
  }
559
+ this.kv.delete(key);
560
+ this.kv.set(key, entry);
414
561
  return entry.value;
415
562
  }
416
563
  delete(key) {
@@ -445,13 +592,19 @@ var MemorySetStore = class {
445
592
  sets = /* @__PURE__ */ new Map();
446
593
  expiresAt = /* @__PURE__ */ new Map();
447
594
  timers = /* @__PURE__ */ new Map();
595
+ maxSets;
596
+ constructor(maxSets = DEFAULT_MAX_ENTRIES) {
597
+ this.maxSets = Math.max(1, maxSets);
598
+ }
448
599
  sAdd(key, member) {
449
600
  const members = Array.isArray(member) ? member : [member];
450
601
  let set = this.sets.get(key);
451
602
  if (!set) {
452
603
  set = /* @__PURE__ */ new Set();
453
- this.sets.set(key, set);
604
+ } else {
605
+ this.sets.delete(key);
454
606
  }
607
+ this.sets.set(key, set);
455
608
  let added = 0;
456
609
  for (const m of members) {
457
610
  if (!set.has(m)) {
@@ -459,6 +612,10 @@ var MemorySetStore = class {
459
612
  added += 1;
460
613
  }
461
614
  }
615
+ while (this.sets.size > this.maxSets) {
616
+ const oldest = this.sets.keys().next().value;
617
+ this.delete(oldest);
618
+ }
462
619
  return added;
463
620
  }
464
621
  sMembers(key) {
@@ -511,6 +668,7 @@ var MemorySetStore = class {
511
668
  };
512
669
 
513
670
  // src/shared/metrics.ts
671
+ init_cjs_shims();
514
672
  var counters = /* @__PURE__ */ new Map();
515
673
  function bump(event) {
516
674
  const existing = counters.get(event.type);
@@ -550,6 +708,7 @@ function getMetricSnapshot() {
550
708
  }
551
709
 
552
710
  // src/shared/namespace.ts
711
+ init_cjs_shims();
553
712
  var FALLBACK = "unversioned";
554
713
  function resolveBuildNamespace(override) {
555
714
  if (override !== void 0) {
@@ -563,6 +722,7 @@ function buildKey(prefix, namespace, rest, hashTag = false) {
563
722
  }
564
723
 
565
724
  // src/shared/build-phase.ts
725
+ init_cjs_shims();
566
726
  var PHASE_PRODUCTION_BUILD = "phase-production-build";
567
727
  function isBuildPhase() {
568
728
  return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD;
@@ -575,6 +735,7 @@ function shouldUseRedis(opts) {
575
735
  }
576
736
 
577
737
  // src/cache-components/serialize.ts
738
+ init_cjs_shims();
578
739
  async function readStreamFully(stream) {
579
740
  const reader = stream.getReader();
580
741
  const chunks = [];
@@ -617,6 +778,7 @@ function decodeEnvelope(raw) {
617
778
  }
618
779
 
619
780
  // src/cache-components/swr.ts
781
+ init_cjs_shims();
620
782
  var SECOND_MS = 1e3;
621
783
  function partitionEntry(entry, now = Date.now()) {
622
784
  const ageMs = now - entry.timestamp;
@@ -643,7 +805,9 @@ var DEFAULT_KEY_PREFIX = "next-cache:";
643
805
  var ENTRY_SUFFIX = "entry:";
644
806
  var TAG_SUFFIX = "tag:";
645
807
  var TAG_EXP_SUFFIX = "tag-expiration:";
808
+ var LOCK_SUFFIX = "lock:";
646
809
  var DEFAULT_ABORT_MS = 1500;
810
+ var DEFAULT_LOCK_TTL_SEC = 10;
647
811
  var ONE_YEAR_SEC = 60 * 60 * 24 * 365;
648
812
  var FALLBACK_TTL_SEC = 3600;
649
813
  var TAG_EXP_MARKER_TTL_SEC = 60 * 60 * 24 * 7;
@@ -666,16 +830,19 @@ function init(opts) {
666
830
  staleWhileRevalidate: opts.staleWhileRevalidate ?? true,
667
831
  hashTag: opts.hashTag ?? false,
668
832
  keyPrefix: opts.keyPrefix ?? DEFAULT_KEY_PREFIX,
833
+ singleFlight: opts.singleFlight ?? false,
834
+ singleFlightLockTtlSec: opts.singleFlightLockTtlSec ?? DEFAULT_LOCK_TTL_SEC,
669
835
  rest: opts
670
836
  },
671
837
  logger,
672
838
  emit,
673
839
  conn,
674
- memEntries: new MemoryStore(),
675
- memTags: new MemorySetStore(),
840
+ memEntries: new MemoryStore(opts.memoryMaxEntries),
841
+ memTags: new MemorySetStore(opts.memoryMaxEntries),
676
842
  memTagExp: /* @__PURE__ */ new Map(),
677
843
  localTagTimestamps: /* @__PURE__ */ new Map(),
678
- resolveNs: () => resolveBuildNamespace(opts.buildNamespace)
844
+ resolveNs: () => resolveBuildNamespace(opts.buildNamespace),
845
+ instanceId: process.env.HOSTNAME || process.env.ECS_TASK_ID || `pid-${process.pid}`
679
846
  };
680
847
  }
681
848
  function entryKey(state, cacheKey) {
@@ -702,6 +869,40 @@ function tagExpKey(state, tag) {
702
869
  state.opts.hashTag
703
870
  );
704
871
  }
872
+ function lockKey(state, cacheKey) {
873
+ return buildKey(
874
+ `${state.opts.keyPrefix}${LOCK_SUFFIX}`,
875
+ state.resolveNs(),
876
+ cacheKey,
877
+ state.opts.hashTag
878
+ );
879
+ }
880
+ async function tryAcquireRefreshLock(state, cacheKey) {
881
+ if (!state.opts.singleFlight) return false;
882
+ if (!gateRedis(state)) return false;
883
+ try {
884
+ return await withAbortSignal(
885
+ "cacheHandlers.singleFlight.lock",
886
+ state.opts.abortTimeoutMs,
887
+ async () => {
888
+ const client = await state.conn.getOrConnect();
889
+ if (!client) return false;
890
+ const result = await execLuaScript(
891
+ client,
892
+ "refreshTagLock",
893
+ [lockKey(state, cacheKey)],
894
+ [
895
+ state.instanceId,
896
+ String(state.opts.singleFlightLockTtlSec)
897
+ ]
898
+ );
899
+ return result === 1;
900
+ }
901
+ );
902
+ } catch {
903
+ return false;
904
+ }
905
+ }
705
906
  function resolveTtlSeconds(entry) {
706
907
  if (Number.isFinite(entry.expire) && entry.expire > 0) {
707
908
  return Math.max(60, Math.ceil(entry.expire));
@@ -754,6 +955,12 @@ async function getImpl(state, cacheKey, softTags) {
754
955
  state.emit({ type: "cache.miss", ms: Date.now() - start });
755
956
  return void 0;
756
957
  }
958
+ try {
959
+ raw = await decompressValue(raw);
960
+ } catch {
961
+ state.emit({ type: "cache.miss", meta: { reason: "decode-error" } });
962
+ return void 0;
963
+ }
757
964
  envelope = decodeEnvelope(raw);
758
965
  if (!envelope) {
759
966
  state.emit({ type: "cache.miss", meta: { reason: "decode-error" } });
@@ -796,11 +1003,28 @@ async function getImpl(state, cacheKey, softTags) {
796
1003
  return void 0;
797
1004
  }
798
1005
  }
799
- state.emit({
800
- type: partition.freshness === "stale" ? "cache.stale" : "cache.hit",
801
- ms: Date.now() - start,
802
- meta: { freshness: partition.freshness }
803
- });
1006
+ if (partition.freshness === "stale") {
1007
+ if (state.opts.singleFlight) {
1008
+ const isLeader = await tryAcquireRefreshLock(state, cacheKey);
1009
+ state.emit({
1010
+ type: isLeader ? "cache.stale.refresh.leader" : "cache.stale.refresh.follower",
1011
+ ms: Date.now() - start,
1012
+ meta: { freshness: partition.freshness }
1013
+ });
1014
+ } else {
1015
+ state.emit({
1016
+ type: "cache.stale",
1017
+ ms: Date.now() - start,
1018
+ meta: { freshness: partition.freshness }
1019
+ });
1020
+ }
1021
+ } else {
1022
+ state.emit({
1023
+ type: "cache.hit",
1024
+ ms: Date.now() - start,
1025
+ meta: { freshness: partition.freshness }
1026
+ });
1027
+ }
804
1028
  return {
805
1029
  value: bufferToStream(Buffer.from(envelope.value, "base64")),
806
1030
  tags: envelope.tags,
@@ -865,10 +1089,14 @@ async function setImpl(state, cacheKey, pendingEntry) {
865
1089
  }
866
1090
  return;
867
1091
  }
1092
+ const wirePayload = await compressValue(
1093
+ payload,
1094
+ state.opts.rest.compression
1095
+ );
868
1096
  if (entry.tags.length === 0) {
869
- await client.set(eKey, payload, { EX: ttl });
1097
+ await client.set(eKey, wirePayload, { EX: ttl });
870
1098
  } else {
871
- await luaSetWithTags(client, state, eKey, payload, ttl, entry.tags);
1099
+ await luaSetWithTags(client, state, eKey, wirePayload, ttl, entry.tags);
872
1100
  }
873
1101
  }
874
1102
  );
@@ -904,28 +1132,36 @@ async function refreshTagsImpl(state) {
904
1132
  async () => {
905
1133
  const client = await state.conn.getOrConnect();
906
1134
  if (!client) return;
907
- const pattern = `${state.opts.keyPrefix}${TAG_EXP_SUFFIX}*`;
908
- const seen = [];
1135
+ const ns = state.opts.hashTag ? `{${state.resolveNs()}}` : state.resolveNs();
1136
+ const pattern = `${escapeMatch(
1137
+ `${state.opts.keyPrefix}${TAG_EXP_SUFFIX}${ns}:`
1138
+ )}*`;
1139
+ const scanStartMs = Date.now();
1140
+ const fresh = /* @__PURE__ */ new Map();
909
1141
  for await (const chunk of client.scanIterator({
910
1142
  MATCH: pattern,
911
1143
  COUNT: 100
912
1144
  })) {
913
1145
  const keys = Array.isArray(chunk) ? chunk.map(String) : [String(chunk)];
914
- seen.push(...keys);
915
- if (seen.length >= 500) break;
1146
+ if (keys.length === 0) continue;
1147
+ const values = await client.mGet(keys);
1148
+ for (let i = 0; i < keys.length; i += 1) {
1149
+ const v = values[i];
1150
+ if (!v) continue;
1151
+ const ts = Number.parseInt(v, 10);
1152
+ if (!Number.isFinite(ts)) continue;
1153
+ const key = keys[i];
1154
+ if (key === void 0) continue;
1155
+ const tag = stripExpPrefix(state, key);
1156
+ if (tag) fresh.set(tag, ts);
1157
+ }
916
1158
  }
917
- if (seen.length === 0) return;
918
- const values = await client.mGet(seen);
919
- for (let i = 0; i < seen.length; i += 1) {
920
- const v = values[i];
921
- if (!v) continue;
922
- const ts = Number.parseInt(v, 10);
923
- if (!Number.isFinite(ts)) continue;
924
- const key = seen[i];
925
- if (key === void 0) continue;
926
- const tag = stripExpPrefix(state, key);
927
- if (tag) state.localTagTimestamps.set(tag, ts);
1159
+ for (const [tag, ts] of state.localTagTimestamps) {
1160
+ if (ts >= scanStartMs && (fresh.get(tag) ?? 0) < ts) {
1161
+ fresh.set(tag, ts);
1162
+ }
928
1163
  }
1164
+ state.localTagTimestamps = fresh;
929
1165
  }
930
1166
  );
931
1167
  } catch (err) {
@@ -934,6 +1170,19 @@ async function refreshTagsImpl(state) {
934
1170
  });
935
1171
  }
936
1172
  }
1173
+ function escapeMatch(s) {
1174
+ return s.replace(/[\\*?[\]]/g, "\\$&");
1175
+ }
1176
+ var MAX_LOCAL_TAG_ENTRIES = 1e4;
1177
+ function pruneOldestByTimestamp(map, maxEntries) {
1178
+ if (map.size <= maxEntries) return;
1179
+ const byAge = [...map.entries()].sort((a, b) => a[1] - b[1]);
1180
+ const drop = map.size - maxEntries;
1181
+ for (let i = 0; i < drop; i += 1) {
1182
+ const entry = byAge[i];
1183
+ if (entry) map.delete(entry[0]);
1184
+ }
1185
+ }
937
1186
  function stripExpPrefix(state, fullKey) {
938
1187
  const ns = state.resolveNs();
939
1188
  const prefix = state.opts.hashTag ? `${state.opts.keyPrefix}${TAG_EXP_SUFFIX}{${ns}}:` : `${state.opts.keyPrefix}${TAG_EXP_SUFFIX}${ns}:`;
@@ -985,6 +1234,7 @@ async function updateTagsImpl(state, tags, durations) {
985
1234
  state.localTagTimestamps.set(t, now);
986
1235
  state.memTagExp.set(t, now);
987
1236
  }
1237
+ pruneOldestByTimestamp(state.memTagExp, MAX_LOCAL_TAG_ENTRIES);
988
1238
  if (!gateRedis(state)) {
989
1239
  if (isHardExpire) {
990
1240
  for (const t of tags) {
@@ -1014,20 +1264,18 @@ async function updateTagsImpl(state, tags, durations) {
1014
1264
  }
1015
1265
  return;
1016
1266
  }
1017
- for (const t of tags) {
1018
- if (isHardExpire) {
1019
- await execLuaScript(
1267
+ await Promise.all(
1268
+ tags.map(
1269
+ (t) => isHardExpire ? execLuaScript(
1020
1270
  client,
1021
1271
  "revalidateHard",
1022
1272
  [tagKey(state, t), tagExpKey(state, t)],
1023
1273
  [String(now), String(TAG_EXP_MARKER_TTL_SEC)]
1024
- );
1025
- } else {
1026
- await client.set(tagExpKey(state, t), String(now), {
1274
+ ) : client.set(tagExpKey(state, t), String(now), {
1027
1275
  EX: TAG_EXP_MARKER_TTL_SEC
1028
- });
1029
- }
1030
- }
1276
+ })
1277
+ )
1278
+ );
1031
1279
  }
1032
1280
  );
1033
1281
  state.emit({
@@ -1057,7 +1305,14 @@ function createCacheComponentsHandler(opts) {
1057
1305
  };
1058
1306
  }
1059
1307
 
1308
+ // src/incremental/index.ts
1309
+ init_cjs_shims();
1310
+
1311
+ // src/incremental/handler.ts
1312
+ init_cjs_shims();
1313
+
1060
1314
  // src/incremental/serialize.ts
1315
+ init_cjs_shims();
1061
1316
  function isEnvelope(value) {
1062
1317
  return typeof value === "object" && value !== null && "__type" in value && typeof value.__type === "string";
1063
1318
  }
@@ -1139,7 +1394,7 @@ function init2(opts) {
1139
1394
  logger,
1140
1395
  emit,
1141
1396
  conn,
1142
- memEntries: new MemoryStore(),
1397
+ memEntries: new MemoryStore(opts.memoryMaxEntries),
1143
1398
  memTagStates: /* @__PURE__ */ new Map(),
1144
1399
  resolveNs: () => resolveBuildNamespace(opts.buildNamespace)
1145
1400
  };
@@ -1198,7 +1453,7 @@ function normalizeTtlSeconds(data, ctx) {
1198
1453
  return ONE_YEAR_SEC2;
1199
1454
  }
1200
1455
  }
1201
- const revalidate = ctx?.cacheControl?.revalidate;
1456
+ const revalidate = ctx?.cacheControl?.revalidate ?? ctx?.revalidate;
1202
1457
  if (revalidate === false || revalidate === void 0) return ONE_YEAR_SEC2;
1203
1458
  if (typeof revalidate === "number" && revalidate > 0) {
1204
1459
  return Math.min(Math.max(Math.ceil(revalidate), MIN_TTL_SEC), ONE_YEAR_SEC2);
@@ -1236,10 +1491,49 @@ async function readTagStates(state, tags) {
1236
1491
  var IncrementalRedisCacheHandler = class {
1237
1492
  state;
1238
1493
  revalidatedTags;
1494
+ /**
1495
+ * Request-scoped memo of Redis entry reads. Next.js instantiates this class
1496
+ * per request (and calls resetRequestCache() at request boundaries when it
1497
+ * reuses an instance), so entries live at most one request. Bounded by the
1498
+ * number of distinct cache keys a single request touches.
1499
+ */
1500
+ requestMemo = /* @__PURE__ */ new Map();
1239
1501
  constructor(state, ctx) {
1240
1502
  this.state = state;
1241
1503
  this.revalidatedTags = normalizeTags(ctx?.revalidatedTags);
1242
1504
  }
1505
+ /**
1506
+ * Redis entry read, deduplicated per request: parallel RSC renders or
1507
+ * repeated fetch() calls hitting the same cache key share one GET. The
1508
+ * memoized promise never rejects — errors are converted to a miss (with
1509
+ * the same timeout metric the inline path emitted before).
1510
+ */
1511
+ readEntryDeduped(eKey) {
1512
+ const memoized = this.requestMemo.get(eKey);
1513
+ if (memoized) {
1514
+ this.state.emit({ type: "cache.get.deduped" });
1515
+ return memoized;
1516
+ }
1517
+ const read = withAbortSignal(
1518
+ "incremental.get",
1519
+ this.state.opts.abortTimeoutMs,
1520
+ async () => {
1521
+ const client = await this.state.conn.getOrConnect();
1522
+ if (!client) return null;
1523
+ return await decompressValue(await client.get(eKey));
1524
+ }
1525
+ ).catch((err) => {
1526
+ if (err instanceof CacheTimeoutError) {
1527
+ this.state.emit({
1528
+ type: "redis.timeout",
1529
+ meta: { op: "incremental.get" }
1530
+ });
1531
+ }
1532
+ return null;
1533
+ });
1534
+ this.requestMemo.set(eKey, read);
1535
+ return read;
1536
+ }
1243
1537
  async get(cacheKey, ctx) {
1244
1538
  try {
1245
1539
  const requestedTags = normalizeTags([
@@ -1251,24 +1545,11 @@ var IncrementalRedisCacheHandler = class {
1251
1545
  const useRedis = !useLocal && gateRedis2(this.state);
1252
1546
  let raw = null;
1253
1547
  if (useRedis) {
1254
- try {
1255
- raw = await withAbortSignal(
1256
- "incremental.get",
1257
- this.state.opts.abortTimeoutMs,
1258
- async () => {
1259
- const client = await this.state.conn.getOrConnect();
1260
- if (!client) return null;
1261
- return await client.get(eKey);
1262
- }
1263
- );
1264
- } catch (err) {
1265
- if (err instanceof CacheTimeoutError) {
1266
- this.state.emit({ type: "redis.timeout", meta: { op: "incremental.get" } });
1267
- }
1268
- raw = null;
1269
- }
1548
+ raw = await this.readEntryDeduped(eKey);
1549
+ }
1550
+ if (raw === null && (useLocal || this.state.opts.fallback !== "never")) {
1551
+ raw = this.state.memEntries.get(eKey);
1270
1552
  }
1271
- if (raw === null) raw = this.state.memEntries.get(eKey);
1272
1553
  if (raw === null) {
1273
1554
  this.state.emit({ type: "cache.miss" });
1274
1555
  return null;
@@ -1281,7 +1562,7 @@ var IncrementalRedisCacheHandler = class {
1281
1562
  return null;
1282
1563
  }
1283
1564
  const tagStates = await readTagStates(this.state, tags);
1284
- const isFetch = ctx?.kind === "FETCH";
1565
+ const isFetch = ctx?.kind === "FETCH" || ctx?.kindHint?.toLowerCase() === "fetch" || typeof parsed.value === "object" && parsed.value !== null && parsed.value.kind === "FETCH";
1285
1566
  if (tagStates.some((s) => isTagStateExpired(s, parsed.lastModified, isFetch))) {
1286
1567
  this.state.emit({ type: "cache.miss", meta: { reason: "tag-state-expired" } });
1287
1568
  return null;
@@ -1304,6 +1585,7 @@ var IncrementalRedisCacheHandler = class {
1304
1585
  const record = { lastModified: Date.now(), value: data };
1305
1586
  const serialized = serializeCacheRecord(record);
1306
1587
  const eKey = entryKey2(this.state, cacheKey);
1588
+ this.requestMemo.delete(eKey);
1307
1589
  const useRedis = !useLocal && gateRedis2(this.state);
1308
1590
  if (useRedis) {
1309
1591
  try {
@@ -1313,10 +1595,19 @@ var IncrementalRedisCacheHandler = class {
1313
1595
  async () => {
1314
1596
  const client = await this.state.conn.getOrConnect();
1315
1597
  if (!client) {
1316
- this.state.memEntries.set(eKey, serialized, ttl);
1598
+ if (this.state.opts.fallback !== "never") {
1599
+ this.state.memEntries.set(eKey, serialized, ttl);
1600
+ }
1317
1601
  return;
1318
1602
  }
1319
- await client.set(eKey, serialized, { EX: ttl });
1603
+ await client.set(
1604
+ eKey,
1605
+ await compressValue(
1606
+ serialized,
1607
+ this.state.opts.rest.compression
1608
+ ),
1609
+ { EX: ttl }
1610
+ );
1320
1611
  }
1321
1612
  );
1322
1613
  this.state.emit({ type: "cache.set", meta: { backend: "redis" } });
@@ -1327,8 +1618,15 @@ var IncrementalRedisCacheHandler = class {
1327
1618
  }
1328
1619
  }
1329
1620
  }
1330
- this.state.memEntries.set(eKey, serialized, ttl);
1331
- this.state.emit({ type: "cache.set", meta: { backend: "memory" } });
1621
+ if (useLocal || this.state.opts.fallback !== "never") {
1622
+ this.state.memEntries.set(eKey, serialized, ttl);
1623
+ this.state.emit({ type: "cache.set", meta: { backend: "memory" } });
1624
+ } else {
1625
+ this.state.emit({
1626
+ type: "cache.set.failed",
1627
+ meta: { reason: "no-fallback" }
1628
+ });
1629
+ }
1332
1630
  } catch (err) {
1333
1631
  this.state.logger.error("set() error", {
1334
1632
  message: err.message
@@ -1382,6 +1680,7 @@ var IncrementalRedisCacheHandler = class {
1382
1680
  }
1383
1681
  }
1384
1682
  resetRequestCache() {
1683
+ this.requestMemo.clear();
1385
1684
  }
1386
1685
  };
1387
1686
  function createIncrementalCacheHandler(opts) {