@trieb.work/nextjs-turbo-redis-cache 1.10.0-beta.14 → 1.11.0-beta.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 (55) hide show
  1. package/.github/workflows/ci.yml +28 -12
  2. package/CHANGELOG.md +6 -94
  3. package/README.md +94 -0
  4. package/dist/index.d.mts +22 -1
  5. package/dist/index.d.ts +22 -1
  6. package/dist/index.js +333 -15
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +330 -14
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +3 -2
  11. package/playwright.config.ts +8 -1
  12. package/src/CacheComponentsHandler.ts +471 -0
  13. package/src/RedisStringsHandler.ts +11 -11
  14. package/src/index.test.ts +1 -1
  15. package/src/index.ts +5 -0
  16. package/test/cache-components/cache-components.integration.spec.ts +188 -0
  17. package/test/integration/next-app-15-4-7/next.config.js +3 -0
  18. package/test/integration/next-app-15-4-7/pnpm-lock.yaml +1 -1
  19. package/test/integration/next-app-16-0-3/next.config.ts +3 -0
  20. package/test/integration/next-app-16-1-1-cache-components/README.md +36 -0
  21. package/test/integration/next-app-16-1-1-cache-components/cache-handler.js +3 -0
  22. package/test/integration/next-app-16-1-1-cache-components/eslint.config.mjs +18 -0
  23. package/test/integration/next-app-16-1-1-cache-components/next.config.ts +13 -0
  24. package/test/integration/next-app-16-1-1-cache-components/package.json +28 -0
  25. package/test/integration/next-app-16-1-1-cache-components/pnpm-lock.yaml +4128 -0
  26. package/test/integration/next-app-16-1-1-cache-components/postcss.config.mjs +7 -0
  27. package/test/integration/next-app-16-1-1-cache-components/public/file.svg +1 -0
  28. package/test/integration/next-app-16-1-1-cache-components/public/globe.svg +1 -0
  29. package/test/integration/next-app-16-1-1-cache-components/public/next.svg +1 -0
  30. package/test/integration/next-app-16-1-1-cache-components/public/public/file.svg +1 -0
  31. package/test/integration/next-app-16-1-1-cache-components/public/public/globe.svg +1 -0
  32. package/test/integration/next-app-16-1-1-cache-components/public/public/next.svg +1 -0
  33. package/test/integration/next-app-16-1-1-cache-components/public/public/vercel.svg +1 -0
  34. package/test/integration/next-app-16-1-1-cache-components/public/public/window.svg +1 -0
  35. package/test/integration/next-app-16-1-1-cache-components/public/vercel.svg +1 -0
  36. package/test/integration/next-app-16-1-1-cache-components/public/window.svg +1 -0
  37. package/test/integration/next-app-16-1-1-cache-components/src/app/api/cached-static-fetch/route.ts +19 -0
  38. package/test/integration/next-app-16-1-1-cache-components/src/app/api/cached-with-tag/route.ts +21 -0
  39. package/test/integration/next-app-16-1-1-cache-components/src/app/api/revalidate-tag/route.ts +19 -0
  40. package/test/integration/next-app-16-1-1-cache-components/src/app/api/revalidated-fetch/route.ts +19 -0
  41. package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/cachelife-short/page.tsx +110 -0
  42. package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/page.tsx +90 -0
  43. package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/runtime-data-suspense/page.tsx +127 -0
  44. package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/stale-while-revalidate/page.tsx +130 -0
  45. package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/tag-invalidation/page.tsx +127 -0
  46. package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/use-cache-nondeterministic/page.tsx +110 -0
  47. package/test/integration/next-app-16-1-1-cache-components/src/app/favicon.ico +0 -0
  48. package/test/integration/next-app-16-1-1-cache-components/src/app/globals.css +26 -0
  49. package/test/integration/next-app-16-1-1-cache-components/src/app/layout.tsx +57 -0
  50. package/test/integration/next-app-16-1-1-cache-components/src/app/page.tsx +755 -0
  51. package/test/integration/next-app-16-1-1-cache-components/src/app/revalidation-interface.tsx +267 -0
  52. package/test/integration/next-app-16-1-1-cache-components/src/app/update-tag-test/page.tsx +22 -0
  53. package/test/integration/next-app-16-1-1-cache-components/tsconfig.json +34 -0
  54. package/tests/cache-lab.spec.ts +157 -0
  55. package/vitest.cache-components.config.ts +16 -0
