@trieb.work/nextjs-turbo-redis-cache 1.17.0 → 1.18.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.
Files changed (51) hide show
  1. package/.github/workflows/release.yml +8 -157
  2. package/ARCHITECTURE.md +86 -47
  3. package/README.md +87 -24
  4. package/dist/index.d.mts +40 -1
  5. package/dist/index.d.ts +40 -1
  6. package/dist/index.js +264 -96
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +261 -95
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -6
  11. package/release.config.cjs +6 -25
  12. package/src/CacheComponentsHandler.ts +141 -111
  13. package/src/RedisStringsHandler.ts +37 -25
  14. package/src/SyncedMap.ts +16 -2
  15. package/src/index.ts +7 -0
  16. package/src/utils/cacheTtl.ts +71 -0
  17. package/src/utils/compareAndUnlink.ts +29 -0
  18. package/src/utils/redisConnection.ts +17 -0
  19. package/src/utils/tagRevalidation.ts +162 -0
  20. package/test/README.md +10 -10
  21. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
  22. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  23. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  24. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
  25. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  26. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  27. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
  28. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  29. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  30. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
  31. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  32. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  33. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +2 -0
  34. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  35. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  36. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +22 -0
  37. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  38. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  39. package/test/playwright/cache-lab.spec.ts +75 -0
  40. package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
  41. package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +16 -2
  42. package/test/vitest/integration/pages-router.integration.test.ts +16 -6
  43. package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
  44. package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
  45. package/test/vitest/unit/cache-ttl.test.ts +67 -0
  46. package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
  47. package/test/vitest/unit/index.test.ts +17 -0
  48. package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
  49. package/test/vitest/unit/redis-connection.test.ts +39 -0
  50. package/test/vitest/unit/tag-revalidation.test.ts +178 -0
  51. package/CHANGELOG.md +0 -411
@@ -5,12 +5,8 @@ on:
5
5
  branches:
6
6
  - main
7
7
  - beta
8
-
9
- # Minimal permissions:
10
- # contents: semantic-release pushes the release commit + tag to the branch
11
- # issues: @semantic-release/github comments on referenced issues
12
- # pull-requests: @semantic-release/github comments on referenced PRs
13
- # id-token: npm Trusted Publishing OIDC token exchange
8
+
9
+ # semantic-release: git tag + GitHub Release + npm publish (OIDC via @semantic-release/npm)
14
10
  permissions:
15
11
  contents: write
16
12
  issues: write
@@ -22,38 +18,25 @@ jobs:
22
18
  runs-on: ubuntu-latest
23
19
 
24
20
  steps:
25
- - name: Create GitHub App token
26
- id: app-token
27
- uses: actions/create-github-app-token@v1
28
- with:
29
- app-id: ${{ vars.RELEASE_APP_ID }}
30
- private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
31
-
32
21
  - name: Checkout code
33
22
  uses: actions/checkout@v6
34
23
  with:
35
- # Need full history so the post-release audit can diff against pre-release SHA.
36
24
  fetch-depth: 0
37
- token: ${{ steps.app-token.outputs.token }}
38
-
39
- - name: Capture pre-release SHA
40
- id: pre
41
- run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
42
25
 
43
26
  - name: Install pnpm
44
27
  run: corepack enable
45
28
 
29
+ # Node 22 ships npm 10.x; OIDC trusted publishing needs npm >=11.5.1 at publish time.
30
+ # Keep CI on 22; only the release job needs the newer runtime for @semantic-release/npm.
46
31
  - name: Setup Node.js
47
32
  uses: actions/setup-node@v6
48
33
  with:
49
- node-version: '22'
34
+ node-version: '24'
50
35
  cache: 'pnpm'
51
36
 
52
37
  - name: Install dependencies
53
- # --ignore-scripts blocks lifecycle scripts (preinstall/install/postinstall/prepare)
54
- # of all dependencies. This is the primary defence against supply-chain attacks
55
- # injected via a compromised transitive dep's postinstall hook.
56
38
  run: pnpm install --frozen-lockfile --ignore-scripts
39
+
57
40
  - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
58
41
  run: npm audit signatures
59
42
 
@@ -61,8 +44,6 @@ jobs:
61
44
  run: pnpm build
62
45
 
63
46
  - name: Verify working tree is clean before release
64
- # If install/build silently modified tracked files, abort before semantic-release
65
- # can include them in the release commit.
66
47
  run: |
67
48
  if [ -n "$(git status --porcelain)" ]; then
68
49
  echo "::error::Working tree is dirty before semantic-release. Aborting."
@@ -71,137 +52,7 @@ jobs:
71
52
  exit 1
72
53
  fi
73
54
 
74
- - name: Run Semantic Release
55
+ - name: Run semantic-release
75
56
  env:
76
- GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # GitHub App token (bypasses required_signatures ruleset)
77
- NPM_CONFIG_PROVENANCE: 'true'
57
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
78
58
  run: pnpm exec semantic-release
