@leejpsd/nextjs-cache-handler 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/README.md +47 -25
  3. package/dist/cache-components/index.cjs +235 -46
  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 +227 -46
  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 +313 -70
  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 +305 -70
  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-Dq1q6qXd.d.cts → types-Bl-rkLKX.d.cts} +37 -1
  42. package/dist/{types-Dq1q6qXd.d.ts → types-Bl-rkLKX.d.ts} +37 -1
  43. package/package.json +27 -5
package/dist/index.js CHANGED
@@ -1,13 +1,12 @@
1
+ import path2 from 'path';
2
+ import 'url';
3
+ import { createRequire } from 'module';
4
+ import { promisify } from 'util';
5
+ import { gzip, gunzip, brotliCompress, brotliDecompress, constants } from 'zlib';
1
6
  import { createHash } from 'crypto';
2
7
 
3
8
  var __defProp = Object.defineProperty;
4
9
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
6
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
7
- }) : x)(function(x) {
8
- if (typeof require !== "undefined") return require.apply(this, arguments);
9
- throw Error('Dynamic require of "' + x + '" is not supported');
10
- });
11
10
  var __esm = (fn, res) => function __init() {
12
11
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
13
12
  };
@@ -15,6 +14,27 @@ var __export = (target, all) => {
15
14
  for (var name in all)
16
15
  __defProp(target, name, { get: all[name], enumerable: true });
17
16
  };
17
+ var init_esm_shims = __esm({
18
+ "node_modules/tsup/assets/esm_shims.js"() {
19
+ }
20
+ });
21
+ function nodeRequire(id) {
22
+ try {
23
+ return packageRequire(id);
24
+ } catch {
25
+ return cwdRequire(id);
26
+ }
27
+ }
28
+ var packageRequire, cwdRequire;
29
+ var init_node_require = __esm({
30
+ "src/shared/node-require.ts"() {
31
+ init_esm_shims();
32
+ packageRequire = createRequire(import.meta.url);
33
+ cwdRequire = createRequire(
34
+ path2.join(process.cwd(), "__resolve-anchor__.js")
35
+ );
36
+ }
37
+ });
18
38
 
19
39
  // src/shared/client/adapter-redis.ts
20
40
  var adapter_redis_exports = {};
@@ -26,7 +46,7 @@ function adaptRedisV5(client) {
26
46
  return client;
27
47
  }
