@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trieb.work/nextjs-turbo-redis-cache",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "homepage": "https://trwk.de/case-studies/nextjs-turbo-redis-cache",
5
5
  "repository": {
6
6
  "type": "git",
@@ -78,10 +78,8 @@
78
78
  "@commitlint/cli": "^19.6.0",
79
79
  "@commitlint/config-conventional": "^19.6.0",
80
80
  "@playwright/test": "^1.62.0",
81
- "@semantic-release/changelog": "^6.0.3",
82
- "@semantic-release/git": "^10.0.1",
83
- "@semantic-release/github": "^12.0.2",
84
- "@semantic-release/npm": "^13.1.3",
81
+ "@semantic-release/github": "12.0.9",
82
+ "@semantic-release/npm": "13.1.5",
85
83
  "@typescript-eslint/eslint-plugin": "^8.15.0",
86
84
  "@typescript-eslint/parser": "^8.15.0",
87
85
  "@vitest/coverage-v8": "^4.0.13",
@@ -91,7 +89,7 @@
91
89
  "node-fetch": "3",
92
90
  "prettier": "^3.3.3",
93
91
  "redis": "4.7.0",
94
- "semantic-release": "^25.0.2",
92
+ "semantic-release": "25.0.9",
95
93
  "tsup": "^8.4.0",
96
94
  "tsx": "^4.21.0",
97
95
  "typescript": "^5.6.3",
@@ -1,31 +1,12 @@
1
1
  module.exports = {
2
2
  branches: [
3
- { name: 'main' }, // Stable releases
4
- { name: 'beta', prerelease: true }, // Beta releases
3
+ { name: 'main' },
4
+ { name: 'beta', prerelease: true },
5
5
  ],
6
6
  plugins: [
7
- '@semantic-release/commit-analyzer', // Analyze commits to determine the release type
8
- '@semantic-release/release-notes-generator', // Generate release notes
9
- '@semantic-release/changelog', // Update changelog
10
- [
11
- '@semantic-release/npm',
12
- {
13
- npmPublish: false, // Publish to NPM
14
- tag: 'beta', // Publish beta releases under the "beta" tag
15
- },
16
- ],
17
- [
18
- '@semantic-release/github',
19
- {
20
- labels: [],
21
- },
22
- ],
23
- [
24
- '@semantic-release/git',
25
- {
26
- assets: ['package.json', 'pnpm-lock.yaml', 'CHANGELOG.md'],
27
- message: 'chore(release): ${nextRelease.version} [skip ci]',
28
- },
29
- ],
7
+ '@semantic-release/commit-analyzer',
8
+ '@semantic-release/release-notes-generator',
9
+ '@semantic-release/npm',
10
+ '@semantic-release/github',
30
11
  ],
31
12
  };
@@ -9,6 +9,17 @@ import { SyncedMap } from './SyncedMap';
9
9
  import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
10
10
  import { debug } from './utils/debug';
11
11
  import { resolveKeyPrefix } from './utils/prefix';
12
+ import { shouldDeferRedisConnection } from './utils/redisConnection';
13
+ import { compareAndUnlink } from './utils/compareAndUnlink';
14
+ import {
15
+ applyTagUpdate,
16
+ areTagsExpired,
17
+ areTagsStale,
18
+ maxExpiredTimestamp,
19
+ normalizeTagManifest,
20
+ persistableTagManifest,
21
+ type TagManifestEntry,
22
+ } from './utils/tagRevalidation';
12
23
 
13
24
  export interface CacheComponentsEntry {
14
25
  value: ReadableStream<Uint8Array>;
@@ -86,7 +97,7 @@ function bufferToReadableStream(
86
97
 
87
98
  class RedisCacheComponentsHandler implements CacheComponentsHandler {
88
99
  private client: Client;
89
- private revalidatedTagsMap: SyncedMap<number>;
100
+ private revalidatedTagsMap: SyncedMap<TagManifestEntry | number>;
90
101
  private sharedTagsMap: SyncedMap<string[]>;
91
102
  private inMemoryDeduplicationCache: SyncedMap<
92
103
  Promise<ReturnType<Client['get']>>
@@ -101,6 +112,7 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
101
112
  private deduplicatedRedisGet: (key: string) => Client['get'];
102
113
  private redisGetDeduplication: boolean;
103
114
  private inMemoryCachingTime: number;
115
+ private redisConnectionDeferred: boolean;
104
116
 
105
117
  constructor({
106
118
  redisUrl = process.env.REDIS_URL
@@ -134,6 +146,7 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
134
146
  this.getTimeoutMs = getTimeoutMs;
135
147
  this.redisGetDeduplication = redisGetDeduplication;
136
148
  this.inMemoryCachingTime = inMemoryCachingTime;
149
+ this.redisConnectionDeferred = shouldDeferRedisConnection();
137
150
 
138
151
  this.client = createClient({
139
152
  url: redisUrl,
@@ -180,32 +193,35 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
180
193
  }
181
194
  });
182
195
 
183
- this.client
184
- .connect()
185
- .then(() => {
186
- debug('green', 'RedisCacheComponentsHandler client connected.');
187
- })
188
- .catch(() => {
189
- this.client.connect().catch((error) => {
190
- console.error(
191
- 'Failed to connect RedisCacheComponentsHandler client:',
192
- error,
193
- );
194
- this.client.disconnect();
195
- throw error;
196
+ if (!this.redisConnectionDeferred) {
197
+ this.client
198
+ .connect()
199
+ .then(() => {
200
+ debug('green', 'RedisCacheComponentsHandler client connected.');
201
+ })
202
+ .catch(() => {
203
+ this.client.connect().catch((error) => {
204
+ console.error(
205
+ 'Failed to connect RedisCacheComponentsHandler client:',
206
+ error,
207
+ );
208
+ this.client.disconnect();
209
+ throw error;
210
+ });
196
211
  });
197
- });
212
+ }
198
213
 
199
214
  const filterKeys = (key: string): boolean =>
200
215
  key !== REVALIDATED_TAGS_KEY && key !== SHARED_TAGS_KEY;
201
216
 
202
- this.revalidatedTagsMap = new SyncedMap<number>({
217
+ this.revalidatedTagsMap = new SyncedMap<TagManifestEntry | number>({
203
218
  client: this.client,
204
219
  keyPrefix: this.keyPrefix,
205
220
  redisKey: REVALIDATED_TAGS_KEY,
206
221
  database,
207
222
  querySize: revalidateTagQuerySize,
208
223
  filterKeys,
224
+ customizedSync: { withoutOrphanCleanup: true },
209
225
  resyncIntervalMs:
210
226
  avgResyncIntervalMs +
211
227
  avgResyncIntervalMs / 10 +
@@ -254,6 +270,14 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
254
270
  }
255
271
 
256
272
  private async assertClientIsReady(): Promise<void> {
273
+ if (this.redisConnectionDeferred) {
274
+ await Promise.all([
275
+ this.revalidatedTagsMap.waitUntilReady(),
276
+ this.sharedTagsMap.waitUntilReady(),
277
+ ]);
278
+ return;
279
+ }
280
+
257
281
  if (!this.client.isReady && !this.client.isOpen) {
258
282
  await this.client.connect().catch((error) => {
259
283
  console.error(
@@ -269,21 +293,23 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
269
293
  ]);
270
294
  }
271
295
 
272
- private async computeMaxRevalidation(tags: string[]): Promise<number> {
273
- let max = 0;
274
- for (const tag of tags) {
275
- const ts = this.revalidatedTagsMap.get(tag);
276
- if (ts && ts > max) {
277
- max = ts;
278
- }
279
- }
280
- return max;
296
+ private isClientUnavailable(): boolean {
297
+ return this.redisConnectionDeferred || !this.client.isReady;
298
+ }
299
+
300
+ private tagManifestFor(tag: string, now = Date.now()): TagManifestEntry {
301
+ return normalizeTagManifest(this.revalidatedTagsMap.get(tag), now);
281
302
  }
282
303
 
283
304
  async get(
284
305
  cacheKey: string,
285
- softTags: string[],
306
+ _softTags: string[],
286
307
  ): Promise<CacheComponentsEntry | undefined> {
308
+ // Soft-tag staleness is handled by Next.js via getExpiration() (implicit
309
+ // `_N_T_` tags). Explicit cacheTag()s are checked below via the tag
310
+ // manifest, matching DefaultCacheHandler.get() in Next.js 16.0–16.3.
311
+ void _softTags;
312
+
287
313
  // Construct the full Redis key
288
314
  // For cache components, Next.js provides the full key including environment prefix
289
315
  // We prepend our keyPrefix for multi-tenant isolation
@@ -291,63 +317,84 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
291
317
 
292
318
  try {
293
319
  await this.assertClientIsReady();
294
-
295
- const clientGet = this.redisGetDeduplication
296
- ? this.deduplicatedRedisGet(cacheKey)
297
- : this.redisGet;
298
-
299
- const serialized = await redisErrorHandler(
300
- 'RedisCacheComponentsHandler.get(), operation: get' +
301
- (this.redisGetDeduplication ? ' deduplicated' : '') +
302
- ' ' +
303
- this.getTimeoutMs +
304
- 'ms ' +
305
- redisKey,
306
- clientGet(
307
- commandOptions({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
308
- redisKey,
309
- ),
310
- );
311
-
312
- if (!serialized) {
320
+ if (this.isClientUnavailable()) {
313
321
  return undefined;
314
322
  }
315
323
 
316
- const stored: StoredCacheEntry = JSON.parse(serialized);
317
- const now = Date.now();
324
+ const readSerialized = (useDedup: boolean) =>
325
+ redisErrorHandler(
326
+ 'RedisCacheComponentsHandler.get(), operation: get' +
327
+ (useDedup ? ' deduplicated' : '') +
328
+ ' ' +
329
+ this.getTimeoutMs +
330
+ 'ms ' +
331
+ redisKey,
332
+ (useDedup ? this.deduplicatedRedisGet(cacheKey) : this.redisGet)(
333
+ commandOptions({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
334
+ redisKey,
335
+ ),
336
+ );
318
337
 
319
- // expire is a duration in seconds, calculate absolute expiry time
320
- const expiryTime = stored.timestamp + stored.expire * 1000;
321
- if (
322
- Number.isFinite(stored.expire) &&
323
- stored.expire > 0 &&
324
- now > expiryTime
325
- ) {
326
- await this.client.unlink(redisKey).catch(() => {});
327
- await this.sharedTagsMap.delete(cacheKey).catch(() => {});
328
- return undefined;
329
- }
338
+ let usedDedup = this.redisGetDeduplication;
339
+ let serialized = await readSerialized(usedDedup);
330
340
 
331
- const maxRevalidation = await this.computeMaxRevalidation([
332
- ...(stored.tags || []),
333
- ...(softTags || []),
334
- ]);
341
+ for (let attempt = 0; attempt < 2; attempt++) {
342
+ if (!serialized) {
343
+ return undefined;
344
+ }
335
345
 
336
- if (maxRevalidation > 0 && maxRevalidation > stored.timestamp) {
337
- await this.client.unlink(redisKey).catch(() => {});
338
- await this.sharedTagsMap.delete(cacheKey).catch(() => {});
339
- return undefined;
340
- }
346
+ const stored: StoredCacheEntry = JSON.parse(serialized);
347
+ const now = Date.now();
348
+ const tags = stored.tags || [];
349
+ const expiryTime = stored.timestamp + stored.expire * 1000;
350
+ const ttlExpired =
351
+ Number.isFinite(stored.expire) &&
352
+ stored.expire > 0 &&
353
+ now > expiryTime;
354
+ const tagsExpired = areTagsExpired(tags, stored.timestamp, now, (tag) =>
355
+ this.tagManifestFor(tag, now),
356
+ );
341
357
 
342
- const valueBuffer =
343
- typeof stored.value === 'string'
344
- ? new Uint8Array(Buffer.from(stored.value, 'base64'))
345
- : stored.value;
358
+ if (ttlExpired || tagsExpired) {
359
+ if (usedDedup) {
360
+ usedDedup = false;
361
+ serialized = await readSerialized(false);
362
+ continue;
363
+ }
364
+
365
+ const dropped = await compareAndUnlink(
366
+ this.client,
367
+ redisKey,
368
+ serialized,
369
+ );
370
+ if (dropped) {
371
+ await this.sharedTagsMap.delete(cacheKey).catch(() => {});
372
+ }
373
+ return undefined;
374
+ }
346
375
 
347
- return {
348
- ...stored,
349
- value: bufferToReadableStream(valueBuffer),
350
- };
376
+ const valueBuffer =
377
+ typeof stored.value === 'string'
378
+ ? new Uint8Array(Buffer.from(stored.value, 'base64'))
379
+ : stored.value;
380
+
381
+ const entry: CacheComponentsEntry = {
382
+ ...stored,
383
+ value: bufferToReadableStream(valueBuffer),
384
+ };
385
+
386
+ if (
387
+ areTagsStale(tags, stored.timestamp, (tag) =>
388
+ this.tagManifestFor(tag, now),
389
+ )
390
+ ) {
391
+ entry.revalidate = -1;
392
+ }
393
+
394
+ return entry;
395
+ }
396
+
397
+ return undefined;
351
398
  } catch (error) {
352
399
  console.error(
353
400
  '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:',
@@ -364,6 +411,9 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
364
411
  ): Promise<void> {
365
412
  try {
366
413
  await this.assertClientIsReady();
414
+ if (this.isClientUnavailable()) {
415
+ return;
416
+ }
367
417
 
368
418
  const entry = await pendingEntry;
369
419
 
@@ -451,7 +501,7 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
451
501
  async getExpiration(tags: string[]): Promise<number> {
452
502
  try {
453
503
  await this.assertClientIsReady();
454
- return this.computeMaxRevalidation(tags || []);
504
+ return maxExpiredTimestamp(tags || [], (tag) => this.tagManifestFor(tag));
455
505
  } catch (error) {
456
506
  console.error(
457
507
  'RedisCacheComponentsHandler.getExpiration() Error occurred while getting expiration for tags. The original error was:',
@@ -463,51 +513,31 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
463
513
 
464
514
  async updateTags(
465
515
  tags: string[],
466
- _durations?: { expire?: number },
516
+ durations?: { expire?: number },
467
517
  ): Promise<void> {
468
518
  try {
469
- // Mark optional argument as used to satisfy lint rules while keeping the signature
470
- void _durations;
471
519
  await this.assertClientIsReady();
472
- const now = Date.now();
473
-
474
- const tagsSet = new Set(tags || []);
475
-
476
- for (const tag of tagsSet) {
477
- await this.revalidatedTagsMap.set(tag, now);
478
- }
479
-
480
- const keysToDelete: Set<string> = new Set();
481
-
482
- for (const [key, storedTags] of this.sharedTagsMap.entries()) {
483
- if (storedTags.some((tag) => tagsSet.has(tag))) {
484
- keysToDelete.add(key);
485
- }
486
- }
487
-
488
- if (keysToDelete.size === 0) {
520
+ if (this.isClientUnavailable()) {
489
521
  return;
490
522
  }
491
523
 
492
- const cacheKeys = Array.from(keysToDelete);
493
-
494
- // Construct full Redis keys (same format as in get/set)
495
- const fullRedisKeys = cacheKeys.map((key) => `${this.keyPrefix}${key}`);
496
-
497
- await redisErrorHandler(
498
- 'RedisCacheComponentsHandler.updateTags(), operation: unlink',
499
- this.client.unlink(fullRedisKeys),
500
- );
524
+ const now = Date.now();
525
+ const updated = new Set(tags || []);
526
+ for (const tag of updated) {
527
+ const next = applyTagUpdate(this.tagManifestFor(tag), durations, now);
528
+ await this.revalidatedTagsMap.set(
529
+ tag,
530
+ persistableTagManifest(next, now),
531
+ );
532
+ }
501
533
 
502
534
  if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
503
- for (const key of keysToDelete) {
504
- this.inMemoryDeduplicationCache.delete(key);
535
+ for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
536
+ if (sharedTags.some((tag) => updated.has(tag))) {
537
+ await this.inMemoryDeduplicationCache.delete(key, true);
538
+ }
505
539
  }
506
540
  }
507
-
508
- // Delete from sharedTagsMap
509
- const deleteTagsOperation = this.sharedTagsMap.delete(cacheKeys);
510
- await deleteTagsOperation;
511
541
  } catch (error) {
512
542
  console.error(
513
543
  'RedisCacheComponentsHandler.updateTags() Error occurred while updating tags. The original error was:',
@@ -3,6 +3,8 @@ import { SyncedMap } from './SyncedMap';
3
3
  import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
4
4
  import { debug } from './utils/debug';
5
5
  import { CacheValueSerializer, jsonCacheValueSerializer } from './serializer';
6
+ import { resolveCacheEntryTtlSeconds } from './utils/cacheTtl';
7
+ import { shouldDeferRedisConnection } from './utils/redisConnection';
6
8
 
7
9
  export type CommandOptions = ReturnType<typeof commandOptions>;
8
10
  export type Client = ReturnType<typeof createClient>;
@@ -262,6 +264,7 @@ export default class RedisStringsHandler {
262
264
  private estimateExpireAge: (staleAge: number) => number;
263
265
  private killContainerOnErrorThreshold: number;
264
266
  private valueSerializer: CacheValueSerializer;
267
+ private redisConnectionDeferred: boolean;
265
268
 
266
269
  constructor({
267
270
  redisUrl = process.env.REDIS_URL
@@ -301,6 +304,7 @@ export default class RedisStringsHandler {
301
304
  this.killContainerOnErrorThreshold = killContainerOnErrorThreshold;
302
305
  this.getTimeoutMs = getTimeoutMs;
303
306
  this.valueSerializer = valueSerializer;
307
+ this.redisConnectionDeferred = shouldDeferRedisConnection();
304
308
 
305
309
  try {
306
310
  // Create Redis client with properly typed configuration
@@ -348,15 +352,17 @@ export default class RedisStringsHandler {
348
352
  }
349
353
  });
350
354
 
351
- this.client
352
- .connect()
353
- .then(() => {
354
- debug('green', 'Redis client connected.');
355
- })
356
- .catch((error) => {
357
- console.error('Failed to connect Redis client:', error);
358
- throw error;
359
- });
355
+ if (!this.redisConnectionDeferred) {
356
+ this.client
357
+ .connect()
358
+ .then(() => {
359
+ debug('green', 'Redis client connected.');
360
+ })
361
+ .catch((error) => {
362
+ console.error('Failed to connect Redis client:', error);
363
+ throw error;
364
+ });
365
+ }
360
366
  } catch (error: unknown) {
361
367
  console.error('Failed to initialize Redis client');
362
368
  throw error;
@@ -385,6 +391,7 @@ export default class RedisStringsHandler {
385
391
  database,
386
392
  querySize: revalidateTagQuerySize,
387
393
  filterKeys,
394
+ customizedSync: { withoutOrphanCleanup: true },
388
395
  resyncIntervalMs:
389
396
  avgResyncIntervalMs +
390
397
  avgResyncIntervalMs / 10 +
@@ -472,12 +479,19 @@ export default class RedisStringsHandler {
472
479
  }
473
480
  this.clientReadyCalls = 0;
474
481
  if (!this.client.isReady) {
482
+ if (this.redisConnectionDeferred) {
483
+ return;
484
+ }
475
485
  throw new Error(
476
486
  'assertClientIsReady: Redis client is not ready yet or connection is lost.',
477
487
  );
478
488
  }
479
489
  }
480
490
 
491
+ private isClientUnavailable(): boolean {
492
+ return this.redisConnectionDeferred || !this.client.isReady;
493
+ }
494
+
481
495
  public async get(key: string, ctx: GetContext): Promise<CacheEntry | null> {
482
496
  try {
483
497
  if (!(SUPPORTED_GET_KINDS as readonly string[]).includes(ctx.kind)) {
@@ -492,6 +506,9 @@ export default class RedisStringsHandler {
492
506
 
493
507
  debug('green', 'RedisStringsHandler.get() called with', key, ctx);
494
508
  await this.assertClientIsReady();
509
+ if (this.isClientUnavailable()) {
510
+ return null;
511
+ }
495
512
 
496
513
  const clientGet = this.redisGetDeduplication
497
514
  ? this.deduplicatedRedisGet(key)
@@ -704,6 +721,9 @@ export default class RedisStringsHandler {
704
721
  }
705
722
 
706
723
  await this.assertClientIsReady();
724
+ if (this.isClientUnavailable()) {
725
+ return;
726
+ }
707
727
 
708
728
  if (data?.kind === 'APP_PAGE' || data?.kind === 'APP_ROUTE') {
709
729
  const tags = data.headers['x-next-cache-tags']?.split(',');
@@ -739,21 +759,10 @@ export default class RedisStringsHandler {
739
759
  );
740
760
  }
741
761
 
742
- // TODO: implement expiration based on cacheControl.expire argument, -> probably relevant for cacheLife and "use cache" etc.: https://nextjs.org/docs/app/api-reference/functions/cacheLife
743
- // Constructing the expire time for the cache entry
744
- const revalidate =
745
- // For fetch requests in newest versions, the revalidate context property is never used, and instead the revalidate property of the passed-in data is used
746
- (data?.kind === 'FETCH' && data.revalidate) ||
747
- ctx.revalidate ||
748
- ctx.cacheControl?.revalidate ||
749
- // Legacy fallback: older Next.js versions attached `revalidate` directly
750
- // to the data payload. FETCH is already handled above, so this only
751
- // matters for those older, non-discriminated shapes.
752
- (data as { revalidate?: number | false } | null)?.revalidate;
753
- const expireAt =
754
- revalidate && Number.isSafeInteger(revalidate) && revalidate > 0
755
- ? this.estimateExpireAge(revalidate)
756
- : this.estimateExpireAge(this.defaultStaleAge);
762
+ const expireAt = resolveCacheEntryTtlSeconds(ctx, data, {
763
+ estimateExpireAge: this.estimateExpireAge,
764
+ defaultStaleAge: this.defaultStaleAge,
765
+ });
757
766
 
758
767
  // Setting the cache entry in redis
759
768
  const setOperation: Promise<string | null> = redisErrorHandler(
@@ -762,7 +771,7 @@ export default class RedisStringsHandler {
762
771
  ' ' +
763
772
  key,
764
773
  this.client.set(this.keyPrefix + key, serializedCacheEntry, {
765
- EX: expireAt,
774
+ ...(expireAt ? { EX: expireAt } : {}),
766
775
  }),
767
776
  );
768
777
 
@@ -838,6 +847,9 @@ export default class RedisStringsHandler {
838
847
  );
839
848
  const tags = new Set([tagOrTags || []].flat());
840
849
  await this.assertClientIsReady();
850
+ if (this.isClientUnavailable()) {
851
+ return;
852
+ }
841
853
 
842
854
  // find all keys that are related to this tag
843
855
  const keysToDelete: Set<string> = new Set();
package/src/SyncedMap.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  // SyncedMap.ts
2
2
  import { Client, redisErrorHandler } from './RedisStringsHandler';
3
3
  import { debugVerbose, debug } from './utils/debug';
4
+ import { shouldDeferRedisConnection } from './utils/redisConnection';
4
5
 
5
6
  type CustomizedSync = {
6
7
  withoutRedisHashmap?: boolean;
7
8
  withoutSetSync?: boolean;
9
+ /**
10
+ * Skip SCAN-based orphan cleanup. Required for maps whose keys are not Redis
11
+ * string keys (tag manifests). Otherwise startup/resync HDELs every tag.
12
+ */
13
+ withoutOrphanCleanup?: boolean;
8
14
  };
9
15
 
10
16
  type SyncedMapOptions = {
@@ -67,6 +73,11 @@ export class SyncedMap<V> {
67
73
  }
68
74
 
69
75
  private async setup() {
76
+ if (shouldDeferRedisConnection()) {
77
+ this.setupLockResolve();
78
+ return;
79
+ }
80
+
70
81
  let setupPromises: Promise<void>[] = [];
71
82
  if (!this.customizedSync?.withoutRedisHashmap) {
72
83
  setupPromises.push(this.initialSync());
@@ -109,8 +120,11 @@ export class SyncedMap<V> {
109
120
  cursor = remoteItems.cursor;
110
121
  } while (cursor !== 0);
111
122
 
112
- // Clean up keys not in Redis
113
- await this.cleanupKeysNotInRedis();
123
+ // sharedTagsMap keys are cache keys (Redis strings). Tag-manifest maps
124
+ // use tag names as hash fields — those never appear in SCAN.
125
+ if (!this.customizedSync?.withoutOrphanCleanup) {
126
+ await this.cleanupKeysNotInRedis();
127
+ }
114
128
  } catch (error) {
115
129
  console.error('Error during initial sync:', error);
116
130
  throw error;
package/src/index.ts CHANGED
@@ -8,6 +8,13 @@ export type { CreateRedisStringsHandlerOptions } from './RedisStringsHandler';
8
8
  export { jsonCacheValueSerializer } from './serializer';
9
9
  export type { CacheValueSerializer } from './serializer';
10
10
  export { bufferAndMapReplacer, bufferAndMapReviver } from './utils/json';
11
+ export { resolveCacheEntryTtlSeconds } from './utils/cacheTtl';
12
+ export type {
13
+ ResolveCacheEntryTtlContext,
14
+ ResolveCacheEntryTtlData,
15
+ ResolveCacheEntryTtlOptions,
16
+ } from './utils/cacheTtl';
17
+ export { shouldDeferRedisConnection } from './utils/redisConnection';
11
18
 
12
19
  import {
13
20
  redisCacheHandler,