@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trieb.work/nextjs-turbo-redis-cache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"homepage": "https://trwk.de/case-studies/nextjs-turbo-redis-cache",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -78,10 +78,8 @@
|
|
|
78
78
|
"@commitlint/cli": "^19.6.0",
|
|
79
79
|
"@commitlint/config-conventional": "^19.6.0",
|
|
80
80
|
"@playwright/test": "^1.62.0",
|
|
81
|
-
"@semantic-release/
|
|
82
|
-
"@semantic-release/
|
|
83
|
-
"@semantic-release/github": "^12.0.2",
|
|
84
|
-
"@semantic-release/npm": "^13.1.3",
|
|
81
|
+
"@semantic-release/github": "12.0.9",
|
|
82
|
+
"@semantic-release/npm": "13.1.5",
|
|
85
83
|
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
|
86
84
|
"@typescript-eslint/parser": "^8.15.0",
|
|
87
85
|
"@vitest/coverage-v8": "^4.0.13",
|
|
@@ -91,14 +89,14 @@
|
|
|
91
89
|
"node-fetch": "3",
|
|
92
90
|
"prettier": "^3.3.3",
|
|
93
91
|
"redis": "4.7.0",
|
|
94
|
-
"semantic-release": "
|
|
92
|
+
"semantic-release": "25.0.9",
|
|
95
93
|
"tsup": "^8.4.0",
|
|
96
94
|
"tsx": "^4.21.0",
|
|
97
95
|
"typescript": "^5.6.3",
|
|
98
96
|
"vitest": "^4.0.13"
|
|
99
97
|
},
|
|
100
98
|
"peerDependencies": {
|
|
101
|
-
"next": ">=15.0.3 <
|
|
99
|
+
"next": ">=15.0.3 <17.0.0",
|
|
102
100
|
"redis": "4.7.0"
|
|
103
101
|
},
|
|
104
102
|
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
package/release.config.cjs
CHANGED
|
@@ -1,31 +1,12 @@
|
|
|
1
1
|
module.exports = {
|
|
2
2
|
branches: [
|
|
3
|
-
{ name: 'main' },
|
|
4
|
-
{ name: 'beta', prerelease: true },
|
|
3
|
+
{ name: 'main' },
|
|
4
|
+
{ name: 'beta', prerelease: true },
|
|
5
5
|
],
|
|
6
6
|
plugins: [
|
|
7
|
-
'@semantic-release/commit-analyzer',
|
|
8
|
-
'@semantic-release/release-notes-generator',
|
|
9
|
-
'@semantic-release/
|
|
10
|
-
|
|
11
|
-
'@semantic-release/npm',
|
|
12
|
-
{
|
|
13
|
-
npmPublish: false, // Publish to NPM
|
|
14
|
-
tag: 'beta', // Publish beta releases under the "beta" tag
|
|
15
|
-
},
|
|
16
|
-
],
|
|
17
|
-
[
|
|
18
|
-
'@semantic-release/github',
|
|
19
|
-
{
|
|
20
|
-
labels: [],
|
|
21
|
-
},
|
|
22
|
-
],
|
|
23
|
-
[
|
|
24
|
-
'@semantic-release/git',
|
|
25
|
-
{
|
|
26
|
-
assets: ['package.json', 'pnpm-lock.yaml', 'CHANGELOG.md'],
|
|
27
|
-
message: 'chore(release): ${nextRelease.version} [skip ci]',
|
|
28
|
-
},
|
|
29
|
-
],
|
|
7
|
+
'@semantic-release/commit-analyzer',
|
|
8
|
+
'@semantic-release/release-notes-generator',
|
|
9
|
+
'@semantic-release/npm',
|
|
10
|
+
'@semantic-release/github',
|
|
30
11
|
],
|
|
31
12
|
};
|
|
@@ -9,6 +9,17 @@ import { SyncedMap } from './SyncedMap';
|
|
|
9
9
|
import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
|
|
10
10
|
import { debug } from './utils/debug';
|
|
11
11
|
import { resolveKeyPrefix } from './utils/prefix';
|
|
12
|
+
import { shouldDeferRedisConnection } from './utils/redisConnection';
|
|
13
|
+
import { compareAndUnlink } from './utils/compareAndUnlink';
|
|
14
|
+
import {
|
|
15
|
+
applyTagUpdate,
|
|
16
|
+
areTagsExpired,
|
|
17
|
+
areTagsStale,
|
|
18
|
+
maxExpiredTimestamp,
|
|
19
|
+
normalizeTagManifest,
|
|
20
|
+
persistableTagManifest,
|
|
21
|
+
type TagManifestEntry,
|
|
22
|
+
} from './utils/tagRevalidation';
|
|
12
23
|
|
|
13
24
|
export interface CacheComponentsEntry {
|
|
14
25
|
value: ReadableStream<Uint8Array>;
|
|
@@ -86,7 +97,7 @@ function bufferToReadableStream(
|
|
|
86
97
|
|
|
87
98
|
class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
88
99
|
private client: Client;
|
|
89
|
-
private revalidatedTagsMap: SyncedMap<number>;
|
|
100
|
+
private revalidatedTagsMap: SyncedMap<TagManifestEntry | number>;
|
|
90
101
|
private sharedTagsMap: SyncedMap<string[]>;
|
|
91
102
|
private inMemoryDeduplicationCache: SyncedMap<
|
|
92
103
|
Promise<ReturnType<Client['get']>>
|
|
@@ -101,6 +112,7 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
101
112
|
private deduplicatedRedisGet: (key: string) => Client['get'];
|
|
102
113
|
private redisGetDeduplication: boolean;
|
|
103
114
|
private inMemoryCachingTime: number;
|
|
115
|
+
private redisConnectionDeferred: boolean;
|
|
104
116
|
|
|
105
117
|
constructor({
|
|
106
118
|
redisUrl = process.env.REDIS_URL
|
|
@@ -134,6 +146,7 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
134
146
|
this.getTimeoutMs = getTimeoutMs;
|
|
135
147
|
this.redisGetDeduplication = redisGetDeduplication;
|
|
136
148
|
this.inMemoryCachingTime = inMemoryCachingTime;
|
|
149
|
+
this.redisConnectionDeferred = shouldDeferRedisConnection();
|
|
137
150
|
|
|
138
151
|
this.client = createClient({
|
|
139
152
|
url: redisUrl,
|
|
@@ -180,32 +193,35 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
180
193
|
}
|
|
181
194
|
});
|
|
182
195
|
|
|
183
|
-
this.
|
|
184
|
-
.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
if (!this.redisConnectionDeferred) {
|
|
197
|
+
this.client
|
|
198
|
+
.connect()
|
|
199
|
+
.then(() => {
|
|
200
|
+
debug('green', 'RedisCacheComponentsHandler client connected.');
|
|
201
|
+
})
|
|
202
|
+
.catch(() => {
|
|
203
|
+
this.client.connect().catch((error) => {
|
|
204
|
+
console.error(
|
|
205
|
+
'Failed to connect RedisCacheComponentsHandler client:',
|
|
206
|
+
error,
|
|
207
|
+
);
|
|
208
|
+
this.client.disconnect();
|
|
209
|
+
throw error;
|
|
210
|
+
});
|
|
196
211
|
});
|
|
197
|
-
|
|
212
|
+
}
|
|
198
213
|
|
|
199
214
|
const filterKeys = (key: string): boolean =>
|
|
200
215
|
key !== REVALIDATED_TAGS_KEY && key !== SHARED_TAGS_KEY;
|
|
201
216
|
|
|
202
|
-
this.revalidatedTagsMap = new SyncedMap<number>({
|
|
217
|
+
this.revalidatedTagsMap = new SyncedMap<TagManifestEntry | number>({
|
|
203
218
|
client: this.client,
|
|
204
219
|
keyPrefix: this.keyPrefix,
|
|
205
220
|
redisKey: REVALIDATED_TAGS_KEY,
|
|
206
221
|
database,
|
|
207
222
|
querySize: revalidateTagQuerySize,
|
|
208
223
|
filterKeys,
|
|
224
|
+
customizedSync: { withoutOrphanCleanup: true },
|
|
209
225
|
resyncIntervalMs:
|
|
210
226
|
avgResyncIntervalMs +
|
|
211
227
|
avgResyncIntervalMs / 10 +
|
|
@@ -254,6 +270,14 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
254
270
|
}
|
|
255
271
|
|
|
256
272
|
private async assertClientIsReady(): Promise<void> {
|
|
273
|
+
if (this.redisConnectionDeferred) {
|
|
274
|
+
await Promise.all([
|
|
275
|
+
this.revalidatedTagsMap.waitUntilReady(),
|
|
276
|
+
this.sharedTagsMap.waitUntilReady(),
|
|
277
|
+
]);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
257
281
|
if (!this.client.isReady && !this.client.isOpen) {
|
|
258
282
|
await this.client.connect().catch((error) => {
|
|
259
283
|
console.error(
|
|
@@ -269,21 +293,23 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
269
293
|
]);
|
|
270
294
|
}
|
|
271
295
|
|
|
272
|
-
private
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
return max;
|
|
296
|
+
private isClientUnavailable(): boolean {
|
|
297
|
+
return this.redisConnectionDeferred || !this.client.isReady;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private tagManifestFor(tag: string, now = Date.now()): TagManifestEntry {
|
|
301
|
+
return normalizeTagManifest(this.revalidatedTagsMap.get(tag), now);
|
|
281
302
|
}
|
|
282
303
|
|
|
283
304
|
async get(
|
|
284
305
|
cacheKey: string,
|
|
285
|
-
|
|
306
|
+
_softTags: string[],
|
|
286
307
|
): Promise<CacheComponentsEntry | undefined> {
|
|
308
|
+
// Soft-tag staleness is handled by Next.js via getExpiration() (implicit
|
|
309
|
+
// `_N_T_` tags). Explicit cacheTag()s are checked below via the tag
|
|
310
|
+
// manifest, matching DefaultCacheHandler.get() in Next.js 16.0–16.3.
|
|
311
|
+
void _softTags;
|
|
312
|
+
|
|
287
313
|
// Construct the full Redis key
|
|
288
314
|
// For cache components, Next.js provides the full key including environment prefix
|
|
289
315
|
// We prepend our keyPrefix for multi-tenant isolation
|
|
@@ -291,63 +317,84 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
291
317
|
|
|
292
318
|
try {
|
|
293
319
|
await this.assertClientIsReady();
|
|
294
|
-
|
|
295
|
-
const clientGet = this.redisGetDeduplication
|
|
296
|
-
? this.deduplicatedRedisGet(cacheKey)
|
|
297
|
-
: this.redisGet;
|
|
298
|
-
|
|
299
|
-
const serialized = await redisErrorHandler(
|
|
300
|
-
'RedisCacheComponentsHandler.get(), operation: get' +
|
|
301
|
-
(this.redisGetDeduplication ? ' deduplicated' : '') +
|
|
302
|
-
' ' +
|
|
303
|
-
this.getTimeoutMs +
|
|
304
|
-
'ms ' +
|
|
305
|
-
redisKey,
|
|
306
|
-
clientGet(
|
|
307
|
-
commandOptions({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
|
|
308
|
-
redisKey,
|
|
309
|
-
),
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
if (!serialized) {
|
|
320
|
+
if (this.isClientUnavailable()) {
|
|
313
321
|
return undefined;
|
|
314
322
|
}
|
|
315
323
|
|
|
316
|
-
const
|
|
317
|
-
|
|
324
|
+
const readSerialized = (useDedup: boolean) =>
|
|
325
|
+
redisErrorHandler(
|
|
326
|
+
'RedisCacheComponentsHandler.get(), operation: get' +
|
|
327
|
+
(useDedup ? ' deduplicated' : '') +
|
|
328
|
+
' ' +
|
|
329
|
+
this.getTimeoutMs +
|
|
330
|
+
'ms ' +
|
|
331
|
+
redisKey,
|
|
332
|
+
(useDedup ? this.deduplicatedRedisGet(cacheKey) : this.redisGet)(
|
|
333
|
+
commandOptions({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
|
|
334
|
+
redisKey,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
318
337
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
Number.isFinite(stored.expire) &&
|
|
323
|
-
stored.expire > 0 &&
|
|
324
|
-
now > expiryTime
|
|
325
|
-
) {
|
|
326
|
-
await this.client.unlink(redisKey).catch(() => {});
|
|
327
|
-
await this.sharedTagsMap.delete(cacheKey).catch(() => {});
|
|
328
|
-
return undefined;
|
|
329
|
-
}
|
|
338
|
+
let usedDedup = this.redisGetDeduplication;
|
|
339
|
+
let serialized = await readSerialized(usedDedup);
|
|
330
340
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
341
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
342
|
+
if (!serialized) {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
335
345
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
346
|
+
const stored: StoredCacheEntry = JSON.parse(serialized);
|
|
347
|
+
const now = Date.now();
|
|
348
|
+
const tags = stored.tags || [];
|
|
349
|
+
const expiryTime = stored.timestamp + stored.expire * 1000;
|
|
350
|
+
const ttlExpired =
|
|
351
|
+
Number.isFinite(stored.expire) &&
|
|
352
|
+
stored.expire > 0 &&
|
|
353
|
+
now > expiryTime;
|
|
354
|
+
const tagsExpired = areTagsExpired(tags, stored.timestamp, now, (tag) =>
|
|
355
|
+
this.tagManifestFor(tag, now),
|
|
356
|
+
);
|
|
341
357
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
358
|
+
if (ttlExpired || tagsExpired) {
|
|
359
|
+
if (usedDedup) {
|
|
360
|
+
usedDedup = false;
|
|
361
|
+
serialized = await readSerialized(false);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const dropped = await compareAndUnlink(
|
|
366
|
+
this.client,
|
|
367
|
+
redisKey,
|
|
368
|
+
serialized,
|
|
369
|
+
);
|
|
370
|
+
if (dropped) {
|
|
371
|
+
await this.sharedTagsMap.delete(cacheKey).catch(() => {});
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
346
375
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
376
|
+
const valueBuffer =
|
|
377
|
+
typeof stored.value === 'string'
|
|
378
|
+
? new Uint8Array(Buffer.from(stored.value, 'base64'))
|
|
379
|
+
: stored.value;
|
|
380
|
+
|
|
381
|
+
const entry: CacheComponentsEntry = {
|
|
382
|
+
...stored,
|
|
383
|
+
value: bufferToReadableStream(valueBuffer),
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (
|
|
387
|
+
areTagsStale(tags, stored.timestamp, (tag) =>
|
|
388
|
+
this.tagManifestFor(tag, now),
|
|
389
|
+
)
|
|
390
|
+
) {
|
|
391
|
+
entry.revalidate = -1;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return entry;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return undefined;
|
|
351
398
|
} catch (error) {
|
|
352
399
|
console.error(
|
|
353
400
|
'RedisCacheComponentsHandler.get() Error occurred while getting cache entry. Returning undefined so site can continue to serve content while cache is disabled. The original error was:',
|
|
@@ -364,6 +411,9 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
364
411
|
): Promise<void> {
|
|
365
412
|
try {
|
|
366
413
|
await this.assertClientIsReady();
|
|
414
|
+
if (this.isClientUnavailable()) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
367
417
|
|
|
368
418
|
const entry = await pendingEntry;
|
|
369
419
|
|
|
@@ -451,7 +501,7 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
451
501
|
async getExpiration(tags: string[]): Promise<number> {
|
|
452
502
|
try {
|
|
453
503
|
await this.assertClientIsReady();
|
|
454
|
-
return
|
|
504
|
+
return maxExpiredTimestamp(tags || [], (tag) => this.tagManifestFor(tag));
|
|
455
505
|
} catch (error) {
|
|
456
506
|
console.error(
|
|
457
507
|
'RedisCacheComponentsHandler.getExpiration() Error occurred while getting expiration for tags. The original error was:',
|
|
@@ -463,51 +513,31 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
|
|
|
463
513
|
|
|
464
514
|
async updateTags(
|
|
465
515
|
tags: string[],
|
|
466
|
-
|
|
516
|
+
durations?: { expire?: number },
|
|
467
517
|
): Promise<void> {
|
|
468
518
|
try {
|
|
469
|
-
// Mark optional argument as used to satisfy lint rules while keeping the signature
|
|
470
|
-
void _durations;
|
|
471
519
|
await this.assertClientIsReady();
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const tagsSet = new Set(tags || []);
|
|
475
|
-
|
|
476
|
-
for (const tag of tagsSet) {
|
|
477
|
-
await this.revalidatedTagsMap.set(tag, now);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const keysToDelete: Set<string> = new Set();
|
|
481
|
-
|
|
482
|
-
for (const [key, storedTags] of this.sharedTagsMap.entries()) {
|
|
483
|
-
if (storedTags.some((tag) => tagsSet.has(tag))) {
|
|
484
|
-
keysToDelete.add(key);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (keysToDelete.size === 0) {
|
|
520
|
+
if (this.isClientUnavailable()) {
|
|
489
521
|
return;
|
|
490
522
|
}
|
|
491
523
|
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
524
|
+
const now = Date.now();
|
|
525
|
+
const updated = new Set(tags || []);
|
|
526
|
+
for (const tag of updated) {
|
|
527
|
+
const next = applyTagUpdate(this.tagManifestFor(tag), durations, now);
|
|
528
|
+
await this.revalidatedTagsMap.set(
|
|
529
|
+
tag,
|
|
530
|
+
persistableTagManifest(next, now),
|
|
531
|
+
);
|
|
532
|
+
}
|
|
501
533
|
|
|
502
534
|
if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
|
|
503
|
-
for (const key of
|
|
504
|
-
|
|
535
|
+
for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
|
|
536
|
+
if (sharedTags.some((tag) => updated.has(tag))) {
|
|
537
|
+
await this.inMemoryDeduplicationCache.delete(key, true);
|
|
538
|
+
}
|
|
505
539
|
}
|
|
506
540
|
}
|
|
507
|
-
|
|
508
|
-
// Delete from sharedTagsMap
|
|
509
|
-
const deleteTagsOperation = this.sharedTagsMap.delete(cacheKeys);
|
|
510
|
-
await deleteTagsOperation;
|
|
511
541
|
} catch (error) {
|
|
512
542
|
console.error(
|
|
513
543
|
'RedisCacheComponentsHandler.updateTags() Error occurred while updating tags. The original error was:',
|
|
@@ -3,6 +3,8 @@ import { SyncedMap } from './SyncedMap';
|
|
|
3
3
|
import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
|
|
4
4
|
import { debug } from './utils/debug';
|
|
5
5
|
import { CacheValueSerializer, jsonCacheValueSerializer } from './serializer';
|
|
6
|
+
import { resolveCacheEntryTtlSeconds } from './utils/cacheTtl';
|
|
7
|
+
import { shouldDeferRedisConnection } from './utils/redisConnection';
|
|
6
8
|
|
|
7
9
|
export type CommandOptions = ReturnType<typeof commandOptions>;
|
|
8
10
|
export type Client = ReturnType<typeof createClient>;
|
|
@@ -262,6 +264,7 @@ export default class RedisStringsHandler {
|
|
|
262
264
|
private estimateExpireAge: (staleAge: number) => number;
|
|
263
265
|
private killContainerOnErrorThreshold: number;
|
|
264
266
|
private valueSerializer: CacheValueSerializer;
|
|
267
|
+
private redisConnectionDeferred: boolean;
|
|
265
268
|
|
|
266
269
|
constructor({
|
|
267
270
|
redisUrl = process.env.REDIS_URL
|
|
@@ -301,6 +304,7 @@ export default class RedisStringsHandler {
|
|
|
301
304
|
this.killContainerOnErrorThreshold = killContainerOnErrorThreshold;
|
|
302
305
|
this.getTimeoutMs = getTimeoutMs;
|
|
303
306
|
this.valueSerializer = valueSerializer;
|
|
307
|
+
this.redisConnectionDeferred = shouldDeferRedisConnection();
|
|
304
308
|
|
|
305
309
|
try {
|
|
306
310
|
// Create Redis client with properly typed configuration
|
|
@@ -348,15 +352,17 @@ export default class RedisStringsHandler {
|
|
|
348
352
|
}
|
|
349
353
|
});
|
|
350
354
|
|
|
351
|
-
this.
|
|
352
|
-
.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
355
|
+
if (!this.redisConnectionDeferred) {
|
|
356
|
+
this.client
|
|
357
|
+
.connect()
|
|
358
|
+
.then(() => {
|
|
359
|
+
debug('green', 'Redis client connected.');
|
|
360
|
+
})
|
|
361
|
+
.catch((error) => {
|
|
362
|
+
console.error('Failed to connect Redis client:', error);
|
|
363
|
+
throw error;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
360
366
|
} catch (error: unknown) {
|
|
361
367
|
console.error('Failed to initialize Redis client');
|
|
362
368
|
throw error;
|
|
@@ -385,6 +391,7 @@ export default class RedisStringsHandler {
|
|
|
385
391
|
database,
|
|
386
392
|
querySize: revalidateTagQuerySize,
|
|
387
393
|
filterKeys,
|
|
394
|
+
customizedSync: { withoutOrphanCleanup: true },
|
|
388
395
|
resyncIntervalMs:
|
|
389
396
|
avgResyncIntervalMs +
|
|
390
397
|
avgResyncIntervalMs / 10 +
|
|
@@ -472,12 +479,19 @@ export default class RedisStringsHandler {
|
|
|
472
479
|
}
|
|
473
480
|
this.clientReadyCalls = 0;
|
|
474
481
|
if (!this.client.isReady) {
|
|
482
|
+
if (this.redisConnectionDeferred) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
475
485
|
throw new Error(
|
|
476
486
|
'assertClientIsReady: Redis client is not ready yet or connection is lost.',
|
|
477
487
|
);
|
|
478
488
|
}
|
|
479
489
|
}
|
|
480
490
|
|
|
491
|
+
private isClientUnavailable(): boolean {
|
|
492
|
+
return this.redisConnectionDeferred || !this.client.isReady;
|
|
493
|
+
}
|
|
494
|
+
|
|
481
495
|
public async get(key: string, ctx: GetContext): Promise<CacheEntry | null> {
|
|
482
496
|
try {
|
|
483
497
|
if (!(SUPPORTED_GET_KINDS as readonly string[]).includes(ctx.kind)) {
|
|
@@ -492,6 +506,9 @@ export default class RedisStringsHandler {
|
|
|
492
506
|
|
|
493
507
|
debug('green', 'RedisStringsHandler.get() called with', key, ctx);
|
|
494
508
|
await this.assertClientIsReady();
|
|
509
|
+
if (this.isClientUnavailable()) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
495
512
|
|
|
496
513
|
const clientGet = this.redisGetDeduplication
|
|
497
514
|
? this.deduplicatedRedisGet(key)
|
|
@@ -704,6 +721,9 @@ export default class RedisStringsHandler {
|
|
|
704
721
|
}
|
|
705
722
|
|
|
706
723
|
await this.assertClientIsReady();
|
|
724
|
+
if (this.isClientUnavailable()) {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
707
727
|
|
|
708
728
|
if (data?.kind === 'APP_PAGE' || data?.kind === 'APP_ROUTE') {
|
|
709
729
|
const tags = data.headers['x-next-cache-tags']?.split(',');
|
|
@@ -739,21 +759,10 @@ export default class RedisStringsHandler {
|
|
|
739
759
|
);
|
|
740
760
|
}
|
|
741
761
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
(data?.kind === 'FETCH' && data.revalidate) ||
|
|
747
|
-
ctx.revalidate ||
|
|
748
|
-
ctx.cacheControl?.revalidate ||
|
|
749
|
-
// Legacy fallback: older Next.js versions attached `revalidate` directly
|
|
750
|
-
// to the data payload. FETCH is already handled above, so this only
|
|
751
|
-
// matters for those older, non-discriminated shapes.
|
|
752
|
-
(data as { revalidate?: number | false } | null)?.revalidate;
|
|
753
|
-
const expireAt =
|
|
754
|
-
revalidate && Number.isSafeInteger(revalidate) && revalidate > 0
|
|
755
|
-
? this.estimateExpireAge(revalidate)
|
|
756
|
-
: this.estimateExpireAge(this.defaultStaleAge);
|
|
762
|
+
const expireAt = resolveCacheEntryTtlSeconds(ctx, data, {
|
|
763
|
+
estimateExpireAge: this.estimateExpireAge,
|
|
764
|
+
defaultStaleAge: this.defaultStaleAge,
|
|
765
|
+
});
|
|
757
766
|
|
|
758
767
|
// Setting the cache entry in redis
|
|
759
768
|
const setOperation: Promise<string | null> = redisErrorHandler(
|
|
@@ -762,7 +771,7 @@ export default class RedisStringsHandler {
|
|
|
762
771
|
' ' +
|
|
763
772
|
key,
|
|
764
773
|
this.client.set(this.keyPrefix + key, serializedCacheEntry, {
|
|
765
|
-
EX: expireAt,
|
|
774
|
+
...(expireAt ? { EX: expireAt } : {}),
|
|
766
775
|
}),
|
|
767
776
|
);
|
|
768
777
|
|
|
@@ -838,6 +847,9 @@ export default class RedisStringsHandler {
|
|
|
838
847
|
);
|
|
839
848
|
const tags = new Set([tagOrTags || []].flat());
|
|
840
849
|
await this.assertClientIsReady();
|
|
850
|
+
if (this.isClientUnavailable()) {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
841
853
|
|
|
842
854
|
// find all keys that are related to this tag
|
|
843
855
|
const keysToDelete: Set<string> = new Set();
|
package/src/SyncedMap.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// SyncedMap.ts
|
|
2
2
|
import { Client, redisErrorHandler } from './RedisStringsHandler';
|
|
3
3
|
import { debugVerbose, debug } from './utils/debug';
|
|
4
|
+
import { shouldDeferRedisConnection } from './utils/redisConnection';
|
|
4
5
|
|
|
5
6
|
type CustomizedSync = {
|
|
6
7
|
withoutRedisHashmap?: boolean;
|
|
7
8
|
withoutSetSync?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Skip SCAN-based orphan cleanup. Required for maps whose keys are not Redis
|
|
11
|
+
* string keys (tag manifests). Otherwise startup/resync HDELs every tag.
|
|
12
|
+
*/
|
|
13
|
+
withoutOrphanCleanup?: boolean;
|
|
8
14
|
};
|
|
9
15
|
|
|
10
16
|
type SyncedMapOptions = {
|
|
@@ -67,6 +73,11 @@ export class SyncedMap<V> {
|
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
private async setup() {
|
|
76
|
+
if (shouldDeferRedisConnection()) {
|
|
77
|
+
this.setupLockResolve();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
let setupPromises: Promise<void>[] = [];
|
|
71
82
|
if (!this.customizedSync?.withoutRedisHashmap) {
|
|
72
83
|
setupPromises.push(this.initialSync());
|
|
@@ -109,8 +120,11 @@ export class SyncedMap<V> {
|
|
|
109
120
|
cursor = remoteItems.cursor;
|
|
110
121
|
} while (cursor !== 0);
|
|
111
122
|
|
|
112
|
-
//
|
|
113
|
-
|
|
123
|
+
// sharedTagsMap keys are cache keys (Redis strings). Tag-manifest maps
|
|
124
|
+
// use tag names as hash fields — those never appear in SCAN.
|
|
125
|
+
if (!this.customizedSync?.withoutOrphanCleanup) {
|
|
126
|
+
await this.cleanupKeysNotInRedis();
|
|
127
|
+
}
|
|
114
128
|
} catch (error) {
|
|
115
129
|
console.error('Error during initial sync:', error);
|
|
116
130
|
throw error;
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,13 @@ export type { CreateRedisStringsHandlerOptions } from './RedisStringsHandler';
|
|
|
8
8
|
export { jsonCacheValueSerializer } from './serializer';
|
|
9
9
|
export type { CacheValueSerializer } from './serializer';
|
|
10
10
|
export { bufferAndMapReplacer, bufferAndMapReviver } from './utils/json';
|
|
11
|
+
export { resolveCacheEntryTtlSeconds } from './utils/cacheTtl';
|
|
12
|
+
export type {
|
|
13
|
+
ResolveCacheEntryTtlContext,
|
|
14
|
+
ResolveCacheEntryTtlData,
|
|
15
|
+
ResolveCacheEntryTtlOptions,
|
|
16
|
+
} from './utils/cacheTtl';
|
|
17
|
+
export { shouldDeferRedisConnection } from './utils/redisConnection';
|
|
11
18
|
|
|
12
19
|
import {
|
|
13
20
|
redisCacheHandler,
|