@trieb.work/nextjs-turbo-redis-cache 1.15.1 → 1.16.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.
@@ -0,0 +1,24 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+
3
+ // On-demand revalidation endpoint: res.revalidate(path) re-renders the page
4
+ // and writes the fresh entry through the cache handler, so all instances
5
+ // sharing the same Redis pick it up.
6
+ export default async function handler(
7
+ req: NextApiRequest,
8
+ res: NextApiResponse,
9
+ ) {
10
+ const path = req.query.path;
11
+ if (typeof path !== 'string') {
12
+ return res
13
+ .status(400)
14
+ .json({ revalidated: false, error: 'Missing ?path= query parameter' });
15
+ }
16
+ try {
17
+ await res.revalidate(path);
18
+ return res.json({ revalidated: true, path });
19
+ } catch (err) {
20
+ return res
21
+ .status(500)
22
+ .json({ revalidated: false, path, error: String(err) });
23
+ }
24
+ }
@@ -0,0 +1,11 @@
1
+ export default function Home() {
2
+ return (
3
+ <main>
4
+ <h1>Pages Router test app</h1>
5
+ <p>
6
+ Fixture for testing the Redis cache handler with the Next.js Pages
7
+ Router (PAGES, REDIRECT and notFound cache entries).
8
+ </p>
9
+ </main>
10
+ );
11
+ }
@@ -0,0 +1,49 @@
1
+ import type { GetStaticPaths, GetStaticProps } from 'next';
2
+
3
+ type Props = { slug: string; timestamp: number; counter: number };
4
+
5
+ // Per-process regeneration counter. Two server instances of the same build
6
+ // have independent counters, while the timestamp proves cross-instance
7
+ // freshness after on-demand revalidation.
8
+ let regenerationCounter = 0;
9
+
10
+ export default function IsrPage({ slug, timestamp, counter }: Props) {
11
+ return (
12
+ <main>
13
+ <h1>ISR page</h1>
14
+ <p>Slug: {slug}</p>
15
+ <p>Timestamp: {timestamp}</p>
16
+ <p>Counter: {counter}</p>
17
+ </main>
18
+ );
19
+ }
20
+
21
+ export const getStaticPaths: GetStaticPaths = async () => {
22
+ return {
23
+ paths: [{ params: { slug: 'prebuilt' } }],
24
+ fallback: 'blocking',
25
+ };
26
+ };
27
+
28
+ // revalidate is set high (300s) so that natural ISR regeneration does not
29
+ // interfere with the on-demand revalidation (res.revalidate) tests.
30
+ export const getStaticProps: GetStaticProps<Props> = async (context) => {
31
+ const slug = context.params?.slug as string;
32
+
33
+ if (slug === 'not-found') {
34
+ return { notFound: true, revalidate: 300 };
35
+ }
36
+
37
+ if (slug === 'redirect') {
38
+ return {
39
+ redirect: { destination: '/static-forever', permanent: false },
40
+ revalidate: 300,
41
+ };
42
+ }
43
+
44
+ regenerationCounter += 1;
45
+ return {
46
+ props: { slug, timestamp: Date.now(), counter: regenerationCounter },
47
+ revalidate: 300,
48
+ };
49
+ };
@@ -0,0 +1,20 @@
1
+ import type { GetStaticProps } from 'next';
2
+
3
+ type Props = { timestamp: number };
4
+
5
+ export default function StaticForever({ timestamp }: Props) {
6
+ return (
7
+ <main>
8
+ <h1>Static forever</h1>
9
+ <p>Timestamp: {timestamp}</p>
10
+ </main>
11
+ );
12
+ }
13
+
14
+ // revalidate is intentionally omitted (revalidate: false): the cache entry
15
+ // must fall through to the defaultStaleAge based TTL in the cache handler.
16
+ export const getStaticProps: GetStaticProps<Props> = async () => {
17
+ return {
18
+ props: { timestamp: Date.now() },
19
+ };
20
+ };
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }
@@ -0,0 +1,420 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
3
+ import fetch from 'node-fetch';
4
+ import { createClient, RedisClientType } from 'redis';
5
+ import { join } from 'path';
6
+ import { readFileSync } from 'fs';
7
+ import { CacheEntry } from '../../../src/RedisStringsHandler';
8
+
9
+ // Select which Pages Router test app to use. Can be overridden via NEXT_PAGES_TEST_APP env var
10
+ const NEXT_PAGES_TEST_APP =
11
+ process.env.NEXT_PAGES_TEST_APP || 'next-pages-16-2-6';
12
+ const NEXT_APP_DIR = join(
13
+ __dirname,
14
+ '..',
15
+ '..',
16
+ 'nextjs-test-projects',
17
+ NEXT_PAGES_TEST_APP,
18
+ );
19
+ console.log('NEXT_APP_DIR', NEXT_APP_DIR);
20
+
21
+ // Two instances of the same build sharing one Redis. This mirrors a
22
+ // load-balanced multi-instance deployment and is used to prove that
23
+ // on-demand revalidation (res.revalidate) on one instance is picked up
24
+ // by the other instance.
25
+ const INSTANCE_A_PORT = 3061;
26
+ const INSTANCE_B_PORT = 3062;
27
+ const INSTANCE_A_URL = `http://localhost:${INSTANCE_A_PORT}`;
28
+ const INSTANCE_B_URL = `http://localhost:${INSTANCE_B_PORT}`;
29
+
30
+ const REDIS_BACKGROUND_SYNC_DELAY = 250; //ms delay to prevent flaky tests in slow CI environments
31
+
32
+ // Default inMemoryCachingTime of the handler is 10s. After a get(), an
33
+ // instance may serve the entry from its in-memory deduplication cache for up
34
+ // to this long, so cross-instance freshness tests wait it out first.
35
+ const IN_MEMORY_CACHE_EXPIRY_DELAY = 11_000;
36
+
37
+ // revalidate value of /isr/[slug] in the test app
38
+ const ISR_REVALIDATE_SECONDS = 300;
39
+ // default stale age of the cache handler (14 days)
40
+ const DEFAULT_STALE_AGE = 60 * 60 * 24 * 14;
41
+
42
+ let instanceA: ChildProcessWithoutNullStreams;
43
+ let instanceB: ChildProcessWithoutNullStreams;
44
+ let redisClient: RedisClientType;
45
+ let buildId: string;
46
+
47
+ async function delay(ms: number) {
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ async function runCommand(cmd: string, args: string[], cwd: string) {
52
+ return new Promise((resolve, reject) => {
53
+ let stderr = '';
54
+ let stdout = '';
55
+ const proc = spawn(cmd, args, { cwd, stdio: 'pipe' });
56
+
57
+ proc.stdout.on('data', (data) => {
58
+ if (process.env.DEBUG_INTEGRATION) {
59
+ console.log(data.toString());
60
+ }
61
+ stdout += data.toString();
62
+ });
63
+
64
+ proc.stderr.on('data', (data) => {
65
+ if (process.env.DEBUG_INTEGRATION) {
66
+ console.error(data.toString());
67
+ }
68
+ stderr += data.toString();
69
+ });
70
+
71
+ proc.on('exit', (code) => {
72
+ if (code === 0) resolve(undefined);
73
+ else {
74
+ reject(
75
+ new Error(
76
+ `${cmd} ${args.join(' ')} failed with code ${code}\n` +
77
+ `stdout: ${stdout}\n` +
78
+ `stderr: ${stderr}`,
79
+ ),
80
+ );
81
+ }
82
+ });
83
+ });
84
+ }
85
+
86
+ function startInstance(port: number): ChildProcessWithoutNullStreams {
87
+ const proc = spawn('npx', ['next', 'start', '-p', String(port)], {
88
+ cwd: NEXT_APP_DIR,
89
+ env: {
90
+ ...process.env,
91
+ },
92
+ stdio: 'pipe',
93
+ });
94
+ if (process.env.DEBUG_INTEGRATION) {
95
+ proc.stdout.on('data', (data) => {
96
+ console.log(`stdout(:${port}): ${data}`);
97
+ });
98
+ }
99
+ proc.stderr.on('data', (data) => {
100
+ console.error(`stderr(:${port}): ${data}`);
101
+ });
102
+ return proc;
103
+ }
104
+
105
+ async function waitForServer(url: string, timeout = 20000) {
106
+ const start = Date.now();
107
+ while (Date.now() - start < timeout) {
108
+ try {
109
+ const res = await fetch(url);
110
+ if (res.ok) return;
111
+ } catch {}
112
+ await new Promise((r) => setTimeout(r, 300));
113
+ }
114
+ throw new Error(`Next.js server at ${url} did not start in time`);
115
+ }
116
+
117
+ function extractTimestamp(html: string): number {
118
+ const match = html.match(/Timestamp: (?:<!-- -->)?(\d+)/)?.[1];
119
+ expect(match).toBeDefined();
120
+ return Number(match);
121
+ }
122
+
123
+ function extractCounter(html: string): number {
124
+ const match = html.match(/Counter: (?:<!-- -->)?(\d+)/)?.[1];
125
+ expect(match).toBeDefined();
126
+ return Number(match);
127
+ }
128
+
129
+ describe('Pages Router Redis cache integration (two instances)', () => {
130
+ beforeAll(async () => {
131
+ // If old servers from a previous run are still around, kill them
132
+ for (const port of [INSTANCE_A_PORT, INSTANCE_B_PORT]) {
133
+ try {
134
+ const res = await fetch(`http://localhost:${port}`);
135
+ if (res.ok) {
136
+ await runCommand('pkill', ['-f', `next start -p ${port}`], '.');
137
+ }
138
+ } catch {}
139
+ }
140
+
141
+ // Set up environment variables
142
+ process.env.VERCEL_ENV = 'production';
143
+ process.env.VERCEL_URL =
144
+ 'pages-integration-test-' + Math.random().toString(36).substring(2, 15);
145
+ console.log('redis key prefix is:', process.env.VERCEL_URL);
146
+
147
+ // Only override redis env vars if not set. This can be set in the CI env.
148
+ process.env.REDISHOST = process.env.REDISHOST || 'localhost';
149
+ process.env.REDISPORT = process.env.REDISPORT || '6379';
150
+
151
+ if (process.env.SKIP_BUILD === 'true') {
152
+ console.log('skipping build');
153
+ } else {
154
+ await runCommand('pnpm', ['i'], NEXT_APP_DIR);
155
+ console.log('pnpm i done');
156
+ await runCommand('pnpm', ['build'], NEXT_APP_DIR);
157
+ console.log('pnpm build done');
158
+ }
159
+
160
+ buildId = readFileSync(join(NEXT_APP_DIR, '.next', 'BUILD_ID'), 'utf-8')
161
+ .toString()
162
+ .trim();
163
+
164
+ // Start two Next.js instances of the same build sharing one Redis
165
+ instanceA = startInstance(INSTANCE_A_PORT);
166
+ instanceB = startInstance(INSTANCE_B_PORT);
167
+ await Promise.all([
168
+ waitForServer(INSTANCE_A_URL),
169
+ waitForServer(INSTANCE_B_URL),
170
+ ]);
171
+ console.log('both next instances started');
172
+
173
+ // Connect to Redis
174
+ redisClient = createClient({
175
+ url: `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`,
176
+ });
177
+ await redisClient.connect();
178
+ }, 240_000);
179
+
180
+ afterAll(async () => {
181
+ if (process.env.KEEP_SERVER_RUNNING === 'true') {
182
+ console.log('keeping servers running');
183
+ } else {
184
+ if (instanceA) instanceA.kill();
185
+ if (instanceB) instanceB.kill();
186
+ }
187
+ if (redisClient) await redisClient.quit();
188
+ });
189
+
190
+ describe('PAGES entries (getStaticProps + revalidate)', () => {
191
+ let firstTimestamp: number;
192
+ let firstCounter: number;
193
+
194
+ it('first request on instance A renders the page and stores a PAGES entry in Redis', async () => {
195
+ const res = await fetch(INSTANCE_A_URL + '/isr/prebuilt');
196
+ expect(res.status).toBe(200);
197
+ const html = await res.text();
198
+ firstTimestamp = extractTimestamp(html);
199
+ firstCounter = extractCounter(html);
200
+
201
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
202
+
203
+ const value = (await redisClient.get(
204
+ process.env.VERCEL_URL + '/isr/prebuilt',
205
+ )) as string;
206
+ expect(value).toBeDefined();
207
+ const cacheEntry: CacheEntry = JSON.parse(value);
208
+ expect(cacheEntry).toMatchObject({
209
+ value: {
210
+ kind: 'PAGES',
211
+ html: expect.any(String),
212
+ pageData: {
213
+ pageProps: expect.objectContaining({
214
+ slug: 'prebuilt',
215
+ timestamp: firstTimestamp,
216
+ }),
217
+ },
218
+ },
219
+ lastModified: expect.any(Number),
220
+ tags: ['_N_T_/isr/prebuilt'],
221
+ });
222
+
223
+ // The implicit path tag is registered in the shared tags hashmap so
224
+ // that revalidatePath()/revalidateTag() can invalidate the page
225
+ const hashmap = (await redisClient.hGet(
226
+ process.env.VERCEL_URL + '__sharedTags__',
227
+ '/isr/prebuilt',
228
+ )) as string;
229
+ expect(JSON.parse(hashmap)).toEqual(['_N_T_/isr/prebuilt']);
230
+ });
231
+
232
+ it('the TTL is derived from the getStaticProps revalidate value', async () => {
233
+ const ttl = await redisClient.ttl(
234
+ process.env.VERCEL_URL + '/isr/prebuilt',
235
+ );
236
+ // VERCEL_ENV=production -> expire age is 2 * revalidate
237
+ expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
238
+ expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
239
+ });
240
+
241
+ it('instance B serves the identical cached HTML from the shared Redis', async () => {
242
+ const res = await fetch(INSTANCE_B_URL + '/isr/prebuilt');
243
+ expect(res.status).toBe(200);
244
+ const html = await res.text();
245
+ expect(extractTimestamp(html)).toBe(firstTimestamp);
246
+ expect(extractCounter(html)).toBe(firstCounter);
247
+ });
248
+
249
+ it('the pageData JSON (client navigation) is served from the same entry', async () => {
250
+ const res = await fetch(
251
+ `${INSTANCE_B_URL}/_next/data/${buildId}/isr/prebuilt.json`,
252
+ );
253
+ expect(res.status).toBe(200);
254
+ const data: any = await res.json();
255
+ expect(data.pageProps.slug).toBe('prebuilt');
256
+ expect(data.pageProps.timestamp).toBe(firstTimestamp);
257
+ });
258
+
259
+ describe('two-instance on-demand revalidation (res.revalidate)', () => {
260
+ it('res.revalidate on instance A updates Redis and instance B serves the fresh HTML and pageData', async () => {
261
+ // Wait until instance B's in-memory deduplication cache entry for
262
+ // the page has expired, so its next get() hits Redis again
263
+ await delay(IN_MEMORY_CACHE_EXPIRY_DELAY);
264
+
265
+ const revalidateRes = await fetch(
266
+ INSTANCE_A_URL + '/api/revalidate?path=/isr/prebuilt',
267
+ );
268
+ const revalidateJson: any = await revalidateRes.json();
269
+ expect(revalidateJson).toEqual({
270
+ revalidated: true,
271
+ path: '/isr/prebuilt',
272
+ });
273
+
274
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
275
+
276
+ // Instance B must serve the fresh HTML rendered by instance A
277
+ const resB = await fetch(INSTANCE_B_URL + '/isr/prebuilt');
278
+ expect(resB.status).toBe(200);
279
+ const htmlB = await resB.text();
280
+ const timestampB = extractTimestamp(htmlB);
281
+ const counterB = extractCounter(htmlB);
282
+ expect(timestampB).toBeGreaterThan(firstTimestamp);
283
+
284
+ // Instance A serves the same regenerated page - both instances
285
+ // return the exact same render, proving it came through Redis and
286
+ // not from an independent re-render per instance
287
+ const resA = await fetch(INSTANCE_A_URL + '/isr/prebuilt');
288
+ const htmlA = await resA.text();
289
+ expect(extractTimestamp(htmlA)).toBe(timestampB);
290
+ expect(extractCounter(htmlA)).toBe(counterB);
291
+
292
+ // The updated pageData JSON for client-side navigation is also
293
+ // served fresh on instance B
294
+ const dataRes = await fetch(
295
+ `${INSTANCE_B_URL}/_next/data/${buildId}/isr/prebuilt.json`,
296
+ );
297
+ expect(dataRes.status).toBe(200);
298
+ const data: any = await dataRes.json();
299
+ expect(data.pageProps.timestamp).toBe(timestampB);
300
+ }, 30_000);
301
+ });
302
+ });
303
+
304
+ describe('fallback: "blocking" (page not prerendered at build time)', () => {
305
+ it('first hit renders the page (blocking) and stores it in Redis', async () => {
306
+ const res = await fetch(INSTANCE_A_URL + '/isr/fallback-test');
307
+ expect(res.status).toBe(200);
308
+ const html = await res.text();
309
+ const timestamp = extractTimestamp(html);
310
+
311
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
312
+
313
+ const value = (await redisClient.get(
314
+ process.env.VERCEL_URL + '/isr/fallback-test',
315
+ )) as string;
316
+ expect(value).toBeDefined();
317
+ const cacheEntry: CacheEntry = JSON.parse(value);
318
+ expect((cacheEntry.value as { kind: string }).kind).toBe('PAGES');
319
+
320
+ // The other instance serves the same render from Redis
321
+ const resB = await fetch(INSTANCE_B_URL + '/isr/fallback-test');
322
+ expect(resB.status).toBe(200);
323
+ expect(extractTimestamp(await resB.text())).toBe(timestamp);
324
+ });
325
+ });
326
+
327
+ describe('notFound: true (null cache entry)', () => {
328
+ it('renders a 404 and stores a null-value entry in Redis', async () => {
329
+ const res = await fetch(INSTANCE_A_URL + '/isr/not-found');
330
+ expect(res.status).toBe(404);
331
+
332
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
333
+
334
+ const value = (await redisClient.get(
335
+ process.env.VERCEL_URL + '/isr/not-found',
336
+ )) as string;
337
+ expect(value).toBeDefined();
338
+ const cacheEntry: CacheEntry = JSON.parse(value);
339
+ expect(cacheEntry).toEqual({
340
+ value: null,
341
+ lastModified: expect.any(Number),
342
+ tags: ['_N_T_/isr/not-found'],
343
+ });
344
+
345
+ // TTL is derived from the revalidate value returned with notFound
346
+ const ttl = await redisClient.ttl(
347
+ process.env.VERCEL_URL + '/isr/not-found',
348
+ );
349
+ expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
350
+ expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
351
+ });
352
+
353
+ it('instance B serves the 404 from the shared cache', async () => {
354
+ const res = await fetch(INSTANCE_B_URL + '/isr/not-found');
355
+ expect(res.status).toBe(404);
356
+ });
357
+ });
358
+
359
+ describe('redirect (getStaticProps redirect return)', () => {
360
+ it('responds with a redirect and stores a REDIRECT entry in Redis', async () => {
361
+ const res = await fetch(INSTANCE_A_URL + '/isr/redirect', {
362
+ redirect: 'manual',
363
+ });
364
+ expect(res.status).toBe(307);
365
+ expect(res.headers.get('location')).toContain('/static-forever');
366
+
367
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
368
+
369
+ const value = (await redisClient.get(
370
+ process.env.VERCEL_URL + '/isr/redirect',
371
+ )) as string;
372
+ expect(value).toBeDefined();
373
+ const cacheEntry: CacheEntry = JSON.parse(value);
374
+ expect(cacheEntry).toMatchObject({
375
+ value: {
376
+ kind: 'REDIRECT',
377
+ props: expect.objectContaining({
378
+ pageProps: expect.objectContaining({
379
+ __N_REDIRECT: '/static-forever',
380
+ __N_REDIRECT_STATUS: 307,
381
+ }),
382
+ }),
383
+ },
384
+ lastModified: expect.any(Number),
385
+ tags: ['_N_T_/isr/redirect'],
386
+ });
387
+ });
388
+
389
+ it('instance B serves the redirect from the shared cache', async () => {
390
+ const res = await fetch(INSTANCE_B_URL + '/isr/redirect', {
391
+ redirect: 'manual',
392
+ });
393
+ expect(res.status).toBe(307);
394
+ expect(res.headers.get('location')).toContain('/static-forever');
395
+ });
396
+ });
397
+
398
+ describe('revalidate: false (fully static page)', () => {
399
+ it('falls back to the defaultStaleAge based TTL', async () => {
400
+ const res = await fetch(INSTANCE_A_URL + '/static-forever');
401
+ expect(res.status).toBe(200);
402
+
403
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
404
+
405
+ const value = (await redisClient.get(
406
+ process.env.VERCEL_URL + '/static-forever',
407
+ )) as string;
408
+ expect(value).toBeDefined();
409
+ const cacheEntry: CacheEntry = JSON.parse(value);
410
+ expect((cacheEntry.value as { kind: string }).kind).toBe('PAGES');
411
+
412
+ const ttl = await redisClient.ttl(
413
+ process.env.VERCEL_URL + '/static-forever',
414
+ );
415
+ // VERCEL_ENV=production -> expire age is 2 * defaultStaleAge (14 days)
416
+ expect(ttl).toBeGreaterThan(2 * DEFAULT_STALE_AGE - 30);
417
+ expect(ttl).toBeLessThanOrEqual(2 * DEFAULT_STALE_AGE);
418
+ });
419
+ });
420
+ });