@trieb.work/nextjs-turbo-redis-cache 1.2.0 → 1.3.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 (60) hide show
  1. package/.github/workflows/ci.yml +30 -6
  2. package/.github/workflows/release.yml +6 -3
  3. package/.next/trace +11 -0
  4. package/.vscode/settings.json +10 -0
  5. package/CHANGELOG.md +61 -0
  6. package/README.md +149 -34
  7. package/dist/index.d.mts +92 -20
  8. package/dist/index.d.ts +92 -20
  9. package/dist/index.js +319 -60
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +315 -60
  12. package/dist/index.mjs.map +1 -1
  13. package/package.json +14 -9
  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 +330 -89
  18. package/src/SyncedMap.ts +74 -4
  19. package/src/index.ts +4 -2
  20. package/src/utils/debug.ts +30 -0
  21. package/src/utils/json.ts +26 -0
  22. package/test/integration/next-app/README.md +36 -0
  23. package/test/integration/next-app/eslint.config.mjs +16 -0
  24. package/test/integration/next-app/next.config.js +6 -0
  25. package/test/integration/next-app/package-lock.json +5833 -0
  26. package/test/integration/next-app/package.json +29 -0
  27. package/test/integration/next-app/pnpm-lock.yaml +3679 -0
  28. package/test/integration/next-app/postcss.config.mjs +5 -0
  29. package/test/integration/next-app/public/file.svg +1 -0
  30. package/test/integration/next-app/public/globe.svg +1 -0
  31. package/test/integration/next-app/public/next.svg +1 -0
  32. package/test/integration/next-app/public/vercel.svg +1 -0
  33. package/test/integration/next-app/public/window.svg +1 -0
  34. package/test/integration/next-app/src/app/api/cached-static-fetch/route.ts +18 -0
  35. package/test/integration/next-app/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
  36. package/test/integration/next-app/src/app/api/revalidatePath/route.ts +15 -0
  37. package/test/integration/next-app/src/app/api/revalidateTag/route.ts +15 -0
  38. package/test/integration/next-app/src/app/api/revalidated-fetch/route.ts +17 -0
  39. package/test/integration/next-app/src/app/api/uncached-fetch/route.ts +15 -0
  40. package/test/integration/next-app/src/app/globals.css +26 -0
  41. package/test/integration/next-app/src/app/layout.tsx +59 -0
  42. package/test/integration/next-app/src/app/page.tsx +755 -0
  43. package/test/integration/next-app/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
  44. package/test/integration/next-app/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
  45. package/test/integration/next-app/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  46. package/test/integration/next-app/src/app/pages/no-fetch/default-page/page.tsx +55 -0
  47. package/test/integration/next-app/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
  48. package/test/integration/next-app/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
  49. package/test/integration/next-app/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  50. package/test/integration/next-app/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
  51. package/test/integration/next-app/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
  52. package/test/integration/next-app/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  53. package/test/integration/next-app/src/app/revalidation-interface.tsx +267 -0
  54. package/test/integration/next-app/tsconfig.json +27 -0
  55. package/test/integration/next-app-customized/README.md +36 -0
  56. package/test/integration/next-app-customized/customized-cache-handler.js +34 -0
  57. package/test/integration/next-app-customized/eslint.config.mjs +16 -0
  58. package/test/integration/next-app-customized/next.config.js +6 -0
  59. package/test/integration/nextjs-cache-handler.integration.test.ts +840 -0
  60. package/vite.config.ts +23 -8
