@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.
- package/CHANGELOG.md +42 -0
- package/README.md +10 -3
- package/bin/cli.cjs +10 -0
- package/dist/cache-components/index.cjs +150 -4
- package/dist/cache-components/index.cjs.map +1 -1
- package/dist/cache-components/index.d.cts +1 -1
- package/dist/cache-components/index.d.ts +1 -1
- package/dist/cache-components/index.js +150 -4
- package/dist/cache-components/index.js.map +1 -1
- package/dist/cli/index.cjs +938 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +28 -0
- package/dist/cli/index.d.ts +28 -0
- package/dist/cli/index.js +928 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/incremental/index.cjs +81 -6
- package/dist/incremental/index.cjs.map +1 -1
- package/dist/incremental/index.d.cts +1 -1
- package/dist/incremental/index.d.ts +1 -1
- package/dist/incremental/index.js +81 -6
- package/dist/incremental/index.js.map +1 -1
- package/dist/index.cjs +157 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +157 -8
- package/dist/index.js.map +1 -1
- package/dist/ops/index.d.cts +1 -1
- package/dist/ops/index.d.ts +1 -1
- package/dist/otel/index.d.cts +1 -1
- package/dist/otel/index.d.ts +1 -1
- package/dist/seed/index.cjs +561 -0
- package/dist/seed/index.cjs.map +1 -0
- package/dist/seed/index.d.cts +53 -0
- package/dist/seed/index.d.ts +53 -0
- package/dist/seed/index.js +550 -0
- package/dist/seed/index.js.map +1 -0
- package/dist/shared/client/adapter-ioredis.cjs +40 -1
- package/dist/shared/client/adapter-ioredis.cjs.map +1 -1
- package/dist/shared/client/adapter-ioredis.d.cts +1 -1
- package/dist/shared/client/adapter-ioredis.d.ts +1 -1
- package/dist/shared/client/adapter-ioredis.js +40 -1
- package/dist/shared/client/adapter-ioredis.js.map +1 -1
- package/dist/shared/client/adapter-redis.cjs +34 -1
- package/dist/shared/client/adapter-redis.cjs.map +1 -1
- package/dist/shared/client/adapter-redis.d.cts +1 -1
- package/dist/shared/client/adapter-redis.d.ts +1 -1
- package/dist/shared/client/adapter-redis.js +34 -1
- package/dist/shared/client/adapter-redis.js.map +1 -1
- package/dist/{types-Bl-rkLKX.d.cts → types-C1vJ_sED.d.cts} +25 -1
- package/dist/{types-Bl-rkLKX.d.ts → types-C1vJ_sED.d.ts} +25 -1
- package/package.json +22 -2
- package/setup-instructions/setup.md +31 -9
- 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
|
|
249
|
-
|
|
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` |
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
984
|
-
|
|
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" } });
|