28
48
  function createRedisV5Client(config) {
29
- const { createClient } = __require("redis");
49
+ const { createClient } = nodeRequire("redis");
30
50
  const useTls = config.tls ?? config.url.startsWith("rediss://");
31
51
  const opts = {
32
52
  url: config.url,
@@ -41,6 +61,8 @@ function createRedisV5Client(config) {
41
61
  }
42
62
  var init_adapter_redis = __esm({
43
63
  "src/shared/client/adapter-redis.ts"() {
64
+ init_esm_shims();
65
+ init_node_require();
44
66
  }
45
67
  });
46
68
 
@@ -50,7 +72,8 @@ __export(adapter_ioredis_exports, {
50
72
  adaptCluster: () => adaptCluster,
51
73
  adaptIoredis: () => adaptIoredis,
52
74
  createIoredisClient: () => createIoredisClient,
53
- createIoredisCluster: () => createIoredisCluster
75
+ createIoredisCluster: () => createIoredisCluster,
76
+ createIoredisSentinel: () => createIoredisSentinel
54
77
  });
55
78
  function adapt(client, isClusterClient = false) {
56
79
  return {
@@ -133,6 +156,9 @@ function adapt(client, isClusterClient = false) {
133
156
  on: (event, listener) => {
134
157
  client.on(event, listener);
135
158
  return client;
159
+ },
160
+ dispose: () => {
161
+ client.disconnect();
136
162
  }
137
163
  };
138
164
  }
@@ -143,18 +169,36 @@ function adaptCluster(cluster) {
143
169
  return adapt(cluster, true);
144
170
  }
145
171
  function createIoredisClient(config) {
146
- const IORedis = __require("ioredis");
172
+ const IORedis = nodeRequire("ioredis");
147
173
  const Ctor = IORedis.default ?? IORedis;
148
174
  return new Ctor(config.url, {
149
175
  password: config.password,
150
176
  connectTimeout: config.connectTimeout ?? 1500,
151
177
  tls: config.tls ? {} : void 0,
152
178
  lazyConnect: true,
153
- maxRetriesPerRequest: 1
179
+ maxRetriesPerRequest: 1,
180
+ // Batch same-tick commands into one RTT (matches redis@5's default
181
+ // behavior) — makes Promise.all fan-outs a single pipeline.
182
+ enableAutoPipelining: true
183
+ });
184
+ }
185
+ function createIoredisSentinel(config) {
186
+ const IORedis = nodeRequire("ioredis");
187
+ const Ctor = IORedis.default ?? IORedis;
188
+ return new Ctor({
189
+ sentinels: config.sentinels,
190
+ name: config.name,
191
+ password: config.password,
192
+ sentinelPassword: config.sentinelPassword,
193
+ connectTimeout: config.connectTimeout ?? 1500,
194
+ tls: config.tls ? {} : void 0,
195
+ lazyConnect: true,
196
+ maxRetriesPerRequest: 1,
197
+ enableAutoPipelining: true
154
198
  });
155
199
  }
156
200
  function createIoredisCluster(config) {
157
- const IORedis = __require("ioredis");
201
+ const IORedis = nodeRequire("ioredis");
158
202
  const ClusterCtor = IORedis.Cluster ?? IORedis.default.Cluster;
159
203
  return new ClusterCtor(config.nodes, {
160
204
  redisOptions: {
@@ -162,15 +206,29 @@ function createIoredisCluster(config) {
162
206
  tls: config.tls ? {} : void 0
163
207
  },
164
208
  enableReadyCheck: true,
165
- lazyConnect: true
209
+ lazyConnect: true,
210
+ // Same-slot commands issued in the same tick share one pipeline.
211
+ enableAutoPipelining: true
166
212
  });
167
213
  }
168
214
  var init_adapter_ioredis = __esm({
169
215
  "src/shared/client/adapter-ioredis.ts"() {
216
+ init_esm_shims();
217
+ init_node_require();
170
218
  }
171
219
  });
172
220
 
221
+ // src/index.ts
222
+ init_esm_shims();
223
+
224
+ // src/cache-components/index.ts
225
+ init_esm_shims();
226
+
227
+ // src/cache-components/handler.ts
228
+ init_esm_shims();
229
+
173
230
  // src/shared/abort.ts
231
+ init_esm_shims();
174
232
  var CacheTimeoutError = class extends Error {
175
233
  constructor(opName, ms) {
176
234
  super(`[@leejpsd/nextjs-cache-handler] ${opName} timed out after ${ms}ms`);
@@ -202,7 +260,41 @@ async function withAbortSignal(opName, ms, fn) {
202
260
  }
203
261
  }
204
262
 
263
+ // src/shared/compress.ts
264
+ init_esm_shims();
265
+ var gzipAsync = promisify(gzip);
266
+ var gunzipAsync = promisify(gunzip);
267
+ var brotliCompressAsync = promisify(brotliCompress);
268
+ var brotliDecompressAsync = promisify(brotliDecompress);
269
+ var GZIP_MARKER = "__ncgz__:";
270
+ var BROTLI_MARKER = "__ncbr__:";
271
+ var MIN_COMPRESS_BYTES = 1024;
272
+ var BROTLI_OPTS = {
273
+ params: { [constants.BROTLI_PARAM_QUALITY]: 5 }
274
+ };
275
+ async function compressValue(value, algo) {
276
+ if (!algo || Buffer.byteLength(value) < MIN_COMPRESS_BYTES) return value;
277
+ const input = Buffer.from(value);
278
+ if (algo === "gzip") {
279
+ return GZIP_MARKER + (await gzipAsync(input)).toString("base64");
280
+ }
281
+ return BROTLI_MARKER + (await brotliCompressAsync(input, BROTLI_OPTS)).toString("base64");
282
+ }
283
+ async function decompressValue(value) {
284
+ if (value === null) return null;
285
+ if (value.startsWith(GZIP_MARKER)) {
286
+ const buf = Buffer.from(value.slice(GZIP_MARKER.length), "base64");
287
+ return (await gunzipAsync(buf)).toString("utf8");
288
+ }
289
+ if (value.startsWith(BROTLI_MARKER)) {
290
+ const buf = Buffer.from(value.slice(BROTLI_MARKER.length), "base64");
291
+ return (await brotliDecompressAsync(buf)).toString("utf8");
292
+ }
293
+ return value;
294
+ }
295
+
205
296
  // src/shared/client/index.ts
297
+ init_esm_shims();
206
298
  async function buildClient(source) {
207
299
  if (typeof source === "function") {
208
300
  return await source();
@@ -220,6 +312,10 @@ async function buildClient(source) {
220
312
  const { adaptCluster: adaptCluster2, createIoredisCluster: createIoredisCluster2 } = await Promise.resolve().then(() => (init_adapter_ioredis(), adapter_ioredis_exports));
221
313
  return adaptCluster2(createIoredisCluster2(source));
222
314
  }
315
+ case "sentinel": {
316
+ const { adaptIoredis: adaptIoredis2, createIoredisSentinel: createIoredisSentinel2 } = await Promise.resolve().then(() => (init_adapter_ioredis(), adapter_ioredis_exports));
317
+ return adaptIoredis2(createIoredisSentinel2(source));
318
+ }
223
319
  default: {
224
320
  const exhaustive = source;
225
321
  throw new Error(
@@ -229,28 +325,45 @@ async function buildClient(source) {
229
325
  }
230
326
  }
231
327
  var ConnectionManager = class {
232
- constructor(source, onError) {
328
+ constructor(source, onError, retry = {}) {
233
329
  this.source = source;
234
330
  this.onError = onError;
331
+ this.baseCooldownMs = retry.baseCooldownMs ?? 1e3;
332
+ this.maxCooldownMs = retry.maxCooldownMs ?? 3e4;
235
333
  }
236
334
  source;
237
335
  onError;
238
336
  client = null;
239
337
  connecting = null;
240
- failed = false;
338
+ consecutiveFailures = 0;
339
+ lastFailureAt = 0;
340
+ baseCooldownMs;
341
+ maxCooldownMs;
342
+ cooldownMs() {
343
+ if (this.consecutiveFailures === 0) return 0;
344
+ return Math.min(
345
+ this.maxCooldownMs,
346
+ this.baseCooldownMs * 2 ** (this.consecutiveFailures - 1)
347
+ );
348
+ }
241
349
  async getOrConnect() {
242
350
  if (this.client?.isOpen) return this.client;
243
- if (this.failed) return null;
244
351
  if (this.connecting) return this.connecting;
352
+ if (Date.now() - this.lastFailureAt < this.cooldownMs()) return null;
353
+ const stale = this.client;
354
+ this.client = null;
245
355
  this.connecting = (async () => {
356
+ disposeQuietly(stale);
246
357
  try {
247
358
  const c = await buildClient(this.source);
248
359
  c.on("error", (err) => this.onError(err));
249
360
  if (!c.isOpen) await c.connect();
250
361
  this.client = c;
362
+ this.consecutiveFailures = 0;
251
363
  return c;
252
364
  } catch (err) {
253
- this.failed = true;
365
+ this.consecutiveFailures += 1;
366
+ this.lastFailureAt = Date.now();
254
367
  this.onError(err);
255
368
  return null;
256
369
  } finally {
@@ -260,13 +373,24 @@ var ConnectionManager = class {
260
373
  return this.connecting;
261
374
  }
262
375
  reset() {
376
+ disposeQuietly(this.client);
263
377
  this.client = null;
264
378
  this.connecting = null;
265
- this.failed = false;
379
+ this.consecutiveFailures = 0;
380
+ this.lastFailureAt = 0;
266
381
  }
267
382
  };
383
+ function disposeQuietly(client) {
384
+ if (!client) return;
385
+ const c = client;
386
+ try {
387
+ (c.dispose ?? c.destroy ?? c.disconnect)?.call(c);
388
+ } catch {
389
+ }
390
+ }
268
391
 
269
392
  // src/shared/logger.ts
393
+ init_esm_shims();
270
394
  var defaultLogger = {
271
395
  debug: () => {
272
396
  },
@@ -282,6 +406,9 @@ var defaultLogger = {
282
406
  }
283
407
  };
284
408
 
409
+ // src/shared/lua/index.ts
410
+ init_esm_shims();
411
+
285
412
  // src/shared/lua/set-with-tags.lua
286
413
  var set_with_tags_default = `-- set-with-tags.lua
287
414
  --
@@ -385,13 +512,19 @@ async function execLuaScript(client, name, keys, args) {
385
512
  }
386
513
 
387
514
  // src/shared/memory-fallback.ts
515
+ init_esm_shims();
388
516
  var MAX_TIMER_MS = 2147483647;
389
517
  function clampTimerMs(ms) {
390
518
  return Math.min(Math.max(0, ms), MAX_TIMER_MS);
391
519
  }
520
+ var DEFAULT_MAX_ENTRIES = 1e3;
392
521
  var MemoryStore = class {
393
522
  kv = /* @__PURE__ */ new Map();
394
523
  timers = /* @__PURE__ */ new Map();
524
+ maxEntries;
525
+ constructor(maxEntries = DEFAULT_MAX_ENTRIES) {
526
+ this.maxEntries = Math.max(1, maxEntries);
527
+ }
395
528
  set(key, value, ttlSeconds) {
396
529
  this.delete(key);
397
530
  const expiresAt = Date.now() + ttlSeconds * 1e3;
@@ -401,6 +534,10 @@ var MemoryStore = class {
401
534
  timer.unref();
402
535
  }
403
536
  this.timers.set(key, timer);
537
+ while (this.kv.size > this.maxEntries) {
538
+ const oldest = this.kv.keys().next().value;
539
+ this.delete(oldest);
540
+ }
404
541
  }
405
542
  get(key) {
406
543
  const entry = this.kv.get(key);
@@ -409,6 +546,8 @@ var MemoryStore = class {
409
546
  this.delete(key);
410
547
  return null;
411
548
  }
549
+ this.kv.delete(key);
550
+ this.kv.set(key, entry);
412
551
  return entry.value;
413
552
  }
414
553
  delete(key) {
@@ -443,13 +582,19 @@ var MemorySetStore = class {
443
582
  sets = /* @__PURE__ */ new Map();
444
583
  expiresAt = /* @__PURE__ */ new Map();
445
584
  timers = /* @__PURE__ */ new Map();
585
+ maxSets;
586
+ constructor(maxSets = DEFAULT_MAX_ENTRIES) {
587
+ this.maxSets = Math.max(1, maxSets);
588
+ }
446
589
  sAdd(key, member) {
447
590
  const members = Array.isArray(member) ? member : [member];
448
591
  let set = this.sets.get(key);
449
592
  if (!set) {
450
593
  set = /* @__PURE__ */ new Set();
451
- this.sets.set(key, set);
594
+ } else {
595
+ this.sets.delete(key);
452
596
  }
597
+ this.sets.set(key, set);
453
598
  let added = 0;
454
599
  for (const m of members) {
455
600
  if (!set.has(m)) {
@@ -457,6 +602,10 @@ var MemorySetStore = class {
457
602
  added += 1;
458
603
  }
459
604
  }
605
+ while (this.sets.size > this.maxSets) {
606
+ const oldest = this.sets.keys().next().value;
607
+ this.delete(oldest);
608
+ }
460
609
  return added;
461
610
  }
462
611
  sMembers(key) {
@@ -509,6 +658,7 @@ var MemorySetStore = class {
509
658
  };
510
659
 
511
660
  // src/shared/metrics.ts
661
+ init_esm_shims();
512
662
  var counters = /* @__PURE__ */ new Map();
513
663
  function bump(event) {
514
664
  const existing = counters.get(event.type);
@@ -548,6 +698,7 @@ function getMetricSnapshot() {
548
698
  }
549
699
 
550
700
  // src/shared/namespace.ts
701
+ init_esm_shims();
551
702
  var FALLBACK = "unversioned";
552
703
  function resolveBuildNamespace(override) {
553
704
  if (override !== void 0) {
@@ -561,6 +712,7 @@ function buildKey(prefix, namespace, rest, hashTag = false) {
561
712
  }
562
713
 
563
714
  // src/shared/build-phase.ts
715
+ init_esm_shims();
564
716
  var PHASE_PRODUCTION_BUILD = "phase-production-build";
565
717
  function isBuildPhase() {
566
718
  return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD;
@@ -573,6 +725,7 @@ function shouldUseRedis(opts) {
573
725
  }
574
726
 
575
727
  // src/cache-components/serialize.ts
728
+ init_esm_shims();
576
729
  async function readStreamFully(stream) {
577
730
  const reader = stream.getReader();
578
731
  const chunks = [];
@@ -615,6 +768,7 @@ function decodeEnvelope(raw) {
615
768
  }
616
769
 
617
770
  // src/cache-components/swr.ts
771
+ init_esm_shims();
618
772
  var SECOND_MS = 1e3;
619
773
  function partitionEntry(entry, now = Date.now()) {
620
774
  const ageMs = now - entry.timestamp;
@@ -673,8 +827,8 @@ function init(opts) {
673
827
  logger,
674
828
  emit,
675
829
  conn,
676
- memEntries: new MemoryStore(),
677
- memTags: new MemorySetStore(),
830
+ memEntries: new MemoryStore(opts.memoryMaxEntries),
831
+ memTags: new MemorySetStore(opts.memoryMaxEntries),
678
832
  memTagExp: /* @__PURE__ */ new Map(),
679
833
  localTagTimestamps: /* @__PURE__ */ new Map(),
680
834
  resolveNs: () => resolveBuildNamespace(opts.buildNamespace),
@@ -791,6 +945,12 @@ async function getImpl(state, cacheKey, softTags) {
791
945
  state.emit({ type: "cache.miss", ms: Date.now() - start });
792
946
  return void 0;
793
947
  }
948
+ try {
949
+ raw = await decompressValue(raw);
950
+ } catch {
951
+ state.emit({ type: "cache.miss", meta: { reason: "decode-error" } });
952
+ return void 0;
953
+ }
794
954
  envelope = decodeEnvelope(raw);
795
955
  if (!envelope) {
796
956
  state.emit({ type: "cache.miss", meta: { reason: "decode-error" } });
@@ -919,10 +1079,14 @@ async function setImpl(state, cacheKey, pendingEntry) {
919
1079
  }
920
1080
  return;
921
1081
  }
1082
+ const wirePayload = await compressValue(
1083
+ payload,
1084
+ state.opts.rest.compression
1085
+ );
922
1086
  if (entry.tags.length === 0) {
923
- await client.set(eKey, payload, { EX: ttl });
1087
+ await client.set(eKey, wirePayload, { EX: ttl });
924
1088
  } else {
925
- await luaSetWithTags(client, state, eKey, payload, ttl, entry.tags);
1089
+ await luaSetWithTags(client, state, eKey, wirePayload, ttl, entry.tags);
926
1090
  }
927
1091
  }
928
1092
  );
@@ -958,28 +1122,36 @@ async function refreshTagsImpl(state) {
958
1122
  async () => {
959
1123
  const client = await state.conn.getOrConnect();
960
1124
  if (!client) return;
961
- const pattern = `${state.opts.keyPrefix}${TAG_EXP_SUFFIX}*`;
962
- const seen = [];
1125
+ const ns = state.opts.hashTag ? `{${state.resolveNs()}}` : state.resolveNs();
1126
+ const pattern = `${escapeMatch(
1127
+ `${state.opts.keyPrefix}${TAG_EXP_SUFFIX}${ns}:`
1128
+ )}*`;
1129
+ const scanStartMs = Date.now();
1130
+ const fresh = /* @__PURE__ */ new Map();
963
1131
  for await (const chunk of client.scanIterator({
964
1132
  MATCH: pattern,
965
1133
  COUNT: 100
966
1134
  })) {
967
1135
  const keys = Array.isArray(chunk) ? chunk.map(String) : [String(chunk)];
968
- seen.push(...keys);
969
- if (seen.length >= 500) break;
1136
+ if (keys.length === 0) continue;
1137
+ const values = await client.mGet(keys);
1138
+ for (let i = 0; i < keys.length; i += 1) {
1139
+ const v = values[i];
1140
+ if (!v) continue;
1141
+ const ts = Number.parseInt(v, 10);
1142
+ if (!Number.isFinite(ts)) continue;
1143
+ const key = keys[i];
1144
+ if (key === void 0) continue;
1145
+ const tag = stripExpPrefix(state, key);
1146
+ if (tag) fresh.set(tag, ts);
1147
+ }
970
1148
  }
971
- if (seen.length === 0) return;
972
- const values = await client.mGet(seen);
973
- for (let i = 0; i < seen.length; i += 1) {
974
- const v = values[i];
975
- if (!v) continue;
976
- const ts = Number.parseInt(v, 10);
977
- if (!Number.isFinite(ts)) continue;
978
- const key = seen[i];
979
- if (key === void 0) continue;
980
- const tag = stripExpPrefix(state, key);
981
- if (tag) state.localTagTimestamps.set(tag, ts);
1149
+ for (const [tag, ts] of state.localTagTimestamps) {
1150
+ if (ts >= scanStartMs && (fresh.get(tag) ?? 0) < ts) {
1151
+ fresh.set(tag, ts);
1152
+ }
982
1153
  }
1154
+ state.localTagTimestamps = fresh;
983
1155
  }
984
1156
  );
985
1157
  } catch (err) {
@@ -988,6 +1160,19 @@ async function refreshTagsImpl(state) {
988
1160
  });
989
1161
  }
990
1162
  }
1163
+ function escapeMatch(s) {
1164
+ return s.replace(/[\\*?[\]]/g, "\\$&");
1165
+ }
1166
+ var MAX_LOCAL_TAG_ENTRIES = 1e4;
1167
+ function pruneOldestByTimestamp(map, maxEntries) {
1168
+ if (map.size <= maxEntries) return;
1169
+ const byAge = [...map.entries()].sort((a, b) => a[1] - b[1]);
1170
+ const drop = map.size - maxEntries;
1171
+ for (let i = 0; i < drop; i += 1) {
1172
+ const entry = byAge[i];
1173
+ if (entry) map.delete(entry[0]);
1174
+ }
1175
+ }
991
1176
  function stripExpPrefix(state, fullKey) {
992
1177
  const ns = state.resolveNs();
993
1178
  const prefix = state.opts.hashTag ? `${state.opts.keyPrefix}${TAG_EXP_SUFFIX}{${ns}}:` : `${state.opts.keyPrefix}${TAG_EXP_SUFFIX}${ns}:`;
@@ -1039,6 +1224,7 @@ async function updateTagsImpl(state, tags, durations) {
1039
1224
  state.localTagTimestamps.set(t, now);
1040
1225
  state.memTagExp.set(t, now);
1041
1226
  }
1227
+ pruneOldestByTimestamp(state.memTagExp, MAX_LOCAL_TAG_ENTRIES);
1042
1228
  if (!gateRedis(state)) {
1043
1229
  if (isHardExpire) {
1044
1230
  for (const t of tags) {
@@ -1068,20 +1254,18 @@ async function updateTagsImpl(state, tags, durations) {
1068
1254
  }
1069
1255
  return;
1070
1256
  }
1071
- for (const t of tags) {
1072
- if (isHardExpire) {
1073
- await execLuaScript(
1257
+ await Promise.all(
1258
+ tags.map(
1259
+ (t) => isHardExpire ? execLuaScript(
1074
1260
  client,
1075
1261
  "revalidateHard",
1076
1262
  [tagKey(state, t), tagExpKey(state, t)],
1077
1263
  [String(now), String(TAG_EXP_MARKER_TTL_SEC)]
1078
- );
1079
- } else {
1080
- await client.set(tagExpKey(state, t), String(now), {
1264
+ ) : client.set(tagExpKey(state, t), String(now), {
1081
1265
  EX: TAG_EXP_MARKER_TTL_SEC
1082
- });
1083
- }
1084
- }
1266
+ })
1267
+ )
1268
+ );
1085
1269
  }
1086
1270
  );
1087
1271
  state.emit({
@@ -1111,7 +1295,14 @@ function createCacheComponentsHandler(opts) {
1111
1295
  };
1112
1296
  }
1113
1297
 
1298
+ // src/incremental/index.ts
1299
+ init_esm_shims();
1300
+
1301
+ // src/incremental/handler.ts
1302
+ init_esm_shims();
1303
+
1114
1304
  // src/incremental/serialize.ts
1305
+ init_esm_shims();
1115
1306
  function isEnvelope(value) {
1116
1307
  return typeof value === "object" && value !== null && "__type" in value && typeof value.__type === "string";
1117
1308
  }
@@ -1193,7 +1384,7 @@ function init2(opts) {
1193
1384
  logger,
1194
1385
  emit,
1195
1386
  conn,
1196
- memEntries: new MemoryStore(),
1387
+ memEntries: new MemoryStore(opts.memoryMaxEntries),
1197
1388
  memTagStates: /* @__PURE__ */ new Map(),
1198
1389
  resolveNs: () => resolveBuildNamespace(opts.buildNamespace)
1199
1390
  };
@@ -1252,7 +1443,7 @@ function normalizeTtlSeconds(data, ctx) {
1252
1443
  return ONE_YEAR_SEC2;
1253
1444
  }
1254
1445
  }
1255
- const revalidate = ctx?.cacheControl?.revalidate;
1446
+ const revalidate = ctx?.cacheControl?.revalidate ?? ctx?.revalidate;
1256
1447
  if (revalidate === false || revalidate === void 0) return ONE_YEAR_SEC2;
1257
1448
  if (typeof revalidate === "number" && revalidate > 0) {
1258
1449
  return Math.min(Math.max(Math.ceil(revalidate), MIN_TTL_SEC), ONE_YEAR_SEC2);
@@ -1290,10 +1481,49 @@ async function readTagStates(state, tags) {
1290
1481
  var IncrementalRedisCacheHandler = class {
1291
1482
  state;
1292
1483
  revalidatedTags;
1484
+ /**
1485
+ * Request-scoped memo of Redis entry reads. Next.js instantiates this class
1486
+ * per request (and calls resetRequestCache() at request boundaries when it
1487
+ * reuses an instance), so entries live at most one request. Bounded by the
1488
+ * number of distinct cache keys a single request touches.
1489
+ */
1490
+ requestMemo = /* @__PURE__ */ new Map();
1293
1491
  constructor(state, ctx) {
1294
1492
  this.state = state;
1295
1493
  this.revalidatedTags = normalizeTags(ctx?.revalidatedTags);
1296
1494
  }
1495
+ /**
1496
+ * Redis entry read, deduplicated per request: parallel RSC renders or
1497
+ * repeated fetch() calls hitting the same cache key share one GET. The
1498
+ * memoized promise never rejects — errors are converted to a miss (with
1499
+ * the same timeout metric the inline path emitted before).
1500
+ */
1501
+ readEntryDeduped(eKey) {
1502
+ const memoized = this.requestMemo.get(eKey);
1503
+ if (memoized) {
1504
+ this.state.emit({ type: "cache.get.deduped" });
1505
+ return memoized;
1506
+ }
1507
+ const read = withAbortSignal(
1508
+ "incremental.get",
1509
+ this.state.opts.abortTimeoutMs,
1510
+ async () => {
1511
+ const client = await this.state.conn.getOrConnect();
1512
+ if (!client) return null;
1513
+ return await decompressValue(await client.get(eKey));
1514
+ }
1515
+ ).catch((err) => {
1516
+ if (err instanceof CacheTimeoutError) {
1517
+ this.state.emit({
1518
+ type: "redis.timeout",
1519
+ meta: { op: "incremental.get" }
1520
+ });
1521
+ }
1522
+ return null;
1523
+ });
1524
+ this.requestMemo.set(eKey, read);
1525
+ return read;
1526
+ }
1297
1527
  async get(cacheKey, ctx) {
1298
1528
  try {
1299
1529
  const requestedTags = normalizeTags([
@@ -1305,24 +1535,11 @@ var IncrementalRedisCacheHandler = class {
1305
1535
  const useRedis = !useLocal && gateRedis2(this.state);
1306
1536
  let raw = null;
1307
1537
  if (useRedis) {
1308
- try {
1309
- raw = await withAbortSignal(
1310
- "incremental.get",
1311
- this.state.opts.abortTimeoutMs,
1312
- async () => {
1313
- const client = await this.state.conn.getOrConnect();
1314
- if (!client) return null;
1315
- return await client.get(eKey);
1316
- }
1317
- );
1318
- } catch (err) {
1319
- if (err instanceof CacheTimeoutError) {
1320
- this.state.emit({ type: "redis.timeout", meta: { op: "incremental.get" } });
1321
- }
1322
- raw = null;
1323
- }
1538
+ raw = await this.readEntryDeduped(eKey);
1539
+ }
1540
+ if (raw === null && (useLocal || this.state.opts.fallback !== "never")) {
1541
+ raw = this.state.memEntries.get(eKey);
1324
1542
  }
1325
- if (raw === null) raw = this.state.memEntries.get(eKey);
1326
1543
  if (raw === null) {
1327
1544
  this.state.emit({ type: "cache.miss" });
1328
1545
  return null;
@@ -1335,7 +1552,7 @@ var IncrementalRedisCacheHandler = class {
1335
1552
  return null;
1336
1553
  }
1337
1554
  const tagStates = await readTagStates(this.state, tags);
1338
- const isFetch = ctx?.kind === "FETCH";
1555
+ const isFetch = ctx?.kind === "FETCH" || ctx?.kindHint?.toLowerCase() === "fetch" || typeof parsed.value === "object" && parsed.value !== null && parsed.value.kind === "FETCH";
1339
1556
  if (tagStates.some((s) => isTagStateExpired(s, parsed.lastModified, isFetch))) {
1340
1557
  this.state.emit({ type: "cache.miss", meta: { reason: "tag-state-expired" } });
1341
1558
  return null;
@@ -1358,6 +1575,7 @@ var IncrementalRedisCacheHandler = class {
1358
1575
  const record = { lastModified: Date.now(), value: data };
1359
1576
  const serialized = serializeCacheRecord(record);
1360
1577
  const eKey = entryKey2(this.state, cacheKey);
1578
+ this.requestMemo.delete(eKey);
1361
1579
  const useRedis = !useLocal && gateRedis2(this.state);
1362
1580
  if (useRedis) {
1363
1581
  try {
@@ -1367,10 +1585,19 @@ var IncrementalRedisCacheHandler = class {
1367
1585
  async () => {
1368
1586
  const client = await this.state.conn.getOrConnect();
1369
1587
  if (!client) {
1370
- this.state.memEntries.set(eKey, serialized, ttl);
1588
+ if (this.state.opts.fallback !== "never") {
1589
+ this.state.memEntries.set(eKey, serialized, ttl);
1590
+ }
1371
1591
  return;
1372
1592
  }
1373
- await client.set(eKey, serialized, { EX: ttl });
1593
+ await client.set(
1594
+ eKey,
1595
+ await compressValue(
1596
+ serialized,
1597
+ this.state.opts.rest.compression
1598
+ ),
1599
+ { EX: ttl }
1600
+ );
1374
1601
  }
1375
1602
  );
1376
1603
  this.state.emit({ type: "cache.set", meta: { backend: "redis" } });
@@ -1381,8 +1608,15 @@ var IncrementalRedisCacheHandler = class {
1381
1608
  }
1382
1609
  }
1383
1610
  }
1384
- this.state.memEntries.set(eKey, serialized, ttl);
1385
- this.state.emit({ type: "cache.set", meta: { backend: "memory" } });
1611
+ if (useLocal || this.state.opts.fallback !== "never") {
1612
+ this.state.memEntries.set(eKey, serialized, ttl);
1613
+ this.state.emit({ type: "cache.set", meta: { backend: "memory" } });
1614
+ } else {
1615
+ this.state.emit({
1616
+ type: "cache.set.failed",
1617
+ meta: { reason: "no-fallback" }
1618
+ });
1619
+ }
1386
1620
  } catch (err) {
1387
1621
  this.state.logger.error("set() error", {
1388
1622
  message: err.message
@@ -1436,6 +1670,7 @@ var IncrementalRedisCacheHandler = class {
1436
1670
  }
1437
1671
  }
1438
1672
  resetRequestCache() {
1673
+ this.requestMemo.clear();
1439
1674
  }
1440
1675
  };
1441
1676
  function createIncrementalCacheHandler(opts) {