79
-
80
- - name: Audit semantic-release commit — fail if files outside allowlist were touched
81
- env:
82
- PRE_SHA: ${{ steps.pre.outputs.sha }}
83
- run: |
84
- # Allowlist must mirror the `assets` array in release.config.cjs.
85
- # Any file outside this list being modified by the release run is suspicious
86
- # and blocks the npm publish step below.
87
- ALLOWED="package.json pnpm-lock.yaml CHANGELOG.md"
88
-
89
- if [ "$(git rev-parse HEAD)" = "$PRE_SHA" ]; then
90
- echo "semantic-release made no commit; nothing to audit."
91
- exit 0
92
- fi
93
-
94
- echo "Auditing commits ${PRE_SHA}..HEAD"
95
- CHANGED=$(git diff --name-only "${PRE_SHA}..HEAD")
96
- echo "Files changed:"
97
- printf '%s\n' "$CHANGED"
98
-
99
- UNEXPECTED=""
100
- while IFS= read -r file; do
101
- [ -z "$file" ] && continue
102
- match=false
103
- for allowed in $ALLOWED; do
104
- if [ "$file" = "$allowed" ]; then
105
- match=true
106
- break
107
- fi
108
- done
109
- [ "$match" = false ] && UNEXPECTED="${UNEXPECTED}\n ${file}"
110
- done <<< "$CHANGED"
111
-
112
- if [ -n "$UNEXPECTED" ]; then
113
- printf '::error::semantic-release touched files outside the allowlist:%b\n' "$UNEXPECTED"
114
- echo "::error::This may indicate a compromised dependency. Aborting publish."
115
- exit 1
116
- fi
117
-
118
- echo "All changed files are within the allowlist."
119
-
120
- - name: Check if current version is already published
121
- id: version-check
122
- env:
123
- NPM_PACKAGE_NAME: '@trieb.work/nextjs-turbo-redis-cache'
124
- run: |
125
- VERSION=$(node -p "require('./package.json').version")
126
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
127
- if npm view "$NPM_PACKAGE_NAME@$VERSION" version --registry https://registry.npmjs.org/ >/dev/null 2>&1; then
128
- echo "should_publish=false" >> "$GITHUB_OUTPUT"
129
- else
130
- echo "should_publish=true" >> "$GITHUB_OUTPUT"
131
- fi
132
-
133
- - name: Exchange GitHub OIDC token for npm token
134
- id: npm-oidc
135
- env:
136
- NPM_PACKAGE_NAME: '@trieb.work/nextjs-turbo-redis-cache'
137
- run: |
138
- node <<'NODE'
139
- const fs = require('node:fs');
140
-
141
- const pkg = process.env.NPM_PACKAGE_NAME;
142
- const reqUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
143
- const reqToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
144
-
145
- if (!pkg || !reqUrl || !reqToken) {
146
- console.error('Missing required env for OIDC token retrieval');
147
- process.exit(1);
148
- }
149
-
150
- const audience = 'npm:registry.npmjs.org';
151
- const url = reqUrl + (reqUrl.includes('?') ? '&' : '?') + 'audience=' + encodeURIComponent(audience);
152
-
153
- (async () => {
154
- const idRes = await fetch(url, { headers: { Authorization: 'Bearer ' + reqToken } });
155
- if (!idRes.ok) {
156
- console.error('Failed to fetch GitHub OIDC token:', idRes.status, await idRes.text());
157
- process.exit(1);
158
- }
159
-
160
- const idBody = await idRes.json();
161
- const idToken = idBody.value;
162
-
163
- const exUrl =
164
- 'https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/' + encodeURIComponent(pkg);
165
- const exRes = await fetch(exUrl, {
166
- method: 'POST',
167
- headers: { Authorization: 'Bearer ' + idToken },
168
- });
169
-
170
- const exText = await exRes.text();
171
- if (!exRes.ok) {
172
- console.error('OIDC token exchange with npm failed:', exRes.status, exText);
173
- process.exit(1);
174
- }
175
-
176
- const exBody = JSON.parse(exText);
177
- const npmToken = exBody.token;
178
- if (!npmToken) {
179
- console.error('npm exchange response missing token');
180
- process.exit(1);
181
- }
182
-
183
- fs.appendFileSync(process.env.GITHUB_OUTPUT, `node_auth_token=${npmToken}\n`);
184
- const npmrcPath = `${process.env.RUNNER_TEMP}/npmrc`;
185
- const npmrc = [
186
- 'registry=https://registry.npmjs.org/',
187
- 'always-auth=true',
188
- '//registry.npmjs.org/:_authToken=' + npmToken,
189
- '',
190
- ].join('\n');
191
- fs.writeFileSync(npmrcPath, npmrc, { encoding: 'utf8' });
192
- fs.appendFileSync(process.env.GITHUB_OUTPUT, `npmrc_path=${npmrcPath}\n`);
193
- console.log('OIDC token exchange with npm registry succeeded');
194
- })().catch((e) => {
195
- console.error(e);
196
- process.exit(1);
197
- });
198
- NODE
199
-
200
- - name: Publish to npm (Trusted Publishing)
201
- if: steps.version-check.outputs.should_publish == 'true'
202
- env:
203
- NPM_CONFIG_PROVENANCE: 'true'
204
- NPM_DIST_TAG: ${{ github.ref_name == 'beta' && 'beta' || 'latest' }}
205
- NPM_CONFIG_USERCONFIG: ${{ steps.npm-oidc.outputs.npmrc_path }}
206
- run: npm publish --provenance --access public --tag $NPM_DIST_TAG --registry https://registry.npmjs.org/
207
-
package/ARCHITECTURE.md CHANGED
@@ -86,10 +86,10 @@ Without an additional data structure, the only way to find all keys for a tag wo
86
86
 
87
87
  ### The Solution: Two SyncedMaps
88
88
 
