@trieb.work/nextjs-turbo-redis-cache 1.16.2 → 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 (125) hide show
  1. package/.github/workflows/ci.yml +11 -2
  2. package/.github/workflows/release.yml +8 -157
  3. package/ARCHITECTURE.md +86 -47
  4. package/README.md +91 -25
  5. package/dist/index.d.mts +40 -1
  6. package/dist/index.d.ts +40 -1
  7. package/dist/index.js +264 -96
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +261 -95
  10. package/dist/index.mjs.map +1 -1
  11. package/package.json +5 -7
  12. package/release.config.cjs +6 -25
  13. package/src/CacheComponentsHandler.ts +141 -111
  14. package/src/RedisStringsHandler.ts +37 -25
  15. package/src/SyncedMap.ts +16 -2
  16. package/src/index.ts +7 -0
  17. package/src/utils/cacheTtl.ts +71 -0
  18. package/src/utils/compareAndUnlink.ts +29 -0
  19. package/src/utils/redisConnection.ts +17 -0
  20. package/src/utils/tagRevalidation.ts +162 -0
  21. package/test/README.md +13 -13
  22. package/test/nextjs-test-projects/next-app-15-4-11/pnpm-lock.yaml +1 -1
  23. package/test/nextjs-test-projects/next-app-16-0-11/pnpm-lock.yaml +1 -1
  24. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
  25. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/pnpm-lock.yaml +1 -1
  26. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  27. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  28. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
  29. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  30. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  31. package/test/nextjs-test-projects/next-app-16-2-6/pnpm-lock.yaml +1 -1
  32. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
  33. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/pnpm-lock.yaml +1 -1
  34. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  35. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  36. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
  37. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  38. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  39. package/test/nextjs-test-projects/next-app-16-3-0/README.md +36 -0
  40. package/test/nextjs-test-projects/next-app-16-3-0/eslint.config.mjs +18 -0
  41. package/test/nextjs-test-projects/next-app-16-3-0/next.config.ts +7 -0
  42. package/test/nextjs-test-projects/next-app-16-3-0/package.json +28 -0
  43. package/test/nextjs-test-projects/next-app-16-3-0/pnpm-lock.yaml +4284 -0
  44. package/test/nextjs-test-projects/next-app-16-3-0/postcss.config.mjs +7 -0
  45. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/cached-static-fetch/route.ts +18 -0
  46. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
  47. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidatePath/route.ts +15 -0
  48. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidateTag/route.ts +20 -0
  49. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidated-fetch/route.ts +17 -0
  50. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/uncached-fetch/route.ts +15 -0
  51. package/test/nextjs-test-projects/next-app-16-3-0/src/app/favicon.ico +0 -0
  52. package/test/nextjs-test-projects/next-app-16-3-0/src/app/globals.css +26 -0
  53. package/test/nextjs-test-projects/next-app-16-3-0/src/app/layout.tsx +59 -0
  54. package/test/nextjs-test-projects/next-app-16-3-0/src/app/page.tsx +755 -0
  55. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
  56. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
  57. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  58. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/no-fetch/default-page/page.tsx +55 -0
  59. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
  60. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
  61. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  62. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
  63. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
  64. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  65. package/test/nextjs-test-projects/next-app-16-3-0/src/app/revalidation-interface.tsx +267 -0
  66. package/test/nextjs-test-projects/next-app-16-3-0/src/app/update-tag-test/page.tsx +25 -0
  67. package/test/nextjs-test-projects/next-app-16-3-0/tsconfig.json +34 -0
  68. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/README.md +36 -0
  69. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/cache-handler.js +3 -0
  70. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/eslint.config.mjs +18 -0
  71. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +15 -0
  72. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/package.json +28 -0
  73. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/pnpm-lock.yaml +4284 -0
  74. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/postcss.config.mjs +7 -0
  75. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/file.svg +1 -0
  76. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/globe.svg +1 -0
  77. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/next.svg +1 -0
  78. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/vercel.svg +1 -0
  79. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/window.svg +1 -0
  80. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-static-fetch/route.ts +19 -0
  81. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-cachelife/route.ts +24 -0
  82. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-tag/route.ts +21 -0
  83. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  84. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  85. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidate-tag/route.ts +19 -0
  86. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidated-fetch/route.ts +19 -0
  87. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/cachelife-short/page.tsx +110 -0
  88. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +112 -0
  89. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  90. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/runtime-data-suspense/page.tsx +127 -0
  91. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/stale-while-revalidate/page.tsx +130 -0
  92. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/tag-invalidation/page.tsx +127 -0
  93. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-nondeterministic/page.tsx +110 -0
  94. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  95. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/favicon.ico +0 -0
  96. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/globals.css +26 -0
  97. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/layout.tsx +57 -0
  98. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/page.tsx +755 -0
  99. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/revalidation-interface.tsx +267 -0
  100. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/update-tag-test/page.tsx +22 -0
  101. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/tsconfig.json +34 -0
  102. package/test/nextjs-test-projects/next-pages-16-2-6/pnpm-lock.yaml +4 -4
  103. package/test/nextjs-test-projects/next-pages-16-3-0/README.md +16 -0
  104. package/test/nextjs-test-projects/next-pages-16-3-0/eslint.config.mjs +18 -0
  105. package/test/nextjs-test-projects/next-pages-16-3-0/next.config.ts +7 -0
  106. package/test/nextjs-test-projects/next-pages-16-3-0/package.json +26 -0
  107. package/test/nextjs-test-projects/next-pages-16-3-0/pnpm-lock.yaml +3939 -0
  108. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/api/revalidate.ts +24 -0
  109. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/index.tsx +11 -0
  110. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/isr/[slug].tsx +49 -0
  111. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/static-forever.tsx +20 -0
  112. package/test/nextjs-test-projects/next-pages-16-3-0/tsconfig.json +34 -0
  113. package/test/playwright/cache-lab.spec.ts +75 -0
  114. package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
  115. package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +67 -34
  116. package/test/vitest/integration/pages-router.integration.test.ts +16 -6
  117. package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
  118. package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
  119. package/test/vitest/unit/cache-ttl.test.ts +67 -0
  120. package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
  121. package/test/vitest/unit/index.test.ts +17 -0
  122. package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
  123. package/test/vitest/unit/redis-connection.test.ts +39 -0
  124. package/test/vitest/unit/tag-revalidation.test.ts +178 -0
  125. package/CHANGELOG.md +0 -404
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # nextjs-turbo-redis-cache - Next.js Cache Handler
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@trieb.work/nextjs-turbo-redis-cache.svg)](https://www.npmjs.com/package/@trieb.work/nextjs-turbo-redis-cache)
4
- ![Turbo redis cache image](https://github.com/user-attachments/assets/4103191e-4f4d-4139-a519-0b5bfab3e8b4)
4
+ <img width="2512" height="1602" alt="Turbo redis cache image" src="https://github.com/user-attachments/assets/6f46e1fb-fcf7-4157-856c-196a1483534f" />
5
5
 
6
6
  The ultimate Redis Cache Handler for Next.js 15 / 16, supporting both the App Router and the Pages Router. Built for production-ready, large-scale projects, it delivers unparalleled performance and efficiency with features tailored for high-traffic applications. This package has been created after extensibly testing the @neshca package and finding several major issues with it.
7
7
 
@@ -34,6 +34,9 @@ Tested versions are:
34
34
  - Nextjs 16.2.6 + redis client 4.7.0 (cacheComponents: false)
35
35
  - Nextjs 16.2.6 + redis client 4.7.0 (cacheComponents: true)
36
36
  - Nextjs 16.2.6 + redis client 4.7.0 (Pages Router)
37
+ - Nextjs 16.3.0 + redis client 4.7.0 (cacheComponents: false)
38
+ - Nextjs 16.3.0 + redis client 4.7.0 (cacheComponents: true)
39
+ - Nextjs 16.3.0 + redis client 4.7.0 (Pages Router)
37
40
 
38
41
  _Cache Components_ (Next.js 16+) are fully supported. Automated test coverage includes `'use cache'`, `cacheTag`, and `cacheLife` flows in the Cache Components integration suite.
39
42
 
@@ -87,6 +90,8 @@ const nextConfig = {
87
90
 
88
91
  Make sure to set either REDIS_URL or REDISHOST and REDISPORT environment variables.
89
92
 
93
+ Redis connections are skipped during `next build` (`NEXT_PHASE=phase-production-build`), so a production build can succeed without Redis. The handler connects when Next.js first calls it at runtime (`next start`).
94
+
90
95
  ### Option B: create a wrapper file to change options
91
96
 
92
97
  create new file `customized-cache-handler.js` in your project root and add the following code:
@@ -128,6 +133,8 @@ module.exports = class CustomizedCacheHandler {
128
133
  }
129
134
  ```
130
135
 
136
+ `defaultStaleAge` and `estimateExpireAge` are fallbacks for when Next.js does not pass `cacheControl.expire`. On Next.js 16.3+ ISR, Redis TTL is `expire` and these options do not change it.
137
+
131
138
  extend `next.config.js` with:
132
139
 
133
140
  ```
@@ -142,23 +149,23 @@ A working example of above can be found in the `test/nextjs-test-projects/next-a
142
149
 
143
150
  ## Available Options
144
151
 
145
- | Option | Description | Default Value |
146
- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
147
- | redisUrl | Redis connection url | `process.env.REDIS_URL? process.env.REDIS_URL : process.env.REDISHOST ? redis://${process.env.REDISHOST}:${process.env.REDISPORT} : 'redis://localhost:6379'` |
148
- | database | Redis database number to use. Uses DB 0 for production, DB 1 otherwise | `process.env.VERCEL_ENV === 'production' ? 0 : 1` |
149
- | keyPrefix | Prefix added to all Redis keys | `RedisStringsHandler` default: `process.env.KEY_PREFIX \|\| process.env.VERCEL_URL \|\| 'UNDEFINED_URL_'`<br> Next handlers resolve: `options.keyPrefix \|\| KEY_PREFIX \|\| VERCEL_URL \|\| BUILD_ID \|\| 'UNDEFINED_URL_'` |
150
- | sharedTagsKey | Key used to store shared tags hash map in Redis | `'__sharedTags__'` |
151
- | getTimeoutMs | Timeout in milliseconds for time critical Redis operations. If Redis get is not fulfilled within this time, returns null to avoid blocking site rendering. | `process.env.REDIS_COMMAND_TIMEOUT_MS ? (Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT_MS) ?? 500) : 500` |
152
- | revalidateTagQuerySize | Number of entries to query in one batch during full sync of shared tags hash map | `250` |
153
- | avgResyncIntervalMs | Average interval in milliseconds between tag map full re-syncs | `3600000` (1 hour) |
154
- | redisGetDeduplication | Enable deduplication of Redis get requests via internal in-memory cache. | `true` |
155
- | inMemoryCachingTime | Time in milliseconds to cache Redis get results in memory. Set this to 0 to disable in-memory caching completely. | `10000` |
156
- | defaultStaleAge | Default stale age in seconds for cached items | `1209600` (14 days) |
157
- | estimateExpireAge | Function to calculate expire age (redis TTL value) from stale age | Production: `staleAge * 2`<br> Other: `staleAge * 1.2` |
158
- | socketOptions | Redis client socket options for TLS/SSL configuration (e.g., `{ tls: true, rejectUnauthorized: false }`) | `{ connectTimeout: timeoutMs }` |
159
- | clientOptions | Additional Redis client options (e.g., username, password) | `undefined` |
160
- | killContainerOnErrorThreshold | Number of consecutive errors before the container is killed. Set to 0 to disable. | `Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0 : 0` |
161
- | valueSerializer | Pluggable wire-format codec for Redis string values (compression, encryption, custom encoding). See [Custom value serializer](#custom-value-serializer-compression-encryption). | `jsonCacheValueSerializer` (`JSON.stringify` with built-in `Buffer` and `Map` encoding) |
152
+ | Option | Description | Default Value |
153
+ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154
+ | redisUrl | Redis connection url | `process.env.REDIS_URL? process.env.REDIS_URL : process.env.REDISHOST ? redis://${process.env.REDISHOST}:${process.env.REDISPORT} : 'redis://localhost:6379'` |
155
+ | database | Redis database number to use. Uses DB 0 for production, DB 1 otherwise | `process.env.VERCEL_ENV === 'production' ? 0 : 1` |
156
+ | keyPrefix | Prefix added to all Redis keys | `RedisStringsHandler` default: `process.env.KEY_PREFIX \|\| process.env.VERCEL_URL \|\| 'UNDEFINED_URL_'`<br> Next handlers resolve: `options.keyPrefix \|\| KEY_PREFIX \|\| VERCEL_URL \|\| BUILD_ID \|\| 'UNDEFINED_URL_'` |
157
+ | sharedTagsKey | Key used to store shared tags hash map in Redis | `'__sharedTags__'` |
158
+ | getTimeoutMs | Timeout in milliseconds for time critical Redis operations. If Redis get is not fulfilled within this time, returns null to avoid blocking site rendering. | `process.env.REDIS_COMMAND_TIMEOUT_MS ? (Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT_MS) ?? 500) : 500` |
159
+ | revalidateTagQuerySize | Number of entries to query in one batch during full sync of shared tags hash map | `250` |
160
+ | avgResyncIntervalMs | Average interval in milliseconds between tag map full re-syncs | `3600000` (1 hour) |
161
+ | redisGetDeduplication | Enable deduplication of Redis get requests via internal in-memory cache. | `true` |
162
+ | inMemoryCachingTime | Time in milliseconds to cache Redis get results in memory. Set this to 0 to disable in-memory caching completely. | `10000` |
163
+ | defaultStaleAge | Fallback stale age in seconds used only when Next.js does not pass a finite `cacheControl.expire` (e.g. `revalidate: false`, or older callers that only send `revalidate`). Next 16.3+ ISR typically sends `expire` (~1 year); that value is the Redis TTL and this option is ignored. | `1209600` (14 days) |
164
+ | estimateExpireAge | Fallback to compute Redis TTL from a stale/`revalidate` age when `cacheControl.expire` is absent. Not applied when Next.js provides `expire`. | Production: `staleAge * 2`<br> Other: `staleAge * 1.2` |
165
+ | socketOptions | Redis client socket options for TLS/SSL configuration (e.g., `{ tls: true, rejectUnauthorized: false }`) | `{ connectTimeout: timeoutMs }` |
166
+ | clientOptions | Additional Redis client options (e.g., username, password) | `undefined` |
167
+ | killContainerOnErrorThreshold | Number of consecutive errors before the container is killed. Set to 0 to disable. | `Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0 : 0` |
168
+ | valueSerializer | Pluggable wire-format codec for Redis string values (compression, encryption, custom encoding). See [Custom value serializer](#custom-value-serializer-compression-encryption). | `jsonCacheValueSerializer` (`JSON.stringify` with built-in `Buffer` and `Map` encoding) |
162
169
 
163
170
  ## Custom value serializer (compression, encryption)
164
171
 
@@ -464,7 +471,50 @@ Install the package in your Next.js app:
464
471
  pnpm add @trieb.work/nextjs-turbo-redis-cache redis
465
472
  ```
466
473
 
467
- In your Next.js app, enable Cache Components and point `cacheHandlers.default` to a module that exports the handler instance:
474
+ #### Hybrid setup (ISR + Cache Components)
475
+
476
+ Next.js has **two different handler APIs**. They are not interchangeable:
477
+
478
+ | Config key | Next.js loads it as | This package export | Methods |
479
+ | ------------------------- | --------------------------------- | ------------------------------------------ | ---------------------------------------------------------- |
480
+ | `cacheHandler` (singular) | `new Handler(options)` | **default export** (`CachedHandler` class) | `get`, `set`, `revalidateTag`, `resetRequestCache` |
481
+ | `cacheHandlers` (plural) | imported object (not constructed) | **`redisCacheHandler`** | `get`, `set`, `getExpiration`, `updateTags`, `refreshTags` |
482
+
483
+ `redisCacheHandler` is not a constructor (`new redisCacheHandler()` throws). Pointing `cacheHandler` at `./cache-handler.js` (the Cache Components object) will fail at runtime. Pointing `cacheHandlers` at the default class export will not provide `getExpiration` / `updateTags`.
484
+
485
+ For a self-hosted app that needs both ISR and `'use cache'` / `'use cache: remote'`:
486
+
487
+ ```ts
488
+ // next.config.ts
489
+ import type { NextConfig } from 'next';
490
+
491
+ const nextConfig: NextConfig = {
492
+ cacheComponents: true,
493
+ cacheHandler: require.resolve('@trieb.work/nextjs-turbo-redis-cache'),
494
+ cacheHandlers: {
495
+ default: require.resolve('./cache-handler.js'),
496
+ remote: require.resolve('./cache-handler.js'),
497
+ },
498
+ cacheMaxMemorySize: 0,
499
+ };
500
+
501
+ export default nextConfig;
502
+ ```
503
+
504
+ ```js
505
+ // cache-handler.js — Cache Components only (`cacheHandlers.default` / `.remote`)
506
+ const { redisCacheHandler } = require('@trieb.work/nextjs-turbo-redis-cache');
507
+
508
+ module.exports = redisCacheHandler;
509
+ ```
510
+
511
+ `default` and `remote` may be the same `redisCacheHandler` module: `'use cache'` uses `default`, `'use cache: remote'` uses `remote`. `cacheMaxMemorySize: 0` disables Next's in-process memory cache so Redis is shared across instances.
512
+
513
+ Do not wrap ISR and Cache Components in one file unless you implement **both** interfaces (class constructed with `new`, and a separate object export). This package ships them as two exports on purpose.
514
+
515
+ #### Cache Components only
516
+
517
+ If you only need Cache Components (no ISR `cacheHandler`), enable Cache Components and point `cacheHandlers` at `redisCacheHandler`. Include `remote` if you use `'use cache: remote'`.
468
518
 
469
519
  ```ts
470
520
  // next.config.ts
@@ -474,7 +524,9 @@ const nextConfig: NextConfig = {
474
524
  cacheComponents: true,
475
525
  cacheHandlers: {
476
526
  default: require.resolve('./cache-handler.js'),
527
+ remote: require.resolve('./cache-handler.js'),
477
528
  },
529
+ cacheMaxMemorySize: 0,
478
530
  };
479
531
 
480
532
  export default nextConfig;
@@ -505,6 +557,19 @@ Optional:
505
557
  - `VERCEL_URL`: used as a key prefix for multi-tenant isolation (also useful in tests). If unset, a default prefix is used.
506
558
  - `REDIS_COMMAND_TIMEOUT_MS`: timeout (ms) for Redis commands used by the handler.
507
559
 
560
+ ### Official caching semantics (Vercel / Next.js self-hosting docs)
561
+
562
+ This package follows the semantics documented in the [Next.js self-hosting guide](https://nextjs.org/docs/app/guides/self-hosting#configuring-caching) and the official `cache-handler-redis` example:
563
+
564
+ | Topic | Behavior |
565
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
566
+ | **ISR Redis TTL** | Key TTL on `cacheControl.expire`, not `revalidate`. Past `revalidate` an entry is only stale (SWR); evicting at that boundary would defeat background refresh. Legacy callers that only pass `revalidate` still get `estimateExpireAge(revalidate)`. |
567
+ | **Tag sources (ISR)** | `APP_PAGE` / `APP_ROUTE` tags come from `data.headers['x-next-cache-tags']` plus `ctx.tags`. `FETCH` tags come from `ctx.tags`. |
568
+ | **Buffer / Map serialization** | `rscData` (`Buffer`) and `segmentData` (`Map`) require custom JSON encoding — use the built-in `jsonCacheValueSerializer` or wrap it. Plain `JSON.stringify` causes `segmentData.get is not a function` on RSC navigation. |
569
+ | **`updateTags(tags, durations)`** | Persists Next.js tag-manifest fields in Redis (`stale` / `expired`). No `durations` or `{ expire: 0 }` hard-expires (`expired = now`). `expire > 0` (including `'max'` ~1 year) is the SWR window: `stale = now`, `expired = now + expire * 1000`, and `get()` returns `revalidate: -1` until that deadline. |
570
+ | **`getExpiration` pattern** | Returns max tag `expired` (may be in the future). Next.js uses this for implicit/soft tags. Explicit `cacheTag()`s are checked in `get()` via `areTagsExpired` / `areTagsStale`. |
571
+ | **Build without Redis** | During `next build` (`NEXT_PHASE`), Redis connections are skipped so CI/build pipelines without Redis still succeed. |
572
+
508
573
  ### Lazy initialization
509
574
 
510
575
  The `redisCacheHandler` export is **lazily initialized** — importing the package does **not** open a Redis connection. The connection is deferred until the first method call on the handler (when Next.js invokes it). This means:
@@ -533,6 +598,8 @@ Then open the Cache Lab pages:
533
598
  - `/cache-lab/tag-invalidation`
534
599
  - `/cache-lab/stale-while-revalidate`
535
600
  - `/cache-lab/runtime-data-suspense`
601
+ - `/cache-lab/use-cache-remote`
602
+ - `/cache-lab/revalidate-durations`
536
603
 
537
604
  To run the Playwright E2E tests against a running dev server:
538
605
 
@@ -542,13 +609,12 @@ PLAYWRIGHT_BASE_URL=http://localhost:3101 pnpm test:e2e
542
609
 
543
610
  ## Some words on nextjs caching internals
544
611
 
545
- Nextjs will use different caching objects for different pages and api routes. Currently supported are kind: APP_ROUTE and APP_PAGE.
546
-
547
- app/<segment>/route.ts files will request using the APP_ROUTE kind.
548
- app/<segment>/page.tsx files will request using the APP_PAGE kind.
549
- /favicon.ico file will request using the APP_ROUTE kind.
612
+ Next.js uses different cache entry kinds. This handler supports `APP_PAGE`, `APP_ROUTE`, `FETCH`, `PAGES`, and `REDIRECT` (plus Pages Router `notFound` stored as a null value).
550
613
 
551
- Fetch requests (inside app route or page) will request using the FETCH kind.
614
+ - `app/<segment>/page.tsx` → `APP_PAGE`
615
+ - `app/<segment>/route.ts` (and `/favicon.ico`) → `APP_ROUTE`
616
+ - `fetch()` inside App Router → `FETCH`
617
+ - Pages Router `getStaticProps` → `PAGES` / `REDIRECT`
552
618
 
553
619
  For details on how these kinds are handled internally (tag maps, deduplication, value transformation), see [ARCHITECTURE.md](./ARCHITECTURE.md).
554
620
 
package/dist/index.d.mts CHANGED
@@ -221,10 +221,12 @@ declare class RedisStringsHandler {
221
221
  private estimateExpireAge;
222
222
  private killContainerOnErrorThreshold;
223
223
  private valueSerializer;
224
+ private redisConnectionDeferred;
224
225
  constructor({ redisUrl, database, keyPrefix, sharedTagsKey, getTimeoutMs, revalidateTagQuerySize, avgResyncIntervalMs, redisGetDeduplication, inMemoryCachingTime, defaultStaleAge, estimateExpireAge, killContainerOnErrorThreshold, socketOptions, clientOptions, valueSerializer, }: CreateRedisStringsHandlerOptions);
225
226
  resetRequestCache(): void;
226
227
  private clientReadyCalls;
227
228
  private assertClientIsReady;
229
+ private isClientUnavailable;
228
230
  get(key: string, ctx: GetContext): Promise<CacheEntry | null>;
229
231
  set(key: string, data: SetCacheValue, ctx: {
230
232
  isRoutePPREnabled: boolean;
@@ -253,6 +255,43 @@ declare class CachedHandler {
253
255
  declare function bufferAndMapReviver(_: string, value: any): any;
254
256
  declare function bufferAndMapReplacer(_: string, value: any): any;
255
257
 
258
+ type ResolveCacheEntryTtlContext = {
259
+ revalidate?: number | false;
260
+ cacheControl?: {
261
+ revalidate?: number | false;
262
+ expire?: number | undefined;
263
+ };
264
+ };
265
+ type ResolveCacheEntryTtlData = {
266
+ kind?: string;
267
+ revalidate?: number | false;
268
+ } | null;
269
+ type ResolveCacheEntryTtlOptions = {
270
+ estimateExpireAge: (staleAge: number) => number;
271
+ defaultStaleAge: number;
272
+ };
273
+ /**
274
+ * Resolves the Redis TTL (seconds) for an ISR / incremental-cache entry.
275
+ *
276
+ * Official Next.js semantics (cache-handler-redis example + self-hosting docs):
277
+ * - Key TTL on `cacheControl.expire`, never on `revalidate` alone.
278
+ * - Past `revalidate` an entry is only stale (SWR); evicting at that boundary
279
+ * would defeat background refresh.
280
+ * - When no finite `expire` is provided, fall back to `estimateExpireAge(revalidate)`
281
+ * for legacy Pages Router / pre-cacheLife callers that only pass `revalidate`.
282
+ * - When neither `expire` nor `revalidate` is available, return `undefined` (no TTL;
283
+ * rely on tag-based invalidation). This is intentional — not a missing
284
+ * `defaultStaleAge` fallback. `revalidate: false` is a separate branch above
285
+ * and is covered by unit + Pages Router integration tests (`/static-forever`).
286
+ */
287
+ declare function resolveCacheEntryTtlSeconds(ctx: ResolveCacheEntryTtlContext, data: ResolveCacheEntryTtlData, options: ResolveCacheEntryTtlOptions): number | undefined;
288
+
289
+ /**
290
+ * Returns true during `next build` so handlers can skip opening Redis
291
+ * connections. Matches the official Next.js cache-handler-redis example.
292
+ */
293
+ declare function shouldDeferRedisConnection(): boolean;
294
+
256
295
  interface CacheComponentsEntry {
257
296
  value: ReadableStream<Uint8Array>;
258
297
  tags: string[];
@@ -276,4 +315,4 @@ type CreateCacheComponentsHandlerOptions = CreateRedisStringsHandlerOptions & {
276
315
  declare function getRedisCacheComponentsHandler(options?: CreateCacheComponentsHandlerOptions): CacheComponentsHandler;
277
316
  declare const redisCacheHandler: CacheComponentsHandler;
278
317
 
279
- export { type CacheValueSerializer, type CreateRedisStringsHandlerOptions, RedisStringsHandler, bufferAndMapReplacer, bufferAndMapReviver, CachedHandler as default, getRedisCacheComponentsHandler, jsonCacheValueSerializer, redisCacheHandler };
318
+ export { type CacheValueSerializer, type CreateRedisStringsHandlerOptions, RedisStringsHandler, type ResolveCacheEntryTtlContext, type ResolveCacheEntryTtlData, type ResolveCacheEntryTtlOptions, bufferAndMapReplacer, bufferAndMapReviver, CachedHandler as default, getRedisCacheComponentsHandler, jsonCacheValueSerializer, redisCacheHandler, resolveCacheEntryTtlSeconds, shouldDeferRedisConnection };
package/dist/index.d.ts CHANGED
@@ -221,10 +221,12 @@ declare class RedisStringsHandler {
221
221
  private estimateExpireAge;
222
222
  private killContainerOnErrorThreshold;
223
223
  private valueSerializer;
224
+ private redisConnectionDeferred;
224
225
  constructor({ redisUrl, database, keyPrefix, sharedTagsKey, getTimeoutMs, revalidateTagQuerySize, avgResyncIntervalMs, redisGetDeduplication, inMemoryCachingTime, defaultStaleAge, estimateExpireAge, killContainerOnErrorThreshold, socketOptions, clientOptions, valueSerializer, }: CreateRedisStringsHandlerOptions);
225
226
  resetRequestCache(): void;
226
227
  private clientReadyCalls;
227
228
  private assertClientIsReady;
229
+ private isClientUnavailable;
228
230
  get(key: string, ctx: GetContext): Promise<CacheEntry | null>;
229
231
  set(key: string, data: SetCacheValue, ctx: {
230
232
  isRoutePPREnabled: boolean;
@@ -253,6 +255,43 @@ declare class CachedHandler {
253
255
  declare function bufferAndMapReviver(_: string, value: any): any;
254
256
  declare function bufferAndMapReplacer(_: string, value: any): any;
255
257
 
258
+ type ResolveCacheEntryTtlContext = {
259
+ revalidate?: number | false;
260
+ cacheControl?: {
261
+ revalidate?: number | false;
262
+ expire?: number | undefined;
263
+ };
264
+ };
265
+ type ResolveCacheEntryTtlData = {
266
+ kind?: string;
267
+ revalidate?: number | false;
268
+ } | null;
269
+ type ResolveCacheEntryTtlOptions = {
270
+ estimateExpireAge: (staleAge: number) => number;
271
+ defaultStaleAge: number;
272
+ };
273
+ /**
274
+ * Resolves the Redis TTL (seconds) for an ISR / incremental-cache entry.
275
+ *
276
+ * Official Next.js semantics (cache-handler-redis example + self-hosting docs):
277
+ * - Key TTL on `cacheControl.expire`, never on `revalidate` alone.
278
+ * - Past `revalidate` an entry is only stale (SWR); evicting at that boundary
279
+ * would defeat background refresh.
280
+ * - When no finite `expire` is provided, fall back to `estimateExpireAge(revalidate)`
281
+ * for legacy Pages Router / pre-cacheLife callers that only pass `revalidate`.
282
+ * - When neither `expire` nor `revalidate` is available, return `undefined` (no TTL;
283
+ * rely on tag-based invalidation). This is intentional — not a missing
284
+ * `defaultStaleAge` fallback. `revalidate: false` is a separate branch above
285
+ * and is covered by unit + Pages Router integration tests (`/static-forever`).
286
+ */
287
+ declare function resolveCacheEntryTtlSeconds(ctx: ResolveCacheEntryTtlContext, data: ResolveCacheEntryTtlData, options: ResolveCacheEntryTtlOptions): number | undefined;
288
+
289
+ /**
290
+ * Returns true during `next build` so handlers can skip opening Redis
291
+ * connections. Matches the official Next.js cache-handler-redis example.
292
+ */
293
+ declare function shouldDeferRedisConnection(): boolean;
294
+
256
295
  interface CacheComponentsEntry {
257
296
  value: ReadableStream<Uint8Array>;
258
297
  tags: string[];
@@ -276,4 +315,4 @@ type CreateCacheComponentsHandlerOptions = CreateRedisStringsHandlerOptions & {
276
315
  declare function getRedisCacheComponentsHandler(options?: CreateCacheComponentsHandlerOptions): CacheComponentsHandler;
277
316
  declare const redisCacheHandler: CacheComponentsHandler;
278
317
 
279
- export { type CacheValueSerializer, type CreateRedisStringsHandlerOptions, RedisStringsHandler, bufferAndMapReplacer, bufferAndMapReviver, CachedHandler as default, getRedisCacheComponentsHandler, jsonCacheValueSerializer, redisCacheHandler };
318
+ export { type CacheValueSerializer, type CreateRedisStringsHandlerOptions, RedisStringsHandler, type ResolveCacheEntryTtlContext, type ResolveCacheEntryTtlData, type ResolveCacheEntryTtlOptions, bufferAndMapReplacer, bufferAndMapReviver, CachedHandler as default, getRedisCacheComponentsHandler, jsonCacheValueSerializer, redisCacheHandler, resolveCacheEntryTtlSeconds, shouldDeferRedisConnection };