@@ -0,0 +1,471 @@
1
+ import { commandOptions, createClient } from 'redis';
2
+ import type { RedisClientOptions } from 'redis';
3
+ import {
4
+ Client,
5
+ CreateRedisStringsHandlerOptions,
6
+ redisErrorHandler,
7
+ } from './RedisStringsHandler';
8
+ import { SyncedMap } from './SyncedMap';
9
+ import { debug } from './utils/debug';
10
+
11
+ export interface CacheComponentsEntry {
12
+ value: ReadableStream<Uint8Array>;
13
+ tags: string[];
14
+ stale: number;
15
+ timestamp: number;
16
+ expire: number;
17
+ revalidate: number;
18
+ }
19
+
20
+ export interface CacheComponentsHandler {
21
+ get(
22
+ cacheKey: string,
23
+ softTags: string[],
24
+ ): Promise<CacheComponentsEntry | undefined>;
25
+ set(
26
+ cacheKey: string,
27
+ pendingEntry: Promise<CacheComponentsEntry>,
28
+ ): Promise<void>;
29
+ refreshTags(): Promise<void>;
30
+ getExpiration(tags: string[]): Promise<number>;
31
+ updateTags(tags: string[], durations?: { expire?: number }): Promise<void>;
32
+ }
33
+
34
+ type StoredCacheEntry = Omit<CacheComponentsEntry, 'value'> & {
35
+ value: Uint8Array | string;
36
+ };
37
+
38
+ const REVALIDATED_TAGS_KEY = '__cacheComponents_revalidated_tags__';
39
+ const SHARED_TAGS_KEY = '__cacheComponents_sharedTags__';
40
+
41
+ let killContainerOnErrorCount = 0;
42
+
43
+ export type CreateCacheComponentsHandlerOptions =
44
+ CreateRedisStringsHandlerOptions;
45
+
46
+ async function streamToBuffer(
47
+ stream: ReadableStream<Uint8Array>,
48
+ ): Promise<Uint8Array> {
49
+ const reader = stream.getReader();
50
+ const chunks: Uint8Array[] = [];
51
+
52
+ while (true) {
53
+ const { value, done } = await reader.read();
54
+ if (done) break;
55
+ if (value) {
56
+ chunks.push(value);
57
+ }
58
+ }
59
+
60
+ if (chunks.length === 1) {
61
+ return chunks[0];
62
+ }
63
+
64
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
65
+ const result = new Uint8Array(totalLength);
66
+ let offset = 0;
67
+ for (const chunk of chunks) {
68
+ result.set(chunk, offset);
69
+ offset += chunk.byteLength;
70
+ }
71
+ return result;
72
+ }
73
+
74
+ function bufferToReadableStream(
75
+ buffer: Uint8Array,
76
+ ): ReadableStream<Uint8Array> {
77
+ return new ReadableStream<Uint8Array>({
78
+ start(controller) {
79
+ controller.enqueue(buffer);
80
+ controller.close();
81
+ },
82
+ });
83
+ }
84
+
85
+ class RedisCacheComponentsHandler implements CacheComponentsHandler {
86
+ private client: Client;
87
+ private revalidatedTagsMap: SyncedMap<number>;
88
+ private sharedTagsMap: SyncedMap<string[]>;
89
+ private keyPrefix: string;
90
+ private getTimeoutMs: number;
91
+
92
+ constructor({
93
+ redisUrl = process.env.REDIS_URL
94
+ ? process.env.REDIS_URL
95
+ : process.env.REDISHOST
96
+ ? `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`
97
+ : 'redis://localhost:6379',
98
+ database = process.env.VERCEL_ENV === 'production' ? 0 : 1,
99
+ keyPrefix = process.env.VERCEL_URL || 'UNDEFINED_URL_',
100
+ getTimeoutMs = process.env.REDIS_COMMAND_TIMEOUT_MS
101
+ ? (Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT_MS) ?? 500)
102
+ : 500,
103
+ revalidateTagQuerySize = 250,
104
+ avgResyncIntervalMs = 60 * 60 * 1_000,
105
+ socketOptions,
106
+ clientOptions,
107
+ killContainerOnErrorThreshold = process.env
108
+ .KILL_CONTAINER_ON_ERROR_THRESHOLD
109
+ ? (Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0)
110
+ : 0,
111
+ }: CreateCacheComponentsHandlerOptions) {
112
+ try {
113
+ this.keyPrefix = keyPrefix;
114
+ this.getTimeoutMs = getTimeoutMs;
115
+
116
+ this.client = createClient({
117
+ url: redisUrl,
118
+ pingInterval: 10_000,
119
+ ...(database !== 0 ? { database } : {}),
120
+ ...(socketOptions
121
+ ? { socket: { ...socketOptions } as RedisClientOptions['socket'] }
122
+ : {}),
123
+ ...(clientOptions || {}),
124
+ });
125
+
126
+ this.client.on('error', (error) => {
127
+ console.error(
128
+ 'RedisCacheComponentsHandler client error',
129
+ error,
130
+ killContainerOnErrorCount++,
131
+ );
132
+ setTimeout(
133
+ () =>
134
+ this.client.connect().catch((err) => {
135
+ console.error(
136
+ 'Failed to reconnect RedisCacheComponentsHandler client after connection loss:',
137
+ err,
138
+ );
139
+ }),
140
+ 1000,
141
+ );
142
+ if (
143
+ killContainerOnErrorThreshold > 0 &&
144
+ killContainerOnErrorCount >= killContainerOnErrorThreshold
145
+ ) {
146
+ console.error(
147
+ 'RedisCacheComponentsHandler client error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)',
148
+ error,
149
+ killContainerOnErrorCount++,
150
+ );
151
+ this.client.disconnect();
152
+ this.client.quit();
153
+ setTimeout(() => {
154
+ process.exit(1);
155
+ }, 500);
156
+ }
157
+ });
158
+
159
+ this.client
160
+ .connect()
161
+ .then(() => {
162
+ debug('green', 'RedisCacheComponentsHandler client connected.');
163
+ })
164
+ .catch(() => {
165
+ this.client.connect().catch((error) => {
166
+ console.error(
167
+ 'Failed to connect RedisCacheComponentsHandler client:',
168
+ error,
169
+ );
170
+ this.client.disconnect();
171
+ throw error;
172
+ });
173
+ });
174
+
175
+ const filterKeys = (key: string): boolean =>
176
+ key !== REVALIDATED_TAGS_KEY && key !== SHARED_TAGS_KEY;
177
+
178
+ this.revalidatedTagsMap = new SyncedMap<number>({
179
+ client: this.client,
180
+ keyPrefix,
181
+ redisKey: REVALIDATED_TAGS_KEY,
182
+ database,
183
+ querySize: revalidateTagQuerySize,
184
+ filterKeys,
185
+ resyncIntervalMs:
186
+ avgResyncIntervalMs +
187
+ avgResyncIntervalMs / 10 +
188
+ Math.random() * (avgResyncIntervalMs / 10),
189
+ });
190
+
191
+ this.sharedTagsMap = new SyncedMap<string[]>({
192
+ client: this.client,
193
+ keyPrefix,
194
+ redisKey: SHARED_TAGS_KEY,
195
+ database,
196
+ querySize: revalidateTagQuerySize,
197
+ filterKeys,
198
+ resyncIntervalMs:
199
+ avgResyncIntervalMs -
200
+ avgResyncIntervalMs / 10 +
201
+ Math.random() * (avgResyncIntervalMs / 10),
202
+ });
203
+ } catch (error) {
204
+ console.error('RedisCacheComponentsHandler constructor error', error);
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ private async assertClientIsReady(): Promise<void> {
210
+ if (!this.client.isReady && !this.client.isOpen) {
211
+ await this.client.connect().catch((error) => {
212
+ console.error(
213
+ 'RedisCacheComponentsHandler assertClientIsReady reconnect error:',
214
+ error,
215
+ );
216
+ throw error;
217
+ });
218
+ }
219
+ await Promise.all([
220
+ this.revalidatedTagsMap.waitUntilReady(),
221
+ this.sharedTagsMap.waitUntilReady(),
222
+ ]);
223
+ }
224
+
225
+ private async computeMaxRevalidation(tags: string[]): Promise<number> {
226
+ let max = 0;
227
+ for (const tag of tags) {
228
+ const ts = this.revalidatedTagsMap.get(tag);
229
+ if (ts && ts > max) {
230
+ max = ts;
231
+ }
232
+ }
233
+ return max;
234
+ }
235
+
236
+ async get(
237
+ cacheKey: string,
238
+ softTags: string[],
239
+ ): Promise<CacheComponentsEntry | undefined> {
240
+ // Construct the full Redis key
241
+ // For cache components, Next.js provides the full key including environment prefix
242
+ // We prepend our keyPrefix for multi-tenant isolation
243
+ const redisKey = `${this.keyPrefix}${cacheKey}`;
244
+
245
+ try {
246
+ await this.assertClientIsReady();
247
+
248
+ const serialized = await redisErrorHandler(
249
+ 'RedisCacheComponentsHandler.get(), operation: get ' +
250
+ this.getTimeoutMs +
251
+ 'ms ' +
252
+ redisKey,
253
+ this.client.get(
254
+ commandOptions({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
255
+ redisKey,
256
+ ),
257
+ );
258
+
259
+ if (!serialized) {
260
+ return undefined;
261
+ }
262
+
263
+ const stored: StoredCacheEntry = JSON.parse(serialized);
264
+ const now = Date.now();
265
+
266
+ // expire is a duration in seconds, calculate absolute expiry time
267
+ const expiryTime = stored.timestamp + stored.expire * 1000;
268
+ if (
269
+ Number.isFinite(stored.expire) &&
270
+ stored.expire > 0 &&
271
+ now > expiryTime
272
+ ) {
273
+ await this.client.unlink(redisKey).catch(() => {});
274
+ await this.sharedTagsMap.delete(cacheKey).catch(() => {});
275
+ return undefined;
276
+ }
277
+
278
+ const maxRevalidation = await this.computeMaxRevalidation([
279
+ ...(stored.tags || []),
280
+ ...(softTags || []),
281
+ ]);
282
+
283
+ if (maxRevalidation > 0 && maxRevalidation > stored.timestamp) {
284
+ await this.client.unlink(redisKey).catch(() => {});
285
+ await this.sharedTagsMap.delete(cacheKey).catch(() => {});
286
+ return undefined;
287
+ }
288
+
289
+ const valueBuffer =
290
+ typeof stored.value === 'string'
291
+ ? new Uint8Array(Buffer.from(stored.value, 'base64'))
292
+ : stored.value;
293
+
294
+ return {
295
+ ...stored,
296
+ value: bufferToReadableStream(valueBuffer),
297
+ };
298
+ } catch (error) {
299
+ console.error(
300
+ 'RedisCacheComponentsHandler.get() Error occurred while getting cache entry. Returning undefined so site can continue to serve content while cache is disabled. The original error was:',
301
+ error,
302
+ killContainerOnErrorCount++,
303
+ );
304
+ return undefined;
305
+ }
306
+ }
307
+
308
+ async set(
309
+ cacheKey: string,
310
+ pendingEntry: Promise<CacheComponentsEntry>,
311
+ ): Promise<void> {
312
+ try {
313
+ await this.assertClientIsReady();
314
+
315
+ const entry = await pendingEntry;
316
+
317
+ const [storeStream] = entry.value.tee();
318
+
319
+ // Don't mutate entry.value as Next.js may still be using it internally
320
+ // entry.value = forwardStream;
321
+
322
+ const buffer = await streamToBuffer(storeStream);
323
+
324
+ const stored: StoredCacheEntry = {
325
+ value: Buffer.from(buffer).toString('base64'),
326
+ tags: entry.tags || [],
327
+ stale: entry.stale,
328
+ timestamp: entry.timestamp,
329
+ expire: entry.expire,
330
+ revalidate: entry.revalidate,
331
+ };
332
+
333
+ let serialized: string;
334
+ try {
335
+ const cleanStored = {
336
+ value: stored.value,
337
+ tags: Array.isArray(stored.tags) ? [...stored.tags] : [],
338
+ stale: Number(stored.stale),
339
+ timestamp: Number(stored.timestamp),
340
+ expire: Number(stored.expire),
341
+ revalidate: Number(stored.revalidate),
342
+ };
343
+ serialized = JSON.stringify(cleanStored);
344
+ } catch (jsonError) {
345
+ console.error('JSON.stringify error:', jsonError);
346
+ console.error('Stored object:', stored);
347
+ throw jsonError;
348
+ }
349
+
350
+ // expire is already a duration in seconds, use it directly
351
+ const ttlSeconds =
352
+ Number.isFinite(stored.expire) && stored.expire > 0
353
+ ? Math.floor(stored.expire)
354
+ : undefined;
355
+
356
+ const redisKey = `${this.keyPrefix}${cacheKey}`;
357
+
358
+ const setOperation = redisErrorHandler(
359
+ 'RedisCacheComponentsHandler.set(), operation: set ' + redisKey,
360
+ this.client.set(redisKey, serialized, {
361
+ ...(ttlSeconds ? { EX: ttlSeconds } : {}),
362
+ }),
363
+ );
364
+
365
+ let tagsOperation: Promise<void> | undefined;
366
+ const tags = stored.tags || [];
367
+ if (tags.length > 0) {
368
+ const currentTags = this.sharedTagsMap.get(cacheKey);
369
+ const currentIsSameAsNew =
370
+ currentTags?.length === tags.length &&
371
+ currentTags.every((v) => tags.includes(v)) &&
372
+ tags.every((v) => currentTags!.includes(v));
373
+
374
+ if (!currentIsSameAsNew) {
375
+ tagsOperation = this.sharedTagsMap.set(cacheKey, [...tags]);
376
+ }
377
+ }
378
+
379
+ await Promise.all([setOperation, tagsOperation]);
380
+ } catch (error) {
381
+ console.error(
382
+ 'RedisCacheComponentsHandler.set() Error occurred while setting cache entry. The original error was:',
383
+ error,
384
+ killContainerOnErrorCount++,
385
+ );
386
+ throw error;
387
+ }
388
+ }
389
+
390
+ async refreshTags(): Promise<void> {
391
+ await this.assertClientIsReady();
392
+ }
393
+
394
+ async getExpiration(tags: string[]): Promise<number> {
395
+ try {
396
+ await this.assertClientIsReady();
397
+ return this.computeMaxRevalidation(tags || []);
398
+ } catch (error) {
399
+ console.error(
400
+ 'RedisCacheComponentsHandler.getExpiration() Error occurred while getting expiration for tags. The original error was:',
401
+ error,
402
+ );
403
+ return 0;
404
+ }
405
+ }
406
+
407
+ async updateTags(
408
+ tags: string[],
409
+ _durations?: { expire?: number },
410
+ ): Promise<void> {
411
+ try {
412
+ // Mark optional argument as used to satisfy lint rules while keeping the signature
413
+ void _durations;
414
+ await this.assertClientIsReady();
415
+ const now = Date.now();
416
+
417
+ const tagsSet = new Set(tags || []);
418
+
419
+ for (const tag of tagsSet) {
420
+ await this.revalidatedTagsMap.set(tag, now);
421
+ }
422
+
423
+ const keysToDelete: Set<string> = new Set();
424
+
425
+ for (const [key, storedTags] of this.sharedTagsMap.entries()) {
426
+ if (storedTags.some((tag) => tagsSet.has(tag))) {
427
+ keysToDelete.add(key);
428
+ }
429
+ }
430
+
431
+ if (keysToDelete.size === 0) {
432
+ return;
433
+ }
434
+
435
+ const cacheKeys = Array.from(keysToDelete);
436
+
437
+ // Construct full Redis keys (same format as in get/set)
438
+ const fullRedisKeys = cacheKeys.map((key) => `${this.keyPrefix}${key}`);
439
+
440
+ await redisErrorHandler(
441
+ 'RedisCacheComponentsHandler.updateTags(), operation: unlink',
442
+ this.client.unlink(fullRedisKeys),
443
+ );
444
+
445
+ // Delete from sharedTagsMap
446
+ const deleteTagsOperation = this.sharedTagsMap.delete(cacheKeys);
447
+ await deleteTagsOperation;
448
+ } catch (error) {
449
+ console.error(
450
+ 'RedisCacheComponentsHandler.updateTags() Error occurred while updating tags. The original error was:',
451
+ error,
452
+ killContainerOnErrorCount++,
453
+ );
454
+ throw error;
455
+ }
456
+ }
457
+ }
458
+
459
+ let singletonHandler: CacheComponentsHandler | undefined;
460
+
461
+ export function getRedisCacheComponentsHandler(
462
+ options: CreateCacheComponentsHandlerOptions = {},
463
+ ): CacheComponentsHandler {
464
+ if (!singletonHandler) {
465
+ singletonHandler = new RedisCacheComponentsHandler(options);
466
+ }
467
+ return singletonHandler;
468
+ }
469
+
470
+ export const redisCacheHandler: CacheComponentsHandler =
471
+ getRedisCacheComponentsHandler();
@@ -190,19 +190,16 @@ export default class RedisStringsHandler {
190
190
  error,
191
191
  killContainerOnErrorCount++,
192
192
  );
193
- setTimeout(() => {
194
- // Avoid overlapping connect() calls which can lead to
195
- // "Socket already opened" errors when a reconnect is already
196
- // in progress or the socket is still open.
197
- if (!this.client.isOpen && !this.client.isReady) {
193
+ setTimeout(
194
+ () =>
198
195
  this.client.connect().catch((error) => {
199
196
  console.error(
200
197
  'Failed to reconnect Redis client after connection loss:',
201
198
  error,
202
199
  );
203
- });
204
- }
205
- }, 1000);
200
+ }),
201
+ 1000,
202
+ );
206
203
  if (
207
204
  this.killContainerOnErrorThreshold > 0 &&
208
205
  killContainerOnErrorCount >= this.killContainerOnErrorThreshold
@@ -225,9 +222,12 @@ export default class RedisStringsHandler {
225
222
  .then(() => {
226
223
  debug('green', 'Redis client connected.');
227
224
  })
228
- .catch((error) => {
229
- console.error('Failed to connect Redis client:', error);
230
- throw error;
225
+ .catch(() => {
226
+ this.client.connect().catch((error) => {
227
+ console.error('Failed to connect Redis client:', error);
228
+ this.client.disconnect();
229
+ throw error;
230
+ });
231
231
  });
232
232
  } catch (error: unknown) {
233
233
  console.error('Failed to initialize Redis client');
package/src/index.test.ts CHANGED
@@ -2,6 +2,6 @@ import { describe, it } from 'vitest';
2
2
 
3
3
  describe('Example Test', () => {
4
4
  it('should work correctly', () => {
5
- console.log("TODO tests")
5
+ console.log('TODO tests');
6
6
  });
7
7
  });
package/src/index.ts CHANGED
@@ -2,3 +2,8 @@ import CachedHandler from './CachedHandler';
2
2
  export default CachedHandler;
3
3
  import RedisStringsHandler from './RedisStringsHandler';
4
4
  export { RedisStringsHandler };
5
+ import {
6
+ redisCacheHandler,
7
+ getRedisCacheComponentsHandler,
8
+ } from './CacheComponentsHandler';
9
+ export { redisCacheHandler, getRedisCacheComponentsHandler };