@trieb.work/nextjs-turbo-redis-cache 1.16.2 → 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/ci.yml +11 -2
- package/.github/workflows/release.yml +8 -157
- package/ARCHITECTURE.md +86 -47
- package/README.md +91 -25
- 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 +5 -7
- 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 +13 -13
- package/test/nextjs-test-projects/next-app-15-4-11/pnpm-lock.yaml +1 -1
- package/test/nextjs-test-projects/next-app-16-0-11/pnpm-lock.yaml +1 -1
- 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/pnpm-lock.yaml +1 -1
- 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/pnpm-lock.yaml +1 -1
- 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/pnpm-lock.yaml +1 -1
- 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/README.md +36 -0
- package/test/nextjs-test-projects/next-app-16-3-0/eslint.config.mjs +18 -0
- package/test/nextjs-test-projects/next-app-16-3-0/next.config.ts +7 -0
- package/test/nextjs-test-projects/next-app-16-3-0/package.json +28 -0
- package/test/nextjs-test-projects/next-app-16-3-0/pnpm-lock.yaml +4284 -0
- package/test/nextjs-test-projects/next-app-16-3-0/postcss.config.mjs +7 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/cached-static-fetch/route.ts +18 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidatePath/route.ts +15 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidateTag/route.ts +20 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidated-fetch/route.ts +17 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/uncached-fetch/route.ts +15 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/favicon.ico +0 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/globals.css +26 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/layout.tsx +59 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/page.tsx +755 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/no-fetch/default-page/page.tsx +55 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/revalidation-interface.tsx +267 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/update-tag-test/page.tsx +25 -0
- package/test/nextjs-test-projects/next-app-16-3-0/tsconfig.json +34 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/README.md +36 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/cache-handler.js +3 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/eslint.config.mjs +18 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +15 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/package.json +28 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/pnpm-lock.yaml +4284 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/postcss.config.mjs +7 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/file.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/globe.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/next.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/vercel.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/window.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-static-fetch/route.ts +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-cachelife/route.ts +24 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-tag/route.ts +21 -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/api/revalidate-tag/route.ts +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidated-fetch/route.ts +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/cachelife-short/page.tsx +110 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +112 -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/runtime-data-suspense/page.tsx +127 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/stale-while-revalidate/page.tsx +130 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/tag-invalidation/page.tsx +127 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-nondeterministic/page.tsx +110 -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/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/favicon.ico +0 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/globals.css +26 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/layout.tsx +57 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/page.tsx +755 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/revalidation-interface.tsx +267 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/update-tag-test/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/tsconfig.json +34 -0
- package/test/nextjs-test-projects/next-pages-16-2-6/pnpm-lock.yaml +4 -4
- package/test/nextjs-test-projects/next-pages-16-3-0/README.md +16 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/eslint.config.mjs +18 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/next.config.ts +7 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/package.json +26 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/pnpm-lock.yaml +3939 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/api/revalidate.ts +24 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/index.tsx +11 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/isr/[slug].tsx +49 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/static-forever.tsx +20 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/tsconfig.json +34 -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 +67 -34
- 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 -404
package/.github/workflows/ci.yml
CHANGED
|
@@ -65,6 +65,7 @@ jobs:
|
|
|
65
65
|
- next-app-15-4-11
|
|
66
66
|
- next-app-16-0-11
|
|
67
67
|
- next-app-16-2-6
|
|
68
|
+
- next-app-16-3-0
|
|
68
69
|
steps:
|
|
69
70
|
- name: Checkout code
|
|
70
71
|
uses: actions/checkout@v6
|
|
@@ -111,6 +112,11 @@ jobs:
|
|
|
111
112
|
integration-pages:
|
|
112
113
|
runs-on: ubuntu-latest
|
|
113
114
|
needs: lint-and-unit
|
|
115
|
+
strategy:
|
|
116
|
+
matrix:
|
|
117
|
+
pages-test-app:
|
|
118
|
+
- next-pages-16-2-6
|
|
119
|
+
- next-pages-16-3-0
|
|
114
120
|
steps:
|
|
115
121
|
- name: Checkout code
|
|
116
122
|
uses: actions/checkout@v6
|
|
@@ -143,15 +149,16 @@ jobs:
|
|
|
143
149
|
run: redis-cli config set notify-keyspace-events Exe
|
|
144
150
|
|
|
145
151
|
- name: Install test project
|
|
146
|
-
run: cd test/nextjs-test-projects
|
|
152
|
+
run: cd test/nextjs-test-projects/${{ matrix.pages-test-app }} && pnpm install
|
|
147
153
|
|
|
148
154
|
- name: Build test project
|
|
149
|
-
run: cd test/nextjs-test-projects
|
|
155
|
+
run: cd test/nextjs-test-projects/${{ matrix.pages-test-app }} && pnpm build
|
|
150
156
|
|
|
151
157
|
- name: Run Pages Router integration tests
|
|
152
158
|
run: pnpm test:integration:pages
|
|
153
159
|
env:
|
|
154
160
|
SKIP_BUILD: true
|
|
161
|
+
NEXT_PAGES_TEST_APP: ${{ matrix.pages-test-app }}
|
|
155
162
|
|
|
156
163
|
integration-build-id-prefix:
|
|
157
164
|
runs-on: ubuntu-latest
|
|
@@ -204,6 +211,7 @@ jobs:
|
|
|
204
211
|
cache-components-app:
|
|
205
212
|
- next-app-16-0-11-cache-components
|
|
206
213
|
- next-app-16-2-6-cache-components
|
|
214
|
+
- next-app-16-3-0-cache-components
|
|
207
215
|
steps:
|
|
208
216
|
- name: Checkout code
|
|
209
217
|
uses: actions/checkout@v6
|
|
@@ -254,6 +262,7 @@ jobs:
|
|
|
254
262
|
cache-components-app:
|
|
255
263
|
- next-app-16-0-11-cache-components
|
|
256
264
|
- next-app-16-2-6-cache-components
|
|
265
|
+
- next-app-16-3-0-cache-components
|
|
257
266
|
steps:
|
|
258
267
|
- name: Checkout code
|
|
259
268
|
uses: actions/checkout@v6
|
|
@@ -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()`.
|