@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.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/dist/cache-components/index.cjs +1053 -0
- package/dist/cache-components/index.cjs.map +1 -0
- package/dist/cache-components/index.d.cts +85 -0
- package/dist/cache-components/index.d.ts +85 -0
- package/dist/cache-components/index.js +1049 -0
- package/dist/cache-components/index.js.map +1 -0
- package/dist/incremental/index.cjs +734 -0
- package/dist/incremental/index.cjs.map +1 -0
- package/dist/incremental/index.d.cts +131 -0
- package/dist/incremental/index.d.ts +131 -0
- package/dist/incremental/index.js +731 -0
- package/dist/incremental/index.js.map +1 -0
- package/dist/index.cjs +1408 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +59 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +1397 -0
- package/dist/index.js.map +1 -0
- package/dist/ops/index.cjs +19 -0
- package/dist/ops/index.cjs.map +1 -0
- package/dist/ops/index.d.cts +22 -0
- package/dist/ops/index.d.ts +22 -0
- package/dist/ops/index.js +17 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/shared/client/adapter-ioredis.cjs +130 -0
- package/dist/shared/client/adapter-ioredis.cjs.map +1 -0
- package/dist/shared/client/adapter-ioredis.d.cts +22 -0
- package/dist/shared/client/adapter-ioredis.d.ts +22 -0
- package/dist/shared/client/adapter-ioredis.js +125 -0
- package/dist/shared/client/adapter-ioredis.js.map +1 -0
- package/dist/shared/client/adapter-redis.cjs +32 -0
- package/dist/shared/client/adapter-redis.cjs.map +1 -0
- package/dist/shared/client/adapter-redis.d.cts +19 -0
- package/dist/shared/client/adapter-redis.d.ts +19 -0
- package/dist/shared/client/adapter-redis.js +29 -0
- package/dist/shared/client/adapter-redis.js.map +1 -0
- package/dist/types-CgriT7My.d.cts +193 -0
- package/dist/types-CgriT7My.d.ts +193 -0
- 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
|