@leejpsd/nextjs-cache-handler 0.3.4 → 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 (54) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +5 -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 +937 -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 +927 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/incremental/index.cjs +77 -3
  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 +77 -3
  21. package/dist/incremental/index.js.map +1 -1
  22. package/dist/index.cjs +153 -5
  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 +153 -5
  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 +560 -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 +549 -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,37 @@
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
+
3
35
  ## 0.3.4
4
36
 
5
37
  ### Patch Changes
package/README.md CHANGED
@@ -245,8 +245,10 @@ Full reference: [`docs/api.md`](./docs/api.md).
245
245
  on the same hash slot. Without `hashTag`, cluster deployments will hit
246
246
  `CROSSSLOT Keys in request don't hash to the same slot`. The flag wraps
247
247
  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.
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.
250
252
  - [ ] **Redis `maxmemory-policy: allkeys-lru` or `noeviction`** — if you need
251
253
  bounded memory, choose `allkeys-lru`. Otherwise `noeviction` keeps
252
254
  tag indices intact.
@@ -261,7 +263,7 @@ Full reference: [`docs/api.md`](./docs/api.md).
261
263
  | Service | How to use | Tested? |
262
264
  |---|---|---|
263
265
  | **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 |
266
+ | **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | ✅ e2e-tested against a real 3-master cluster (CI); not yet load-tested at scale |
265
267
  | **Upstash Redis** | `{ type: "redis", url: "rediss://..." }` (TLS auto-detected) | not yet validated, expected to work via the standard Redis protocol |
266
268
  | **AWS ElastiCache (replication group)** | `{ type: "redis", url: "rediss://..." }` | ✅ reference deployment (re-verified 2026-08-01, Seoul) |
267
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" } });