@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.
Files changed (125) hide show
  1. package/.github/workflows/ci.yml +11 -2
  2. package/.github/workflows/release.yml +8 -157
  3. package/ARCHITECTURE.md +86 -47
  4. package/README.md +91 -25
  5. package/dist/index.d.mts +40 -1
  6. package/dist/index.d.ts +40 -1
  7. package/dist/index.js +264 -96
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +261 -95
  10. package/dist/index.mjs.map +1 -1
  11. package/package.json +5 -7
  12. package/release.config.cjs +6 -25
  13. package/src/CacheComponentsHandler.ts +141 -111
  14. package/src/RedisStringsHandler.ts +37 -25
  15. package/src/SyncedMap.ts +16 -2
  16. package/src/index.ts +7 -0
  17. package/src/utils/cacheTtl.ts +71 -0
  18. package/src/utils/compareAndUnlink.ts +29 -0
  19. package/src/utils/redisConnection.ts +17 -0
  20. package/src/utils/tagRevalidation.ts +162 -0
  21. package/test/README.md +13 -13
  22. package/test/nextjs-test-projects/next-app-15-4-11/pnpm-lock.yaml +1 -1
  23. package/test/nextjs-test-projects/next-app-16-0-11/pnpm-lock.yaml +1 -1
  24. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
  25. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/pnpm-lock.yaml +1 -1
  26. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  27. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  28. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
  29. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  30. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  31. package/test/nextjs-test-projects/next-app-16-2-6/pnpm-lock.yaml +1 -1
  32. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
  33. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/pnpm-lock.yaml +1 -1
  34. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  35. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  36. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
  37. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  38. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  39. package/test/nextjs-test-projects/next-app-16-3-0/README.md +36 -0
  40. package/test/nextjs-test-projects/next-app-16-3-0/eslint.config.mjs +18 -0
  41. package/test/nextjs-test-projects/next-app-16-3-0/next.config.ts +7 -0
  42. package/test/nextjs-test-projects/next-app-16-3-0/package.json +28 -0
  43. package/test/nextjs-test-projects/next-app-16-3-0/pnpm-lock.yaml +4284 -0
  44. package/test/nextjs-test-projects/next-app-16-3-0/postcss.config.mjs +7 -0
  45. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/cached-static-fetch/route.ts +18 -0
  46. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
  47. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidatePath/route.ts +15 -0
  48. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidateTag/route.ts +20 -0
  49. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidated-fetch/route.ts +17 -0
  50. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/uncached-fetch/route.ts +15 -0
  51. package/test/nextjs-test-projects/next-app-16-3-0/src/app/favicon.ico +0 -0
  52. package/test/nextjs-test-projects/next-app-16-3-0/src/app/globals.css +26 -0
  53. package/test/nextjs-test-projects/next-app-16-3-0/src/app/layout.tsx +59 -0
  54. package/test/nextjs-test-projects/next-app-16-3-0/src/app/page.tsx +755 -0
  55. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
  56. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
  57. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  58. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/no-fetch/default-page/page.tsx +55 -0
  59. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
  60. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
  61. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  62. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
  63. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
  64. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  65. package/test/nextjs-test-projects/next-app-16-3-0/src/app/revalidation-interface.tsx +267 -0
  66. package/test/nextjs-test-projects/next-app-16-3-0/src/app/update-tag-test/page.tsx +25 -0
  67. package/test/nextjs-test-projects/next-app-16-3-0/tsconfig.json +34 -0
  68. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/README.md +36 -0
  69. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/cache-handler.js +3 -0
  70. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/eslint.config.mjs +18 -0
  71. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +15 -0
  72. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/package.json +28 -0
  73. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/pnpm-lock.yaml +4284 -0
  74. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/postcss.config.mjs +7 -0
  75. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/file.svg +1 -0
  76. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/globe.svg +1 -0
  77. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/next.svg +1 -0
  78. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/vercel.svg +1 -0
  79. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/window.svg +1 -0
  80. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-static-fetch/route.ts +19 -0
  81. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-cachelife/route.ts +24 -0
  82. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-tag/route.ts +21 -0
  83. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  84. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  85. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidate-tag/route.ts +19 -0
  86. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidated-fetch/route.ts +19 -0
  87. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/cachelife-short/page.tsx +110 -0
  88. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +112 -0
  89. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  90. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/runtime-data-suspense/page.tsx +127 -0
  91. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/stale-while-revalidate/page.tsx +130 -0
  92. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/tag-invalidation/page.tsx +127 -0
  93. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-nondeterministic/page.tsx +110 -0
  94. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  95. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/favicon.ico +0 -0
  96. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/globals.css +26 -0
  97. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/layout.tsx +57 -0
  98. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/page.tsx +755 -0
  99. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/revalidation-interface.tsx +267 -0
  100. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/update-tag-test/page.tsx +22 -0
  101. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/tsconfig.json +34 -0
  102. package/test/nextjs-test-projects/next-pages-16-2-6/pnpm-lock.yaml +4 -4
  103. package/test/nextjs-test-projects/next-pages-16-3-0/README.md +16 -0
  104. package/test/nextjs-test-projects/next-pages-16-3-0/eslint.config.mjs +18 -0
  105. package/test/nextjs-test-projects/next-pages-16-3-0/next.config.ts +7 -0
  106. package/test/nextjs-test-projects/next-pages-16-3-0/package.json +26 -0
  107. package/test/nextjs-test-projects/next-pages-16-3-0/pnpm-lock.yaml +3939 -0
  108. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/api/revalidate.ts +24 -0
  109. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/index.tsx +11 -0
  110. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/isr/[slug].tsx +49 -0
  111. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/static-forever.tsx +20 -0
  112. package/test/nextjs-test-projects/next-pages-16-3-0/tsconfig.json +34 -0
  113. package/test/playwright/cache-lab.spec.ts +75 -0
  114. package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
  115. package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +67 -34
  116. package/test/vitest/integration/pages-router.integration.test.ts +16 -6
  117. package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
  118. package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
  119. package/test/vitest/unit/cache-ttl.test.ts +67 -0
  120. package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
  121. package/test/vitest/unit/index.test.ts +17 -0
  122. package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
  123. package/test/vitest/unit/redis-connection.test.ts +39 -0
  124. package/test/vitest/unit/tag-revalidation.test.ts +178 -0
  125. package/CHANGELOG.md +0 -404
@@ -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/next-pages-16-2-6 && pnpm install
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/next-pages-16-2-6 && pnpm build
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
- # 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()`.