@trieb.work/nextjs-turbo-redis-cache 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.github/workflows/release.yml +8 -157
  2. package/ARCHITECTURE.md +86 -47
  3. package/README.md +87 -24
  4. package/dist/index.d.mts +40 -1
  5. package/dist/index.d.ts +40 -1
  6. package/dist/index.js +264 -96
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +261 -95
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -6
  11. package/release.config.cjs +6 -25
  12. package/src/CacheComponentsHandler.ts +141 -111
  13. package/src/RedisStringsHandler.ts +37 -25
  14. package/src/SyncedMap.ts +16 -2
  15. package/src/index.ts +7 -0
  16. package/src/utils/cacheTtl.ts +71 -0
  17. package/src/utils/compareAndUnlink.ts +29 -0
  18. package/src/utils/redisConnection.ts +17 -0
  19. package/src/utils/tagRevalidation.ts +162 -0
  20. package/test/README.md +10 -10
  21. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
  22. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  23. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  24. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
  25. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  26. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  27. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
  28. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  29. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  30. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
  31. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  32. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  33. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +2 -0
  34. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  35. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  36. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +22 -0
  37. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  38. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  39. package/test/playwright/cache-lab.spec.ts +75 -0
  40. package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
  41. package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +16 -2
  42. package/test/vitest/integration/pages-router.integration.test.ts +16 -6
  43. package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
  44. package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
  45. package/test/vitest/unit/cache-ttl.test.ts +67 -0
  46. package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
  47. package/test/vitest/unit/index.test.ts +17 -0
  48. package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
  49. package/test/vitest/unit/redis-connection.test.ts +39 -0
  50. package/test/vitest/unit/tag-revalidation.test.ts +178 -0
  51. package/CHANGELOG.md +0 -411
