@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.
- package/.github/workflows/release.yml +8 -157
- package/ARCHITECTURE.md +86 -47
- package/README.md +87 -24
- package/dist/index.d.mts +40 -1
- package/dist/index.d.ts +40 -1
- package/dist/index.js +264 -96
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +261 -95
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -6
- package/release.config.cjs +6 -25
- package/src/CacheComponentsHandler.ts +141 -111
- package/src/RedisStringsHandler.ts +37 -25
- package/src/SyncedMap.ts +16 -2
- package/src/index.ts +7 -0
- package/src/utils/cacheTtl.ts +71 -0
- package/src/utils/compareAndUnlink.ts +29 -0
- package/src/utils/redisConnection.ts +17 -0
- package/src/utils/tagRevalidation.ts +162 -0
- package/test/README.md +10 -10
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/playwright/cache-lab.spec.ts +75 -0
- package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
- package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +16 -2
- package/test/vitest/integration/pages-router.integration.test.ts +16 -6
- package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
- package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
- package/test/vitest/unit/cache-ttl.test.ts +67 -0
- package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
- package/test/vitest/unit/index.test.ts +17 -0
- package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
- package/test/vitest/unit/redis-connection.test.ts +39 -0
- package/test/vitest/unit/tag-revalidation.test.ts +178 -0
- package/CHANGELOG.md +0 -411
|
@@ -5,12 +5,8 @@ on:
|
|
|
5
5
|
branches:
|
|
6
6
|
- main
|
|
7
7
|
- beta
|
|
8
|
-
|
|
9
|
-
#
|
|
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: '
|
|
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
|
|
55
|
+
- name: Run semantic-release
|
|
75
56
|
env:
|
|
76
|
-
GITHUB_TOKEN: ${{
|
|
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
|
|
90
|
-
| -------------------- | ------------------------------- |
|
|
91
|
-
| `sharedTagsMap` | cache key (e.g. `/products/42`) | `string[]` of tags
|
|
92
|
-
| `revalidatedTagsMap` | tag name (e.g. `product`) | `
|
|
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
|
|
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
|
|
232
|
-
CC -->|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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,
|
|
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:\
|
|
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
|
|
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
|
|
365
|
-
|
|
366
|
-
G -->
|
|
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. **
|
|
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
|
-
|
|
420
|
+
**Rolling upgrades and `{ stale, expired }` objects**
|
|
381
421
|
|
|
382
|
-
|
|
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
|
|
389
|
-
| ------------------------- |
|
|
390
|
-
| **Next.js version** | 15+ (legacy `cacheHandler`)
|
|
391
|
-
| **Cache kinds** | `APP_PAGE`, `APP_ROUTE`, `FETCH`
|
|
392
|
-
| **Value format** | Arbitrary JSON (page HTML, RSC data, fetch response)
|
|
393
|
-
| **Entry shape** | `{ value, lastModified, tags }`
|
|
394
|
-
| **set() receives** | Resolved data
|
|
395
|
-
| **TTL calculation** | `estimateExpireAge(revalidate)`
|
|
396
|
-
| **Tag source in set** | `x-next-cache-tags` header + `ctx.tags`
|
|
397
|
-
| **Request deduplication** | Yes (`DeduplicatedRequestHandler`, default on)
|
|
398
|
-
| **In-memory caching** | Yes (configurable `inMemoryCachingTime`, default 10s)
|
|
399
|
-
| **Revalidation function** | `revalidateTag(tagOrTags)`
|
|
400
|
-
| **Implicit tag handling** | Stores timestamp in `revalidatedTagsMap`, lazy check in `get()` for `FETCH` kind
|
|
401
|
-
| **Singleton pattern** | External (user wraps in `module.exports`)
|
|
402
|
-
| **Key prefix resolution** | `keyPrefix` option or `KEY_PREFIX` / `VERCEL_URL` env
|
|
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
|
|
149
|
-
| ----------------------------- |
|
|
150
|
-
| redisUrl | Redis connection url
|
|
151
|
-
| database | Redis database number to use. Uses DB 0 for production, DB 1 otherwise
|
|
152
|
-
| keyPrefix | Prefix added to all Redis keys
|
|
153
|
-
| sharedTagsKey | Key used to store shared tags hash map in Redis
|
|
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.
|
|
155
|
-
| revalidateTagQuerySize | Number of entries to query in one batch during full sync of shared tags hash map
|
|
156
|
-
| avgResyncIntervalMs | Average interval in milliseconds between tag map full re-syncs
|
|
157
|
-
| redisGetDeduplication | Enable deduplication of Redis get requests via internal in-memory cache.
|
|
158
|
-
| inMemoryCachingTime | Time in milliseconds to cache Redis get results in memory. Set this to 0 to disable in-memory caching completely.
|
|
159
|
-
| defaultStaleAge |
|
|
160
|
-
| estimateExpireAge |
|
|
161
|
-
| socketOptions | Redis client socket options for TLS/SSL configuration (e.g., `{ tls: true, rejectUnauthorized: false }`)
|
|
162
|
-
| clientOptions | Additional Redis client options (e.g., username, password)
|
|
163
|
-
| killContainerOnErrorThreshold | Number of consecutive errors before the container is killed. Set to 0 to disable.
|
|
164
|
-
| valueSerializer | Pluggable wire-format codec for Redis string values (compression, encryption, custom encoding). See [Custom value serializer](#custom-value-serializer-compression-encryption).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|