@leejpsd/nextjs-cache-handler 0.1.1 → 0.2.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.
- package/CHANGELOG.md +58 -0
- package/README.md +91 -12
- package/dist/cache-components/index.cjs +62 -6
- 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 +62 -6
- package/dist/cache-components/index.js.map +1 -1
- package/dist/incremental/index.d.cts +1 -1
- package/dist/incremental/index.d.ts +1 -1
- package/dist/index.cjs +62 -6
- 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 +62 -6
- 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/shared/client/adapter-ioredis.d.cts +1 -1
- package/dist/shared/client/adapter-ioredis.d.ts +1 -1
- package/dist/shared/client/adapter-redis.d.cts +1 -1
- package/dist/shared/client/adapter-redis.d.ts +1 -1
- package/dist/{types-CgriT7My.d.cts → types-Dq1q6qXd.d.cts} +23 -1
- package/dist/{types-CgriT7My.d.ts → types-Dq1q6qXd.d.ts} +23 -1
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,64 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-05-10
|
|
10
|
+
|
|
11
|
+
First minor bump. Three differentiators land in this release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Single-flight refresh lock** (`singleFlight: true`,
|
|
16
|
+
`singleFlightLockTtlSec: 10`). Opt-in stampede protection at the SWR
|
|
17
|
+
boundary. Uses the bundled `refresh-tag-lock.lua` script over a Redis
|
|
18
|
+
SETNX-style lock; only the leader's stale read triggers the
|
|
19
|
+
background refresh, all others fall through to the follower path.
|
|
20
|
+
Two new `MetricEvent` types (`cache.stale.refresh.leader`,
|
|
21
|
+
`cache.stale.refresh.follower`) appear in `onMetric` so operators can
|
|
22
|
+
verify leadership balance. Lock-acquisition failures are best-effort
|
|
23
|
+
and always degrade to the safe follower path; the stale entry is
|
|
24
|
+
served regardless.
|
|
25
|
+
- **OpenTelemetry reference adapter** at
|
|
26
|
+
[`examples/opentelemetry/`](./examples/opentelemetry/). The library
|
|
27
|
+
itself stays dependency-free; the example shows the smallest viable
|
|
28
|
+
wiring of `onMetric` to OTel counters and histograms with bounded
|
|
29
|
+
cardinality (no cache keys or tag names emitted as attributes).
|
|
30
|
+
- **Integration tests against real Redis 7** (21 scenarios) covering
|
|
31
|
+
both `redis@5` and `ioredis` adapters. Each adapter runs the same
|
|
32
|
+
test grid so a regression in either client library is pinpointed
|
|
33
|
+
immediately. New scripts: `npm run test:integration`,
|
|
34
|
+
`npm run test:integration:up` / `:down` for the docker-compose
|
|
35
|
+
fixture.
|
|
36
|
+
- **CI integration job** runs the integration suite against an
|
|
37
|
+
ephemeral Redis 7 service container on every PR.
|
|
38
|
+
- **GitHub Actions OIDC publish path** in `.github/workflows/release.yml`
|
|
39
|
+
with `id-token: write` and `NPM_CONFIG_PROVENANCE=true`. Currently
|
|
40
|
+
scoped to `workflow_dispatch` trigger so the first GHA-driven publish
|
|
41
|
+
can be observed; flip to `push: main` once the changesets cadence
|
|
42
|
+
stabilizes. Tarballs published from the workflow will land on npm
|
|
43
|
+
with verified provenance.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `MetricEventType` union grew three new entries:
|
|
48
|
+
`cache.stale.refresh.leader`, `cache.stale.refresh.follower`,
|
|
49
|
+
`cache.stale.refresh.skipped`. The legacy `cache.stale` event still
|
|
50
|
+
fires when `singleFlight` is off (default).
|
|
51
|
+
- `HandlerState` now carries an `instanceId` (read from `HOSTNAME` /
|
|
52
|
+
`ECS_TASK_ID` / `pid-<pid>`) and embeds it in every refresh-lock
|
|
53
|
+
acquisition for operator-side observability.
|
|
54
|
+
|
|
55
|
+
### Compatibility
|
|
56
|
+
|
|
57
|
+
- No breaking changes. All existing 0.1.x configurations continue to
|
|
58
|
+
work; `singleFlight` defaults to `false`.
|
|
59
|
+
|
|
60
|
+
### Verified
|
|
61
|
+
|
|
62
|
+
- 72 unit tests (8 files) + 21 integration tests (1 file × 21 scenarios)
|
|
63
|
+
all green.
|
|
64
|
+
- arethetypeswrong: 28/28 cells green.
|
|
65
|
+
- publint: clean.
|
|
66
|
+
|
|
9
67
|
## [0.1.1] - 2026-05-10
|
|
10
68
|
|
|
11
69
|
Documentation-only patch released alongside the first round of external
|
package/README.md
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler)
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
|
|
6
|
-
>
|
|
6
|
+
> **`v0.2.0`** — install with
|
|
7
7
|
> `npm install @leejpsd/nextjs-cache-handler`. Production-validated against
|
|
8
8
|
> AWS ECS Fargate with multi-instance Redis (24h live-traffic soak: 0
|
|
9
9
|
> errors, 0 leaks, 2ms Redis ping, namespace isolation working).
|
|
10
|
+
>
|
|
11
|
+
> v0.2 adds: optional **single-flight refresh lock** for cache-stampede
|
|
12
|
+
> protection at the SWR boundary, a reference **OpenTelemetry** wrapper,
|
|
13
|
+
> and **integration tests** (21 scenarios) running against real Redis 7
|
|
14
|
+
> over both `redis@5` and `ioredis` adapters.
|
|
10
15
|
|
|
11
16
|
The Redis cache handler for **Next.js 16** that ships **both** `cacheHandler`
|
|
12
17
|
(ISR / Pages Router) **and** `cacheHandlers` (`'use cache'` directive,
|
|
@@ -171,13 +176,15 @@ export default async function Page() {
|
|
|
171
176
|
```ts
|
|
172
177
|
interface CacheHandlerOptions {
|
|
173
178
|
client: RedisClientFactory | RedisClientConfig;
|
|
174
|
-
keyPrefix?: string;
|
|
179
|
+
keyPrefix?: string; // default: "next-cache:" / "next-incremental:"
|
|
175
180
|
buildNamespace?: string | (() => string); // default: env DEPLOYMENT_VERSION || GIT_HASH || "unversioned"
|
|
176
|
-
abortTimeoutMs?: number;
|
|
181
|
+
abortTimeoutMs?: number; // default: 1500
|
|
177
182
|
fallback?: "auto" | "always" | "never"; // default: "auto"
|
|
178
183
|
staleWhileRevalidate?: boolean; // default: true (cache-components only)
|
|
179
|
-
|
|
180
|
-
|
|
184
|
+
singleFlight?: boolean; // default: false — see "Single-flight refresh lock" below
|
|
185
|
+
singleFlightLockTtlSec?: number; // default: 10
|
|
186
|
+
isBuildPhase?: () => boolean; // override PHASE_PRODUCTION_BUILD detection
|
|
187
|
+
hashTag?: boolean; // default: false (set true on Redis Cluster)
|
|
181
188
|
onMetric?: (event: MetricEvent) => void;
|
|
182
189
|
logger?: Logger;
|
|
183
190
|
}
|
|
@@ -233,6 +240,75 @@ Full reference: [`docs/api.md`](./docs/api.md) (auto-generated).
|
|
|
233
240
|
|
|
234
241
|
---
|
|
235
242
|
|
|
243
|
+
## Single-flight refresh lock (v0.2)
|
|
244
|
+
|
|
245
|
+
The `cacheHandlers` (plural) interface returns stale entries inside the
|
|
246
|
+
SWR window so users get an instant response while the background
|
|
247
|
+
refresh completes. With many instances, the moment an entry crosses the
|
|
248
|
+
`revalidate` boundary, every instance independently triggers its own
|
|
249
|
+
refresh — N parallel re-renders for the same key, each hitting your
|
|
250
|
+
origin once.
|
|
251
|
+
|
|
252
|
+
`singleFlight: true` adds an opt-in Redis lock (`refresh-tag-lock.lua`,
|
|
253
|
+
default TTL 10s) at the SWR boundary. The first instance to acquire it
|
|
254
|
+
becomes the **leader** and runs the refresh; the rest become
|
|
255
|
+
**followers**, keep serving the same stale entry, and wait for the
|
|
256
|
+
leader's write to land. The lock is observability-only at the handler
|
|
257
|
+
layer — Next.js still drives the actual refresh; we just suppress the
|
|
258
|
+
stampede.
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
createCacheComponentsHandler({
|
|
262
|
+
client: { type: "redis", url: process.env.REDIS_URL },
|
|
263
|
+
singleFlight: true, // default false
|
|
264
|
+
singleFlightLockTtlSec: 10, // default 10
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Two new `MetricEvent` types appear on `onMetric`:
|
|
269
|
+
|
|
270
|
+
| event type | meaning |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `cache.stale.refresh.leader` | this instance just acquired the lock and is the designated refresher |
|
|
273
|
+
| `cache.stale.refresh.follower` | another instance holds the lock; we serve stale and skip the refresh |
|
|
274
|
+
|
|
275
|
+
If lock acquisition fails (Redis hiccup, TTL race), the handler
|
|
276
|
+
defaults to the follower path — the stale entry is always served, never
|
|
277
|
+
dropped. This is intentional: the lock is an optimization, not a
|
|
278
|
+
correctness-critical primitive.
|
|
279
|
+
|
|
280
|
+
When **not** to enable single-flight: small fleets (1–2 instances) where
|
|
281
|
+
Next's per-process serialization already covers the stampede risk.
|
|
282
|
+
Adding a Redis round-trip per stale read isn't free.
|
|
283
|
+
|
|
284
|
+
See [`docs/architecture.md`](./docs/architecture.md#single-flight-refresh-lock-v02-)
|
|
285
|
+
for the full state machine and a reference to the Lua script body.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## OpenTelemetry instrumentation (v0.2)
|
|
290
|
+
|
|
291
|
+
The handler doesn't bundle `@opentelemetry/api` (zero runtime
|
|
292
|
+
dependencies stays a goal). Instead, the `onMetric(event)` hook gives
|
|
293
|
+
strictly-typed events you can pipe into whatever observability stack
|
|
294
|
+
you already run.
|
|
295
|
+
|
|
296
|
+
[`examples/opentelemetry/`](./examples/opentelemetry/) is a copy-paste
|
|
297
|
+
reference wrapper that:
|
|
298
|
+
|
|
299
|
+
- exposes a `nextjs_cache.events_total` counter dimensioned on
|
|
300
|
+
`type` / `freshness` / `backend` / `reason` / `op`
|
|
301
|
+
- exposes a `nextjs_cache.op_latency_ms` histogram for events that
|
|
302
|
+
carry an `ms` field
|
|
303
|
+
- keeps cardinality bounded — cache keys and tag names are never
|
|
304
|
+
emitted as attributes
|
|
305
|
+
|
|
306
|
+
See [`examples/opentelemetry/README.md`](./examples/opentelemetry/README.md)
|
|
307
|
+
for setup and three suggested dashboards (hit rate, single-flight
|
|
308
|
+
leadership distribution, op latency tails).
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
236
312
|
## How it differs from `@fortedigital/nextjs-cache-handler`
|
|
237
313
|
|
|
238
314
|
Three deliberate departures, all rooted in lessons from production
|
|
@@ -274,14 +350,17 @@ ESM and CJS dual-published, full TypeScript types, validated via
|
|
|
274
350
|
|
|
275
351
|
## Roadmap
|
|
276
352
|
|
|
277
|
-
- **v0.1.0** *(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
- **v0.2.0** — Single-flight refresh lock
|
|
281
|
-
|
|
353
|
+
- **v0.1.0** *(2026-05)* — Both handlers, SWR, Lua atomicity, build-phase
|
|
354
|
+
skip, `redis@5` + `ioredis` adapters, AbortSignal, in-memory fallback
|
|
355
|
+
with TTL, soft-tag freshness check ✅
|
|
356
|
+
- **v0.2.0** *(2026-05)* — Single-flight refresh lock with leader/follower
|
|
357
|
+
metrics, OpenTelemetry reference adapter, integration tests against
|
|
358
|
+
real Redis 7 (21 scenarios over `redis@5` + `ioredis`), GitHub Actions
|
|
359
|
+
OIDC publish path with provenance attestation ✅
|
|
282
360
|
- **v0.3.0** — Vercel KV / Upstash Redis adapter, `'use cache: remote'`
|
|
283
|
-
multi-tier setup
|
|
284
|
-
- **v0.4.0** — Cache stampede protection
|
|
361
|
+
multi-tier setup, Redis Cluster load testing
|
|
362
|
+
- **v0.4.0** — Cache stampede protection beyond single-flight,
|
|
363
|
+
request-scoped memoization
|
|
285
364
|
|
|
286
365
|
---
|
|
287
366
|
|
|
@@ -632,7 +632,9 @@ var DEFAULT_KEY_PREFIX = "next-cache:";
|
|
|
632
632
|
var ENTRY_SUFFIX = "entry:";
|
|
633
633
|
var TAG_SUFFIX = "tag:";
|
|
634
634
|
var TAG_EXP_SUFFIX = "tag-expiration:";
|
|
635
|
+
var LOCK_SUFFIX = "lock:";
|
|
635
636
|
var DEFAULT_ABORT_MS = 1500;
|
|
637
|
+
var DEFAULT_LOCK_TTL_SEC = 10;
|
|
636
638
|
var ONE_YEAR_SEC = 60 * 60 * 24 * 365;
|
|
637
639
|
var FALLBACK_TTL_SEC = 3600;
|
|
638
640
|
var TAG_EXP_MARKER_TTL_SEC = 60 * 60 * 24 * 7;
|
|
@@ -655,6 +657,8 @@ function init(opts) {
|
|
|
655
657
|
staleWhileRevalidate: opts.staleWhileRevalidate ?? true,
|
|
656
658
|
hashTag: opts.hashTag ?? false,
|
|
657
659
|
keyPrefix: opts.keyPrefix ?? DEFAULT_KEY_PREFIX,
|
|
660
|
+
singleFlight: opts.singleFlight ?? false,
|
|
661
|
+
singleFlightLockTtlSec: opts.singleFlightLockTtlSec ?? DEFAULT_LOCK_TTL_SEC,
|
|
658
662
|
rest: opts
|
|
659
663
|
},
|
|
660
664
|
logger,
|
|
@@ -664,7 +668,8 @@ function init(opts) {
|
|
|
664
668
|
memTags: new MemorySetStore(),
|
|
665
669
|
memTagExp: /* @__PURE__ */ new Map(),
|
|
666
670
|
localTagTimestamps: /* @__PURE__ */ new Map(),
|
|
667
|
-
resolveNs: () => resolveBuildNamespace(opts.buildNamespace)
|
|
671
|
+
resolveNs: () => resolveBuildNamespace(opts.buildNamespace),
|
|
672
|
+
instanceId: process.env.HOSTNAME || process.env.ECS_TASK_ID || `pid-${process.pid}`
|
|
668
673
|
};
|
|
669
674
|
}
|
|
670
675
|
function entryKey(state, cacheKey) {
|
|
@@ -691,6 +696,40 @@ function tagExpKey(state, tag) {
|
|
|
691
696
|
state.opts.hashTag
|
|
692
697
|
);
|
|
693
698
|
}
|
|
699
|
+
function lockKey(state, cacheKey) {
|
|
700
|
+
return buildKey(
|
|
701
|
+
`${state.opts.keyPrefix}${LOCK_SUFFIX}`,
|
|
702
|
+
state.resolveNs(),
|
|
703
|
+
cacheKey,
|
|
704
|
+
state.opts.hashTag
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
async function tryAcquireRefreshLock(state, cacheKey) {
|
|
708
|
+
if (!state.opts.singleFlight) return false;
|
|
709
|
+
if (!gateRedis(state)) return false;
|
|
710
|
+
try {
|
|
711
|
+
return await withAbortSignal(
|
|
712
|
+
"cacheHandlers.singleFlight.lock",
|
|
713
|
+
state.opts.abortTimeoutMs,
|
|
714
|
+
async () => {
|
|
715
|
+
const client = await state.conn.getOrConnect();
|
|
716
|
+
if (!client) return false;
|
|
717
|
+
const result = await execLuaScript(
|
|
718
|
+
client,
|
|
719
|
+
"refreshTagLock",
|
|
720
|
+
[lockKey(state, cacheKey)],
|
|
721
|
+
[
|
|
722
|
+
state.instanceId,
|
|
723
|
+
String(state.opts.singleFlightLockTtlSec)
|
|
724
|
+
]
|
|
725
|
+
);
|
|
726
|
+
return result === 1;
|
|
727
|
+
}
|
|
728
|
+
);
|
|
729
|
+
} catch {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
694
733
|
function resolveTtlSeconds(entry) {
|
|
695
734
|
if (Number.isFinite(entry.expire) && entry.expire > 0) {
|
|
696
735
|
return Math.max(60, Math.ceil(entry.expire));
|
|
@@ -785,11 +824,28 @@ async function getImpl(state, cacheKey, softTags) {
|
|
|
785
824
|
return void 0;
|
|
786
825
|
}
|
|
787
826
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
827
|
+
if (partition.freshness === "stale") {
|
|
828
|
+
if (state.opts.singleFlight) {
|
|
829
|
+
const isLeader = await tryAcquireRefreshLock(state, cacheKey);
|
|
830
|
+
state.emit({
|
|
831
|
+
type: isLeader ? "cache.stale.refresh.leader" : "cache.stale.refresh.follower",
|
|
832
|
+
ms: Date.now() - start,
|
|
833
|
+
meta: { freshness: partition.freshness }
|
|
834
|
+
});
|
|
835
|
+
} else {
|
|
836
|
+
state.emit({
|
|
837
|
+
type: "cache.stale",
|
|
838
|
+
ms: Date.now() - start,
|
|
839
|
+
meta: { freshness: partition.freshness }
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
state.emit({
|
|
844
|
+
type: "cache.hit",
|
|
845
|
+
ms: Date.now() - start,
|
|
846
|
+
meta: { freshness: partition.freshness }
|
|
847
|
+
});
|
|
848
|
+
}
|
|
793
849
|
return {
|
|
794
850
|
value: bufferToStream(Buffer.from(envelope.value, "base64")),
|
|
795
851
|
tags: envelope.tags,
|