@@ -15,6 +15,21 @@ describe('Next.js 16 Cache Components Integration', () => {
15
15
  return new Promise((resolve) => setTimeout(resolve, ms));
16
16
  }
17
17
 
18
+ async function waitForRedisKeys(
19
+ pattern: string,
20
+ minCount = 1,
21
+ timeoutMs = 10_000,
22
+ ) {
23
+ for (let elapsed = 0; elapsed < timeoutMs; elapsed += 100) {
24
+ const keys = await redisClient.keys(pattern);
25
+ if (keys.length >= minCount) {
26
+ return keys;
27
+ }
28
+ await delay(100);
29
+ }
30
+ return redisClient.keys(pattern);
31
+ }
32
+
18
33
  beforeAll(async () => {
19
34
  // Connect to Redis
20
35
  redisClient = createClient({
@@ -106,12 +121,13 @@ describe('Next.js 16 Cache Components Integration', () => {
106
121
  });
107
122
 
108
123
  it('should store cache entry in Redis', async () => {
109
- await fetch(`${BASE_URL}/api/cached-static-fetch`);
124
+ // expire-matrix is dynamic (`ƒ`); static prerendered `use cache` routes may
125
+ // not write through to Redis at runtime on some Next.js versions.
126
+ await fetch(`${BASE_URL}/api/expire-matrix?id=redis-smoke`);
110
127
 
111
- // Check Redis for cache keys
112
- const keys = await redisClient.keys(`${keyPrefix}*`);
128
+ const keys = await waitForRedisKeys(`${keyPrefix}*`);
113
129
  expect(keys.length).toBeGreaterThan(0);
114
- });
130
+ }, 15_000);
115
131
  });
116
132
 
117
133
  describe('cacheTag functionality', () => {
@@ -128,46 +144,36 @@ describe('Next.js 16 Cache Components Integration', () => {
128
144
  expect(data2.counter).toBe(data1.counter);
129
145
  });
130
146
 
131
- it('should invalidate cache when tag is revalidated (Stale while revalidate)', async () => {
132
- // Get initial cached data
133
- const res1 = await fetch(`${BASE_URL}/api/cached-with-tag`);
134
- const data1 = await res1.json();
147
+ it('should invalidate cache when tag is revalidated', async () => {
148
+ const id = `tag-invalidation-${Date.now()}`;
149
+ const matrixUrl = `${BASE_URL}/api/expire-matrix?id=${encodeURIComponent(id)}`;
135
150
 
136
- // Revalidate the tag
137
- await fetch(`${BASE_URL}/api/revalidate-tag`, {
138
- method: 'POST',
139
- headers: { 'Content-Type': 'application/json' },
140
- body: JSON.stringify({ tag: 'test-tag' }),
141
- });
142
-
143
- // The cache should be invalidated - verify by making multiple requests
144
- // until we get fresh data (with retries for async revalidation)
145
- let freshDataReceived = false;
146
- // Next.js tag revalidation can be async and may take longer under some runtimes.
147
- // Use a more tolerant window to avoid flaky failures.
148
- for (let i = 0; i < 60; i++) {
149
- await new Promise((resolve) => setTimeout(resolve, 500));
150
- const res = await fetch(`${BASE_URL}/api/cached-with-tag`);
151
- const data = await res.json();
152
-
153
- if (
154
- data.counter !== data1.counter ||
155
- data.timestamp !== data1.timestamp
156
- ) {
157
- freshDataReceived = true;
158
- break;
159
- }
160
- }
151
+ const data1 = await (await fetch(matrixUrl)).json();
152
+ const data2 = await (await fetch(matrixUrl)).json();
153
+ expect(data2.counter).toBe(data1.counter);
161
154
 
162
- expect(freshDataReceived).toBe(true);
163
- }, 20_000);
155
+ const revalidateRes = await fetch(
156
+ `${BASE_URL}/api/expire-matrix/revalidate`,
157
+ {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({
161
+ tag: `expire-matrix-${id}`,
162
+ profile: { expire: 0 },
163
+ }),
164
+ },
165
+ );
166
+ expect(revalidateRes.status).toBe(200);
167
+
168
+ const after = await (await fetch(matrixUrl)).json();
169
+ expect(after.counter).toBeGreaterThan(data1.counter);
170
+ });
164
171
  });
165
172
 
166
173
  describe('cacheLife functionality', () => {
167
174
  it('should respect expire window and eventually return refreshed data', async () => {
168
175
  const res1 = await fetch(`${BASE_URL}/api/cached-with-cachelife`);
169
176
  const data1 = await res1.json();
170
- expect(data1.counter).toBe(1);
171
177
 
172
178
  const res2 = await fetch(`${BASE_URL}/api/cached-with-cachelife`);
173
179
  const data2 = await res2.json();
@@ -198,6 +204,158 @@ describe('Next.js 16 Cache Components Integration', () => {
198
204
  }, 20_000);
199
205
  });
200
206
 
207
+ describe('revalidateTag expire settings (next start)', () => {
208
+ // Expire seconds from next/dist/server/config-shared.js cacheLife presets
209
+ // (identical in Next.js 16.0.11, 16.2.6, 16.3.0). `default.expire` is
210
+ // INFINITE_CACHE = 0xfffffffe.
211
+ const CACHE_LIFE_EXPIRE_SECONDS = {
212
+ default: 0xfffffffe,
213
+ seconds: 60,
214
+ minutes: 60 * 60,
215
+ hours: 60 * 60 * 24,
216
+ days: 60 * 60 * 24 * 7,
217
+ weeks: 60 * 60 * 24 * 30,
218
+ max: 60 * 60 * 24 * 365,
219
+ } as const;
220
+
221
+ async function getMatrix(id: string) {
222
+ const res = await fetch(
223
+ `${BASE_URL}/api/expire-matrix?id=${encodeURIComponent(id)}`,
224
+ );
225
+ expect(res.status).toBe(200);
226
+ return res.json() as Promise<{
227
+ counter: number;
228
+ timestamp: number;
229
+ id: string;
230
+ }>;
231
+ }
232
+
233
+ async function revalidateMatrix(
234
+ id: string,
235
+ profile: string | { expire: number } | null,
236
+ ) {
237
+ const tag = `expire-matrix-${id}`;
238
+ const body = profile === null ? { tag, profile: null } : { tag, profile };
239
+ const res = await fetch(`${BASE_URL}/api/expire-matrix/revalidate`, {
240
+ method: 'POST',
241
+ headers: { 'Content-Type': 'application/json' },
242
+ body: JSON.stringify(body),
243
+ });
244
+ expect(res.status).toBe(200);
245
+ return res.json() as Promise<{ timestamp: number }>;
246
+ }
247
+
248
+ async function readTagManifest(tag: string) {
249
+ const hashKey = `${keyPrefix}__cacheComponents_revalidated_tags__`;
250
+ for (let elapsed = 0; elapsed < 5_000; elapsed += 50) {
251
+ const raw = await redisClient.hGet(hashKey, tag);
252
+ if (raw) {
253
+ const parsed: unknown = JSON.parse(raw);
254
+ if (typeof parsed === 'number') {
255
+ return { expired: parsed } as { stale?: number; expired?: number };
256
+ }
257
+ return parsed as { stale?: number; expired?: number };
258
+ }
259
+ await delay(50);
260
+ }
261
+ throw new Error(`tag manifest missing for ${tag} in ${hashKey}`);
262
+ }
263
+
264
+ function expectApproxMs(actual: number | undefined, expected: number) {
265
+ expect(actual, `expected ~${expected}, got ${actual}`).toBeDefined();
266
+ expect(Math.abs(actual! - expected)).toBeLessThan(5_000);
267
+ }
268
+
269
+ it.each(Object.entries(CACHE_LIFE_EXPIRE_SECONDS) as [string, number][])(
270
+ 'revalidateTag(tag, %s) marks stale now and expired=now+expire*1000, then SWR',
271
+ async (profile, expireSeconds) => {
272
+ const id = `profile-${profile}`;
273
+ await getMatrix(id);
274
+ const before = await getMatrix(id);
275
+
276
+ const startedAt = Date.now();
277
+ await revalidateMatrix(id, profile);
278
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
279
+
280
+ expectApproxMs(manifest.stale, startedAt);
281
+ expectApproxMs(manifest.expired, startedAt + expireSeconds * 1000);
282
+
283
+ const after = await getMatrix(id);
284
+ expect(after.counter).toBe(before.counter);
285
+ expect(after.timestamp).toBe(before.timestamp);
286
+ },
287
+ );
288
+
289
+ it('{ expire: 5 } uses the object expire, not a named profile', async () => {
290
+ const id = 'object-expire-5';
291
+ await getMatrix(id);
292
+ const before = await getMatrix(id);
293
+
294
+ const startedAt = Date.now();
295
+ await revalidateMatrix(id, { expire: 5 });
296
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
297
+
298
+ expectApproxMs(manifest.stale, startedAt);
299
+ expectApproxMs(manifest.expired, startedAt + 5_000);
300
+
301
+ const after = await getMatrix(id);
302
+ expect(after.counter).toBe(before.counter);
303
+ });
304
+
305
+ it('{ expire: 0 } writes expired=now and the next GET is a blocking miss', async () => {
306
+ const id = 'expire-zero';
307
+ await getMatrix(id);
308
+ const before = await getMatrix(id);
309
+
310
+ const startedAt = Date.now();
311
+ await revalidateMatrix(id, { expire: 0 });
312
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
313
+
314
+ expectApproxMs(manifest.expired, startedAt);
315
+ expect(manifest.stale).toBeUndefined();
316
+
317
+ const after = await getMatrix(id);
318
+ expect(after.counter).toBeGreaterThan(before.counter);
319
+ });
320
+
321
+ it('omitted profile writes expired=now without stale and the next GET misses', async () => {
322
+ const id = 'omit-profile';
323
+ await getMatrix(id);
324
+ const before = await getMatrix(id);
325
+
326
+ const startedAt = Date.now();
327
+ await revalidateMatrix(id, null);
328
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
329
+
330
+ expect(manifest.stale).toBeUndefined();
331
+ expectApproxMs(manifest.expired, startedAt);
332
+
333
+ const after = await getMatrix(id);
334
+ expect(after.counter).toBeGreaterThan(before.counter);
335
+ });
336
+
337
+ it('{ expire: 2 } serves stale, then hard-expires after the window', async () => {
338
+ const id = 'expire-two';
339
+ await getMatrix(id);
340
+ const before = await getMatrix(id);
341
+
342
+ const startedAt = Date.now();
343
+ await revalidateMatrix(id, { expire: 2 });
344
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
345
+
346
+ expectApproxMs(manifest.stale, startedAt);
347
+ expectApproxMs(manifest.expired, startedAt + 2_000);
348
+
349
+ const stale = await getMatrix(id);
350
+ expect(stale.counter).toBe(before.counter);
351
+
352
+ await delay(3_000);
353
+
354
+ const afterWindow = await getMatrix(id);
355
+ expect(afterWindow.counter).toBeGreaterThan(before.counter);
356
+ }, 15_000);
357
+ });
358
+
201
359
  describe('Redis cache handler integration', () => {
202
360
  it('should call cache handler get and set methods', async () => {
203
361
  // Make request to trigger cache (don't clear first)
@@ -313,6 +313,15 @@ describe('Next.js Turbo Redis Cache Integration', () => {
313
313
  const ttl = await redisClient.ttl(
314
314
  process.env.VERCEL_URL + '/api/revalidated-fetch',
315
315
  );
316
+
317
+ // Next.js 16.3+ passes cacheControl.expire for ISR fetch routes (~1 year).
318
+ // TTL is keyed on expire (SWR-safe), not 2 × revalidate — skip the
319
+ // long-running expiry wait in that case.
320
+ if (NEXT_TEST_APP.includes('16-3')) {
321
+ expect(ttl).toBeGreaterThan(2 * revalidatedFetchRouteRevalidate);
322
+ return;
323
+ }
324
+
316
325
  expect(ttl).toBeLessThan(2 * revalidatedFetchRouteRevalidate);
317
326
  expect(ttl).toBeGreaterThan(
318
327
  2 * revalidatedFetchRouteRevalidate -
@@ -923,9 +932,14 @@ describe('Next.js Turbo Redis Cache Integration', () => {
923
932
  expect(timestamp).toBeDefined();
924
933
  expect(Number(timestamp)).toBeGreaterThan(Number(firstTimestamp));
925
934
 
926
- //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)
935
+ // Next.js 16.3 revalidates nested fetch entries when the parent page path
936
+ // is revalidated; older versions keep the nested fetch cache intact.
927
937
  expect(secondCounter).toBeDefined();
928
- expect(secondCounter).toBe(firstCounter);
938
+ if (NEXT_TEST_APP === 'next-app-16-3-0') {
939
+ expect(Number(secondCounter)).toBeGreaterThan(Number(firstCounter));
940
+ } else {
941
+ expect(secondCounter).toBe(firstCounter);
942
+ }
929
943
  });
930
944
  });
931
945
 
@@ -48,6 +48,18 @@ async function delay(ms: number) {
48
48
  return new Promise((resolve) => setTimeout(resolve, ms));
49
49
  }
50
50
 
51
+ function expectIsrRedisTtl(ttl: number) {
52
+ // Next.js 16.3+ passes cacheControl.expire (~1 year) for ISR pages.
53
+ // Redis TTL is keyed on expire, not 2 × revalidate.
54
+ if (NEXT_PAGES_TEST_APP.includes('16-3')) {
55
+ expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS);
56
+ return;
57
+ }
58
+ // VERCEL_ENV=production -> expire age is 2 * revalidate
59
+ expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
60
+ expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
61
+ }
62
+
51
63
  async function runCommand(cmd: string, args: string[], cwd: string) {
52
64
  return new Promise((resolve, reject) => {
53
65
  let stderr = '';
@@ -233,9 +245,7 @@ describe('Pages Router Redis cache integration (two instances)', () => {
233
245
  const ttl = await redisClient.ttl(
234
246
  process.env.VERCEL_URL + '/isr/prebuilt',
235
247
  );
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);
248
+ expectIsrRedisTtl(ttl);
239
249
  });
240
250
 
241
251
  it('instance B serves the identical cached HTML from the shared Redis', async () => {
@@ -342,12 +352,12 @@ describe('Pages Router Redis cache integration (two instances)', () => {
342
352
  tags: ['_N_T_/isr/not-found'],
343
353
  });
344
354
 
345
- // TTL is derived from the revalidate value returned with notFound
355
+ // TTL: Next.js 16.3+ keys on cacheControl.expire (SWR-safe); older
356
+ // versions use estimateExpireAge(revalidate).
346
357
  const ttl = await redisClient.ttl(
347
358
  process.env.VERCEL_URL + '/isr/not-found',
348
359
  );
349
- expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
350
- expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
360
+ expectIsrRedisTtl(ttl);
351
361
  });
352
362
 
353
363
  it('instance B serves the 404 from the shared cache', async () => {
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SyncedMap } from '../../../src/SyncedMap';
3
+
4
+ class HashMockClient {
5
+ hash = new Map<string, string>();
6
+ stringKeys: string[] = [];
7
+ hDelCalls: string[][] = [];
8
+ isOpen = true;
9
+ isReady = true;
10
+
11
+ on() {
12
+ return this;
13
+ }
14
+
15
+ async connect() {
16
+ this.isOpen = true;
17
+ this.isReady = true;
18
+ }
19
+
20
+ async configGet() {
21
+ return { 'notify-keyspace-events': 'Exe' };
22
+ }
23
+
24
+ async subscribe() {}
25
+
26
+ async quit() {}
27
+
28
+ async disconnect() {
29
+ this.isOpen = false;
30
+ this.isReady = false;
31
+ }
32
+
33
+ duplicate() {
34
+ return new HashMockClient();
35
+ }
36
+
37
+ async hScan() {
38
+ return {
39
+ cursor: 0,
40
+ tuples: [...this.hash.entries()].map(([field, value]) => ({
41
+ field,
42
+ value,
43
+ })),
44
+ };
45
+ }
46
+
47
+ async scan() {
48
+ return { cursor: 0, keys: this.stringKeys };
49
+ }
50
+
51
+ async hSet(_key: string, field: string, value: string) {
52
+ this.hash.set(field, value);
53
+ return 1;
54
+ }
55
+
56
+ async hDel(_key: string, fields: string | string[]) {
57
+ const list = Array.isArray(fields) ? fields : [fields];
58
+ this.hDelCalls.push(list);
59
+ for (const field of list) {
60
+ this.hash.delete(field);
61
+ }
62
+ return list.length;
63
+ }
64
+
65
+ async publish() {
66
+ return 1;
67
+ }
68
+ }
69
+
70
+ describe('SyncedMap orphan cleanup', () => {
71
+ it('deletes hash fields whose names are not Redis string keys', async () => {
72
+ const client = new HashMockClient();
73
+ const map = new SyncedMap<number>({
74
+ client: client as never,
75
+ keyPrefix: 'app:',
76
+ redisKey: '__revalidated_tags__',
77
+ database: 0,
78
+ querySize: 50,
79
+ filterKeys: () => true,
80
+ });
81
+ await map.waitUntilReady();
82
+ await map.set('posts', 1);
83
+
84
+ client.stringKeys = ['app:some-cache-entry'];
85
+ await (map as any).initialSync();
86
+
87
+ expect(client.hDelCalls.flat()).toContain('posts');
88
+ expect(map.get('posts')).toBeUndefined();
89
+ });
90
+
91
+ it('keeps tag-manifest fields when withoutOrphanCleanup is set', async () => {
92
+ const client = new HashMockClient();
93
+ const map = new SyncedMap<number>({
94
+ client: client as never,
95
+ keyPrefix: 'app:',
96
+ redisKey: '__cacheComponents_revalidated_tags__',
97
+ database: 0,
98
+ querySize: 50,
99
+ filterKeys: () => true,
100
+ customizedSync: { withoutOrphanCleanup: true },
101
+ });
102
+ await map.waitUntilReady();
103
+ await map.set('posts', 1);
104
+
105
+ client.stringKeys = ['app:some-cache-entry'];
106
+ await (map as any).initialSync();
107
+
108
+ expect(client.hDelCalls).toEqual([]);
109
+ expect(map.get('posts')).toBe(1);
110
+ });
111
+ });