@trieb.work/nextjs-turbo-redis-cache 1.15.1 → 1.16.1

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 (31) hide show
  1. package/.github/workflows/ci.yml +45 -0
  2. package/.github/workflows/release.yml +9 -1
  3. package/CHANGELOG.md +28 -0
  4. package/README.md +13 -2
  5. package/dist/index.d.mts +71 -45
  6. package/dist/index.d.ts +71 -45
  7. package/dist/index.js +45 -8
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +45 -8
  10. package/dist/index.mjs.map +1 -1
  11. package/docs/index.html +7 -6
  12. package/package.json +2 -1
  13. package/src/CacheComponentsHandler.ts +35 -2
  14. package/src/RedisStringsHandler.ts +125 -66
  15. package/test/README.md +21 -6
  16. package/test/nextjs-test-projects/next-pages-16-2-6/README.md +16 -0
  17. package/test/nextjs-test-projects/next-pages-16-2-6/eslint.config.mjs +18 -0
  18. package/test/nextjs-test-projects/next-pages-16-2-6/next.config.ts +7 -0
  19. package/test/nextjs-test-projects/next-pages-16-2-6/package.json +26 -0
  20. package/test/nextjs-test-projects/next-pages-16-2-6/pnpm-lock.yaml +3896 -0
  21. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/api/revalidate.ts +24 -0
  22. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/index.tsx +11 -0
  23. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/isr/[slug].tsx +49 -0
  24. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/static-forever.tsx +20 -0
  25. package/test/nextjs-test-projects/next-pages-16-2-6/tsconfig.json +34 -0
  26. package/test/vitest/integration/cache-components/redis-kill-reconnect.test.ts +6 -0
  27. package/test/vitest/integration/cache-components/scripts/redis-kill-reconnect.ts +3 -5
  28. package/test/vitest/integration/pages-router.integration.test.ts +420 -0
  29. package/test/vitest/unit/index.test.ts +49 -2
  30. package/test/vitest/unit/pages-router-kinds.test.ts +292 -0
  31. package/test/vitest/unit/reconnect-socket-already-opened.test.ts +56 -0
