@leejpsd/nextjs-cache-handler 0.3.3 → 0.4.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 (57) hide show
  1. package/AGENTS.md +33 -0
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +22 -3
  4. package/bin/cli.cjs +10 -0
  5. package/dist/cache-components/index.cjs +150 -4
  6. package/dist/cache-components/index.cjs.map +1 -1
  7. package/dist/cache-components/index.d.cts +1 -1
  8. package/dist/cache-components/index.d.ts +1 -1
  9. package/dist/cache-components/index.js +150 -4
  10. package/dist/cache-components/index.js.map +1 -1
  11. package/dist/cli/index.cjs +937 -0
  12. package/dist/cli/index.cjs.map +1 -0
  13. package/dist/cli/index.d.cts +28 -0
  14. package/dist/cli/index.d.ts +28 -0
  15. package/dist/cli/index.js +927 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/incremental/index.cjs +77 -3
  18. package/dist/incremental/index.cjs.map +1 -1
  19. package/dist/incremental/index.d.cts +1 -1
  20. package/dist/incremental/index.d.ts +1 -1
  21. package/dist/incremental/index.js +77 -3
  22. package/dist/incremental/index.js.map +1 -1
  23. package/dist/index.cjs +153 -5
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +153 -5
  28. package/dist/index.js.map +1 -1
  29. package/dist/ops/index.d.cts +1 -1
  30. package/dist/ops/index.d.ts +1 -1
  31. package/dist/otel/index.d.cts +1 -1
  32. package/dist/otel/index.d.ts +1 -1
  33. package/dist/seed/index.cjs +560 -0
  34. package/dist/seed/index.cjs.map +1 -0
  35. package/dist/seed/index.d.cts +53 -0
  36. package/dist/seed/index.d.ts +53 -0
  37. package/dist/seed/index.js +549 -0
  38. package/dist/seed/index.js.map +1 -0
  39. package/dist/shared/client/adapter-ioredis.cjs +40 -1
  40. package/dist/shared/client/adapter-ioredis.cjs.map +1 -1
  41. package/dist/shared/client/adapter-ioredis.d.cts +1 -1
  42. package/dist/shared/client/adapter-ioredis.d.ts +1 -1
  43. package/dist/shared/client/adapter-ioredis.js +40 -1
  44. package/dist/shared/client/adapter-ioredis.js.map +1 -1
  45. package/dist/shared/client/adapter-redis.cjs +34 -1
  46. package/dist/shared/client/adapter-redis.cjs.map +1 -1
  47. package/dist/shared/client/adapter-redis.d.cts +1 -1
  48. package/dist/shared/client/adapter-redis.d.ts +1 -1
  49. package/dist/shared/client/adapter-redis.js +34 -1
  50. package/dist/shared/client/adapter-redis.js.map +1 -1
  51. package/dist/{types-Bl-rkLKX.d.cts → types-C1vJ_sED.d.cts} +25 -1
  52. package/dist/{types-Bl-rkLKX.d.ts → types-C1vJ_sED.d.ts} +25 -1
  53. package/llms.txt +27 -0
  54. package/package.json +27 -2
  55. package/rules/nextjs-cache-rules.md +23 -0
  56. package/setup-instructions/setup.md +179 -0
  57. package/skills/nextjs-redis-cache/SKILL.md +182 -0
