@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.
Files changed (94) hide show
  1. package/.github/workflows/ci.yml +31 -6
  2. package/.github/workflows/release.yml +7 -3
  3. package/.next/trace +11 -0
  4. package/.vscode/settings.json +10 -0
  5. package/CHANGELOG.md +71 -0
  6. package/README.md +154 -34
  7. package/dist/index.d.mts +96 -20
  8. package/dist/index.d.ts +96 -20
  9. package/dist/index.js +317 -61
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +313 -61
  12. package/dist/index.mjs.map +1 -1
  13. package/package.json +14 -7
  14. package/scripts/vitest-run-staged.cjs +1 -1
  15. package/src/CachedHandler.ts +23 -9
  16. package/src/DeduplicatedRequestHandler.ts +50 -1
  17. package/src/RedisStringsHandler.ts +331 -91
  18. package/src/SyncedMap.ts +74 -4
  19. package/src/ZodHandler.ts +45 -0
  20. package/src/index.ts +4 -2
  21. package/src/utils/debug.ts +30 -0
  22. package/src/utils/json.ts +26 -0
  23. package/test/integration/next-app-15-0-3/README.md +36 -0
  24. package/test/integration/next-app-15-0-3/eslint.config.mjs +16 -0
  25. package/test/integration/next-app-15-0-3/next.config.js +6 -0
  26. package/test/integration/next-app-15-0-3/package-lock.json +5833 -0
  27. package/test/integration/next-app-15-0-3/package.json +29 -0
  28. package/test/integration/next-app-15-0-3/pnpm-lock.yaml +3679 -0
  29. package/test/integration/next-app-15-0-3/postcss.config.mjs +5 -0
  30. package/test/integration/next-app-15-0-3/public/file.svg +1 -0
  31. package/test/integration/next-app-15-0-3/public/globe.svg +1 -0
  32. package/test/integration/next-app-15-0-3/public/next.svg +1 -0
  33. package/test/integration/next-app-15-0-3/public/vercel.svg +1 -0
  34. package/test/integration/next-app-15-0-3/public/window.svg +1 -0
  35. package/test/integration/next-app-15-0-3/src/app/api/cached-static-fetch/route.ts +18 -0
  36. package/test/integration/next-app-15-0-3/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
  37. package/test/integration/next-app-15-0-3/src/app/api/revalidatePath/route.ts +15 -0
  38. package/test/integration/next-app-15-0-3/src/app/api/revalidateTag/route.ts +15 -0
  39. package/test/integration/next-app-15-0-3/src/app/api/revalidated-fetch/route.ts +17 -0
  40. package/test/integration/next-app-15-0-3/src/app/api/uncached-fetch/route.ts +15 -0
  41. package/test/integration/next-app-15-0-3/src/app/globals.css +26 -0
  42. package/test/integration/next-app-15-0-3/src/app/layout.tsx +59 -0
  43. package/test/integration/next-app-15-0-3/src/app/page.tsx +755 -0
  44. package/test/integration/next-app-15-0-3/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
  45. package/test/integration/next-app-15-0-3/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
  46. package/test/integration/next-app-15-0-3/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  47. package/test/integration/next-app-15-0-3/src/app/pages/no-fetch/default-page/page.tsx +55 -0
  48. package/test/integration/next-app-15-0-3/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
  49. package/test/integration/next-app-15-0-3/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
  50. package/test/integration/next-app-15-0-3/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  51. package/test/integration/next-app-15-0-3/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
  52. package/test/integration/next-app-15-0-3/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
  53. package/test/integration/next-app-15-0-3/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  54. package/test/integration/next-app-15-0-3/src/app/revalidation-interface.tsx +267 -0
  55. package/test/integration/next-app-15-0-3/tsconfig.json +27 -0
  56. package/test/integration/next-app-15-3-2/README.md +36 -0
  57. package/test/integration/next-app-15-3-2/eslint.config.mjs +16 -0
  58. package/test/integration/next-app-15-3-2/next.config.js +6 -0
  59. package/test/integration/next-app-15-3-2/package-lock.json +5969 -0
  60. package/test/integration/next-app-15-3-2/package.json +33 -0
  61. package/test/integration/next-app-15-3-2/pnpm-lock.yaml +3688 -0
  62. package/test/integration/next-app-15-3-2/postcss.config.mjs +5 -0
  63. package/test/integration/next-app-15-3-2/public/file.svg +1 -0
  64. package/test/integration/next-app-15-3-2/public/globe.svg +1 -0
  65. package/test/integration/next-app-15-3-2/public/next.svg +1 -0
  66. package/test/integration/next-app-15-3-2/public/vercel.svg +1 -0
  67. package/test/integration/next-app-15-3-2/public/window.svg +1 -0
  68. package/test/integration/next-app-15-3-2/src/app/api/cached-static-fetch/route.ts +18 -0
  69. package/test/integration/next-app-15-3-2/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
  70. package/test/integration/next-app-15-3-2/src/app/api/revalidatePath/route.ts +15 -0
  71. package/test/integration/next-app-15-3-2/src/app/api/revalidateTag/route.ts +15 -0
  72. package/test/integration/next-app-15-3-2/src/app/api/revalidated-fetch/route.ts +17 -0
  73. package/test/integration/next-app-15-3-2/src/app/api/uncached-fetch/route.ts +15 -0
  74. package/test/integration/next-app-15-3-2/src/app/globals.css +26 -0
  75. package/test/integration/next-app-15-3-2/src/app/layout.tsx +59 -0
  76. package/test/integration/next-app-15-3-2/src/app/page.tsx +755 -0
  77. package/test/integration/next-app-15-3-2/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
  78. package/test/integration/next-app-15-3-2/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
  79. package/test/integration/next-app-15-3-2/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  80. package/test/integration/next-app-15-3-2/src/app/pages/no-fetch/default-page/page.tsx +55 -0
  81. package/test/integration/next-app-15-3-2/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
  82. package/test/integration/next-app-15-3-2/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
  83. package/test/integration/next-app-15-3-2/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  84. package/test/integration/next-app-15-3-2/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
  85. package/test/integration/next-app-15-3-2/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
  86. package/test/integration/next-app-15-3-2/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  87. package/test/integration/next-app-15-3-2/src/app/revalidation-interface.tsx +267 -0
  88. package/test/integration/next-app-15-3-2/tsconfig.json +27 -0
  89. package/test/integration/next-app-customized/README.md +36 -0
  90. package/test/integration/next-app-customized/customized-cache-handler.js +34 -0
  91. package/test/integration/next-app-customized/eslint.config.mjs +16 -0
  92. package/test/integration/next-app-customized/next.config.js +6 -0
  93. package/test/integration/nextjs-cache-handler.integration.test.ts +859 -0
  94. 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
