@leejpsd/nextjs-cache-handler 0.1.0 → 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 CHANGED
@@ -6,6 +6,90 @@ 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
+
67
+ ## [0.1.1] - 2026-05-10
68
+
69
+ Documentation-only patch released alongside the first round of external
70
+ write-ups. No code changes.
71
+
72
+ ### Changed
73
+
74
+ - **Compatibility matrix** now carries an explicit verification date and a
75
+ `nextjs-turbo-redis-cache` column, so readers can spot-check the comparison
76
+ against the upstream READMEs as both packages evolve. The previous
77
+ two-column shape didn't make it obvious which competitors are tracked.
78
+ - **Production checklist** gains an explicit Redis Cluster row: cluster
79
+ deployments must set `hashTag: true` to avoid `CROSSSLOT` errors from
80
+ the multi-key Lua scripts. Also notes that cluster support is
81
+ unit-tested but not yet load-tested at scale.
82
+ - **Compatibility table for Redis-protocol services** (ElastiCache,
83
+ Upstash, DragonflyDB, KeyDB, Vercel KV) added to the production
84
+ section, separating "validated" from "expected to work via the
85
+ standard Redis protocol".
86
+ - **`docs/architecture.md`** now documents two memory-fallback caveats
87
+ that previously required reading the source: per-process isolation
88
+ (cross-instance correctness degrades during Redis outages) and the
89
+ 32-bit `setTimeout` clamp (TTLs above ≈24.85 days fire the eviction
90
+ timer early, though `get()`'s lazy `expiresAt` check still honors the
91
+ configured expiration).
92
+
9
93
  ## [0.1.0] - 2026-05-10
10
94
 
11
95
  First stable release. Promoted from `0.1.0-rc.1` after a 24-hour live-traffic
