@leejpsd/nextjs-cache-handler 0.3.4 → 0.4.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 (54) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +10 -3
  3. package/bin/cli.cjs +10 -0
  4. package/dist/cache-components/index.cjs +150 -4
  5. package/dist/cache-components/index.cjs.map +1 -1
  6. package/dist/cache-components/index.d.cts +1 -1
  7. package/dist/cache-components/index.d.ts +1 -1
  8. package/dist/cache-components/index.js +150 -4
  9. package/dist/cache-components/index.js.map +1 -1
  10. package/dist/cli/index.cjs +938 -0
  11. package/dist/cli/index.cjs.map +1 -0
  12. package/dist/cli/index.d.cts +28 -0
  13. package/dist/cli/index.d.ts +28 -0
  14. package/dist/cli/index.js +928 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/incremental/index.cjs +81 -6
  17. package/dist/incremental/index.cjs.map +1 -1
  18. package/dist/incremental/index.d.cts +1 -1
  19. package/dist/incremental/index.d.ts +1 -1
  20. package/dist/incremental/index.js +81 -6
  21. package/dist/incremental/index.js.map +1 -1
  22. package/dist/index.cjs +157 -8
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.cts +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +157 -8
  27. package/dist/index.js.map +1 -1
  28. package/dist/ops/index.d.cts +1 -1
  29. package/dist/ops/index.d.ts +1 -1
  30. package/dist/otel/index.d.cts +1 -1
  31. package/dist/otel/index.d.ts +1 -1
  32. package/dist/seed/index.cjs +561 -0
  33. package/dist/seed/index.cjs.map +1 -0
  34. package/dist/seed/index.d.cts +53 -0
  35. package/dist/seed/index.d.ts +53 -0
  36. package/dist/seed/index.js +550 -0
  37. package/dist/seed/index.js.map +1 -0
  38. package/dist/shared/client/adapter-ioredis.cjs +40 -1
  39. package/dist/shared/client/adapter-ioredis.cjs.map +1 -1
  40. package/dist/shared/client/adapter-ioredis.d.cts +1 -1
  41. package/dist/shared/client/adapter-ioredis.d.ts +1 -1
  42. package/dist/shared/client/adapter-ioredis.js +40 -1
  43. package/dist/shared/client/adapter-ioredis.js.map +1 -1
  44. package/dist/shared/client/adapter-redis.cjs +34 -1
  45. package/dist/shared/client/adapter-redis.cjs.map +1 -1
  46. package/dist/shared/client/adapter-redis.d.cts +1 -1
  47. package/dist/shared/client/adapter-redis.d.ts +1 -1
  48. package/dist/shared/client/adapter-redis.js +34 -1
  49. package/dist/shared/client/adapter-redis.js.map +1 -1
  50. package/dist/{types-Bl-rkLKX.d.cts → types-C1vJ_sED.d.cts} +25 -1
  51. package/dist/{types-Bl-rkLKX.d.ts → types-C1vJ_sED.d.ts} +25 -1
  52. package/package.json +22 -2
  53. package/setup-instructions/setup.md +31 -9
  54. package/skills/nextjs-redis-cache/SKILL.md +15 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`8555fbe`](https://github.com/leejpsd/nextjs-cache-handler/commit/8555fbeb10c8681f101f13157a078b0a1e3fcea5) Thanks [@leejpsd](https://github.com/leejpsd)! - Fix ISR record serialization writing Buffers as JSON byte arrays instead of base64.
8
+
9
+ `Buffer.prototype.toJSON` runs before a `JSON.stringify` replacer sees the value, so the base64 envelope branch was unreachable and every Buffer field (rendered HTML, RSC payloads, PPR segment data) was stored as `{ type: "Buffer", data: [...] }` — roughly 2.8x larger than base64. The replacer now reads the pre-`toJSON` original via `this[key]`.
10
+
11
+ No migration needed: the reviver has always accepted both shapes, so records written by either version deserialize correctly in both directions.
12
+
13
+ ## 0.4.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [#7](https://github.com/leejpsd/nextjs-cache-handler/pull/7) [`bf047c9`](https://github.com/leejpsd/nextjs-cache-handler/commit/bf047c937985dafabed7c60ab1972a749a8f3b9f) Thanks [@leejpsd](https://github.com/leejpsd)! - Build-output cache seeding: `seedBuildOutput()` (new `/seed` entry point) and
18
+ `npx nextjs-cache-handler seed` walk `.next/` after a build and insert
19
+ prerendered App Router routes (including PPR segment data), Pages Router
20
+ routes, and fetch-cache entries into Redis in the handler's own record
21
+ format — with NX semantics so entries already written by live instances are
22
+ never overwritten. A fresh deployment's first requests are cache HITs
23
+ instead of a regeneration stampede (verified on a real Next 16 app: cold
24
+ server + seeded Redis → first request `x-nextjs-cache: HIT`). The
25
+ `RedisClientLike.set` contract gains an optional `NX` flag.
26
+
27
+ - [#7](https://github.com/leejpsd/nextjs-cache-handler/pull/7) [`1e57e03`](https://github.com/leejpsd/nextjs-cache-handler/commit/1e57e03020926933f524796645b279d004dddf2a) Thanks [@leejpsd](https://github.com/leejpsd)! - New `nextjs-cache-handler` CLI (zero-dependency): `init` detects the Next.js
28
+ version and Redis client, generates the handler wrapper shims, shows the
29
+ next.config keys to add (never edits it), appends env templates, and injects
30
+ the agent rules block into CLAUDE.md/AGENTS.md idempotently (`--yes` to
31
+ apply, `--skills` to install the agent skill locally). `doctor` verifies
32
+ Redis connectivity, inspects cache key namespaces, and runs a write/read
33
+ round-trip — the first command an agent should reach for when debugging.
34
+
35
+ - [#7](https://github.com/leejpsd/nextjs-cache-handler/pull/7) [`6cd5901`](https://github.com/leejpsd/nextjs-cache-handler/commit/6cd59010d30b55e8a5f9b99db8ec53b0e64e03ce) Thanks [@leejpsd](https://github.com/leejpsd)! - Opt-in push-based tag propagation (`tagPubSub: true`, plural handler):
36
+ `updateTags()` publishes invalidations on a namespaced channel and every
37
+ instance maintains a subscription on a dedicated duplicate connection,
38
+ updating its local tag mirror in ~3 ms (measured cross-instance over real
39
+ Redis with both redis@5 and ioredis) instead of waiting for the next
40
+ `refreshTags()` scan (~seconds). The scan keeps running as the consistency
41
+ safety net, so a dropped subscription degrades to the previous behavior —
42
+ never to staleness. Cluster clients fall back to polling with a one-time
43
+ warning. `RedisClientLike` gains optional `publish`/`subscribe`.
44
+
3
45
  ## 0.3.4
4
46
 
5
47
  ### Patch Changes
package/README.md CHANGED
@@ -31,6 +31,11 @@ troubleshooting playbook ships in the package (`AGENTS.md`,
31
31
  `skills/nextjs-redis-cache/SKILL.md`) and via
32
32
  `npx skills add leejpsd/nextjs-cache-handler`.
33
33
 
34
+ For cache operations from your agent (health, tag state, safe invalidation),
35
+ the companion MCP server is on the official registry as
36
+ `io.github.leejpsd/nextjs-cache-handler-mcp` — or one command:
37
+ `npx nextjs-cache-handler init --yes` wires handlers, rules, and `.mcp.json` together.
38
+
34
39
  ---
35
40
 
36
41
  The Redis cache handler for **Next.js 15/16** that ships **both** `cacheHandler`
@@ -245,8 +250,10 @@ Full reference: [`docs/api.md`](./docs/api.md).
245
250
  on the same hash slot. Without `hashTag`, cluster deployments will hit
246
251
  `CROSSSLOT Keys in request don't hash to the same slot`. The flag wraps
247
252
  the namespace in `{}` so every key for a given deploy hashes together.
248
- Cluster support is implemented but **not yet load-tested at production
249
- scale**; PRs welcome.
253
+ Cluster support is validated by a dedicated e2e suite against a real
254
+ 3-master cluster (`npm run test:cluster`, also in CI) — covering the
255
+ multi-key Lua scripts, per-master SCAN propagation, and both handlers.
256
+ Not yet load-tested at production scale.
250
257
  - [ ] **Redis `maxmemory-policy: allkeys-lru` or `noeviction`** — if you need
251
258
  bounded memory, choose `allkeys-lru`. Otherwise `noeviction` keeps
252
259
  tag indices intact.
@@ -261,7 +268,7 @@ Full reference: [`docs/api.md`](./docs/api.md).
261
268
  | Service | How to use | Tested? |
262
269
  |---|---|---|
263
270
  | **Self-hosted Redis 7+** | `{ type: "redis", url }` or `{ type: "ioredis", url }` | ✅ AWS ElastiCache 24h soak |
264
- | **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | unit-tested, not yet load-tested at scale |
271
+ | **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | ✅ e2e-tested against a real 3-master cluster (CI); not yet load-tested at scale |
265
272
  | **Upstash Redis** | `{ type: "redis", url: "rediss://..." }` (TLS auto-detected) | not yet validated, expected to work via the standard Redis protocol |
266
273
  | **AWS ElastiCache (replication group)** | `{ type: "redis", url: "rediss://..." }` | ✅ reference deployment (re-verified 2026-08-01, Seoul) |
267
274
  | **Redis Sentinel** | `{ type: "sentinel", sentinels, name }` | ✅ local master/replica failover drill |
package/bin/cli.cjs ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // Thin launcher for the built CLI (keeps the tsup build shebang-free).
3
+ const { main } = require("../dist/cli/index.cjs");
4
+ main().then(
5
+ (code) => process.exit(code),
6
+ (err) => {
7
+ console.error("[error]", err && err.message ? err.message : err);
8
+ process.exit(1);
9
+ }
10
+ );
@@ -53,7 +53,40 @@ __export(adapter_redis_exports, {
53
53
  createRedisV5Client: () => createRedisV5Client
54
54
  });
55
55
  function adaptRedisV5(client) {
56
- return client;
56
+ const like = client;
57
+ like.dispose = () => {
58
+ try {
59
+ client.destroy();
60
+ } catch {
61
+ }
62
+ };
63
+ like.subscribe = async (channel, onMessage, onDown) => {
64
+ const sub = client.duplicate();
65
+ const teardown = () => {
66
+ try {
67
+ sub.destroy();
68
+ } catch {
69
+ }
70
+ };
71
+ let down = false;
72
+ const markDown = () => {
73
+ if (down) return;
74
+ down = true;
75
+ teardown();
76
+ onDown?.();
77
+ };
78
+ sub.on("error", markDown);
79
+ sub.on("end", markDown);
80
+ try {
81
+ await sub.connect();
82
+ await sub.subscribe(channel, (message) => onMessage(message));
83
+ } catch (err) {
84
+ teardown();
85
+ throw err;
86
+ }
87
+ return async () => teardown();
88
+ };
89
+ return like;
57
90
  }
58
91
  function createRedisV5Client(config) {
59
92
  const { createClient } = nodeRequire("redis");
@@ -96,7 +129,12 @@ function adapt(client, isClusterClient = false) {
96
129
  }
97
130
  },
98
131
  get: (k) => client.get(k),
99
- set: (k, v, opts) => opts?.EX !== void 0 ? client.set(k, v, "EX", opts.EX) : client.set(k, v),
132
+ set: (k, v, opts) => {
133
+ const args = [];
134
+ if (opts?.EX !== void 0) args.push("EX", opts.EX);
135
+ if (opts?.NX) args.push("NX");
136
+ return client.set(k, v, ...args);
137
+ },
100
138
  del: async (keys) => {
101
139
  const arr = Array.isArray(keys) ? keys : [keys];
102
140
  if (arr.length === 0) return 0;
@@ -167,6 +205,40 @@ function adapt(client, isClusterClient = false) {
167
205
  client.on(event, listener);
168
206
  return client;
169
207
  },
208
+ publish: (channel, message) => client.publish(channel, message),
209
+ ...isClusterClient ? {} : {
210
+ subscribe: async (channel, onMessage, onDown) => {
211
+ const sub = client.duplicate();
212
+ let down = false;
213
+ const markDown = () => {
214
+ if (down) return;
215
+ down = true;
216
+ try {
217
+ sub.disconnect();
218
+ } catch {
219
+ }
220
+ onDown?.();
221
+ };
222
+ sub.on("error", markDown);
223
+ sub.on("end", markDown);
224
+ try {
225
+ if (sub.status !== "ready" && sub.status !== "connecting") {
226
+ await sub.connect();
227
+ }
228
+ await sub.subscribe(channel);
229
+ } catch (err) {
230
+ markDown();
231
+ throw err;
232
+ }
233
+ sub.on("message", (ch, message) => {
234
+ if (ch === channel) onMessage(message);
235
+ });
236
+ return async () => {
237
+ down = true;
238
+ sub.disconnect();
239
+ };
240
+ }
241
+ },
170
242
  dispose: () => {
171
243
  client.disconnect();
172
244
  }
@@ -817,6 +889,7 @@ function init(opts) {
817
889
  hashTag: opts.hashTag ?? false,
818
890
  keyPrefix: opts.keyPrefix ?? DEFAULT_KEY_PREFIX,
819
891
  singleFlight: opts.singleFlight ?? false,
892
+ tagPubSub: opts.tagPubSub ?? false,
820
893
  singleFlightLockTtlSec: opts.singleFlightLockTtlSec ?? DEFAULT_LOCK_TTL_SEC,
821
894
  rest: opts
822
895
  },
@@ -827,6 +900,7 @@ function init(opts) {
827
900
  memTags: new MemorySetStore(opts.memoryMaxEntries),
828
901
  memTagExp: /* @__PURE__ */ new Map(),
829
902
  localTagTimestamps: /* @__PURE__ */ new Map(),
903
+ pubsub: { active: false, disabled: false, attempting: false, lastAttempt: 0 },
830
904
  resolveNs: () => resolveBuildNamespace(opts.buildNamespace),
831
905
  instanceId: process.env.HOSTNAME || process.env.ECS_TASK_ID || `pid-${process.pid}`
832
906
  };
@@ -904,6 +978,7 @@ function resolveTtlSeconds(entry) {
904
978
  async function getImpl(state, cacheKey, softTags) {
905
979
  const start = Date.now();
906
980
  const useRedis = gateRedis(state);
981
+ if (useRedis) void ensureTagSubscription(state);
907
982
  if (!useRedis && state.opts.fallback === "never") {
908
983
  state.emit({ type: "cache.miss", meta: { reason: "no-redis" } });
909
984
  return void 0;
@@ -980,8 +1055,11 @@ async function getImpl(state, cacheKey, softTags) {
980
1055
  if (freshnessTags.length > 0) {
981
1056
  let mostRecent = 0;
982
1057
  for (const t of freshnessTags) {
983
- const ts = state.localTagTimestamps.get(t);
984
- if (ts !== void 0 && ts > mostRecent) mostRecent = ts;
1058
+ const ts = Math.max(
1059
+ state.localTagTimestamps.get(t) ?? 0,
1060
+ state.memTagExp.get(t) ?? 0
1061
+ );
1062
+ if (ts > mostRecent) mostRecent = ts;
985
1063
  }
986
1064
  if (mostRecent > envelope.timestamp) {
987
1065
  if (state.opts.staleWhileRevalidate && partition.freshness !== "expired") {
@@ -1114,10 +1192,68 @@ async function luaSetWithTags(client, state, eKey, payload, ttl, tags) {
1114
1192
  [payload, String(ttl), String(ttl), String(tags.length)]
1115
1193
  );
1116
1194
  }
1195
+ function invalChannel(state) {
1196
+ const ns = state.opts.hashTag ? `{${state.resolveNs()}}` : state.resolveNs();
1197
+ return `${state.opts.keyPrefix}inval:${ns}`;
1198
+ }
1199
+ var SUBSCRIBE_RETRY_MS = 5e3;
1200
+ async function ensureTagSubscription(state) {
1201
+ if (!state.opts.tagPubSub || state.pubsub.active || state.pubsub.disabled || state.pubsub.attempting) {
1202
+ return;
1203
+ }
1204
+ const now = Date.now();
1205
+ if (now - state.pubsub.lastAttempt < SUBSCRIBE_RETRY_MS) return;
1206
+ state.pubsub.lastAttempt = now;
1207
+ state.pubsub.attempting = true;
1208
+ try {
1209
+ const client = await state.conn.getOrConnect();
1210
+ if (!client) return;
1211
+ if (typeof client.subscribe !== "function") {
1212
+ state.pubsub.disabled = true;
1213
+ state.logger.warn(
1214
+ "tagPubSub unavailable on this client (no subscribe support, e.g. Cluster) \u2014 falling back to scan-based propagation"
1215
+ );
1216
+ return;
1217
+ }
1218
+ const stop = await client.subscribe(
1219
+ invalChannel(state),
1220
+ (message) => {
1221
+ try {
1222
+ const parsed = JSON.parse(message);
1223
+ if (!Array.isArray(parsed.t) || typeof parsed.ts !== "number") return;
1224
+ for (const tag of parsed.t) {
1225
+ const prev = state.localTagTimestamps.get(tag) ?? 0;
1226
+ if (parsed.ts > prev) state.localTagTimestamps.set(tag, parsed.ts);
1227
+ const prevMem = state.memTagExp.get(tag) ?? 0;
1228
+ if (parsed.ts > prevMem) state.memTagExp.set(tag, parsed.ts);
1229
+ }
1230
+ pruneOldestByTimestamp(state.localTagTimestamps, MAX_LOCAL_TAG_ENTRIES);
1231
+ pruneOldestByTimestamp(state.memTagExp, MAX_LOCAL_TAG_ENTRIES);
1232
+ } catch {
1233
+ }
1234
+ },
1235
+ () => {
1236
+ state.pubsub.active = false;
1237
+ }
1238
+ );
1239
+ state.pubsub.active = true;
1240
+ state.pubsub.stop = async () => {
1241
+ state.pubsub.active = false;
1242
+ await stop();
1243
+ };
1244
+ } catch (err) {
1245
+ state.logger.warn("tagPubSub subscribe failed \u2014 will retry", {
1246
+ message: err.message
1247
+ });
1248
+ } finally {
1249
+ state.pubsub.attempting = false;
1250
+ }
1251
+ }
1117
1252
  async function refreshTagsImpl(state) {
1118
1253
  if (!gateRedis(state)) {
1119
1254
  return;
1120
1255
  }
1256
+ void ensureTagSubscription(state);
1121
1257
  try {
1122
1258
  await withAbortSignal(
1123
1259
  "cacheHandlers.refreshTags",
@@ -1275,6 +1411,16 @@ async function updateTagsImpl(state, tags, durations) {
1275
1411
  type: isHardExpire ? "tag.invalidate.hard" : "tag.invalidate.soft",
1276
1412
  meta: { count: tags.length, backend: "redis" }
1277
1413
  });
1414
+ if (state.opts.tagPubSub) {
1415
+ try {
1416
+ const client = await state.conn.getOrConnect();
1417
+ await client?.publish?.(
1418
+ invalChannel(state),
1419
+ JSON.stringify({ t: tags, ts: now })
1420
+ );
1421
+ } catch {
1422
+ }
1423
+ }
1278
1424
  } catch (err) {
1279
1425
  if (err instanceof CacheTimeoutError) {
1280
1426
  state.emit({ type: "redis.timeout", meta: { op: "updateTags" } });