@@ -0,0 +1,292 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ import RedisStringsHandler, {
4
+ CacheEntry,
5
+ } from '../../../src/RedisStringsHandler';
6
+
7
+ // In-memory Redis string store shared between the mocked client and the tests
8
+ const hoisted = vi.hoisted(() => {
9
+ const store = new Map<string, { value: string; ex?: number }>();
10
+ return { store };
11
+ });
12
+
13
+ vi.mock('redis', () => {
14
+ const createClient = () => {
15
+ return {
16
+ isReady: true,
17
+ on: vi.fn(),
18
+ connect: vi.fn(async () => undefined),
19
+ disconnect: vi.fn(),
20
+ quit: vi.fn(),
21
+ duplicate: vi.fn(() => ({
22
+ connect: vi.fn(async () => undefined),
23
+ subscribe: vi.fn(async () => undefined),
24
+ on: vi.fn(),
25
+ quit: vi.fn(async () => undefined),
26
+ configGet: vi.fn(async () => ({ 'notify-keyspace-events': 'Exe' })),
27
+ })),
28
+ get: vi.fn(
29
+ async (_opts: unknown, key: string) =>
30
+ hoisted.store.get(key)?.value ?? null,
31
+ ),
32
+ hScan: vi.fn(async () => ({ cursor: 0, tuples: [] })),
33
+ scan: vi.fn(async () => ({ cursor: 0, keys: [] })),
34
+ hSet: vi.fn(async () => 1),
35
+ hDel: vi.fn(async () => 1),
36
+ publish: vi.fn(async () => 1),
37
+ unlink: vi.fn(async () => 1),
38
+ set: vi.fn(async (key: string, value: string, opts?: { EX?: number }) => {
39
+ hoisted.store.set(key, { value, ex: opts?.EX });
40
+ return 'OK';
41
+ }),
42
+ };
43
+ };
44
+ return {
45
+ createClient,
46
+ commandOptions: vi.fn((opts) => opts),
47
+ };
48
+ });
49
+
50
+ const KEY_PREFIX = 'test:';
51
+ const DEFAULT_STALE_AGE = 60 * 60 * 24 * 14;
52
+ // Default estimateExpireAge outside production is staleAge * 1.2
53
+ const expireAge = (staleAge: number) => staleAge * 1.2;
54
+
55
+ function createHandler() {
56
+ return new RedisStringsHandler({
57
+ redisUrl: 'redis://localhost:6379',
58
+ keyPrefix: KEY_PREFIX,
59
+ database: 0,
60
+ getTimeoutMs: 100,
61
+ redisGetDeduplication: false,
62
+ inMemoryCachingTime: 0,
63
+ });
64
+ }
65
+
66
+ function storedEntry(key: string): { entry: CacheEntry; ex?: number } {
67
+ const raw = hoisted.store.get(KEY_PREFIX + key);
68
+ expect(raw).toBeDefined();
69
+ return { entry: JSON.parse(raw!.value), ex: raw!.ex };
70
+ }
71
+
72
+ const baseCtx = { isRoutePPREnabled: false, isFallback: false };
73
+
74
+ describe('RedisStringsHandler Pages Router kinds', () => {
75
+ let warnSpy: ReturnType<typeof vi.spyOn>;
76
+ let errorSpy: ReturnType<typeof vi.spyOn>;
77
+
78
+ beforeEach(() => {
79
+ hoisted.store.clear();
80
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
81
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
82
+ });
83
+
84
+ afterEach(() => {
85
+ vi.restoreAllMocks();
86
+ });
87
+
88
+ describe('PAGES entries', () => {
89
+ const pagesData = {
90
+ kind: 'PAGES' as const,
91
+ html: '<html><body>hello</body></html>',
92
+ pageData: { pageProps: { title: 'hello' }, __N_SSG: true },
93
+ headers: undefined,
94
+ status: 200,
95
+ };
96
+
97
+ it('set() stores a PAGES entry without warnings and with the implicit path tag', async () => {
98
+ const handler = createHandler();
99
+ await handler.set('/blog/post-1', pagesData, {
100
+ ...baseCtx,
101
+ cacheControl: { revalidate: 60, expire: undefined },
102
+ });
103
+
104
+ const { entry, ex } = storedEntry('/blog/post-1');
105
+ expect(entry).toEqual({
106
+ value: pagesData,
107
+ lastModified: expect.any(Number),
108
+ tags: ['_N_T_/blog/post-1'],
109
+ });
110
+ // TTL is derived from cacheControl.revalidate (getStaticProps revalidate)
111
+ expect(ex).toBe(expireAge(60));
112
+ expect(warnSpy).not.toHaveBeenCalled();
113
+ expect(errorSpy).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it('get() returns a PAGES entry without warnings', async () => {
117
+ const handler = createHandler();
118
+ await handler.set('/blog/post-1', pagesData, {
119
+ ...baseCtx,
120
+ cacheControl: { revalidate: 60, expire: undefined },
121
+ });
122
+
123
+ const result = await handler.get('/blog/post-1', {
124
+ kind: 'PAGES',
125
+ isFallback: false,
126
+ });
127
+
128
+ expect(result).toEqual({
129
+ value: pagesData,
130
+ lastModified: expect.any(Number),
131
+ tags: ['_N_T_/blog/post-1'],
132
+ });
133
+ expect(warnSpy).not.toHaveBeenCalled();
134
+ expect(errorSpy).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it('set() with revalidate: false falls back to defaultStaleAge for the TTL', async () => {
138
+ const handler = createHandler();
139
+ await handler.set('/static-forever', pagesData, {
140
+ ...baseCtx,
141
+ cacheControl: { revalidate: false, expire: undefined },
142
+ });
143
+
144
+ const { ex } = storedEntry('/static-forever');
145
+ expect(ex).toBe(expireAge(DEFAULT_STALE_AGE));
146
+ });
147
+
148
+ it('set() derives the TTL from the legacy ctx.revalidate argument (Next.js 15.0.3)', async () => {
149
+ const handler = createHandler();
150
+ await handler.set('/blog/post-legacy', pagesData, {
151
+ ...baseCtx,
152
+ revalidate: 60,
153
+ });
154
+
155
+ const { ex } = storedEntry('/blog/post-legacy');
156
+ expect(ex).toBe(expireAge(60));
157
+ });
158
+ });
159
+
160
+ describe('REDIRECT entries (getStaticProps redirect)', () => {
161
+ const redirectData = {
162
+ kind: 'REDIRECT' as const,
163
+ // Next.js nests the redirect directives under `pageProps`, matching the
164
+ // real getStaticProps redirect payload asserted in the integration test.
165
+ props: {
166
+ pageProps: { __N_REDIRECT: '/target', __N_REDIRECT_STATUS: 307 },
167
+ },
168
+ };
169
+
170
+ it('set() and get() round-trip a REDIRECT entry without warnings', async () => {
171
+ const handler = createHandler();
172
+ await handler.set('/old-location', redirectData, {
173
+ ...baseCtx,
174
+ cacheControl: { revalidate: 30, expire: undefined },
175
+ });
176
+
177
+ const { entry, ex } = storedEntry('/old-location');
178
+ expect(entry).toEqual({
179
+ value: redirectData,
180
+ lastModified: expect.any(Number),
181
+ tags: ['_N_T_/old-location'],
182
+ });
183
+ expect(ex).toBe(expireAge(30));
184
+
185
+ const result = await handler.get('/old-location', {
186
+ kind: 'PAGES',
187
+ isFallback: false,
188
+ });
189
+ expect(result?.value).toEqual(redirectData);
190
+ expect(warnSpy).not.toHaveBeenCalled();
191
+ expect(errorSpy).not.toHaveBeenCalled();
192
+ });
193
+ });
194
+
195
+ describe('notFound entries (data === null)', () => {
196
+ it('set() stores a null-value entry without throwing or warning', async () => {
197
+ const handler = createHandler();
198
+ await handler.set('/missing-page', null, {
199
+ ...baseCtx,
200
+ cacheControl: { revalidate: 15, expire: undefined },
201
+ });
202
+
203
+ const { entry, ex } = storedEntry('/missing-page');
204
+ expect(entry).toEqual({
205
+ value: null,
206
+ lastModified: expect.any(Number),
207
+ tags: ['_N_T_/missing-page'],
208
+ });
209
+ expect(ex).toBe(expireAge(15));
210
+ expect(warnSpy).not.toHaveBeenCalled();
211
+ expect(errorSpy).not.toHaveBeenCalled();
212
+ });
213
+
214
+ it('get() returns the null-value entry without malformed warnings or errors', async () => {
215
+ const handler = createHandler();
216
+ await handler.set('/missing-page', null, {
217
+ ...baseCtx,
218
+ cacheControl: { revalidate: 15, expire: undefined },
219
+ });
220
+
221
+ const result = await handler.get('/missing-page', {
222
+ kind: 'PAGES',
223
+ isFallback: false,
224
+ });
225
+
226
+ expect(result).toEqual({
227
+ value: null,
228
+ lastModified: expect.any(Number),
229
+ tags: ['_N_T_/missing-page'],
230
+ });
231
+ expect(warnSpy).not.toHaveBeenCalled();
232
+ expect(errorSpy).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('get() still warns for a malformed entry with a missing (undefined) value', async () => {
236
+ const handler = createHandler();
237
+ hoisted.store.set(KEY_PREFIX + '/malformed', {
238
+ value: JSON.stringify({ lastModified: Date.now(), tags: [] }),
239
+ });
240
+
241
+ await handler.get('/malformed', {
242
+ kind: 'PAGES',
243
+ isFallback: false,
244
+ });
245
+
246
+ expect(warnSpy).toHaveBeenCalledWith(
247
+ 'RedisStringsHandler.get() called with',
248
+ '/malformed',
249
+ expect.anything(),
250
+ 'cacheEntry is mall formed (missing value)',
251
+ );
252
+ });
253
+ });
254
+
255
+ describe('App Router entries are unaffected', () => {
256
+ it('set() still extracts tags from the x-next-cache-tags header and adds no implicit tag', async () => {
257
+ const handler = createHandler();
258
+ await handler.set(
259
+ '/app-page',
260
+ {
261
+ kind: 'APP_PAGE',
262
+ html: '<html></html>',
263
+ rscData: Buffer.from('rsc'),
264
+ headers: {
265
+ 'x-nextjs-stale-time': '1000',
266
+ 'x-next-cache-tags': '_N_T_/layout,_N_T_/app-page',
267
+ },
268
+ segmentData: undefined,
269
+ postboned: undefined,
270
+ },
271
+ { ...baseCtx, cacheControl: { revalidate: 60, expire: undefined } },
272
+ );
273
+
274
+ const { entry } = storedEntry('/app-page');
275
+ expect(entry.tags).toEqual(['_N_T_/layout', '_N_T_/app-page']);
276
+ expect(warnSpy).not.toHaveBeenCalled();
277
+ });
278
+
279
+ it('set() still warns for unsupported kinds (e.g. IMAGE)', async () => {
280
+ const handler = createHandler();
281
+ await handler.set(
282
+ '/some-image',
283
+ { kind: 'IMAGE' } as unknown as Parameters<
284
+ RedisStringsHandler['set']
285
+ >[1],
286
+ baseCtx,
287
+ );
288
+
289
+ expect(warnSpy).toHaveBeenCalled();
290
+ });
291
+ });
292
+ });
@@ -1,5 +1,58 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
2
 
3
+ vi.mock('redis', () => ({
4
+ createClient: vi.fn(() => {
5
+ const listeners = new Map<string, Function[]>();
6
+ const client = {
7
+ isOpen: false,
8
+ isReady: false,
9
+ on: vi.fn((event: string, cb: Function) => {
10
+ if (!listeners.has(event)) listeners.set(event, []);
11
+ listeners.get(event)!.push(cb);
12
+ }),
13
+ emit: vi.fn((event: string, ...args: unknown[]) => {
14
+ (listeners.get(event) ?? []).forEach((cb) => cb(...args));
15
+ }),
16
+ connect: vi.fn(async () => undefined),
17
+ disconnect: vi.fn(),
18
+ quit: vi.fn(async () => undefined),
19
+ duplicate: vi.fn(() => ({
20
+ connect: vi.fn(async () => undefined),
21
+ subscribe: vi.fn(async () => undefined),
22
+ on: vi.fn(),
23
+ quit: vi.fn(async () => undefined),
24
+ configGet: vi.fn(async () => ({ 'notify-keyspace-events': 'Exe' })),
25
+ })),
26
+ get: vi.fn(async () => null),
27
+ hScan: vi.fn(async () => ({ cursor: 0, tuples: [] })),
28
+ scan: vi.fn(async () => ({ cursor: 0, keys: [] })),
29
+ hSet: vi.fn(async () => 1),
30
+ hDel: vi.fn(async () => 1),
31
+ publish: vi.fn(async () => 1),
32
+ unlink: vi.fn(async () => 1),
33
+ set: vi.fn(async () => 'OK'),
34
+ };
35
+ return client;
36
+ }),
37
+ commandOptions: vi.fn((opts) => opts),
38
+ }));
39
+
40
+ vi.mock('../../../src/SyncedMap', () => {
41
+ class SyncedMap {
42
+ waitUntilReady = vi.fn(async () => undefined);
43
+ get = vi.fn(() => undefined);
44
+ set = vi.fn(async () => undefined);
45
+ delete = vi.fn(async () => undefined);
46
+ entries = vi.fn(function* () {
47
+ return;
48
+ });
49
+
50
+ constructor() {}
51
+ }
52
+
53
+ return { SyncedMap };
54
+ });
55
+
3
56
  /**
4
57
  * Deterministic regression test for:
5
58
  * "Failed to reconnect RedisCacheComponentsHandler client after connection loss: Error: Socket already opened"
@@ -34,6 +87,9 @@ describe('RedisCacheComponentsHandler reconnect logic', () => {
34
87
 
35
88
  const client = (handler as any).client;
36
89
 
90
+ // Clear the initial connect() call from the constructor.
91
+ vi.mocked(client.connect).mockClear();
92
+
37
93
  // Simulate a connection-loss situation where the socket is still open.
38
94
  Object.defineProperty(client, 'isOpen', { value: true });
39
95
  Object.defineProperty(client, 'isReady', { value: false });