package/README.md CHANGED
@@ -3,10 +3,15 @@
3
3
  [![npm](https://img.shields.io/npm/v/@leejpsd/nextjs-cache-handler.svg)](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler)
4
4
  [![license](https://img.shields.io/npm/l/@leejpsd/nextjs-cache-handler.svg)](./LICENSE)
5
5
 
6
- > **Stable `v0.1.0`** — install with
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,
@@ -49,21 +54,29 @@ As of 2026-05, the leading OSS Redis handler `@fortedigital/nextjs-cache-handler
49
54
  declares `peerDependencies.next: ">=16.1.5"` but the README marks the new
50
55
  plural interface as ❌ **"Not yet supported - Help needed"**:
51
56
 
52
- | Next 16 feature | @fortedigital | this |
53
- |---|---|---|
54
- | `cacheHandlers` config (plural) | ❌ Help needed | ✅ |
55
- | `'use cache'` directive | ❌ Help needed | ✅ |
56
- | `'use cache: remote'` | ❌ Help needed | ✅ |
57
- | `'use cache: private'` | n/a (uncustomizable) | n/a |
58
- | `cacheComponents: true` | ❌ Help needed | ✅ |
59
- | Build-phase skip (`PHASE_PRODUCTION_BUILD`) | ✅ | ✅ |
60
- | Auto deploy isolation (`BUILD_NAMESPACE`) | manual | ✅ auto |
61
- | Lua-atomic SET+tag | partial (MULTI) | ✅ |
62
- | AbortSignal timeout | ✅ | ✅ |
63
- | Redis Cluster | ✅ | ✅ |
64
- | ioredis support | ✅ | ✅ |
65
- | In-memory fallback (TTL-aware) | partial | ✅ |
66
- | OpenTelemetry hook | ❌ | ✅ (`onMetric`) |
57
+ > 📅 **Compatibility matrix verified 2026-05-10.** The OSS Next.js cache
58
+ > handler ecosystem moves quickly — please verify
59
+ > [`@fortedigital`](https://github.com/fortedigital/nextjs-cache-handler#compatibility)
60
+ > and
61
+ > [`nextjs-turbo-redis-cache`](https://github.com/trieb-work/nextjs-turbo-redis-cache#features)
62
+ > directly before relying on this comparison.
63
+
64
+ | Next 16 feature | this | @fortedigital 3.2.0 | nextjs-turbo-redis-cache 1.13 |
65
+ |---|---|---|---|
66
+ | `cacheHandlers` config (plural) | ✅ | ❌ Help needed | ✅ since 1.11 |
67
+ | `'use cache'` directive | ✅ | ❌ Help needed | ✅ since 1.11 |
68
+ | `'use cache: remote'` | ✅ | ❌ Help needed | partial |
69
+ | `'use cache: private'` | n/a (uncustomizable) | n/a | n/a |
70
+ | `cacheComponents: true` | ✅ | ❌ Help needed | ✅ |
71
+ | Build-phase skip (`PHASE_PRODUCTION_BUILD`) | ✅ | ✅ (singular only) | ✅ |
72
+ | Auto deploy isolation | ✅ `BUILD_NAMESPACE` env-resolved | manual | ✅ `BUILD_ID` since 1.13 |
73
+ | Lua-atomic SET+tag | ✅ Lua scripts | partial (MULTI) | partial |
74
+ | AbortSignal timeout | ✅ per-op | ✅ Proxy-wrapped | ❌ |
75
+ | Redis Cluster | ✅ (cluster adapter, see Production checklist) | ✅ | ✅ |
76
+ | ioredis support | ✅ | ✅ | ✅ |
77
+ | In-memory fallback (TTL-aware) | ✅ | partial | ✅ L1 + Redis L2 |
78
+ | OpenTelemetry hook | ✅ `onMetric` | ❌ | ❌ |
79
+ | Live-traffic dogfood (24h+) | ✅ AWS ECS Fargate | not published | not published |
67
80
 
68
81
  PR [#207](https://github.com/fortedigital/nextjs-cache-handler/pull/207) on
69
82
  `@fortedigital` has been stalled for 3+ months on the same issue: the
@@ -163,13 +176,15 @@ export default async function Page() {
163
176
  ```ts
164
177
  interface CacheHandlerOptions {
165
178
  client: RedisClientFactory | RedisClientConfig;
166
- keyPrefix?: string; // default: "next-cache:" / "next-incremental:"
179
+ keyPrefix?: string; // default: "next-cache:" / "next-incremental:"
167
180
  buildNamespace?: string | (() => string); // default: env DEPLOYMENT_VERSION || GIT_HASH || "unversioned"
168
- abortTimeoutMs?: number; // default: 1500
181
+ abortTimeoutMs?: number; // default: 1500
169
182
  fallback?: "auto" | "always" | "never"; // default: "auto"
170
183
  staleWhileRevalidate?: boolean; // default: true (cache-components only)
171
- isBuildPhase?: () => boolean; // override PHASE_PRODUCTION_BUILD detection
172
- hashTag?: boolean; // default: false (set true on Redis Cluster)
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)
173
188
  onMetric?: (event: MetricEvent) => void;
174
189
  logger?: Logger;
175
190
  }
@@ -196,6 +211,13 @@ Full reference: [`docs/api.md`](./docs/api.md) (auto-generated).
196
211
  to avoid static-chunk-404 issues during a deploy.
197
212
  - [ ] **`abortTimeoutMs: 1500`** (default) — protects against a stuck Redis
198
213
  connection from hanging the request thread.
214
+ - [ ] **Redis Cluster: set `hashTag: true`** — multi-key Lua scripts
215
+ (`set-with-tags.lua`, `revalidate-hard.lua`) require all KEYS to land
216
+ on the same hash slot. Without `hashTag`, cluster deployments will hit
217
+ `CROSSSLOT Keys in request don't hash to the same slot`. The flag wraps
218
+ the namespace in `{}` so every key for a given deploy hashes together.
219
+ Cluster support is implemented but **not yet load-tested at production
220
+ scale**; PRs welcome.
199
221
  - [ ] **Redis `maxmemory-policy: allkeys-lru` or `noeviction`** — if you need
200
222
  bounded memory, choose `allkeys-lru`. Otherwise `noeviction` keeps
201
223
  tag indices intact.
@@ -205,6 +227,86 @@ Full reference: [`docs/api.md`](./docs/api.md) (auto-generated).
205
227
  (separate from the handler) so a Redis outage surfaces in your
206
228
  monitoring without inducing 5xx in user requests.
207
229
 
230
+ ### Compatibility with Redis-protocol services
231
+
232
+ | Service | How to use | Tested? |
233
+ |---|---|---|
234
+ | **Self-hosted Redis 7+** | `{ type: "redis", url }` or `{ type: "ioredis", url }` | ✅ AWS ElastiCache 24h soak |
235
+ | **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | unit-tested, not yet load-tested at scale |
236
+ | **Upstash Redis** | `{ type: "redis", url: "rediss://..." }` (TLS auto-detected) | not yet validated, expected to work via the standard Redis protocol |
237
+ | **AWS ElastiCache (replication group)** | `{ type: "redis", url: "rediss://..." }` | ✅ reference deployment |
238
+ | **Vercel KV** | not yet supported — `@vercel/kv` adapter ships in v0.3 | — |
239
+ | **DragonflyDB / KeyDB** | Redis-protocol compatible — `{ type: "redis", url }` should work | not validated |
240
+
241
+ ---
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
+
208
310
  ---
209
311
 
210
312
  ## How it differs from `@fortedigital/nextjs-cache-handler`
@@ -248,14 +350,17 @@ ESM and CJS dual-published, full TypeScript types, validated via
248
350
 
249
351
  ## Roadmap
250
352
 
251
- - **v0.1.0** *(target: 2026-05)* — Both handlers, SWR, Lua atomicity,
252
- build-phase skip, `redis@5` + `ioredis` adapters, AbortSignal,
253
- in-memory fallback with TTL, soft-tag freshness check
254
- - **v0.2.0** — Single-flight refresh lock (Lua SETNX), OpenTelemetry
255
- forwarder example
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 ✅
256
360
  - **v0.3.0** — Vercel KV / Upstash Redis adapter, `'use cache: remote'`
257
- multi-tier setup
258
- - **v0.4.0** — Cache stampede protection, request-scoped memoization
361
+ multi-tier setup, Redis Cluster load testing
362
+ - **v0.4.0** — Cache stampede protection beyond single-flight,
363
+ request-scoped memoization
259
364
 
260
365
  ---
261
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
- state.emit({
789
- type: partition.freshness === "stale" ? "cache.stale" : "cache.hit",
790
- ms: Date.now() - start,
791
- meta: { freshness: partition.freshness }
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,