@leejpsd/nextjs-cache-handler 0.1.0-rc.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 (42) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/LICENSE +21 -0
  3. package/README.md +259 -0
  4. package/dist/cache-components/index.cjs +1053 -0
  5. package/dist/cache-components/index.cjs.map +1 -0
  6. package/dist/cache-components/index.d.cts +85 -0
  7. package/dist/cache-components/index.d.ts +85 -0
  8. package/dist/cache-components/index.js +1049 -0
  9. package/dist/cache-components/index.js.map +1 -0
  10. package/dist/incremental/index.cjs +734 -0
  11. package/dist/incremental/index.cjs.map +1 -0
  12. package/dist/incremental/index.d.cts +131 -0
  13. package/dist/incremental/index.d.ts +131 -0
  14. package/dist/incremental/index.js +731 -0
  15. package/dist/incremental/index.js.map +1 -0
  16. package/dist/index.cjs +1408 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +59 -0
  19. package/dist/index.d.ts +59 -0
  20. package/dist/index.js +1397 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/ops/index.cjs +19 -0
  23. package/dist/ops/index.cjs.map +1 -0
  24. package/dist/ops/index.d.cts +22 -0
  25. package/dist/ops/index.d.ts +22 -0
  26. package/dist/ops/index.js +17 -0
  27. package/dist/ops/index.js.map +1 -0
  28. package/dist/shared/client/adapter-ioredis.cjs +130 -0
  29. package/dist/shared/client/adapter-ioredis.cjs.map +1 -0
  30. package/dist/shared/client/adapter-ioredis.d.cts +22 -0
  31. package/dist/shared/client/adapter-ioredis.d.ts +22 -0
  32. package/dist/shared/client/adapter-ioredis.js +125 -0
  33. package/dist/shared/client/adapter-ioredis.js.map +1 -0
  34. package/dist/shared/client/adapter-redis.cjs +32 -0
  35. package/dist/shared/client/adapter-redis.cjs.map +1 -0
  36. package/dist/shared/client/adapter-redis.d.cts +19 -0
  37. package/dist/shared/client/adapter-redis.d.ts +19 -0
  38. package/dist/shared/client/adapter-redis.js +29 -0
  39. package/dist/shared/client/adapter-redis.js.map +1 -0
  40. package/dist/types-CgriT7My.d.cts +193 -0
  41. package/dist/types-CgriT7My.d.ts +193 -0
  42. package/package.json +178 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
+ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added — `cacheHandlers` (plural) for `'use cache'`
10
+
11
+ - `createCacheComponentsHandler(opts)` factory implementing the full
12
+ Next.js 16 spec: `get`, `set`, `refreshTags`, `getExpiration`,
13
+ `updateTags`
14
+ - 3-axis stale-while-revalidate partition (fresh / stale / expired)
15
+ - Lua-atomic `SET entry + SADD tag + EXPIRE tag` via `set-with-tags.lua`
16
+ - Lua-atomic hard invalidation via `revalidate-hard.lua`
17
+ - Soft-tag freshness check (per spec § 2.3)
18
+ - ReadableStream serialization to base64 envelopes
19
+
20
+ ### Added — `cacheHandler` (singular) for ISR / Pages Router
21
+
22
+ - `createIncrementalCacheHandler(opts)` factory returning a class
23
+ compatible with `next.config.ts#cacheHandler`
24
+ - Buffer/Map JSON serialization preserved across Redis round-trip
25
+ - Tag-state model with separate `stale` (FETCH-only) and `expired`
26
+ semantics
27
+ - `instance-local:*` tag prefix bypasses Redis (per-instance cache)
28
+
29
+ ### Added — Shared infrastructure
30
+
31
+ - `BUILD_NAMESPACE` automatic deployment isolation
32
+ (`DEPLOYMENT_VERSION` ?? `GIT_HASH` ?? `unversioned`)
33
+ - `withAbortSignal` timeout wrapper (default 1500ms; `CacheTimeoutError`)
34
+ - Build-phase skip via `process.env.NEXT_PHASE === "phase-production-build"`
35
+ detection — fixes PR #207 root cause
36
+ - TTL-aware in-memory fallback with 32-bit `setTimeout` clamp
37
+ - `RedisClientLike` adapters: `redis@5`, `ioredis`, `ioredis.Cluster`
38
+ - `EVALSHA` with `EVAL` fallback on `NOSCRIPT`
39
+ - Process-local metric counters with optional `onMetric` emit hook
40
+ - Configurable cluster-safe `hashTag` mode
41
+
42
+ ### Build / publish
43
+
44
+ - ESM + CJS dual publish via `tsup`
45
+ - 6 entry points: `.`, `/cache-components`, `/incremental`,
46
+ `/client/redis`, `/client/ioredis`, `/ops`
47
+ - `arethetypeswrong` 100% green across `node10`, `node16` (CJS), `node16`
48
+ (ESM), `bundler`
49
+ - `publint` clean
50
+ - 66 unit tests covering build-phase skip, SWR, abort, memory fallback,
51
+ serialization, and both handlers end-to-end
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eddy Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # @leejpsd/nextjs-cache-handler
2
+
3
+ > **Pre-alpha (v0.0.0).** Working toward v0.1.0 publish. Production dogfood
4
+ > on AWS ECS Fargate is in progress. APIs may change before v0.1.0.
5
+
6
+ The Redis cache handler for **Next.js 16** that ships **both** `cacheHandler`
7
+ (ISR / Pages Router) **and** `cacheHandlers` (`'use cache'` directive,
8
+ `cacheComponents: true`) — the area where
9
+ [`@fortedigital/nextjs-cache-handler`](https://github.com/fortedigital/nextjs-cache-handler)
10
+ currently lists "Help needed".
11
+
12
+ ```ts
13
+ // next.config.ts
14
+ const nextConfig = {
15
+ cacheComponents: true,
16
+ cacheHandler: require.resolve("./cache-incremental.cjs"),
17
+ cacheHandlers: { default: require.resolve("./cache-components.cjs") },
18
+ };
19
+ ```
20
+
21
+ ```js
22
+ // cache-components.cjs
23
+ const { createCacheComponentsHandler } = require("@leejpsd/nextjs-cache-handler/cache-components");
24
+ module.exports = createCacheComponentsHandler({
25
+ client: { type: "redis", url: process.env.REDIS_URL },
26
+ buildNamespace: process.env.DEPLOYMENT_VERSION, // auto-isolates deploys
27
+ });
28
+ ```
29
+
30
+ That's it. `'use cache'`, `revalidateTag`, `updateTag`, `cacheLife` all work.
31
+
32
+ ---
33
+
34
+ ## Why this exists
35
+
36
+ Next.js 16 split caching into two handler interfaces:
37
+
38
+ | Option | Used by | Methods |
39
+ |---|---|---|
40
+ | `cacheHandler` (singular) | Pages Router ISR, on-demand revalidation | `get`, `set`, `revalidateTag`, `resetRequestCache` |
41
+ | `cacheHandlers` (plural) | `'use cache'` directive, `cacheComponents: true` | `get`, `set`, `refreshTags`, `getExpiration`, `updateTags` |
42
+
43
+ As of 2026-05, the leading OSS Redis handler `@fortedigital/nextjs-cache-handler@3.2.0`
44
+ declares `peerDependencies.next: ">=16.1.5"` but the README marks the new
45
+ plural interface as ❌ **"Not yet supported - Help needed"**:
46
+
47
+ | Next 16 feature | @fortedigital | this |
48
+ |---|---|---|
49
+ | `cacheHandlers` config (plural) | ❌ Help needed | ✅ |
50
+ | `'use cache'` directive | ❌ Help needed | ✅ |
51
+ | `'use cache: remote'` | ❌ Help needed | ✅ |
52
+ | `'use cache: private'` | n/a (uncustomizable) | n/a |
53
+ | `cacheComponents: true` | ❌ Help needed | ✅ |
54
+ | Build-phase skip (`PHASE_PRODUCTION_BUILD`) | ✅ | ✅ |
55
+ | Auto deploy isolation (`BUILD_NAMESPACE`) | manual | ✅ auto |
56
+ | Lua-atomic SET+tag | partial (MULTI) | ✅ |
57
+ | AbortSignal timeout | ✅ | ✅ |
58
+ | Redis Cluster | ✅ | ✅ |
59
+ | ioredis support | ✅ | ✅ |
60
+ | In-memory fallback (TTL-aware) | partial | ✅ |
61
+ | OpenTelemetry hook | ❌ | ✅ (`onMetric`) |
62
+
63
+ PR [#207](https://github.com/fortedigital/nextjs-cache-handler/pull/207) on
64
+ `@fortedigital` has been stalled for 3+ months on the same issue: the
65
+ upstream review insisted on `PHASE_PRODUCTION_BUILD` handling, which this
66
+ package has from the start.
67
+
68
+ ---
69
+
70
+ ## Quick start
71
+
72
+ ### Install
73
+
74
+ ```bash
75
+ npm install @leejpsd/nextjs-cache-handler redis
76
+ # or
77
+ npm install @leejpsd/nextjs-cache-handler ioredis
78
+ ```
79
+
80
+ `redis` and `ioredis` are optional peer dependencies — install whichever
81
+ client you use. Both can be present.
82
+
83
+ ### Wire up
84
+
85
+ Two CommonJS wrapper files in your project root (Next.js's
86
+ `require.resolve` pattern doesn't accept ESM directly):
87
+
88
+ ```js
89
+ // cache-components.cjs
90
+ const { createCacheComponentsHandler } = require("@leejpsd/nextjs-cache-handler/cache-components");
91
+ module.exports = createCacheComponentsHandler({
92
+ client: { type: "redis", url: process.env.REDIS_URL },
93
+ buildNamespace: process.env.DEPLOYMENT_VERSION,
94
+ abortTimeoutMs: 1500,
95
+ });
96
+ ```
97
+
98
+ ```js
99
+ // cache-incremental.cjs
100
+ const { createIncrementalCacheHandler } = require("@leejpsd/nextjs-cache-handler/incremental");
101
+ module.exports = createIncrementalCacheHandler({
102
+ client: { type: "redis", url: process.env.REDIS_URL },
103
+ buildNamespace: process.env.DEPLOYMENT_VERSION,
104
+ abortTimeoutMs: 1500,
105
+ });
106
+ ```
107
+
108
+ ```ts
109
+ // next.config.ts
110
+ import path from "path";
111
+ import type { NextConfig } from "next";
112
+
113
+ const enabled = !!process.env.REDIS_URL && process.env.DISABLE_REDIS_CACHE_HANDLER !== "true";
114
+
115
+ const nextConfig: NextConfig = {
116
+ output: "standalone",
117
+ outputFileTracingRoot: path.join(__dirname),
118
+ cacheComponents: true,
119
+ deploymentId: process.env.DEPLOYMENT_VERSION,
120
+ generateBuildId: async () => process.env.DEPLOYMENT_VERSION ?? "dev-build",
121
+ cacheMaxMemorySize: 0, // delegate everything to the Redis handler
122
+ cacheHandler: enabled ? require.resolve("./cache-incremental.cjs") : undefined,
123
+ cacheHandlers: enabled ? { default: require.resolve("./cache-components.cjs") } : {},
124
+ };
125
+ export default nextConfig;
126
+ ```
127
+
128
+ ### Use in your code
129
+
130
+ ```tsx
131
+ // app/blog/page.tsx
132
+ import { cacheLife, cacheTag, revalidateTag } from "next/cache";
133
+
134
+ async function getPosts() {
135
+ "use cache";
136
+ cacheLife("hours");
137
+ cacheTag("posts");
138
+ return await db.post.findMany();
139
+ }
140
+
141
+ // Server Action — invalidate
142
+ async function publishPost(formData: FormData) {
143
+ "use server";
144
+ await db.post.create({ data: Object.fromEntries(formData) });
145
+ revalidateTag("posts", "max");
146
+ }
147
+
148
+ export default async function Page() {
149
+ const posts = await getPosts();
150
+ return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
151
+ }
152
+ ```
153
+
154
+ ---
155
+
156
+ ## Configuration reference
157
+
158
+ ```ts
159
+ interface CacheHandlerOptions {
160
+ client: RedisClientFactory | RedisClientConfig;
161
+ keyPrefix?: string; // default: "next-cache:" / "next-incremental:"
162
+ buildNamespace?: string | (() => string); // default: env DEPLOYMENT_VERSION || GIT_HASH || "unversioned"
163
+ abortTimeoutMs?: number; // default: 1500
164
+ fallback?: "auto" | "always" | "never"; // default: "auto"
165
+ staleWhileRevalidate?: boolean; // default: true (cache-components only)
166
+ isBuildPhase?: () => boolean; // override PHASE_PRODUCTION_BUILD detection
167
+ hashTag?: boolean; // default: false (set true on Redis Cluster)
168
+ onMetric?: (event: MetricEvent) => void;
169
+ logger?: Logger;
170
+ }
171
+
172
+ type RedisClientConfig =
173
+ | { type: "redis"; url: string; password?: string; tls?: boolean; connectTimeout?: number }
174
+ | { type: "ioredis"; url: string; password?: string; tls?: boolean; connectTimeout?: number }
175
+ | { type: "cluster"; nodes: { host: string; port: number }[]; password?: string; tls?: boolean };
176
+ ```
177
+
178
+ Full reference: [`docs/api.md`](./docs/api.md) (auto-generated).
179
+
180
+ ---
181
+
182
+ ## Production checklist
183
+
184
+ - [ ] **`DEPLOYMENT_VERSION` env injected at runtime** — every entry key is
185
+ prefixed with this so old prerender HTML can't bleed across deploys.
186
+ For Docker, set `ENV DEPLOYMENT_VERSION=...` in your **runner** stage,
187
+ not just the builder. (See [`docs/build-phase.md`](./docs/build-phase.md).)
188
+ - [ ] **`cacheMaxMemorySize: 0`** — turn off Next's local LRU so multi-instance
189
+ reads always hit Redis (or the explicit memory fallback).
190
+ - [ ] **`outputFileTracingRoot` pinned** — required for `output: "standalone"`
191
+ to avoid static-chunk-404 issues during a deploy.
192
+ - [ ] **`abortTimeoutMs: 1500`** (default) — protects against a stuck Redis
193
+ connection from hanging the request thread.
194
+ - [ ] **Redis `maxmemory-policy: allkeys-lru` or `noeviction`** — if you need
195
+ bounded memory, choose `allkeys-lru`. Otherwise `noeviction` keeps
196
+ tag indices intact.
197
+ - [ ] **TLS** — use `rediss://` URLs (e.g. ElastiCache in-transit
198
+ encryption). The library auto-detects from the URL scheme.
199
+ - [ ] **Health check** — call your own `/api/health` that pings Redis
200
+ (separate from the handler) so a Redis outage surfaces in your
201
+ monitoring without inducing 5xx in user requests.
202
+
203
+ ---
204
+
205
+ ## How it differs from `@fortedigital/nextjs-cache-handler`
206
+
207
+ Three deliberate departures, all rooted in lessons from production
208
+ incidents (see [`docs/`](./docs/)):
209
+
210
+ 1. **Build-phase skip is the default**, not an opt-in. Every Redis call
211
+ goes through a `shouldUseRedis()` gate that short-circuits when
212
+ `process.env.NEXT_PHASE === "phase-production-build"`. PR #207 on
213
+ `@fortedigital` was rejected for missing exactly this.
214
+ 2. **Deployment isolation is automatic**. Every entry key includes
215
+ `BUILD_NAMESPACE` (=`process.env.DEPLOYMENT_VERSION`) by default. New
216
+ deploys can never read entries written by old ones — fixes the
217
+ "static chunk 404 after deploy" failure mode without manual cache
218
+ flushes.
219
+ 3. **Lua-atomic tag updates**. `set` writes the entry and updates tag
220
+ indices in a single Lua transaction. `updateTags(..., {expire: 0})`
221
+ removes matching entries with one `EVALSHA`. No window for half-applied
222
+ sets to leak dangling tag members.
223
+
224
+ When `@fortedigital` ships its `cacheHandlers` support (PR #207 / feature
225
+ branch `feature/cache-components`), this package will continue to differ
226
+ on (2) and (3). For (1), we consider it table-stakes; the upstream's
227
+ eventual implementation should converge on the same behavior.
228
+
229
+ ---
230
+
231
+ ## Compatibility
232
+
233
+ - **Next.js**: `>=16.1.5 <17`
234
+ - **Node.js**: `>=20`
235
+ - **redis**: `>=5.0.0` (peer, optional)
236
+ - **ioredis**: `>=5.0.0` (peer, optional)
237
+
238
+ ESM and CJS dual-published, full TypeScript types, validated via
239
+ [`arethetypeswrong`](https://arethetypeswrong.github.io) and
240
+ [`publint`](https://publint.dev).
241
+
242
+ ---
243
+
244
+ ## Roadmap
245
+
246
+ - **v0.1.0** *(target: 2026-05)* — Both handlers, SWR, Lua atomicity,
247
+ build-phase skip, `redis@5` + `ioredis` adapters, AbortSignal,
248
+ in-memory fallback with TTL, soft-tag freshness check
249
+ - **v0.2.0** — Single-flight refresh lock (Lua SETNX), OpenTelemetry
250
+ forwarder example
251
+ - **v0.3.0** — Vercel KV / Upstash Redis adapter, `'use cache: remote'`
252
+ multi-tier setup
253
+ - **v0.4.0** — Cache stampede protection, request-scoped memoization
254
+
255
+ ---
256
+
257
+ ## License
258
+
259
+ [MIT](./LICENSE) © 2026 Eddy Lee