@trieb.work/nextjs-turbo-redis-cache 1.10.0 → 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.
- package/.github/workflows/ci.yml +27 -11
- package/CHANGELOG.md +9 -0
- package/README.md +94 -0
- package/dist/index.d.mts +22 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.js +318 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +315 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/playwright.config.ts +8 -1
- package/src/CacheComponentsHandler.ts +471 -0
- package/src/index.test.ts +1 -1
- package/src/index.ts +5 -0
- package/test/cache-components/cache-components.integration.spec.ts +188 -0
- package/test/integration/next-app-15-4-7/next.config.js +3 -0
- package/test/integration/next-app-15-4-7/pnpm-lock.yaml +1 -1
- package/test/integration/next-app-16-0-3/next.config.ts +3 -0
- package/test/integration/next-app-16-1-1-cache-components/README.md +36 -0
- package/test/integration/next-app-16-1-1-cache-components/cache-handler.js +3 -0
- package/test/integration/next-app-16-1-1-cache-components/eslint.config.mjs +18 -0
- package/test/integration/next-app-16-1-1-cache-components/next.config.ts +13 -0
- package/test/integration/next-app-16-1-1-cache-components/package.json +28 -0
- package/test/integration/next-app-16-1-1-cache-components/pnpm-lock.yaml +4128 -0
- package/test/integration/next-app-16-1-1-cache-components/postcss.config.mjs +7 -0
- package/test/integration/next-app-16-1-1-cache-components/public/file.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/globe.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/next.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/public/file.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/public/globe.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/public/next.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/public/vercel.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/public/window.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/vercel.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/public/window.svg +1 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/api/cached-static-fetch/route.ts +19 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/api/cached-with-tag/route.ts +21 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/api/revalidate-tag/route.ts +19 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/api/revalidated-fetch/route.ts +19 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/cachelife-short/page.tsx +110 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/page.tsx +90 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/runtime-data-suspense/page.tsx +127 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/stale-while-revalidate/page.tsx +130 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/tag-invalidation/page.tsx +127 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/cache-lab/use-cache-nondeterministic/page.tsx +110 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/favicon.ico +0 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/globals.css +26 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/layout.tsx +57 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/page.tsx +755 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/revalidation-interface.tsx +267 -0
- package/test/integration/next-app-16-1-1-cache-components/src/app/update-tag-test/page.tsx +22 -0
- package/test/integration/next-app-16-1-1-cache-components/tsconfig.json +34 -0
- package/tests/cache-lab.spec.ts +157 -0
- 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();
|
package/src/index.test.ts
CHANGED
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 };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { spawn, ChildProcess } from 'child_process';
|
|
3
|
+
import { createClient, RedisClientType } from 'redis';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const PORT = Number(process.env.CACHE_COMPONENTS_PORT || '3065');
|
|
7
|
+
const BASE_URL = `http://localhost:${PORT}`;
|
|
8
|
+
|
|
9
|
+
describe('Next.js 16 Cache Components Integration', () => {
|
|
10
|
+
let nextProcess: ChildProcess;
|
|
11
|
+
let redisClient: RedisClientType;
|
|
12
|
+
let keyPrefix: string;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
// Connect to Redis
|
|
16
|
+
redisClient = createClient({
|
|
17
|
+
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
|
18
|
+
database: 1,
|
|
19
|
+
});
|
|
20
|
+
await redisClient.connect();
|
|
21
|
+
|
|
22
|
+
// Generate unique key prefix for this test run
|
|
23
|
+
keyPrefix = `cache-components-test-${Math.random().toString(36).substring(7)}`;
|
|
24
|
+
process.env.VERCEL_URL = keyPrefix;
|
|
25
|
+
|
|
26
|
+
// Build and start Next.js app
|
|
27
|
+
const appDir = path.join(
|
|
28
|
+
__dirname,
|
|
29
|
+
'..',
|
|
30
|
+
'integration',
|
|
31
|
+
'next-app-16-1-1-cache-components',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
console.log('Installing Next.js app dependencies...');
|
|
35
|
+
await new Promise<void>((resolve, reject) => {
|
|
36
|
+
const installProcess = spawn('pnpm', ['install'], {
|
|
37
|
+
cwd: appDir,
|
|
38
|
+
stdio: 'inherit',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
installProcess.on('close', (code) => {
|
|
42
|
+
if (code === 0) resolve();
|
|
43
|
+
else reject(new Error(`Install failed with code ${code}`));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
console.log('Building Next.js app...');
|
|
48
|
+
await new Promise<void>((resolve, reject) => {
|
|
49
|
+
const buildProcess = spawn('pnpm', ['build'], {
|
|
50
|
+
cwd: appDir,
|
|
51
|
+
stdio: 'inherit',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
buildProcess.on('close', (code) => {
|
|
55
|
+
if (code === 0) resolve();
|
|
56
|
+
else reject(new Error(`Build failed with code ${code}`));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
console.log('Starting Next.js app...');
|
|
61
|
+
nextProcess = spawn('pnpm', ['start', '-p', PORT.toString()], {
|
|
62
|
+
cwd: appDir,
|
|
63
|
+
env: { ...process.env, VERCEL_URL: keyPrefix },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Wait for server to be ready
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
68
|
+
}, 120000);
|
|
69
|
+
|
|
70
|
+
afterAll(async () => {
|
|
71
|
+
// Clean up Redis keys
|
|
72
|
+
const keys = await redisClient.keys(`${keyPrefix}*`);
|
|
73
|
+
if (keys.length > 0) {
|
|
74
|
+
await redisClient.del(keys);
|
|
75
|
+
}
|
|
76
|
+
await redisClient.quit();
|
|
77
|
+
|
|
78
|
+
// Kill Next.js process
|
|
79
|
+
if (nextProcess) {
|
|
80
|
+
nextProcess.kill();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('Basic use cache functionality', () => {
|
|
85
|
+
it('should cache data and return same counter value on subsequent requests', async () => {
|
|
86
|
+
// First request
|
|
87
|
+
const res1 = await fetch(`${BASE_URL}/api/cached-static-fetch`);
|
|
88
|
+
const data1 = await res1.json();
|
|
89
|
+
|
|
90
|
+
expect(data1.counter).toBe(1);
|
|
91
|
+
|
|
92
|
+
// Second request should return cached data
|
|
93
|
+
const res2 = await fetch(`${BASE_URL}/api/cached-static-fetch`);
|
|
94
|
+
const data2 = await res2.json();
|
|
95
|
+
|
|
96
|
+
expect(data2.counter).toBe(1); // Same counter value
|
|
97
|
+
expect(data2.timestamp).toBe(data1.timestamp); // Same timestamp
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should store cache entry in Redis', async () => {
|
|
101
|
+
await fetch(`${BASE_URL}/api/cached-static-fetch`);
|
|
102
|
+
|
|
103
|
+
// Check Redis for cache keys
|
|
104
|
+
const keys = await redisClient.keys(`${keyPrefix}*`);
|
|
105
|
+
expect(keys.length).toBeGreaterThan(0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('cacheTag functionality', () => {
|
|
110
|
+
it('should cache data with tags', async () => {
|
|
111
|
+
const res1 = await fetch(`${BASE_URL}/api/cached-with-tag`);
|
|
112
|
+
const data1 = await res1.json();
|
|
113
|
+
|
|
114
|
+
expect(data1.counter).toBeDefined();
|
|
115
|
+
|
|
116
|
+
// Second request should return cached data
|
|
117
|
+
const res2 = await fetch(`${BASE_URL}/api/cached-with-tag`);
|
|
118
|
+
const data2 = await res2.json();
|
|
119
|
+
|
|
120
|
+
expect(data2.counter).toBe(data1.counter);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should invalidate cache when tag is revalidated (Stale while revalidate)', async () => {
|
|
124
|
+
// Get initial cached data
|
|
125
|
+
const res1 = await fetch(`${BASE_URL}/api/cached-with-tag`);
|
|
126
|
+
const data1 = await res1.json();
|
|
127
|
+
|
|
128
|
+
// Revalidate the tag
|
|
129
|
+
await fetch(`${BASE_URL}/api/revalidate-tag`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify({ tag: 'test-tag' }),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// The cache should be invalidated - verify by making multiple requests
|
|
136
|
+
// until we get fresh data (with retries for async revalidation)
|
|
137
|
+
let freshDataReceived = false;
|
|
138
|
+
// Next.js tag revalidation can be async and may take longer under some runtimes.
|
|
139
|
+
// Use a more tolerant window to avoid flaky failures.
|
|
140
|
+
for (let i = 0; i < 60; i++) {
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
142
|
+
const res = await fetch(`${BASE_URL}/api/cached-with-tag`);
|
|
143
|
+
const data = await res.json();
|
|
144
|
+
|
|
145
|
+
if (
|
|
146
|
+
data.counter !== data1.counter ||
|
|
147
|
+
data.timestamp !== data1.timestamp
|
|
148
|
+
) {
|
|
149
|
+
freshDataReceived = true;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
expect(freshDataReceived).toBe(true);
|
|
155
|
+
}, 20_000);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('Redis cache handler integration', () => {
|
|
159
|
+
it('should call cache handler get and set methods', async () => {
|
|
160
|
+
// Make request to trigger cache (don't clear first)
|
|
161
|
+
await fetch(`${BASE_URL}/api/cached-static-fetch`);
|
|
162
|
+
|
|
163
|
+
// Verify Redis has the cached data
|
|
164
|
+
const redisKeys = await redisClient.keys(`${keyPrefix}*`);
|
|
165
|
+
expect(redisKeys.length).toBeGreaterThan(0);
|
|
166
|
+
|
|
167
|
+
// Filter out hash keys (sharedTagsMap) and only check string keys (cache entries)
|
|
168
|
+
// Try to get each key and verify at least one is a string value
|
|
169
|
+
let foundStringKey = false;
|
|
170
|
+
for (const key of redisKeys) {
|
|
171
|
+
try {
|
|
172
|
+
const type = await redisClient.type(key);
|
|
173
|
+
if (type === 'string') {
|
|
174
|
+
const cachedValue = await redisClient.get(key);
|
|
175
|
+
if (cachedValue) {
|
|
176
|
+
foundStringKey = true;
|
|
177
|
+
expect(cachedValue).toBeTruthy();
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// Skip non-string keys
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
expect(foundStringKey).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -471,7 +471,7 @@ packages:
|
|
|
471
471
|
'@trieb.work/nextjs-turbo-redis-cache@file:../../..':
|
|
472
472
|
resolution: {directory: ../../.., type: directory}
|
|
473
473
|
peerDependencies:
|
|
474
|
-
next: '>=15.0.3
|
|
474
|
+
next: '>=15.0.3 <16.2.0'
|
|
475
475
|
redis: 4.7.0
|
|
476
476
|
|
|
477
477
|
'@tybys/wasm-util@0.9.0':
|