- CacheHandler,
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
- function isImplicitTag(tag: string): boolean {
33
- return tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID);
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 implements CacheHandler {
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 = 5000,
97
+ timeoutMs = 5_000,
67
98
  revalidateTagQuerySize = 250,
68
- avgResyncIntervalMs = 60 * 60 * 1000,
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 === 'preview' ? staleAge * 1.2 : staleAge * 2,
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((error) => {
100
- console.error('Failed to connect Redis client:', error);
101
- this.client.disconnect();
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
- resetRequestCache(...args: never[]): void {
164
- console.warn('WARNING resetRequestCache() was called', args);
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(key: GetParams[0], ctx: GetParams[1]) {
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 result = await clientGet(
248
+ const serializedCacheEntry = await clientGet(
184
249
  getTimeoutRedisCommandOptions(this.timeoutMs),
185
250
  this.keyPrefix + key,
186
251
  );
187
252
 
188
- if (!result) {
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 cacheValue = JSON.parse(result) as
193
- | (CacheHandlerValue & { lastModified: number })
194
- | null;
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 (!cacheValue) {
274
+ if (!cacheEntry) {
197
275
  return null;
198
276
  }
199
277
 
200
- if (cacheValue.value?.kind === 'FETCH') {
201
- cacheValue.value.data.body = Buffer.from(
202
- cacheValue.value.data.body,
203
- ).toString('base64');
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
- const combinedTags = new Set([
207
- ...(ctx?.softTags || []),
208
- ...(ctx?.tags || []),
209
- ]);
303
+ if (ctx.kind === 'FETCH') {
304
+ const combinedTags = new Set([
305
+ ...(ctx?.softTags || []),
306
+ ...(ctx?.tags || []),
307
+ ]);
210
308
 
211
- if (combinedTags.size === 0) {
212
- return cacheValue;
213
- }
309
+ if (combinedTags.size === 0) {
310
+ return cacheEntry;
311
+ }
214
312
 
215
- for (const tag of combinedTags) {
216
- // TODO: check how this revalidatedTagsMap is used or if it can be deleted
217
- const revalidationTime = this.revalidatedTagsMap.get(tag);
218
- if (revalidationTime && revalidationTime > cacheValue.lastModified) {
219
- const redisKey = this.keyPrefix + key;
220
- // Do not await here as this can happen in the background while we can already serve the cacheValue
221
- this.client
222
- .unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey)
223
- .catch((err) => {
224
- console.error(
225
- 'Error occurred while unlinking stale data. Retrying now. Error was:',
226
- err,
227
- );
228
- this.client.unlink(
229
- getTimeoutRedisCommandOptions(this.timeoutMs),
230
- redisKey,
231
- );
232
- })
233
- .finally(async () => {
234
- await this.sharedTagsMap.delete(key);
235
- await this.revalidatedTagsMap.delete(tag);
236
- });
237
- return null;
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 cacheValue;
360
+ return cacheEntry;
242
361
  }
243
362
  public async set(
244
- key: SetParams[0],
245
- data: SetParams[1] & { lastModified: number },
246
- ctx: SetParams[2],
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 (data.kind === 'FETCH') {
249
- console.time('encoding' + key);
250
- data.data.body = Buffer.from(data.data.body, 'base64').toString();
251
- console.timeEnd('encoding' + key);
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.lastModified = Date.now();
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
- const value = JSON.stringify(data);
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(key, value);
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
- ctx.revalidate &&
267
- Number.isSafeInteger(ctx.revalidate) &&
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
- value,
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
- public async revalidateTag(tagOrTags: RevalidateParams[0]) {
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
- // TODO: check how this revalidatedTagsMap is used or if it can be deleted
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
- if (isImplicitTag(tag)) {
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
- const keysToDelete: string[] = [];
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.push(key);
541
+ keysToDelete.add(key);
316
542
  }
317
543
  }
318
544
 
319
- if (keysToDelete.length === 0) {
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
- const fullRedisKeys = keysToDelete.map((key) => this.keyPrefix + key);
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
- const deleteTagsOperation = this.sharedTagsMap.delete(keysToDelete);
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
  }