package/AGENTS.md ADDED
@@ -0,0 +1,33 @@
1
+ # @leejpsd/nextjs-cache-handler — notes for AI agents
2
+
3
+ Redis cache handler for **self-hosted** Next.js 15/16. Ships BOTH interfaces:
4
+ `cacheHandler` (ISR, Next 15+16) and `cacheHandlers` (`'use cache'`,
5
+ Next >= 16.1.5). Not for Vercel-hosted apps.
6
+
7
+ **Full agent guidance:** `skills/nextjs-redis-cache/SKILL.md` in this package
8
+ (also installable via `npx skills add leejpsd/nextjs-cache-handler`).
9
+ **Agent-executable setup:**
10
+ https://raw.githubusercontent.com/leejpsd/nextjs-cache-handler/main/setup-instructions/setup.md
11
+
12
+ ## Rules that prevent the most common mistakes
13
+
14
+ 1. `revalidateTag(tag, "max")` is SOFT = stale-while-revalidate: instant
15
+ stale serve + background re-render, cross-instance convergence in ~1-2s.
16
+ Brief stale content is BY DESIGN — not a bug. `updateTag(tag)` is HARD =
17
+ delete + blocking regeneration. Requires version >= 0.3.3.
18
+ 2. `cacheMaxMemorySize: 0` in next.config is mandatory for multi-instance
19
+ correctness. Do not remove it.
20
+ 3. `DEPLOYMENT_VERSION` env must exist at RUNTIME (Docker runner stage).
21
+ Missing → cross-deploy cache bleed / static chunk 404s.
22
+ 4. Redis Cluster needs `hashTag: true`; TLS via `rediss://` URLs.
23
+ 5. Build-time `ECONNREFUSED` toward Redis is expected (build-phase gate) —
24
+ only investigate if the build fails.
25
+ 6. Redis outages degrade to a bounded in-memory fallback with automatic
26
+ reconnect (backoff 1s→30s). Do not wrap cache calls in extra try/catch.
27
+ 7. Wiring shape: two CJS wrapper files + `require.resolve` in next.config —
28
+ see README "Quick start" or the setup URL above.
29
+
30
+ Entry points: `/incremental` (ISR), `/cache-components` ('use cache'),
31
+ `/client/redis`, `/client/ioredis` (incl. cluster/sentinel), `/ops`
32
+ (metric snapshot), `/otel` (OpenTelemetry emitter). API details: README.md
33
+ and https://github.com/leejpsd/nextjs-cache-handler/blob/main/docs/api.md
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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
8
+ `npx nextjs-cache-handler seed` walk `.next/` after a build and insert
9
+ prerendered App Router routes (including PPR segment data), Pages Router
10
+ routes, and fetch-cache entries into Redis in the handler's own record
11
+ format — with NX semantics so entries already written by live instances are
12
+ never overwritten. A fresh deployment's first requests are cache HITs
13
+ instead of a regeneration stampede (verified on a real Next 16 app: cold
14
+ server + seeded Redis → first request `x-nextjs-cache: HIT`). The
15
+ `RedisClientLike.set` contract gains an optional `NX` flag.
16
+
17
+ - [#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
18
+ version and Redis client, generates the handler wrapper shims, shows the
19
+ next.config keys to add (never edits it), appends env templates, and injects
20
+ the agent rules block into CLAUDE.md/AGENTS.md idempotently (`--yes` to
21
+ apply, `--skills` to install the agent skill locally). `doctor` verifies
22
+ Redis connectivity, inspects cache key namespaces, and runs a write/read
23
+ round-trip — the first command an agent should reach for when debugging.
24
+
25
+ - [#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):
26
+ `updateTags()` publishes invalidations on a namespaced channel and every
27
+ instance maintains a subscription on a dedicated duplicate connection,
28
+ updating its local tag mirror in ~3 ms (measured cross-instance over real
29
+ Redis with both redis@5 and ioredis) instead of waiting for the next
30
+ `refreshTags()` scan (~seconds). The scan keeps running as the consistency
31
+ safety net, so a dropped subscription degrades to the previous behavior —
32
+ never to staleness. Cluster clients fall back to polling with a one-time
33
+ warning. `RedisClientLike` gains optional `publish`/`subscribe`.
34
+
35
+ ## 0.3.4
36
+
37
+ ### Patch Changes
38
+
39
+ - [`79e112b`](https://github.com/leejpsd/nextjs-cache-handler/commit/79e112bc03249e6e042da717c4558de733cc62f0) Thanks [@leejpsd](https://github.com/leejpsd)! - Ship agent-native assets in the package: `AGENTS.md` (agent guidance
40
+ discovered via node_modules scans), an agent skill
41
+ (`skills/nextjs-redis-cache/SKILL.md` — decision tables, invalidation
42
+ semantics, troubleshooting playbook), agent-executable setup instructions
43
+ (`setup-instructions/setup.md`), a project rules block (`rules/`), and
44
+ `llms.txt`. README gains a "For AI agents" section. No runtime code changes.
45
+
3
46
  ## 0.3.3
4
47
 
5
48
  ### Patch Changes
package/README.md CHANGED
@@ -16,6 +16,23 @@
16
16
  > Sentinel** support, a built-in **OpenTelemetry emitter** at `/otel`, and an
17
17
  > LRU-bounded memory fallback.
18
18
 
19
+ ## 🤖 For AI agents
20
+
21
+ Working with Claude Code / Codex / Cursor? Give your agent this URL and it
22
+ will install and wire everything (version detection, wrapper files,
23
+ next.config patch, verification):
24
+
25
+ ```
26
+ https://raw.githubusercontent.com/leejpsd/nextjs-cache-handler/main/setup-instructions/setup.md
27
+ ```
28
+
29
+ An agent skill with decision tables, invalidation semantics, and a
30
+ troubleshooting playbook ships in the package (`AGENTS.md`,
31
+ `skills/nextjs-redis-cache/SKILL.md`) and via
32
+ `npx skills add leejpsd/nextjs-cache-handler`.
33
+
34
+ ---
35
+
19
36
  The Redis cache handler for **Next.js 15/16** that ships **both** `cacheHandler`
20
37
  (ISR / Pages Router) **and** `cacheHandlers` (`'use cache'` directive,
21
38
  `cacheComponents: true`) — the area where
@@ -228,8 +245,10 @@ Full reference: [`docs/api.md`](./docs/api.md).
228
245
  on the same hash slot. Without `hashTag`, cluster deployments will hit
229
246
  `CROSSSLOT Keys in request don't hash to the same slot`. The flag wraps
230
247
  the namespace in `{}` so every key for a given deploy hashes together.
231
- Cluster support is implemented but **not yet load-tested at production
232
- scale**; PRs welcome.
248
+ Cluster support is validated by a dedicated e2e suite against a real
249
+ 3-master cluster (`npm run test:cluster`, also in CI) — covering the
250
+ multi-key Lua scripts, per-master SCAN propagation, and both handlers.
251
+ Not yet load-tested at production scale.
233
252
  - [ ] **Redis `maxmemory-policy: allkeys-lru` or `noeviction`** — if you need
234
253
  bounded memory, choose `allkeys-lru`. Otherwise `noeviction` keeps
235
254
  tag indices intact.
@@ -244,7 +263,7 @@ Full reference: [`docs/api.md`](./docs/api.md).
244
263
  | Service | How to use | Tested? |
245
264
  |---|---|---|
246
265
  | **Self-hosted Redis 7+** | `{ type: "redis", url }` or `{ type: "ioredis", url }` | ✅ AWS ElastiCache 24h soak |
247
- | **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | unit-tested, not yet load-tested at scale |
266
+ | **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | ✅ e2e-tested against a real 3-master cluster (CI); not yet load-tested at scale |
248
267
  | **Upstash Redis** | `{ type: "redis", url: "rediss://..." }` (TLS auto-detected) | not yet validated, expected to work via the standard Redis protocol |
249
268
  | **AWS ElastiCache (replication group)** | `{ type: "redis", url: "rediss://..." }` | ✅ reference deployment (re-verified 2026-08-01, Seoul) |
250
269
  | **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" } });