@trieb.work/nextjs-turbo-redis-cache 1.2.1 → 1.4.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 +31 -6
- package/.github/workflows/release.yml +7 -3
- package/.next/trace +11 -0
- package/.vscode/settings.json +10 -0
- package/CHANGELOG.md +71 -0
- package/README.md +154 -34
- package/dist/index.d.mts +96 -20
- package/dist/index.d.ts +96 -20
- package/dist/index.js +317 -61
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +313 -61
- package/dist/index.mjs.map +1 -1
- package/package.json +14 -7
- package/scripts/vitest-run-staged.cjs +1 -1
- package/src/CachedHandler.ts +23 -9
- package/src/DeduplicatedRequestHandler.ts +50 -1
- package/src/RedisStringsHandler.ts +331 -91
- package/src/SyncedMap.ts +74 -4
- package/src/ZodHandler.ts +45 -0
- package/src/index.ts +4 -2
- package/src/utils/debug.ts +30 -0
- package/src/utils/json.ts +26 -0
- package/test/integration/next-app-15-0-3/README.md +36 -0
- package/test/integration/next-app-15-0-3/eslint.config.mjs +16 -0
- package/test/integration/next-app-15-0-3/next.config.js +6 -0
- package/test/integration/next-app-15-0-3/package-lock.json +5833 -0
- package/test/integration/next-app-15-0-3/package.json +29 -0
- package/test/integration/next-app-15-0-3/pnpm-lock.yaml +3679 -0
- package/test/integration/next-app-15-0-3/postcss.config.mjs +5 -0
- package/test/integration/next-app-15-0-3/public/file.svg +1 -0
- package/test/integration/next-app-15-0-3/public/globe.svg +1 -0
- package/test/integration/next-app-15-0-3/public/next.svg +1 -0
- package/test/integration/next-app-15-0-3/public/vercel.svg +1 -0
- package/test/integration/next-app-15-0-3/public/window.svg +1 -0
- package/test/integration/next-app-15-0-3/src/app/api/cached-static-fetch/route.ts +18 -0
- package/test/integration/next-app-15-0-3/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
- package/test/integration/next-app-15-0-3/src/app/api/revalidatePath/route.ts +15 -0
- package/test/integration/next-app-15-0-3/src/app/api/revalidateTag/route.ts +15 -0
- package/test/integration/next-app-15-0-3/src/app/api/revalidated-fetch/route.ts +17 -0
- package/test/integration/next-app-15-0-3/src/app/api/uncached-fetch/route.ts +15 -0
- package/test/integration/next-app-15-0-3/src/app/globals.css +26 -0
- package/test/integration/next-app-15-0-3/src/app/layout.tsx +59 -0
- package/test/integration/next-app-15-0-3/src/app/page.tsx +755 -0
- package/test/integration/next-app-15-0-3/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app-15-0-3/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
- package/test/integration/next-app-15-0-3/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app-15-0-3/src/app/pages/no-fetch/default-page/page.tsx +55 -0
- package/test/integration/next-app-15-0-3/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app-15-0-3/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
- package/test/integration/next-app-15-0-3/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app-15-0-3/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app-15-0-3/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
- package/test/integration/next-app-15-0-3/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app-15-0-3/src/app/revalidation-interface.tsx +267 -0
- package/test/integration/next-app-15-0-3/tsconfig.json +27 -0
- package/test/integration/next-app-15-3-2/README.md +36 -0
- package/test/integration/next-app-15-3-2/eslint.config.mjs +16 -0
- package/test/integration/next-app-15-3-2/next.config.js +6 -0
- package/test/integration/next-app-15-3-2/package-lock.json +5969 -0
- package/test/integration/next-app-15-3-2/package.json +33 -0
- package/test/integration/next-app-15-3-2/pnpm-lock.yaml +3688 -0
- package/test/integration/next-app-15-3-2/postcss.config.mjs +5 -0
- package/test/integration/next-app-15-3-2/public/file.svg +1 -0
- package/test/integration/next-app-15-3-2/public/globe.svg +1 -0
- package/test/integration/next-app-15-3-2/public/next.svg +1 -0
- package/test/integration/next-app-15-3-2/public/vercel.svg +1 -0
- package/test/integration/next-app-15-3-2/public/window.svg +1 -0
- package/test/integration/next-app-15-3-2/src/app/api/cached-static-fetch/route.ts +18 -0
- package/test/integration/next-app-15-3-2/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
- package/test/integration/next-app-15-3-2/src/app/api/revalidatePath/route.ts +15 -0
- package/test/integration/next-app-15-3-2/src/app/api/revalidateTag/route.ts +15 -0
- package/test/integration/next-app-15-3-2/src/app/api/revalidated-fetch/route.ts +17 -0
- package/test/integration/next-app-15-3-2/src/app/api/uncached-fetch/route.ts +15 -0
- package/test/integration/next-app-15-3-2/src/app/globals.css +26 -0
- package/test/integration/next-app-15-3-2/src/app/layout.tsx +59 -0
- package/test/integration/next-app-15-3-2/src/app/page.tsx +755 -0
- package/test/integration/next-app-15-3-2/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app-15-3-2/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
- package/test/integration/next-app-15-3-2/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app-15-3-2/src/app/pages/no-fetch/default-page/page.tsx +55 -0
- package/test/integration/next-app-15-3-2/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app-15-3-2/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
- package/test/integration/next-app-15-3-2/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app-15-3-2/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app-15-3-2/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
- package/test/integration/next-app-15-3-2/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app-15-3-2/src/app/revalidation-interface.tsx +267 -0
- package/test/integration/next-app-15-3-2/tsconfig.json +27 -0
- package/test/integration/next-app-customized/README.md +36 -0
- package/test/integration/next-app-customized/customized-cache-handler.js +34 -0
- package/test/integration/next-app-customized/eslint.config.mjs +16 -0
- package/test/integration/next-app-customized/next.config.js +6 -0
- package/test/integration/nextjs-cache-handler.integration.test.ts +859 -0
- package/vite.config.ts +23 -8
|
@@ -1,37 +1,68 @@
|
|
|
1
1
|
import { commandOptions, createClient } from 'redis';
|
|
2
2
|
import { SyncedMap } from './SyncedMap';
|
|
3
3
|
import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
CacheHandlerValue,
|
|
7
|
-
IncrementalCache,
|
|
8
|
-
} from 'next/dist/server/lib/incremental-cache';
|
|
4
|
+
import { debug } from './utils/debug';
|
|
5
|
+
import { bufferReviver, bufferReplacer } from './utils/json';
|
|
9
6
|
|
|
10
7
|
export type CommandOptions = ReturnType<typeof commandOptions>;
|
|
11
|
-
type GetParams = Parameters<IncrementalCache['get']>;
|
|
12
|
-
type SetParams = Parameters<IncrementalCache['set']>;
|
|
13
|
-
type RevalidateParams = Parameters<IncrementalCache['revalidateTag']>;
|
|
14
8
|
export type Client = ReturnType<typeof createClient>;
|
|
15
9
|
|
|
10
|
+
export type CacheEntry = {
|
|
11
|
+
value: unknown;
|
|
12
|
+
lastModified: number;
|
|
13
|
+
tags: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
16
|
export type CreateRedisStringsHandlerOptions = {
|
|
17
|
+
/** Redis database number to use. Uses DB 0 for production, DB 1 otherwise
|
|
18
|
+
* @default process.env.VERCEL_ENV === 'production' ? 0 : 1
|
|
19
|
+
*/
|
|
17
20
|
database?: number;
|
|
21
|
+
/** Prefix added to all Redis keys
|
|
22
|
+
* @default process.env.VERCEL_URL || 'UNDEFINED_URL_'
|
|
23
|
+
*/
|
|
18
24
|
keyPrefix?: string;
|
|
25
|
+
/** Timeout in milliseconds for Redis operations
|
|
26
|
+
* @default 5000
|
|
27
|
+
*/
|
|
19
28
|
timeoutMs?: number;
|
|
29
|
+
/** Number of entries to query in one batch during full sync of shared tags hash map
|
|
30
|
+
* @default 250
|
|
31
|
+
*/
|
|
20
32
|
revalidateTagQuerySize?: number;
|
|
33
|
+
/** Key used to store shared tags hash map in Redis
|
|
34
|
+
* @default '__sharedTags__'
|
|
35
|
+
*/
|
|
21
36
|
sharedTagsKey?: string;
|
|
37
|
+
/** Average interval in milliseconds between tag map full re-syncs
|
|
38
|
+
* @default 3600000 (1 hour)
|
|
39
|
+
*/
|
|
22
40
|
avgResyncIntervalMs?: number;
|
|
41
|
+
/** Enable deduplication of Redis get requests via internal in-memory cache
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
23
44
|
redisGetDeduplication?: boolean;
|
|
45
|
+
/** Time in milliseconds to cache Redis get results in memory. Set this to 0 to disable in-memory caching completely
|
|
46
|
+
* @default 10000
|
|
47
|
+
*/
|
|
24
48
|
inMemoryCachingTime?: number;
|
|
49
|
+
/** Default stale age in seconds for cached items
|
|
50
|
+
* @default 1209600 (14 days)
|
|
51
|
+
*/
|
|
25
52
|
defaultStaleAge?: number;
|
|
53
|
+
/** Function to calculate expire age (redis TTL value) from stale age
|
|
54
|
+
* @default Production: staleAge * 2, Other: staleAge * 1.2
|
|
55
|
+
*/
|
|
26
56
|
estimateExpireAge?: (staleAge: number) => number;
|
|
27
57
|
};
|
|
28
58
|
|
|
59
|
+
// Identifier prefix used by Next.js to mark automatically generated cache tags
|
|
60
|
+
// These tags are created internally by Next.js for route-based invalidation
|
|
29
61
|
const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';
|
|
30
|
-
const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
|
|
31
62
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
63
|
+
// Redis key used to store a map of tags and their last revalidation timestamps
|
|
64
|
+
// This helps track when specific tags were last invalidated
|
|
65
|
+
const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
|
|
35
66
|
|
|
36
67
|
export function getTimeoutRedisCommandOptions(
|
|
37
68
|
timeoutMs: number,
|
|
@@ -39,7 +70,7 @@ export function getTimeoutRedisCommandOptions(
|
|
|
39
70
|
return commandOptions({ signal: AbortSignal.timeout(timeoutMs) });
|
|
40
71
|
}
|
|
41
72
|
|
|
42
|
-
export default class RedisStringsHandler
|
|
73
|
+
export default class RedisStringsHandler {
|
|
43
74
|
private client: Client;
|
|
44
75
|
private sharedTagsMap: SyncedMap<string[]>;
|
|
45
76
|
private revalidatedTagsMap: SyncedMap<number>;
|
|
@@ -63,14 +94,14 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
63
94
|
database = process.env.VERCEL_ENV === 'production' ? 0 : 1,
|
|
64
95
|
keyPrefix = process.env.VERCEL_URL || 'UNDEFINED_URL_',
|
|
65
96
|
sharedTagsKey = '__sharedTags__',
|
|
66
|
-
timeoutMs =
|
|
97
|
+
timeoutMs = 5_000,
|
|
67
98
|
revalidateTagQuerySize = 250,
|
|
68
|
-
avgResyncIntervalMs = 60 * 60 *
|
|
99
|
+
avgResyncIntervalMs = 60 * 60 * 1_000,
|
|
69
100
|
redisGetDeduplication = true,
|
|
70
101
|
inMemoryCachingTime = 10_000,
|
|
71
102
|
defaultStaleAge = 60 * 60 * 24 * 14,
|
|
72
103
|
estimateExpireAge = (staleAge) =>
|
|
73
|
-
process.env.VERCEL_ENV === '
|
|
104
|
+
process.env.VERCEL_ENV === 'production' ? staleAge * 2 : staleAge * 1.2,
|
|
74
105
|
}: CreateRedisStringsHandlerOptions) {
|
|
75
106
|
this.keyPrefix = keyPrefix;
|
|
76
107
|
this.timeoutMs = timeoutMs;
|
|
@@ -96,9 +127,12 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
96
127
|
.then(() => {
|
|
97
128
|
console.info('Redis client connected.');
|
|
98
129
|
})
|
|
99
|
-
.catch((
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
.catch(() => {
|
|
131
|
+
this.client.connect().catch((error) => {
|
|
132
|
+
console.error('Failed to connect Redis client:', error);
|
|
133
|
+
this.client.disconnect();
|
|
134
|
+
throw error;
|
|
135
|
+
});
|
|
102
136
|
});
|
|
103
137
|
} catch (error: unknown) {
|
|
104
138
|
console.error('Failed to initialize Redis client');
|
|
@@ -160,9 +194,8 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
160
194
|
this.deduplicatedRedisGet =
|
|
161
195
|
this.redisDeduplicationHandler.deduplicatedFunction;
|
|
162
196
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
197
|
+
|
|
198
|
+
resetRequestCache(): void {}
|
|
166
199
|
|
|
167
200
|
private async assertClientIsReady(): Promise<void> {
|
|
168
201
|
await Promise.all([
|
|
@@ -174,110 +207,272 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
174
207
|
}
|
|
175
208
|
}
|
|
176
209
|
|
|
177
|
-
public async get(
|
|
210
|
+
public async get(
|
|
211
|
+
key: string,
|
|
212
|
+
ctx:
|
|
213
|
+
| {
|
|
214
|
+
kind: 'APP_ROUTE' | 'APP_PAGE';
|
|
215
|
+
isRoutePPREnabled: boolean;
|
|
216
|
+
isFallback: boolean;
|
|
217
|
+
}
|
|
218
|
+
| {
|
|
219
|
+
kind: 'FETCH';
|
|
220
|
+
revalidate: number;
|
|
221
|
+
fetchUrl: string;
|
|
222
|
+
fetchIdx: number;
|
|
223
|
+
tags: string[];
|
|
224
|
+
softTags: string[];
|
|
225
|
+
isFallback: boolean;
|
|
226
|
+
},
|
|
227
|
+
): Promise<CacheEntry | null> {
|
|
228
|
+
if (
|
|
229
|
+
ctx.kind !== 'APP_ROUTE' &&
|
|
230
|
+
ctx.kind !== 'APP_PAGE' &&
|
|
231
|
+
ctx.kind !== 'FETCH'
|
|
232
|
+
) {
|
|
233
|
+
console.warn(
|
|
234
|
+
'RedisStringsHandler.get() called with',
|
|
235
|
+
key,
|
|
236
|
+
ctx,
|
|
237
|
+
' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
|
|
238
|
+
(ctx as { kind: string })?.kind,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
debug('green', 'RedisStringsHandler.get() called with', key, ctx);
|
|
178
243
|
await this.assertClientIsReady();
|
|
179
244
|
|
|
180
245
|
const clientGet = this.redisGetDeduplication
|
|
181
246
|
? this.deduplicatedRedisGet(key)
|
|
182
247
|
: this.redisGet;
|
|
183
|
-
const
|
|
248
|
+
const serializedCacheEntry = await clientGet(
|
|
184
249
|
getTimeoutRedisCommandOptions(this.timeoutMs),
|
|
185
250
|
this.keyPrefix + key,
|
|
186
251
|
);
|
|
187
252
|
|
|
188
|
-
|
|
253
|
+
debug(
|
|
254
|
+
'green',
|
|
255
|
+
'RedisStringsHandler.get() finished with result (serializedCacheEntry)',
|
|
256
|
+
serializedCacheEntry?.substring(0, 200),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (!serializedCacheEntry) {
|
|
189
260
|
return null;
|
|
190
261
|
}
|
|
191
262
|
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
263
|
+
const cacheEntry: CacheEntry | null = JSON.parse(
|
|
264
|
+
serializedCacheEntry,
|
|
265
|
+
bufferReviver,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
debug(
|
|
269
|
+
'green',
|
|
270
|
+
'RedisStringsHandler.get() finished with result (cacheEntry)',
|
|
271
|
+
JSON.stringify(cacheEntry).substring(0, 200),
|
|
272
|
+
);
|
|
195
273
|
|
|
196
|
-
if (!
|
|
274
|
+
if (!cacheEntry) {
|
|
197
275
|
return null;
|
|
198
276
|
}
|
|
199
277
|
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
278
|
+
if (!cacheEntry?.tags) {
|
|
279
|
+
console.warn(
|
|
280
|
+
'RedisStringsHandler.get() called with',
|
|
281
|
+
key,
|
|
282
|
+
ctx,
|
|
283
|
+
'cacheEntry is mall formed (missing tags)',
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (!cacheEntry?.value) {
|
|
287
|
+
console.warn(
|
|
288
|
+
'RedisStringsHandler.get() called with',
|
|
289
|
+
key,
|
|
290
|
+
ctx,
|
|
291
|
+
'cacheEntry is mall formed (missing value)',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (!cacheEntry?.lastModified) {
|
|
295
|
+
console.warn(
|
|
296
|
+
'RedisStringsHandler.get() called with',
|
|
297
|
+
key,
|
|
298
|
+
ctx,
|
|
299
|
+
'cacheEntry is mall formed (missing lastModified)',
|
|
300
|
+
);
|
|
204
301
|
}
|
|
205
302
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
303
|
+
if (ctx.kind === 'FETCH') {
|
|
304
|
+
const combinedTags = new Set([
|
|
305
|
+
...(ctx?.softTags || []),
|
|
306
|
+
...(ctx?.tags || []),
|
|
307
|
+
]);
|
|
210
308
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
309
|
+
if (combinedTags.size === 0) {
|
|
310
|
+
return cacheEntry;
|
|
311
|
+
}
|
|
214
312
|
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
this
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
313
|
+
// INFO: implicit tags (revalidate of nested fetch in api route/page on revalidatePath call of the page/api route). See revalidateTag() for more information
|
|
314
|
+
//
|
|
315
|
+
// This code checks if any of the cache tags associated with this entry (normally the internal tag of the parent page/api route containing the fetch request)
|
|
316
|
+
// have been revalidated since the entry was last modified. If any tag was revalidated more recently than the entry's
|
|
317
|
+
// lastModified timestamp, then the cached content is considered stale (therefore return null) and should be removed.
|
|
318
|
+
for (const tag of combinedTags) {
|
|
319
|
+
// Get the last revalidation time for this tag from our revalidatedTagsMap
|
|
320
|
+
const revalidationTime = this.revalidatedTagsMap.get(tag);
|
|
321
|
+
|
|
322
|
+
// If we have a revalidation time for this tag and it's more recent than when
|
|
323
|
+
// this cache entry was last modified, the entry is stale
|
|
324
|
+
if (revalidationTime && revalidationTime > cacheEntry.lastModified) {
|
|
325
|
+
const redisKey = this.keyPrefix + key;
|
|
326
|
+
|
|
327
|
+
// We don't await this cleanup since it can happen asynchronously in the background.
|
|
328
|
+
// The cache entry is already considered invalid at this point.
|
|
329
|
+
this.client
|
|
330
|
+
.unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey)
|
|
331
|
+
.catch((err) => {
|
|
332
|
+
// If the first unlink fails, only log the error
|
|
333
|
+
// Never implement a retry here as the cache entry will be updated directly after this get request
|
|
334
|
+
console.error(
|
|
335
|
+
'Error occurred while unlinking stale data. Error was:',
|
|
336
|
+
err,
|
|
337
|
+
);
|
|
338
|
+
})
|
|
339
|
+
.finally(async () => {
|
|
340
|
+
// Clean up our tag tracking maps after the Redis key is removed
|
|
341
|
+
await this.sharedTagsMap.delete(key);
|
|
342
|
+
await this.revalidatedTagsMap.delete(tag);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
debug(
|
|
346
|
+
'green',
|
|
347
|
+
'RedisStringsHandler.get() found revalidation time for tag. Cache entry is stale and will be deleted and "null" will be returned.',
|
|
348
|
+
tag,
|
|
349
|
+
redisKey,
|
|
350
|
+
revalidationTime,
|
|
351
|
+
cacheEntry,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// Return null to indicate no valid cache entry was found
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
238
357
|
}
|
|
239
358
|
}
|
|
240
359
|
|
|
241
|
-
return
|
|
360
|
+
return cacheEntry;
|
|
242
361
|
}
|
|
243
362
|
public async set(
|
|
244
|
-
key:
|
|
245
|
-
data:
|
|
246
|
-
|
|
363
|
+
key: string,
|
|
364
|
+
data:
|
|
365
|
+
| {
|
|
366
|
+
kind: 'APP_PAGE';
|
|
367
|
+
status: number;
|
|
368
|
+
headers: {
|
|
369
|
+
'x-nextjs-stale-time': string; // timestamp in ms
|
|
370
|
+
'x-next-cache-tags': string; // comma separated paths (tags)
|
|
371
|
+
};
|
|
372
|
+
html: string;
|
|
373
|
+
rscData: Buffer;
|
|
374
|
+
segmentData: unknown;
|
|
375
|
+
postboned: unknown;
|
|
376
|
+
}
|
|
377
|
+
| {
|
|
378
|
+
kind: 'APP_ROUTE';
|
|
379
|
+
status: number;
|
|
380
|
+
headers: {
|
|
381
|
+
'cache-control'?: string;
|
|
382
|
+
'x-nextjs-stale-time': string; // timestamp in ms
|
|
383
|
+
'x-next-cache-tags': string; // comma separated paths (tags)
|
|
384
|
+
};
|
|
385
|
+
body: Buffer;
|
|
386
|
+
}
|
|
387
|
+
| {
|
|
388
|
+
kind: 'FETCH';
|
|
389
|
+
data: {
|
|
390
|
+
headers: Record<string, string>;
|
|
391
|
+
body: string; // base64 encoded
|
|
392
|
+
status: number;
|
|
393
|
+
url: string;
|
|
394
|
+
};
|
|
395
|
+
revalidate: number | false;
|
|
396
|
+
},
|
|
397
|
+
ctx: {
|
|
398
|
+
isRoutePPREnabled: boolean;
|
|
399
|
+
isFallback: boolean;
|
|
400
|
+
tags?: string[];
|
|
401
|
+
// Different versions of Next.js use different arguments for the same functionality
|
|
402
|
+
revalidate?: number | false; // Version 15.0.3
|
|
403
|
+
cacheControl?: { revalidate: 5; expire: undefined }; // Version 15.0.3
|
|
404
|
+
},
|
|
247
405
|
) {
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
data.
|
|
251
|
-
|
|
406
|
+
if (
|
|
407
|
+
data.kind !== 'APP_ROUTE' &&
|
|
408
|
+
data.kind !== 'APP_PAGE' &&
|
|
409
|
+
data.kind !== 'FETCH'
|
|
410
|
+
) {
|
|
411
|
+
console.warn(
|
|
412
|
+
'RedisStringsHandler.set() called with',
|
|
413
|
+
key,
|
|
414
|
+
ctx,
|
|
415
|
+
data,
|
|
416
|
+
' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
|
|
417
|
+
(data as { kind: string })?.kind,
|
|
418
|
+
);
|
|
252
419
|
}
|
|
420
|
+
|
|
253
421
|
await this.assertClientIsReady();
|
|
254
422
|
|
|
255
|
-
data.
|
|
423
|
+
if (data.kind === 'APP_PAGE' || data.kind === 'APP_ROUTE') {
|
|
424
|
+
const tags = data.headers['x-next-cache-tags']?.split(',');
|
|
425
|
+
ctx.tags = [...(ctx.tags || []), ...(tags || [])];
|
|
426
|
+
}
|
|
256
427
|
|
|
257
|
-
|
|
428
|
+
// Constructing and serializing the value for storing it in redis
|
|
429
|
+
const cacheEntry: CacheEntry = {
|
|
430
|
+
lastModified: Date.now(),
|
|
431
|
+
tags: ctx?.tags || [],
|
|
432
|
+
value: data,
|
|
433
|
+
};
|
|
434
|
+
const serializedCacheEntry = JSON.stringify(cacheEntry, bufferReplacer);
|
|
258
435
|
|
|
259
436
|
// pre seed data into deduplicated get client. This will reduce redis load by not requesting
|
|
260
437
|
// the same value from redis which was just set.
|
|
261
438
|
if (this.redisGetDeduplication) {
|
|
262
|
-
this.redisDeduplicationHandler.seedRequestReturn(
|
|
439
|
+
this.redisDeduplicationHandler.seedRequestReturn(
|
|
440
|
+
key,
|
|
441
|
+
serializedCacheEntry,
|
|
442
|
+
);
|
|
263
443
|
}
|
|
264
444
|
|
|
445
|
+
// TODO: implement expiration based on cacheControl.expire argument, -> probably relevant for cacheLife and "use cache" etc.: https://nextjs.org/docs/app/api-reference/functions/cacheLife
|
|
446
|
+
// Constructing the expire time for the cache entry
|
|
447
|
+
const revalidate = ctx.revalidate || ctx.cacheControl?.revalidate;
|
|
265
448
|
const expireAt =
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
ctx.revalidate > 0
|
|
269
|
-
? this.estimateExpireAge(ctx.revalidate)
|
|
449
|
+
revalidate && Number.isSafeInteger(revalidate) && revalidate > 0
|
|
450
|
+
? this.estimateExpireAge(revalidate)
|
|
270
451
|
: this.estimateExpireAge(this.defaultStaleAge);
|
|
452
|
+
|
|
453
|
+
// Setting the cache entry in redis
|
|
271
454
|
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
|
|
272
455
|
const setOperation: Promise<string | null> = this.client.set(
|
|
273
456
|
options,
|
|
274
457
|
this.keyPrefix + key,
|
|
275
|
-
|
|
458
|
+
serializedCacheEntry,
|
|
276
459
|
{
|
|
277
460
|
EX: expireAt,
|
|
278
461
|
},
|
|
279
462
|
);
|
|
280
463
|
|
|
464
|
+
debug(
|
|
465
|
+
'blue',
|
|
466
|
+
'RedisStringsHandler.set() will set the following serializedCacheEntry',
|
|
467
|
+
this.keyPrefix,
|
|
468
|
+
key,
|
|
469
|
+
data,
|
|
470
|
+
ctx,
|
|
471
|
+
serializedCacheEntry?.substring(0, 200),
|
|
472
|
+
expireAt,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
// Setting the tags for the cache entry in the sharedTagsMap (locally stored hashmap synced via redis)
|
|
281
476
|
let setTagsOperation: Promise<void> | undefined;
|
|
282
477
|
if (ctx.tags && ctx.tags.length > 0) {
|
|
283
478
|
const currentTags = this.sharedTagsMap.get(key);
|
|
@@ -294,47 +489,92 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
294
489
|
}
|
|
295
490
|
}
|
|
296
491
|
|
|
492
|
+
debug(
|
|
493
|
+
'blue',
|
|
494
|
+
'RedisStringsHandler.set() will set the following sharedTagsMap',
|
|
495
|
+
key,
|
|
496
|
+
ctx.tags as string[],
|
|
497
|
+
);
|
|
498
|
+
|
|
297
499
|
await Promise.all([setOperation, setTagsOperation]);
|
|
298
500
|
}
|
|
299
|
-
|
|
501
|
+
|
|
502
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
503
|
+
public async revalidateTag(tagOrTags: string | string[], ...rest: any[]) {
|
|
504
|
+
debug(
|
|
505
|
+
'red',
|
|
506
|
+
'RedisStringsHandler.revalidateTag() called with',
|
|
507
|
+
tagOrTags,
|
|
508
|
+
rest,
|
|
509
|
+
);
|
|
300
510
|
const tags = new Set([tagOrTags || []].flat());
|
|
301
511
|
await this.assertClientIsReady();
|
|
302
512
|
|
|
303
|
-
//
|
|
513
|
+
// find all keys that are related to this tag
|
|
514
|
+
const keysToDelete: Set<string> = new Set();
|
|
515
|
+
|
|
304
516
|
for (const tag of tags) {
|
|
305
|
-
|
|
517
|
+
// INFO: implicit tags (revalidate of nested fetch in api route/page on revalidatePath call of the page/api route)
|
|
518
|
+
//
|
|
519
|
+
// Invalidation logic for fetch requests that are related to a invalidated page.
|
|
520
|
+
// revalidateTag is called for the page tag (_N_T_...) and the fetch request needs to be invalidated as well
|
|
521
|
+
// unfortunately this is not possible since the revalidateTag is not called with any data that would allow us to find the cache entry of the fetch request
|
|
522
|
+
// in case of a fetch request get method call, the get method of the cache handler is called with some information about the pages/routes the fetch request is inside
|
|
523
|
+
// therefore we only mark the page/route as stale here (with help of the revalidatedTagsMap)
|
|
524
|
+
// and delete the cache entry of the fetch request on the next request to the get function
|
|
525
|
+
if (tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID)) {
|
|
306
526
|
const now = Date.now();
|
|
527
|
+
debug(
|
|
528
|
+
'red',
|
|
529
|
+
'RedisStringsHandler.revalidateTag() set revalidation time for tag',
|
|
530
|
+
tag,
|
|
531
|
+
'to',
|
|
532
|
+
now,
|
|
533
|
+
);
|
|
307
534
|
await this.revalidatedTagsMap.set(tag, now);
|
|
308
535
|
}
|
|
309
536
|
}
|
|
310
537
|
|
|
311
|
-
|
|
312
|
-
|
|
538
|
+
// Scan the whole sharedTagsMap for keys that are dependent on any of the revalidated tags
|
|
313
539
|
for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
|
|
314
540
|
if (sharedTags.some((tag) => tags.has(tag))) {
|
|
315
|
-
keysToDelete.
|
|
541
|
+
keysToDelete.add(key);
|
|
316
542
|
}
|
|
317
543
|
}
|
|
318
544
|
|
|
319
|
-
|
|
545
|
+
debug(
|
|
546
|
+
'red',
|
|
547
|
+
'RedisStringsHandler.revalidateTag() found',
|
|
548
|
+
keysToDelete,
|
|
549
|
+
'keys to delete',
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// exit early if no keys are related to this tag
|
|
553
|
+
if (keysToDelete.size === 0) {
|
|
320
554
|
return;
|
|
321
555
|
}
|
|
322
556
|
|
|
323
|
-
|
|
324
|
-
|
|
557
|
+
// prepare deletion of all keys in redis that are related to this tag
|
|
558
|
+
const redisKeys = Array.from(keysToDelete);
|
|
559
|
+
const fullRedisKeys = redisKeys.map((key) => this.keyPrefix + key);
|
|
325
560
|
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
|
|
326
|
-
|
|
327
561
|
const deleteKeysOperation = this.client.unlink(options, fullRedisKeys);
|
|
328
562
|
|
|
329
|
-
// delete entries from in-memory deduplication cache
|
|
563
|
+
// also delete entries from in-memory deduplication cache if they get revalidated
|
|
330
564
|
if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
|
|
331
565
|
for (const key of keysToDelete) {
|
|
332
566
|
this.inMemoryDeduplicationCache.delete(key);
|
|
333
567
|
}
|
|
334
568
|
}
|
|
335
569
|
|
|
336
|
-
|
|
570
|
+
// prepare deletion of entries from shared tags map if they get revalidated so that the map will not grow indefinitely
|
|
571
|
+
const deleteTagsOperation = this.sharedTagsMap.delete(redisKeys);
|
|
337
572
|
|
|
573
|
+
// execute keys and tag maps deletion
|
|
338
574
|
await Promise.all([deleteKeysOperation, deleteTagsOperation]);
|
|
575
|
+
debug(
|
|
576
|
+
'red',
|
|
577
|
+
'RedisStringsHandler.revalidateTag() finished delete operations',
|
|
578
|
+
);
|
|
339
579
|
}
|
|
340
580
|
}
|