@@ -0,0 +1,840 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { spawn } from 'child_process';
3
+ import fetch from 'node-fetch';
4
+ import { createClient } from 'redis';
5
+ import { join } from 'path';
6
+ import { CacheEntry } from '../../src/RedisStringsHandler';
7
+
8
+ const NEXT_APP_DIR = join(__dirname, 'next-app');
9
+ console.log('NEXT_APP_DIR', NEXT_APP_DIR);
10
+ const NEXT_START_PORT = 3055;
11
+ const NEXT_START_URL = `http://localhost:${NEXT_START_PORT}`;
12
+
13
+ const REDIS_BACKGROUND_SYNC_DELAY = 250; //ms delay to prevent flaky tests in slow CI environments
14
+
15
+ let nextProcess;
16
+ let redisClient;
17
+
18
+ async function delay(ms: number) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+
22
+ async function runCommand(cmd: string, args: string[], cwd: string) {
23
+ return new Promise((resolve, reject) => {
24
+ let stderr = '';
25
+ let stdout = '';
26
+ const proc = spawn(cmd, args, { cwd, stdio: 'pipe' });
27
+
28
+ proc.stdout.on('data', (data) => {
29
+ if (process.env.DEBUG_INTEGRATION) {
30
+ console.log(data.toString());
31
+ }
32
+ stdout += data.toString();
33
+ });
34
+
35
+ proc.stderr.on('data', (data) => {
36
+ if (process.env.DEBUG_INTEGRATION) {
37
+ console.error(data.toString());
38
+ }
39
+ stderr += data.toString();
40
+ });
41
+
42
+ proc.on('exit', (code) => {
43
+ if (code === 0) resolve(undefined);
44
+ else {
45
+ reject(
46
+ new Error(
47
+ `${cmd} ${args.join(' ')} failed with code ${code}\n` +
48
+ `stdout: ${stdout}\n` +
49
+ `stderr: ${stderr}`,
50
+ ),
51
+ );
52
+ }
53
+ });
54
+ });
55
+ }
56
+
57
+ async function waitForServer(url, timeout = 20000) {
58
+ const start = Date.now();
59
+ while (Date.now() - start < timeout) {
60
+ try {
61
+ const res = await fetch(url + '/api/cached-static-fetch');
62
+ if (res.ok) return;
63
+ } catch {}
64
+ await new Promise((r) => setTimeout(r, 300));
65
+ }
66
+ throw new Error('Next.js server did not start in time');
67
+ }
68
+
69
+ describe('Next.js Turbo Redis Cache Integration', () => {
70
+ beforeAll(async () => {
71
+ // If there was detected to run a server before (any old server which was not stopped correctly), kill it
72
+ try {
73
+ const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
74
+ if (res.ok) {
75
+ await runCommand('pkill', ['next'], NEXT_APP_DIR);
76
+ }
77
+ } catch {}
78
+
79
+ // Set up environment variables
80
+ process.env.VERCEL_ENV = 'production';
81
+ process.env.VERCEL_URL =
82
+ 'integration-test-' + Math.random().toString(36).substring(2, 15);
83
+ console.log('redis key prefix is:', process.env.VERCEL_URL);
84
+
85
+ // Only override if redis env vars if not set. This can be set in the CI env.
86
+ process.env.REDISHOST = process.env.REDISHOST || 'localhost';
87
+ process.env.REDISPORT = process.env.REDISPORT || '6379';
88
+ process.env.NEXT_START_PORT = String(NEXT_START_PORT);
89
+
90
+ if (process.env.SKIP_BUILD === 'true') {
91
+ console.log('skipping build');
92
+ } else {
93
+ // Build Next.js app first
94
+ await runCommand('pnpm', ['i'], NEXT_APP_DIR);
95
+ console.log('pnpm i done');
96
+ await runCommand('pnpm', ['build'], NEXT_APP_DIR);
97
+ console.log('pnpm build done');
98
+ }
99
+
100
+ // Start Next.js app
101
+ nextProcess = spawn(
102
+ 'npx',
103
+ ['next', 'start', '-p', String(NEXT_START_PORT)],
104
+ {
105
+ cwd: NEXT_APP_DIR,
106
+ env: {
107
+ ...process.env,
108
+ },
109
+ stdio: 'pipe',
110
+ },
111
+ );
112
+ if (process.env.DEBUG_INTEGRATION) {
113
+ nextProcess.stdout.on('data', (data) => {
114
+ console.log(`stdout: ${data}`);
115
+ });
116
+ }
117
+
118
+ nextProcess.stderr.on('data', (data) => {
119
+ console.error(`stderr: ${data}`);
120
+ });
121
+ await waitForServer(NEXT_START_URL);
122
+ console.log('next start successful');
123
+
124
+ // Connect to Redis
125
+ redisClient = createClient({
126
+ url: `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`,
127
+ });
128
+ await redisClient.connect();
129
+ }, 60_000);
130
+
131
+ afterAll(async () => {
132
+ if (process.env.KEEP_SERVER_RUNNING === 'true') {
133
+ console.log('keeping server running');
134
+ } else {
135
+ if (nextProcess) nextProcess.kill();
136
+ }
137
+ if (redisClient) await redisClient.quit();
138
+ });
139
+
140
+ describe('should have the correct caching behavior for API routes', () => {
141
+ describe('should cache static API routes in Redis', () => {
142
+ let counter1: number;
143
+
144
+ it('First request (should increment counter)', async () => {
145
+ const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
146
+ const data: any = await res.json();
147
+ expect(data.counter).toBe(1);
148
+ counter1 = data.counter;
149
+ });
150
+
151
+ it('Second request (should hit cache, counter should not increment if cache works)', async () => {
152
+ const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
153
+ const data: any = await res.json();
154
+ // If cache is working, counter should stay 1; if not, it will increment
155
+ expect(data.counter).toBe(counter1);
156
+ });
157
+
158
+ it('The data in the redis key should match the expected format', async () => {
159
+ const keys = await redisClient.keys(process.env.VERCEL_URL + '*');
160
+ expect(keys.length).toBeGreaterThan(0);
161
+
162
+ // check the content of redis key
163
+ const value = await redisClient.get(
164
+ process.env.VERCEL_URL + '/api/cached-static-fetch',
165
+ );
166
+ expect(value).toBeDefined();
167
+ const cacheEntry: CacheEntry = JSON.parse(value);
168
+
169
+ // The format should be as expected
170
+ expect(cacheEntry).toEqual({
171
+ value: {
172
+ kind: 'APP_ROUTE',
173
+ status: 200,
174
+ body: { $binary: 'eyJjb3VudGVyIjoxfQ==' },
175
+ headers: {
176
+ 'cache-control': 'public, max-age=1',
177
+ 'content-type': 'application/json',
178
+ 'x-next-cache-tags':
179
+ '_N_T_/layout,_N_T_/api/layout,_N_T_/api/cached-static-fetch/layout,_N_T_/api/cached-static-fetch/route,_N_T_/api/cached-static-fetch',
180
+ },
181
+ },
182
+ lastModified: expect.any(Number),
183
+ tags: [
184
+ '_N_T_/layout',
185
+ '_N_T_/api/layout',
186
+ '_N_T_/api/cached-static-fetch/layout',
187
+ '_N_T_/api/cached-static-fetch/route',
188
+ '_N_T_/api/cached-static-fetch',
189
+ ],
190
+ });
191
+
192
+ expect((cacheEntry.value as any).kind).toBe('APP_ROUTE');
193
+ const bodyBuffer = Buffer.from(
194
+ (cacheEntry.value as any)?.body?.$binary,
195
+ 'base64',
196
+ );
197
+ const bodyJson = JSON.parse(bodyBuffer.toString('utf-8'));
198
+ expect(bodyJson.counter).toBe(counter1);
199
+ });
200
+
201
+ it('A request to revalidatePath API should remove the route from redis (string and hashmap)', async () => {
202
+ const revalidateRes = await fetch(
203
+ NEXT_START_URL + '/api/revalidatePath?path=/api/cached-static-fetch',
204
+ );
205
+ const revalidateResJson: any = await revalidateRes.json();
206
+ expect(revalidateResJson.success).toBe(true);
207
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
208
+
209
+ // check Redis keys
210
+ const keys = await redisClient.keys(
211
+ process.env.VERCEL_URL + '/api/cached-static-fetch',
212
+ );
213
+ expect(keys.length).toBe(0);
214
+
215
+ const hashmap = await redisClient.hGet(
216
+ process.env.VERCEL_URL + '__sharedTags__',
217
+ '/api/cached-static-fetch',
218
+ );
219
+ expect(hashmap).toBeNull();
220
+ });
221
+
222
+ it('A new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
223
+ const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
224
+ const data: any = await res.json();
225
+ expect(data.counter).toBe(counter1 + 1);
226
+ });
227
+
228
+ it('After the new request was made the redis key and hashmap should be set again', async () => {
229
+ // check Redis keys
230
+ const keys = await redisClient.keys(
231
+ process.env.VERCEL_URL + '/api/cached-static-fetch',
232
+ );
233
+ expect(keys.length).toBe(1);
234
+
235
+ const hashmap = await redisClient.hGet(
236
+ process.env.VERCEL_URL + '__sharedTags__',
237
+ '/api/cached-static-fetch',
238
+ );
239
+ expect(JSON.parse(hashmap)).toEqual([
240
+ '_N_T_/layout',
241
+ '_N_T_/api/layout',
242
+ '_N_T_/api/cached-static-fetch/layout',
243
+ '_N_T_/api/cached-static-fetch/route',
244
+ '_N_T_/api/cached-static-fetch',
245
+ ]);
246
+ });
247
+ });
248
+
249
+ describe('should cache revalidation API routes in Redis', () => {
250
+ let counter1: number;
251
+
252
+ it('First request (should increment counter)', async () => {
253
+ const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
254
+ const data: any = await res.json();
255
+ expect(data.counter).toBe(1);
256
+ counter1 = data.counter;
257
+ });
258
+
259
+ it('Second request which is send in revalidation time should hit cache (counter should not increment)', async () => {
260
+ const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
261
+ const data: any = await res.json();
262
+ expect(data.counter).toBe(counter1);
263
+ });
264
+
265
+ if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
266
+ it('Third request which is send directly after revalidation time will still serve cache but trigger re-evaluation (stale-while-revalidate)', async () => {
267
+ await delay(6000);
268
+ const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
269
+ const data: any = await res.json();
270
+ expect(data.counter).toBe(counter1);
271
+ }, 10_000);
272
+
273
+ it('Third request which is send directly after revalidation time will serve re-evaluated data (stale-while-revalidate)', async () => {
274
+ await delay(1000);
275
+ const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
276
+ const data: any = await res.json();
277
+ expect(data.counter).toBe(counter1 + 1);
278
+ });
279
+
280
+ it('After expiration, the redis key should be removed from redis and the hashmap', async () => {
281
+ const ttl = await redisClient.ttl(
282
+ process.env.VERCEL_URL + '/api/revalidated-fetch',
283
+ );
284
+ expect(ttl).toBeGreaterThan(3);
285
+
286
+ await delay(ttl * 1000 + 500);
287
+
288
+ // check Redis keys
289
+ const keys = await redisClient.keys(
290
+ process.env.VERCEL_URL + '/api/revalidated-fetch',
291
+ );
292
+ expect(keys.length).toBe(0);
293
+
294
+ await delay(1000);
295
+
296
+ // The key should also be removed from the hashmap
297
+ const hashmap = await redisClient.hGet(
298
+ process.env.VERCEL_URL + '__sharedTags__',
299
+ '/api/revalidated-fetch',
300
+ );
301
+ expect(hashmap).toBeNull();
302
+ }, 15_000);
303
+ }
304
+ });
305
+
306
+ describe('should not cache uncached API routes in Redis', () => {
307
+ let counter1: number;
308
+
309
+ it('First request should increment counter', async () => {
310
+ const res1 = await fetch(NEXT_START_URL + '/api/uncached-fetch');
311
+ const data1: any = await res1.json();
312
+ expect(data1.counter).toBe(1);
313
+ counter1 = data1.counter;
314
+ });
315
+
316
+ it('Second request should hit cache (counter should not increment if cache works)', async () => {
317
+ const res2 = await fetch(NEXT_START_URL + '/api/uncached-fetch');
318
+ const data2: any = await res2.json();
319
+
320
+ // If not caching it is working request 2 should be higher as request one
321
+ expect(data2.counter).toBe(counter1 + 1);
322
+ });
323
+
324
+ it('The redis key should not be set', async () => {
325
+ // check the content of redis key
326
+ const value = await redisClient.get(
327
+ process.env.VERCEL_URL + '/api/uncached-fetch',
328
+ );
329
+ expect(value).toBeNull();
330
+ });
331
+ });
332
+
333
+ describe('should cache a nested fetch request inside a uncached API route', () => {
334
+ describe('should cache the nested fetch request (but not the API route itself)', () => {
335
+ let counter: number;
336
+ let subCounter: number;
337
+
338
+ it('should deduplicate requests to the sub-fetch-request, but not to the API route itself', async () => {
339
+ // make two requests, both should return the same subFetchData but different counter
340
+ const res1 = await fetch(
341
+ NEXT_START_URL + '/api/nested-fetch-in-api-route/revalidated-fetch',
342
+ );
343
+ const res2 = await fetch(
344
+ NEXT_START_URL + '/api/nested-fetch-in-api-route/revalidated-fetch',
345
+ );
346
+ const [data1, data2]: any[] = await Promise.all([
347
+ res1.json(),
348
+ res2.json(),
349
+ ]);
350
+
351
+ // API route counter itself increments for each request
352
+ // But we do not know which request is first and which is second
353
+ if (data1.counter < data2.counter) {
354
+ expect(data2.counter).toBeGreaterThan(data1.counter);
355
+ counter = data2.counter;
356
+ } else {
357
+ expect(data1.counter).toBeGreaterThan(data2.counter);
358
+ counter = data1.counter;
359
+ }
360
+
361
+ // API route counter of revalidated sub-fetch-request should be the same (request deduplication of fetch requests)
362
+ expect(data1.subFetchData.counter).toBe(data1.subFetchData.counter);
363
+ subCounter = data1.subFetchData.counter;
364
+ });
365
+
366
+ if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
367
+ it('should return the same subFetchData after 2 seconds (regular caching within revalidation interval (=3s) works)', async () => {
368
+ // make another request after 2 seconds, it should return the same subFetchData
369
+ await delay(2000); // 2s < 3s (revalidate interval)
370
+ const res = await fetch(
371
+ NEXT_START_URL +
372
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
373
+ );
374
+ const data: any = await res.json();
375
+
376
+ expect(data.counter).toBe(counter + 1);
377
+ expect(data.subFetchData.counter).toBe(subCounter);
378
+ });
379
+
380
+ it('should return the same subFetchData after 2 seconds and new data after another 2 seconds (caching while revalidation works)', async () => {
381
+ // make another request after another 2 seconds, it should return the same subFetchData (caching while revalidation works)
382
+ await delay(2000); // 2s+2s < 3s*2 (=TTL = revalidate=3s*2)
383
+ const res1 = await fetch(
384
+ NEXT_START_URL +
385
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
386
+ );
387
+ const data1: any = await res1.json();
388
+ expect(data1.counter).toBe(counter + 2);
389
+ expect(data1.subFetchData.counter).toBe(subCounter);
390
+
391
+ // make another request directly after first request which was still in TTL, it should return new data (caching while revalidation works)
392
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
393
+ const res2 = await fetch(
394
+ NEXT_START_URL +
395
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
396
+ );
397
+ const data2: any = await res2.json();
398
+ expect(data2.counter).toBe(counter + 3);
399
+ expect(data2.subFetchData.counter).toBe(subCounter + 1);
400
+ });
401
+
402
+ it('A request to revalidatePage API should remove the route from redis (string and hashmap)', async () => {
403
+ const revalidateRes = await fetch(
404
+ NEXT_START_URL +
405
+ '/api/revalidatePath?path=/api/nested-fetch-in-api-route/revalidated-fetch',
406
+ );
407
+ const revalidateResJson: any = await revalidateRes.json();
408
+ expect(revalidateResJson.success).toBe(true);
409
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
410
+
411
+ // check Redis keys
412
+ const keys = await redisClient.keys(
413
+ process.env.VERCEL_URL +
414
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
415
+ );
416
+ expect(keys.length).toBe(0);
417
+
418
+ const hashmap = await redisClient.hGet(
419
+ process.env.VERCEL_URL + '__sharedTags__',
420
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
421
+ );
422
+ expect(hashmap).toBeNull();
423
+ });
424
+
425
+ it('A new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
426
+ const res = await fetch(
427
+ NEXT_START_URL +
428
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
429
+ );
430
+ const data: any = await res.json();
431
+ expect(data.counter).toBe(counter + 4);
432
+ expect(data.subFetchData.counter).toBe(subCounter + 2); // <- fails in CI only
433
+ });
434
+
435
+ it('After the new request was made the redis key and hashmap should be set again', async () => {
436
+ // This cache entry key is the key of the sub-fetch-request, it will be generated by nextjs based on the headers/payload etc.
437
+ // So it should stay the same unless nextjs will change something in there implementation
438
+ const cacheEntryKey =
439
+ '094a786b7ad391852168d3a7bcf75736777697d24a856a0089837f4b7de921df';
440
+
441
+ // check Redis keys
442
+ const keys = await redisClient.keys(
443
+ process.env.VERCEL_URL + cacheEntryKey,
444
+ );
445
+ expect(keys.length).toBe(1);
446
+
447
+ const hashmap = await redisClient.hGet(
448
+ process.env.VERCEL_URL + '__sharedTags__',
449
+ cacheEntryKey,
450
+ );
451
+ expect(JSON.parse(hashmap)).toEqual([
452
+ 'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
453
+ ]);
454
+ });
455
+
456
+ it('A request to revalidateTag API should remove the route from redis (string and hashmap)', async () => {
457
+ const revalidateRes = await fetch(
458
+ NEXT_START_URL +
459
+ '/api/revalidateTag?tag=revalidated-fetch-revalidate3-nested-fetch-in-api-route',
460
+ );
461
+ const revalidateResJson: any = await revalidateRes.json();
462
+ expect(revalidateResJson.success).toBe(true);
463
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
464
+
465
+ // check Redis keys
466
+ const keys = await redisClient.keys(
467
+ process.env.VERCEL_URL +
468
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
469
+ );
470
+ expect(keys.length).toBe(0);
471
+
472
+ const hashmap = await redisClient.hGet(
473
+ process.env.VERCEL_URL + '__sharedTags__',
474
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
475
+ );
476
+ expect(hashmap).toBeNull();
477
+ });
478
+
479
+ it('Another new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
480
+ const res = await fetch(
481
+ NEXT_START_URL +
482
+ '/api/nested-fetch-in-api-route/revalidated-fetch',
483
+ );
484
+ const data: any = await res.json();
485
+ expect(data.counter).toBe(counter + 5);
486
+ expect(data.subFetchData.counter).toBe(subCounter + 3); // <- fails in CI only
487
+ });
488
+
489
+ it('After the new request was made the redis key and hashmap should be set again', async () => {
490
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
491
+ // This cache entry key is the key of the sub-fetch-request, it will be generated by nextjs based on the headers/payload etc.
492
+ // So it should stay the same unless nextjs will change something in there implementation
493
+ const cacheEntryKey =
494
+ '094a786b7ad391852168d3a7bcf75736777697d24a856a0089837f4b7de921df';
495
+
496
+ // check Redis keys
497
+ const keys = await redisClient.keys(
498
+ process.env.VERCEL_URL + cacheEntryKey,
499
+ );
500
+ expect(keys.length).toBe(1);
501
+
502
+ const hashmap = await redisClient.hGet(
503
+ process.env.VERCEL_URL + '__sharedTags__',
504
+ cacheEntryKey,
505
+ );
506
+ expect(JSON.parse(hashmap)).toEqual([
507
+ 'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
508
+ ]);
509
+ });
510
+ }
511
+ });
512
+ });
513
+
514
+ // describe('With a API route that has a unstable_cacheTag', () => {
515
+ // // TODO: implement API route for this test as well as the test itself
516
+ // });
517
+
518
+ // describe('With a API route that has a unstable_cacheLife', () => {
519
+ // // TODO: implement API route for this test as well as the test itself
520
+ // });
521
+ });
522
+
523
+ describe('should have the correct caching behavior for pages', () => {
524
+ describe('Without any fetch requests inside the page', () => {
525
+ describe('With default page configuration for revalidate and dynamic values', () => {
526
+ let timestamp1: string | undefined;
527
+
528
+ it('Two parallel requests should return the same timestamp (because requests are deduplicated)', async () => {
529
+ // First request (should increment counter)
530
+ const [pageRes1, pageRes2] = await Promise.all([
531
+ fetch(NEXT_START_URL + '/pages/no-fetch/default-page'),
532
+ fetch(NEXT_START_URL + '/pages/no-fetch/default-page'),
533
+ ]);
534
+
535
+ const pageText1 = await pageRes1.text();
536
+ timestamp1 = pageText1.match(/Timestamp: <!-- -->(\d+)/)?.[1];
537
+ expect(timestamp1).toBeDefined();
538
+
539
+ const pageText2 = await pageRes2.text();
540
+ const timestamp2 = pageText2.match(/Timestamp: <!-- -->(\d+)/)?.[1];
541
+ expect(timestamp2).toBeDefined();
542
+ expect(timestamp1).toBe(timestamp2);
543
+ });
544
+
545
+ it('Redis should have a key for the page which should have a TTL set to 28 days (2 * 14 days default revalidate time)', async () => {
546
+ // check Redis keys
547
+ const ttl = await redisClient.ttl(
548
+ process.env.VERCEL_URL + '/pages/no-fetch/default-page',
549
+ );
550
+ // 14 days is default revalidate for pages -> expiration time is 2 * revalidate time -> -10 seconds for testing offset stability
551
+ expect(ttl).toBeGreaterThan(2 * 14 * 24 * 60 * 60 - 30);
552
+ });
553
+
554
+ it('The data in the redis key should match the expected format', async () => {
555
+ const data = await redisClient.get(
556
+ process.env.VERCEL_URL + '/pages/no-fetch/default-page',
557
+ );
558
+ expect(data).toBeDefined();
559
+ const cacheEntry: CacheEntry = JSON.parse(data);
560
+
561
+ // The format should be as expected
562
+ expect(cacheEntry).toEqual({
563
+ value: {
564
+ kind: 'APP_PAGE',
565
+ html: expect.any(String),
566
+ rscData: {
567
+ $binary: expect.any(String),
568
+ },
569
+ headers: {
570
+ 'x-nextjs-stale-time': expect.any(String),
571
+ 'x-next-cache-tags':
572
+ '_N_T_/layout,_N_T_/pages/layout,_N_T_/pages/no-fetch/layout,_N_T_/pages/no-fetch/default-page/layout,_N_T_/pages/no-fetch/default-page/page,_N_T_/pages/no-fetch/default-page',
573
+ },
574
+ status: 200,
575
+ },
576
+ lastModified: expect.any(Number),
577
+ tags: [
578
+ '_N_T_/layout',
579
+ '_N_T_/pages/layout',
580
+ '_N_T_/pages/no-fetch/layout',
581
+ '_N_T_/pages/no-fetch/default-page/layout',
582
+ '_N_T_/pages/no-fetch/default-page/page',
583
+ '_N_T_/pages/no-fetch/default-page',
584
+ ],
585
+ });
586
+ });
587
+
588
+ if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
589
+ it('A new request after 3 seconds should return the same timestamp (because the page was cached in in-memory cache)', async () => {
590
+ await delay(3_000);
591
+ const pageRes3 = await fetch(
592
+ NEXT_START_URL + '/pages/no-fetch/default-page',
593
+ );
594
+ const pageText3 = await pageRes3.text();
595
+ const timestamp3 = pageText3.match(/Timestamp: <!-- -->(\d+)/)?.[1];
596
+ expect(timestamp3).toBeDefined();
597
+ expect(timestamp1).toBe(timestamp3);
598
+ });
599
+
600
+ it('A new request after 11 seconds should return the same timestamp (because the page was cached in redis cache)', async () => {
601
+ await delay(11_000);
602
+ const pageRes4 = await fetch(
603
+ NEXT_START_URL + '/pages/no-fetch/default-page',
604
+ );
605
+ const pageText4 = await pageRes4.text();
606
+ const timestamp4 = pageText4.match(/Timestamp: <!-- -->(\d+)/)?.[1];
607
+ expect(timestamp4).toBeDefined();
608
+ expect(timestamp1).toBe(timestamp4);
609
+ }, 15_000);
610
+ }
611
+
612
+ it('A request to revalidatePage API should remove the page from redis (string and hashmap)', async () => {
613
+ const revalidateRes = await fetch(
614
+ NEXT_START_URL +
615
+ '/api/revalidatePath?path=/pages/no-fetch/default-page',
616
+ );
617
+ const revalidateResJson: any = await revalidateRes.json();
618
+ expect(revalidateResJson.success).toBe(true);
619
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
620
+
621
+ // check Redis keys
622
+ const keys = await redisClient.keys(
623
+ process.env.VERCEL_URL + '/pages/no-fetch/default-page',
624
+ );
625
+ expect(keys.length).toBe(0);
626
+
627
+ const hashmap = await redisClient.hGet(
628
+ process.env.VERCEL_URL + '__sharedTags__',
629
+ '/pages/no-fetch/default-page',
630
+ );
631
+ expect(hashmap).toBeNull();
632
+ });
633
+
634
+ it('A new request after the revalidation should return a new timestamp (because the page was recreated)', async () => {
635
+ const pageRes4 = await fetch(
636
+ NEXT_START_URL + '/pages/no-fetch/default-page',
637
+ );
638
+ const pageText4 = await pageRes4.text();
639
+ const timestamp4 = pageText4.match(/Timestamp: <!-- -->(\d+)/)?.[1];
640
+ expect(timestamp4).toBeDefined();
641
+ expect(Number(timestamp4)).toBeGreaterThan(Number(timestamp1));
642
+ });
643
+
644
+ it('After the new request was made the redis key and hashmap should be set again', async () => {
645
+ // check Redis keys
646
+ const keys = await redisClient.keys(
647
+ process.env.VERCEL_URL + '/pages/no-fetch/default-page',
648
+ );
649
+ expect(keys.length).toBe(1);
650
+
651
+ const hashmap = await redisClient.hGet(
652
+ process.env.VERCEL_URL + '__sharedTags__',
653
+ '/pages/no-fetch/default-page',
654
+ );
655
+ expect(JSON.parse(hashmap)).toEqual([
656
+ '_N_T_/layout',
657
+ '_N_T_/pages/layout',
658
+ '_N_T_/pages/no-fetch/layout',
659
+ '_N_T_/pages/no-fetch/default-page/layout',
660
+ '_N_T_/pages/no-fetch/default-page/page',
661
+ '_N_T_/pages/no-fetch/default-page',
662
+ ]);
663
+ });
664
+ });
665
+ });
666
+
667
+ // describe('With a cached static fetch request inside a page', () => {
668
+ // // TODO: implement test for `test/integration/next-app/src/app/pages/cached-static-fetch`
669
+ // });
670
+
671
+ describe('With a cached revalidation fetch request inside a page', () => {
672
+ let firstTimestamp: string;
673
+ let firstCounter: string;
674
+
675
+ it('should set all cache entries for this page after request is finished', async () => {
676
+ const pageRes = await fetch(
677
+ NEXT_START_URL +
678
+ '/pages/revalidated-fetch/revalidate15--default-page',
679
+ );
680
+ const pageText = await pageRes.text();
681
+ const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
682
+ const counter = pageText.match(/Counter: <!-- -->(\d+)/)?.[1];
683
+ expect(timestamp).toBeDefined();
684
+ expect(counter).toBeDefined();
685
+ firstTimestamp = timestamp!;
686
+ firstCounter = counter!;
687
+
688
+ // test cache entry for 3 keys are set
689
+ const keys1 = await redisClient.keys(
690
+ process.env.VERCEL_URL +
691
+ '/pages/revalidated-fetch/revalidate15--default-page',
692
+ );
693
+ expect(keys1.length).toBe(1);
694
+ const keys2 = await redisClient.keys(
695
+ process.env.VERCEL_URL +
696
+ 'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
697
+ );
698
+ expect(keys2.length).toBe(1);
699
+ const keys3 = await redisClient.keys(
700
+ process.env.VERCEL_URL + '/api/revalidated-fetch',
701
+ );
702
+ expect(keys3.length).toBe(1);
703
+
704
+ // test shared tag hashmap to be set for all keys
705
+ const hashmap1 = await redisClient.hGet(
706
+ process.env.VERCEL_URL + '__sharedTags__',
707
+ '/pages/revalidated-fetch/revalidate15--default-page',
708
+ );
709
+ expect(JSON.parse(hashmap1)).toEqual([
710
+ '_N_T_/layout',
711
+ '_N_T_/pages/layout',
712
+ '_N_T_/pages/revalidated-fetch/layout',
713
+ '_N_T_/pages/revalidated-fetch/revalidate15--default-page/layout',
714
+ '_N_T_/pages/revalidated-fetch/revalidate15--default-page/page',
715
+ '_N_T_/pages/revalidated-fetch/revalidate15--default-page',
716
+ 'revalidated-fetch-revalidate15-default-page',
717
+ ]);
718
+ const hashmap2 = await redisClient.hGet(
719
+ process.env.VERCEL_URL + '__sharedTags__',
720
+ 'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
721
+ );
722
+ expect(JSON.parse(hashmap2)).toEqual([
723
+ 'revalidated-fetch-revalidate15-default-page',
724
+ ]);
725
+ const hashmap3 = await redisClient.hGet(
726
+ process.env.VERCEL_URL + '__sharedTags__',
727
+ '/api/revalidated-fetch',
728
+ );
729
+ expect(JSON.parse(hashmap3)).toEqual([
730
+ '_N_T_/layout',
731
+ '_N_T_/api/layout',
732
+ '_N_T_/api/revalidated-fetch/layout',
733
+ '_N_T_/api/revalidated-fetch/route',
734
+ '_N_T_/api/revalidated-fetch',
735
+ ]);
736
+ });
737
+
738
+ it('a new request should return the same timestamp as the first request', async () => {
739
+ const pageRes = await fetch(
740
+ NEXT_START_URL +
741
+ '/pages/revalidated-fetch/revalidate15--default-page',
742
+ );
743
+ const pageText = await pageRes.text();
744
+ const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
745
+ expect(timestamp).toBeDefined();
746
+ expect(timestamp).toBe(firstTimestamp);
747
+ });
748
+
749
+ it('A request to revalidatePath API should remove the page from redis (string and hashmap) but not the api route', async () => {
750
+ const revalidateRes = await fetch(
751
+ NEXT_START_URL +
752
+ '/api/revalidatePath?path=/pages/revalidated-fetch/revalidate15--default-page',
753
+ );
754
+ const revalidateResJson: any = await revalidateRes.json();
755
+ expect(revalidateResJson.success).toBe(true);
756
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
757
+
758
+ // test no cache entry for 2 keys
759
+ const keys1 = await redisClient.keys(
760
+ process.env.VERCEL_URL +
761
+ '/pages/revalidated-fetch/revalidate15--default-page',
762
+ );
763
+ expect(keys1.length).toBe(0);
764
+
765
+ const hashmap1 = await redisClient.hGet(
766
+ process.env.VERCEL_URL + '__sharedTags__',
767
+ '/pages/revalidated-fetch/revalidate15--default-page',
768
+ );
769
+ expect(hashmap1).toBeNull();
770
+
771
+ // sub-fetch-request is not removed directly but will be removed on next get request
772
+ const keys2 = await redisClient.keys(
773
+ process.env.VERCEL_URL +
774
+ 'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
775
+ );
776
+ expect(keys2.length).toBe(1);
777
+ const hashmap2 = await redisClient.hGet(
778
+ process.env.VERCEL_URL + '__sharedTags__',
779
+ 'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
780
+ );
781
+ expect(hashmap2).toBeDefined();
782
+
783
+ // the page should also be in revalidatedTagsMap so that the nested fetch requests knows that the page was invalidated
784
+ const revalidationTimestamp = await redisClient.hGet(
785
+ process.env.VERCEL_URL + '__revalidated_tags__',
786
+ '/pages/revalidated-fetch/revalidate15--default-page',
787
+ );
788
+ console.log('revalidationTimestamp', revalidationTimestamp);
789
+ expect(revalidationTimestamp).toBeDefined();
790
+ expect(Number(revalidationTimestamp)).toBeLessThan(Number(Date.now()));
791
+
792
+ // API route should still be cached
793
+ const keys3 = await redisClient.keys(
794
+ process.env.VERCEL_URL + '/api/revalidated-fetch',
795
+ );
796
+ expect(keys3.length).toBe(1);
797
+ const hashmap3 = await redisClient.hGet(
798
+ process.env.VERCEL_URL + '__sharedTags__',
799
+ '/api/revalidated-fetch',
800
+ );
801
+ expect(JSON.parse(hashmap3)).toEqual([
802
+ '_N_T_/layout',
803
+ '_N_T_/api/layout',
804
+ '_N_T_/api/revalidated-fetch/layout',
805
+ '_N_T_/api/revalidated-fetch/route',
806
+ '_N_T_/api/revalidated-fetch',
807
+ ]);
808
+ });
809
+
810
+ it('a new request should return a newer timestamp as the first request (which was invalidated by revalidatePath)', async () => {
811
+ const pageRes = await fetch(
812
+ NEXT_START_URL +
813
+ '/pages/revalidated-fetch/revalidate15--default-page',
814
+ );
815
+ const pageText = await pageRes.text();
816
+ const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
817
+ const secondCounter = pageText.match(/Counter: <!-- -->(\d+)/)?.[1];
818
+ expect(timestamp).toBeDefined();
819
+ expect(Number(timestamp)).toBeGreaterThan(Number(firstTimestamp));
820
+
821
+ //but the new request should not have a higher counter than the first request (because the cache of the API route should not be invalidated)
822
+ expect(secondCounter).toBeDefined();
823
+ expect(secondCounter).toBe(firstCounter);
824
+ });
825
+ });
826
+
827
+ // describe('With a uncached fetch request inside a page', () => {
828
+ // // TODO: implement test for `test/integration/next-app/src/app/pages/uncached-fetch`
829
+ //
830
+ // });
831
+
832
+ // describe('With a page that has a unstable_cacheTag', () => {
833
+ // // TODO: implement page for this test as well as the test itself
834
+ // });
835
+
836
+ // describe('With a page that has a unstable_cacheLife', () => {
837
+ // // TODO: implement page for this test as well as the test itself
838
+ // });
839
+ });
840
+ });