89
- | Map | Key | Value | Purpose |
90
- | -------------------- | ------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
91
- | `sharedTagsMap` | cache key (e.g. `/products/42`) | `string[]` of tags | Reverse index: given a tag, iterate this map to find all affected cache keys |
92
- | `revalidatedTagsMap` | tag name (e.g. `product`) | `number` (timestamp) | Tracks _when_ a tag was last revalidated, used for lazy invalidation of fetch entries (implicit tags / `_N_T_` prefix) |
89
+ | Map | Key | Value | Purpose |
90
+ | -------------------- | ------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `sharedTagsMap` | cache key (e.g. `/products/42`) | `string[]` of tags | Reverse index: given a tag, iterate this map to find all affected cache keys |
92
+ | `revalidatedTagsMap` | tag name (e.g. `product`) | timestamp or `{ stale, expired }` | Tracks _when_ a tag was last revalidated. ISR uses a number and lazy-checks it in `get()`. Cache Components stores `{ stale, expired }` matching Next.js `tagsManifest`; `get()` applies `areTagsExpired` / `areTagsStale`, and `getExpiration()` returns max `expired` |
93
93
 
94
94
  Both maps live **in-memory** in every Node.js process and are **synchronized across instances** through Redis Hash + Pub/Sub (see [SyncedMap](#syncedmap--the-synchronization-primitive)).
95
95
 
@@ -217,7 +217,7 @@ get(key: string, ctx: {
217
217
  get(cacheKey: string, softTags: string[])
218
218
  ```
219
219
 
220
- The Cache Components interface is simpler: it receives only the cache key and soft tags (implicit tags for lazy invalidation).
220
+ The Cache Components interface is simpler: it receives only the cache key and soft tags. Soft-tag staleness for implicit `_N_T_` tags is handled by Next.js via `getExpiration()`. Explicit `cacheTag()`s are checked in `get()` (`areTagsExpired` / `areTagsStale`), matching `DefaultCacheHandler.get()`.
221
221
 
222
222
  ### What `get` Does
223
223
 
@@ -228,14 +228,20 @@ flowchart TD
228
228
  C -->|No| RN["Return null/undefined"]
229
229
  C -->|Yes| D["JSON.parse result"]
230
230
 
231
- D --> CC{CacheComponents?\nCheck expire}
232
- CC -->|Expired| DEL1["UNLINK key\nDelete from sharedTagsMap\nReturn undefined"]
233
- CC -->|Valid| E
234
-
235
- D --> E["Check revalidatedTagsMap\nfor all tags + softTags"]
231
+ D --> CC{CacheComponents?}
232
+ CC -->|Yes| EXP{"entry.expire TTL elapsed?"}
233
+ EXP -->|Yes| DEL1["UNLINK key\nDelete from sharedTagsMap\nReturn undefined"]
234
+ EXP -->|No| TAGX{"areTagsExpired(entry.tags)?"}
235
+ TAGX -->|Yes| DEL1
236
+ TAGX -->|No| TAGS{"areTagsStale(entry.tags)?"}
237
+ TAGS -->|Yes| SETR["Set revalidate = -1"]
238
+ TAGS -->|No| H["Return cache entry"]
239
+ SETR --> H
240
+
241
+ CC -->|No / ISR| E["Check revalidatedTagsMap\nfor all tags + softTags"]
236
242
  E --> F{Any tag revalidated\nafter entry.lastModified/timestamp?}
237
243
  F -->|Yes| G["UNLINK key from Redis\nDelete from sharedTagsMap\nReturn null/undefined"]
238
- F -->|No| H["Return cache entry"]
244
+ F -->|No| H
239
245
 
240
246
  H --> H2["RedisStringsHandler:\nreturn { value, lastModified, tags }"]
241
247
  H --> H3["CacheComponentsHandler:\nConvert base64 → Uint8Array → ReadableStream\nreturn { value, tags, stale, timestamp, expire, revalidate }"]
@@ -247,7 +253,7 @@ flowchart TD
247
253
 
248
254
  2. **Deduplication** (both handlers): Before hitting Redis, the deduplication cache is checked for an existing in-flight or recently resolved promise for the same key. Enabled by default (`redisGetDeduplication: true`) with a 10s caching window (`inMemoryCachingTime: 10_000`).
249
255
 
250
- 3. **Lazy tag invalidation**: Instead of eagerly deleting all fetch entries when a page tag is revalidated, the handler records the revalidation timestamp in `revalidatedTagsMap`. During `get`, it compares `lastModified` / `timestamp` against the max revalidation timestamp of all related tags. If the entry is stale, it is deleted and `null` is returned. This is necessary because `revalidateTag` for implicit tags (`_N_T_` prefix) does not know which fetch cache keys are affected.
256
+ 3. **Lazy tag invalidation**: Instead of eagerly deleting all fetch entries when a page tag is revalidated, `RedisStringsHandler` records the revalidation timestamp in `revalidatedTagsMap`. During `get`, it compares `lastModified` against the max revalidation timestamp of all related tags. If the entry is stale, it is deleted and `null` is returned. This is necessary because `revalidateTag` for implicit tags (`_N_T_` prefix) does not know which fetch cache keys are affected. **Cache Components** matches Next.js `DefaultCacheHandler.get()`: `areTagsExpired` → miss, `areTagsStale` → return the entry with `revalidate: -1` (SWR). Next.js uses `getExpiration()` only for implicit/soft tags.
251
257
 
252
258
  4. **Value transformation** (CacheComponentsHandler): The stored value is a base64-encoded string (from a `ReadableStream<Uint8Array>`). On read, it is decoded back to `Uint8Array` and wrapped in a new `ReadableStream`.
253
259
 
@@ -300,7 +306,7 @@ flowchart TD
300
306
  B3 --> C
301
307
 
302
308
  C --> D["Calculate TTL"]
303
- D --> D2["RedisStringsHandler:\nestimateExpireAge(revalidate || defaultStaleAge)"]
309
+ D --> D2["RedisStringsHandler:\nresolveCacheEntryTtlSeconds()\nprefer cacheControl.expire,\nlegacy fallback to estimateExpireAge(revalidate)"]
304
310
  D --> D3["CacheComponentsHandler:\nentry.expire (already in seconds)"]
305
311
 
306
312
  D2 --> E["Redis SET prefix:key serialized EX ttl"]
@@ -352,54 +358,88 @@ In both cases, the handler receives **only tag names** – no cache keys.
352
358
 
353
359
  ```mermaid
354
360
  flowchart TD
355
- A["revalidateTag(tags)"] --> B["Normalize tags to Set"]
356
-
357
- B --> C["For implicit tags (_N_T_ prefix):\nMark in revalidatedTagsMap with Date.now()"]
358
- C --> NOTE["This enables lazy invalidation\nof nested fetch entries on next get()"]
359
-
360
- B --> D["Scan sharedTagsMap:\nFor each (key, storedTags):\n if any storedTag ∈ tags → add key to keysToDelete"]
361
+ A["revalidateTag / updateTags"] --> B["Normalize tags to Set"]
362
+ B --> ISR{Cache Components?}
361
363
 
364
+ ISR -->|No ISR| C["Persist Date.now() in revalidatedTagsMap"]
365
+ C --> D["Scan sharedTagsMap for matching keys"]
362
366
  D --> E{keysToDelete empty?}
363
367
  E -->|Yes| F["Return early"]
364
- E -->|No| G["UNLINK all matching Redis keys\n(batch operation)"]
365
-
366
- G --> H["Delete from sharedTagsMap\n→ HDEL + PUBLISH"]
367
-
368
- G --> I["Delete from inMemoryDeduplicationCache\n(if redisGetDeduplication enabled)"]
369
-
368
+ E -->|No| G["UNLINK matching Redis keys"]
369
+ G --> H["Delete from sharedTagsMap + Pub/Sub"]
370
+ G --> I["Delete from inMemoryDeduplicationCache"]
370
371
  H --> J["Done"]
371
372
  I --> J
373
+
374
+ ISR -->|Yes| K{"durations provided?"}
375
+ K -->|No updateTag / deprecated revalidateTag| L["Write { expired: now }"]
376
+ K -->|Yes| M["Write stale = now"]
377
+ M --> N{"durations.expire defined?"}
378
+ N -->|Yes| O["expired = now + expire * 1000"]
379
+ N -->|No| P["keep prior expired"]
380
+ L --> Q["Persist in revalidatedTagsMap\nno UNLINK"]
381
+ O --> Q2["expire: 0 → expired=now blocking miss\nexpire>0 → SWR window"]
382
+ P --> Q
383
+ Q2 --> Q
372
384
  ```
373
385
 
374
386
  ### Key Details
375
387
 
376
388
  1. **Implicit tags (`_N_T_` prefix)**: When Next.js calls `revalidatePath("/products")`, it internally translates this to `revalidateTag("_N_T_/products")`. The handler cannot know which _fetch_ cache keys are nested inside that page. Therefore, it only records the timestamp in `revalidatedTagsMap`. The actual cleanup happens lazily in `get()` when the fetch entry is next accessed.
377
389
 
378
- 2. **Batch deletion**: All matching Redis keys are deleted in a single `UNLINK` call (non-blocking Redis delete), minimizing network round-trips.
390
+ 2. **SWR `updateTags` (Cache Components)**: Matches Next.js `DefaultCacheHandler.updateTags()` (`default.js` in 16.0.11, 16.2.6, 16.3.0) and the [revalidateTag docs](https://nextjs.org/docs/app/api-reference/functions/revalidateTag). `revalidateTag(tag, profile)` looks up `cacheLife[profile].expire` (seconds) and calls `updateTags(tags, { expire })`. The handler never unlinks in `updateTags`:
391
+
392
+ - No `durations` (`updateTag` / deprecated single-arg `revalidateTag`): `{ expired: now }` — next `get()` is a hard miss.
393
+ - `{ expire: 0 }`: `{ stale: now, expired: now }` — blocking miss, same HTTP effect.
394
+ - `{ expire: N }` including `'max'` (~1 year) and `'default'` (`INFINITE_CACHE`): `{ stale: now, expired: now + N * 1000 }`. `get()` returns the entry with `revalidate: -1` until `expired`. Past `expired`, `areTagsExpired` is a miss.
395
+
396
+ 3. **Batch deletion (ISR only)**: `RedisStringsHandler.revalidateTag` deletes matching Redis keys in a single `UNLINK`. Cache Components leaves keys in place; `get()` drops them only after `areTagsExpired`.
397
+
398
+ 4. **Cross-instance propagation**: Tag-manifest writes publish via Pub/Sub, so all instances see `{ stale, expired }` without waiting for `refreshTags()`.
399
+
400
+ 5. **Preset expire seconds** (from Next.js `config-shared.js`): `seconds` 60, `minutes` 3600, `hours` 86400, `days` 604800, `weeks` 2592000, `max` 31536000, `default` 4294967294 (`0xfffffffe`).
401
+
402
+ ### Reviewer notes (automated review false positives)
403
+
404
+ **Legacy plain numbers in `__cacheComponents_revalidated_tags__`**
405
+
406
+ Next.js always calls `updateTags(tags, durations?)` with `{ stale, expired }` semantics. It does not write plain numbers into the cache handler. Plain numbers in Redis are a rolling-upgrade artifact from older package versions that stored `Date.now()` directly. A _future-looking_ plain number is not an SWR `expired` window (those always include `stale`); it only arises from cross-instance clock skew. `normalizeTagManifest()` clamps legacy plain numbers to `Math.min(stored, now)` on read so slower instances do not delay invalidation.
407
+
408
+ **`resolveCacheEntryTtlSeconds()` returning `undefined`**
409
+
410
+ Returning `undefined` when both `cacheControl.expire` and `revalidate` are absent is deliberate: Redis keys are written without `EX` and invalidation relies on tags. `revalidate: false` is handled separately and maps to `defaultStaleAge` (see `cache-ttl.test.ts` and Pages Router `/static-forever` integration). Next.js supplies `revalidate: false` for fully static pages in practice.
411
+
412
+ **Tag-manifest keys vs `SyncedMap` orphan cleanup**
413
+
414
+ `cleanupKeysNotInRedis()` SCANs Redis string keys and HDELs hash fields whose names are missing from that set. That is correct for `sharedTagsMap` (cache key → tags). It is wrong for `revalidatedTagsMap` (tag name → `{ stale, expired }`): tag names are never top-level keys, so startup or the hourly resync would wipe every invalidation and publish the delete fleet-wide. Tag-manifest maps set `customizedSync.withoutOrphanCleanup`.
415
+
416
+ **Dedup cache vs Redis UNLINK**
417
+
418
+ `updateTags` no longer deletes Redis entries (Next.js `DefaultCacheHandler` does not either). A 10s in-memory GET cache can still hold the pre-invalidation payload. `get()` must not `UNLINK` a Redis value that differs from the payload it just judged expired — that would delete a replacement written by another instance. Expired reads bypass dedup, compare-and-delete only the same serialized value, and `updateTags` evicts matching dedup entries.
379
419
 
380
- 3. **Cross-instance propagation**: The `sharedTagsMap.delete()` publishes a Pub/Sub message, so all other instances immediately remove the deleted keys from their local maps as well.
420
+ **Rolling upgrades and `{ stale, expired }` objects**
381
421
 
382
- 4. **Dedup cache cleanup** (both handlers): Revalidated keys are also removed from the `inMemoryDeduplicationCache` to prevent stale data from being served from memory.
422
+ Older package versions stored a number and compared it numerically. Immediate hard-expires (`expired <= now` and no newer `stale`) are still persisted as that number so mixed fleets invalidate. A later stale-only update (`updateTags(tags, {})`) that outruns a past `expired` stays an object. SWR windows stay `{ stale, expired }` objects; instances that cannot parse them keep serving until TTL. Prefer a coordinated rollout (or a unique `keyPrefix`) when using `revalidateTag(tag, profile)` SWR.
383
423
 
384
424
  ---
385
425
 
386
426
  ## RedisStringsHandler vs CacheComponentsHandler
387
427
 
388
- | Aspect | RedisStringsHandler | CacheComponentsHandler |
389
- | ------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
390
- | **Next.js version** | 15+ (legacy `cacheHandler`) | 16+ (`cacheHandlers.default`) |
391
- | **Cache kinds** | `APP_PAGE`, `APP_ROUTE`, `FETCH` | Unified (all via `'use cache'`, `cacheTag`, `cacheLife`) |
392
- | **Value format** | Arbitrary JSON (page HTML, RSC data, fetch response) | `ReadableStream<Uint8Array>` ↔ base64 string |
393
- | **Entry shape** | `{ value, lastModified, tags }` | `{ value, tags, stale, timestamp, expire, revalidate }` |
394
- | **set() receives** | Resolved data | `Promise<CacheComponentsEntry>` (may not yet be resolved) |
395
- | **TTL calculation** | `estimateExpireAge(revalidate)` – configurable function | `entry.expire` – passed directly by Next.js |
396
- | **Tag source in set** | `x-next-cache-tags` header + `ctx.tags` | `entry.tags` |
397
- | **Request deduplication** | Yes (`DeduplicatedRequestHandler`, default on) | Yes (`DeduplicatedRequestHandler`, default on) |
398
- | **In-memory caching** | Yes (configurable `inMemoryCachingTime`, default 10s) | Yes (configurable `inMemoryCachingTime`, default 10s) |
399
- | **Revalidation function** | `revalidateTag(tagOrTags)` | `updateTags(tags, durations?)` |
400
- | **Implicit tag handling** | Stores timestamp in `revalidatedTagsMap`, lazy check in `get()` for `FETCH` kind | Stores timestamp in `revalidatedTagsMap`, lazy check in `get()` for all entries |
401
- | **Singleton pattern** | External (user wraps in `module.exports`) | Built-in `getRedisCacheComponentsHandler()` singleton |
402
- | **Key prefix resolution** | `keyPrefix` option or `KEY_PREFIX` / `VERCEL_URL` env | `resolveKeyPrefix()` with BUILD_ID fallback |
428
+ | Aspect | RedisStringsHandler | CacheComponentsHandler |
429
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
430
+ | **Next.js version** | 15+ (legacy `cacheHandler`) | 16+ (`cacheHandlers.default`) |
431
+ | **Cache kinds** | `APP_PAGE`, `APP_ROUTE`, `FETCH` | Unified (all via `'use cache'`, `cacheTag`, `cacheLife`) |
432
+ | **Value format** | Arbitrary JSON (page HTML, RSC data, fetch response) | `ReadableStream<Uint8Array>` ↔ base64 string |
433
+ | **Entry shape** | `{ value, lastModified, tags }` | `{ value, tags, stale, timestamp, expire, revalidate }` |
434
+ | **set() receives** | Resolved data | `Promise<CacheComponentsEntry>` (may not yet be resolved) |
435
+ | **TTL calculation** | `resolveCacheEntryTtlSeconds()` – prefers `cacheControl.expire`, legacy `estimateExpireAge(revalidate)` fallback | `entry.expire` – passed directly by Next.js |
436
+ | **Tag source in set** | `x-next-cache-tags` header + `ctx.tags` | `entry.tags` |
437
+ | **Request deduplication** | Yes (`DeduplicatedRequestHandler`, default on) | Yes (`DeduplicatedRequestHandler`, default on) |
438
+ | **In-memory caching** | Yes (configurable `inMemoryCachingTime`, default 10s) | Yes (configurable `inMemoryCachingTime`, default 10s) |
439
+ | **Revalidation function** | `revalidateTag(tagOrTags)` | `updateTags(tags, durations?)` |
440
+ | **Implicit tag handling** | Stores timestamp in `revalidatedTagsMap`, lazy check in `get()` for `FETCH` kind | Stores `{ stale, expired }` in `revalidatedTagsMap`; `get()` applies `areTagsExpired` / `areTagsStale`; `getExpiration()` returns max `expired` (soft tags) |
441
+ | **Singleton pattern** | External (user wraps in `module.exports`) | Built-in `getRedisCacheComponentsHandler()` singleton |
442
+ | **Key prefix resolution** | `keyPrefix` option or `KEY_PREFIX` / `VERCEL_URL` env | `resolveKeyPrefix()` with BUILD_ID fallback |
403
443
 
404
444
  ### Shared Architecture
405
445
 
@@ -420,12 +460,11 @@ flowchart LR
420
460
  SET -.->|"seed"| D
421
461
  GET["get()"] --> D
422
462
  GET -->|"on miss"| A
423
- GET -.->|"check staleness"| C
463
+ GET -.->|"ISR: check staleness"| C
464
+ EXP["getExpiration()"] -->|"CC: timestamp now"| C
424
465
  REV["revalidateTag()\nupdateTags()"] --> C
425
- REV -->|"find keys via"| B
426
- REV -->|"UNLINK"| A
427
- REV -->|"cleanup"| B
428
466
  REV -->|"evict"| D
467
+ GET -->|"expired + same value: UNLINK"| A
429
468
  ```
430
469
 
431
470
  Both handlers rely on `SyncedMap` for cross-instance consistency of the tag maps and use the same pattern of "find affected keys via `sharedTagsMap` → batch delete from Redis → clean up maps". Both also use `DeduplicatedRequestHandler` (enabled by default) to reduce Redis load by deduplicating concurrent `get()` calls for the same key and seeding the cache on `set()`.
package/README.md CHANGED
@@ -90,6 +90,8 @@ const nextConfig = {
90
90
 
91
91
  Make sure to set either REDIS_URL or REDISHOST and REDISPORT environment variables.
92
92
 
93
+ Redis connections are skipped during `next build` (`NEXT_PHASE=phase-production-build`), so a production build can succeed without Redis. The handler connects when Next.js first calls it at runtime (`next start`).
94
+
93
95
  ### Option B: create a wrapper file to change options
94
96
 
95
97
  create new file `customized-cache-handler.js` in your project root and add the following code:
@@ -131,6 +133,8 @@ module.exports = class CustomizedCacheHandler {
131
133
  }
132
134
  ```
133
135
 
136
+ `defaultStaleAge` and `estimateExpireAge` are fallbacks for when Next.js does not pass `cacheControl.expire`. On Next.js 16.3+ ISR, Redis TTL is `expire` and these options do not change it.
137
+
134
138
  extend `next.config.js` with:
135
139
 
136
140
  ```
@@ -145,23 +149,23 @@ A working example of above can be found in the `test/nextjs-test-projects/next-a
145
149
 
146
150
  ## Available Options
147
151
 
148
- | Option | Description | Default Value |
149
- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
150
- | redisUrl | Redis connection url | `process.env.REDIS_URL? process.env.REDIS_URL : process.env.REDISHOST ? redis://${process.env.REDISHOST}:${process.env.REDISPORT} : 'redis://localhost:6379'` |
151
- | database | Redis database number to use. Uses DB 0 for production, DB 1 otherwise | `process.env.VERCEL_ENV === 'production' ? 0 : 1` |
152
- | keyPrefix | Prefix added to all Redis keys | `RedisStringsHandler` default: `process.env.KEY_PREFIX \|\| process.env.VERCEL_URL \|\| 'UNDEFINED_URL_'`<br> Next handlers resolve: `options.keyPrefix \|\| KEY_PREFIX \|\| VERCEL_URL \|\| BUILD_ID \|\| 'UNDEFINED_URL_'` |
153
- | sharedTagsKey | Key used to store shared tags hash map in Redis | `'__sharedTags__'` |
154
- | getTimeoutMs | Timeout in milliseconds for time critical Redis operations. If Redis get is not fulfilled within this time, returns null to avoid blocking site rendering. | `process.env.REDIS_COMMAND_TIMEOUT_MS ? (Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT_MS) ?? 500) : 500` |
155
- | revalidateTagQuerySize | Number of entries to query in one batch during full sync of shared tags hash map | `250` |
156
- | avgResyncIntervalMs | Average interval in milliseconds between tag map full re-syncs | `3600000` (1 hour) |
157
- | redisGetDeduplication | Enable deduplication of Redis get requests via internal in-memory cache. | `true` |
158
- | inMemoryCachingTime | Time in milliseconds to cache Redis get results in memory. Set this to 0 to disable in-memory caching completely. | `10000` |
159
- | defaultStaleAge | Default stale age in seconds for cached items | `1209600` (14 days) |
160
- | estimateExpireAge | Function to calculate expire age (redis TTL value) from stale age | Production: `staleAge * 2`<br> Other: `staleAge * 1.2` |
161
- | socketOptions | Redis client socket options for TLS/SSL configuration (e.g., `{ tls: true, rejectUnauthorized: false }`) | `{ connectTimeout: timeoutMs }` |
162
- | clientOptions | Additional Redis client options (e.g., username, password) | `undefined` |
163
- | killContainerOnErrorThreshold | Number of consecutive errors before the container is killed. Set to 0 to disable. | `Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0 : 0` |
164
- | valueSerializer | Pluggable wire-format codec for Redis string values (compression, encryption, custom encoding). See [Custom value serializer](#custom-value-serializer-compression-encryption). | `jsonCacheValueSerializer` (`JSON.stringify` with built-in `Buffer` and `Map` encoding) |
152
+ | Option | Description | Default Value |
153
+ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154
+ | redisUrl | Redis connection url | `process.env.REDIS_URL? process.env.REDIS_URL : process.env.REDISHOST ? redis://${process.env.REDISHOST}:${process.env.REDISPORT} : 'redis://localhost:6379'` |
155
+ | database | Redis database number to use. Uses DB 0 for production, DB 1 otherwise | `process.env.VERCEL_ENV === 'production' ? 0 : 1` |
156
+ | keyPrefix | Prefix added to all Redis keys | `RedisStringsHandler` default: `process.env.KEY_PREFIX \|\| process.env.VERCEL_URL \|\| 'UNDEFINED_URL_'`<br> Next handlers resolve: `options.keyPrefix \|\| KEY_PREFIX \|\| VERCEL_URL \|\| BUILD_ID \|\| 'UNDEFINED_URL_'` |
157
+ | sharedTagsKey | Key used to store shared tags hash map in Redis | `'__sharedTags__'` |
158
+ | getTimeoutMs | Timeout in milliseconds for time critical Redis operations. If Redis get is not fulfilled within this time, returns null to avoid blocking site rendering. | `process.env.REDIS_COMMAND_TIMEOUT_MS ? (Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT_MS) ?? 500) : 500` |
159
+ | revalidateTagQuerySize | Number of entries to query in one batch during full sync of shared tags hash map | `250` |
160
+ | avgResyncIntervalMs | Average interval in milliseconds between tag map full re-syncs | `3600000` (1 hour) |
161
+ | redisGetDeduplication | Enable deduplication of Redis get requests via internal in-memory cache. | `true` |
162
+ | inMemoryCachingTime | Time in milliseconds to cache Redis get results in memory. Set this to 0 to disable in-memory caching completely. | `10000` |
163
+ | defaultStaleAge | Fallback stale age in seconds used only when Next.js does not pass a finite `cacheControl.expire` (e.g. `revalidate: false`, or older callers that only send `revalidate`). Next 16.3+ ISR typically sends `expire` (~1 year); that value is the Redis TTL and this option is ignored. | `1209600` (14 days) |
164
+ | estimateExpireAge | Fallback to compute Redis TTL from a stale/`revalidate` age when `cacheControl.expire` is absent. Not applied when Next.js provides `expire`. | Production: `staleAge * 2`<br> Other: `staleAge * 1.2` |
165
+ | socketOptions | Redis client socket options for TLS/SSL configuration (e.g., `{ tls: true, rejectUnauthorized: false }`) | `{ connectTimeout: timeoutMs }` |
166
+ | clientOptions | Additional Redis client options (e.g., username, password) | `undefined` |
167
+ | killContainerOnErrorThreshold | Number of consecutive errors before the container is killed. Set to 0 to disable. | `Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0 : 0` |
168
+ | valueSerializer | Pluggable wire-format codec for Redis string values (compression, encryption, custom encoding). See [Custom value serializer](#custom-value-serializer-compression-encryption). | `jsonCacheValueSerializer` (`JSON.stringify` with built-in `Buffer` and `Map` encoding) |
165
169
 
166
170
  ## Custom value serializer (compression, encryption)
167
171
 
@@ -467,7 +471,50 @@ Install the package in your Next.js app:
467
471
  pnpm add @trieb.work/nextjs-turbo-redis-cache redis
468
472
  ```
469
473
 
470
- In your Next.js app, enable Cache Components and point `cacheHandlers.default` to a module that exports the handler instance:
474
+ #### Hybrid setup (ISR + Cache Components)
475
+
476
+ Next.js has **two different handler APIs**. They are not interchangeable:
477
+
478
+ | Config key | Next.js loads it as | This package export | Methods |
479
+ | ------------------------- | --------------------------------- | ------------------------------------------ | ---------------------------------------------------------- |
480
+ | `cacheHandler` (singular) | `new Handler(options)` | **default export** (`CachedHandler` class) | `get`, `set`, `revalidateTag`, `resetRequestCache` |
481
+ | `cacheHandlers` (plural) | imported object (not constructed) | **`redisCacheHandler`** | `get`, `set`, `getExpiration`, `updateTags`, `refreshTags` |
482
+
483
+ `redisCacheHandler` is not a constructor (`new redisCacheHandler()` throws). Pointing `cacheHandler` at `./cache-handler.js` (the Cache Components object) will fail at runtime. Pointing `cacheHandlers` at the default class export will not provide `getExpiration` / `updateTags`.
484
+
485
+ For a self-hosted app that needs both ISR and `'use cache'` / `'use cache: remote'`:
486
+
487
+ ```ts
488
+ // next.config.ts
489
+ import type { NextConfig } from 'next';
490
+
491
+ const nextConfig: NextConfig = {
492
+ cacheComponents: true,
493
+ cacheHandler: require.resolve('@trieb.work/nextjs-turbo-redis-cache'),
494
+ cacheHandlers: {
495
+ default: require.resolve('./cache-handler.js'),
496
+ remote: require.resolve('./cache-handler.js'),
497
+ },
498
+ cacheMaxMemorySize: 0,
499
+ };
500
+
501
+ export default nextConfig;
502
+ ```
503
+
504
+ ```js
505
+ // cache-handler.js — Cache Components only (`cacheHandlers.default` / `.remote`)
506
+ const { redisCacheHandler } = require('@trieb.work/nextjs-turbo-redis-cache');
507
+
508
+ module.exports = redisCacheHandler;
509
+ ```
510
+
511
+ `default` and `remote` may be the same `redisCacheHandler` module: `'use cache'` uses `default`, `'use cache: remote'` uses `remote`. `cacheMaxMemorySize: 0` disables Next's in-process memory cache so Redis is shared across instances.
512
+
513
+ Do not wrap ISR and Cache Components in one file unless you implement **both** interfaces (class constructed with `new`, and a separate object export). This package ships them as two exports on purpose.
514
+
515
+ #### Cache Components only
516
+
517
+ If you only need Cache Components (no ISR `cacheHandler`), enable Cache Components and point `cacheHandlers` at `redisCacheHandler`. Include `remote` if you use `'use cache: remote'`.
471
518
 
472
519
  ```ts
473
520
  // next.config.ts
@@ -477,7 +524,9 @@ const nextConfig: NextConfig = {
477
524
  cacheComponents: true,
478
525
  cacheHandlers: {
479
526
  default: require.resolve('./cache-handler.js'),
527
+ remote: require.resolve('./cache-handler.js'),
480
528
  },
529
+ cacheMaxMemorySize: 0,
481
530
  };
482
531
 
483
532
  export default nextConfig;
@@ -508,6 +557,19 @@ Optional:
508
557
  - `VERCEL_URL`: used as a key prefix for multi-tenant isolation (also useful in tests). If unset, a default prefix is used.
509
558
  - `REDIS_COMMAND_TIMEOUT_MS`: timeout (ms) for Redis commands used by the handler.
510
559
 
560
+ ### Official caching semantics (Vercel / Next.js self-hosting docs)
561
+
562
+ This package follows the semantics documented in the [Next.js self-hosting guide](https://nextjs.org/docs/app/guides/self-hosting#configuring-caching) and the official `cache-handler-redis` example:
563
+
564
+ | Topic | Behavior |
565
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
566
+ | **ISR Redis TTL** | Key TTL on `cacheControl.expire`, not `revalidate`. Past `revalidate` an entry is only stale (SWR); evicting at that boundary would defeat background refresh. Legacy callers that only pass `revalidate` still get `estimateExpireAge(revalidate)`. |
567
+ | **Tag sources (ISR)** | `APP_PAGE` / `APP_ROUTE` tags come from `data.headers['x-next-cache-tags']` plus `ctx.tags`. `FETCH` tags come from `ctx.tags`. |
568
+ | **Buffer / Map serialization** | `rscData` (`Buffer`) and `segmentData` (`Map`) require custom JSON encoding — use the built-in `jsonCacheValueSerializer` or wrap it. Plain `JSON.stringify` causes `segmentData.get is not a function` on RSC navigation. |
569
+ | **`updateTags(tags, durations)`** | Persists Next.js tag-manifest fields in Redis (`stale` / `expired`). No `durations` or `{ expire: 0 }` hard-expires (`expired = now`). `expire > 0` (including `'max'` ~1 year) is the SWR window: `stale = now`, `expired = now + expire * 1000`, and `get()` returns `revalidate: -1` until that deadline. |
570
+ | **`getExpiration` pattern** | Returns max tag `expired` (may be in the future). Next.js uses this for implicit/soft tags. Explicit `cacheTag()`s are checked in `get()` via `areTagsExpired` / `areTagsStale`. |
571
+ | **Build without Redis** | During `next build` (`NEXT_PHASE`), Redis connections are skipped so CI/build pipelines without Redis still succeed. |
572
+
511
573
  ### Lazy initialization
512
574
 
513
575
  The `redisCacheHandler` export is **lazily initialized** — importing the package does **not** open a Redis connection. The connection is deferred until the first method call on the handler (when Next.js invokes it). This means:
@@ -536,6 +598,8 @@ Then open the Cache Lab pages:
536
598
  - `/cache-lab/tag-invalidation`
537
599
  - `/cache-lab/stale-while-revalidate`
538
600
  - `/cache-lab/runtime-data-suspense`
601
+ - `/cache-lab/use-cache-remote`
602
+ - `/cache-lab/revalidate-durations`
539
603
 
540
604
  To run the Playwright E2E tests against a running dev server:
541
605
 
@@ -545,13 +609,12 @@ PLAYWRIGHT_BASE_URL=http://localhost:3101 pnpm test:e2e
545
609
 
546
610
  ## Some words on nextjs caching internals
547
611
 
548
- Nextjs will use different caching objects for different pages and api routes. Currently supported are kind: APP_ROUTE and APP_PAGE.
549
-
550
- app/<segment>/route.ts files will request using the APP_ROUTE kind.
551
- app/<segment>/page.tsx files will request using the APP_PAGE kind.
552
- /favicon.ico file will request using the APP_ROUTE kind.
612
+ Next.js uses different cache entry kinds. This handler supports `APP_PAGE`, `APP_ROUTE`, `FETCH`, `PAGES`, and `REDIRECT` (plus Pages Router `notFound` stored as a null value).
553
613
 
554
- Fetch requests (inside app route or page) will request using the FETCH kind.
614
+ - `app/<segment>/page.tsx` → `APP_PAGE`
615
+ - `app/<segment>/route.ts` (and `/favicon.ico`) → `APP_ROUTE`
616
+ - `fetch()` inside App Router → `FETCH`
617
+ - Pages Router `getStaticProps` → `PAGES` / `REDIRECT`
555
618
 
556
619
  For details on how these kinds are handled internally (tag maps, deduplication, value transformation), see [ARCHITECTURE.md](./ARCHITECTURE.md).
557
620