@trieb.work/nextjs-turbo-redis-cache 1.15.0 → 1.16.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 (263) hide show
  1. package/.cursor/skills/testing/SKILL.md +121 -0
  2. package/.github/workflows/ci.yml +199 -69
  3. package/.github/workflows/release.yml +9 -1
  4. package/ARCHITECTURE.md +431 -0
  5. package/CHANGELOG.md +28 -0
  6. package/README.md +77 -49
  7. package/dist/index.d.mts +71 -45
  8. package/dist/index.d.ts +71 -45
  9. package/dist/index.js +107 -21
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +107 -21
  12. package/dist/index.mjs.map +1 -1
  13. package/docs/index.html +7 -6
  14. package/eslint.config.mjs +1 -1
  15. package/package.json +15 -16
  16. package/playwright.config.ts +5 -3
  17. package/src/CacheComponentsHandler.ts +57 -2
  18. package/src/RedisStringsHandler.ts +125 -66
  19. package/src/SyncedMap.ts +62 -13
  20. package/test/README.md +194 -0
  21. package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/package.json +6 -6
  22. package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-15-4-11}/pnpm-lock.yaml +89 -105
  23. package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11}/package.json +3 -3
  24. package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11}/pnpm-lock.yaml +53 -53
  25. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/package.json +28 -0
  26. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/pnpm-lock.yaml +4206 -0
  27. package/test/nextjs-test-projects/next-app-16-2-6/eslint.config.mjs +18 -0
  28. package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6}/package.json +3 -3
  29. package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6}/pnpm-lock.yaml +54 -60
  30. package/test/nextjs-test-projects/next-app-16-2-6/postcss.config.mjs +7 -0
  31. package/test/nextjs-test-projects/next-app-16-2-6/src/app/favicon.ico +0 -0
  32. package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-2-6}/tsconfig.json +9 -2
  33. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/cache-handler.js +3 -0
  34. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +13 -0
  35. package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-2-6-cache-components}/package.json +3 -3
  36. package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-2-6-cache-components}/pnpm-lock.yaml +54 -60
  37. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/cached-static-fetch/route.ts +19 -0
  38. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/cached-with-cachelife/route.ts +24 -0
  39. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/cached-with-tag/route.ts +21 -0
  40. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/revalidate-tag/route.ts +19 -0
  41. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/revalidated-fetch/route.ts +19 -0
  42. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/cachelife-short/page.tsx +110 -0
  43. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +90 -0
  44. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/runtime-data-suspense/page.tsx +127 -0
  45. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/stale-while-revalidate/page.tsx +130 -0
  46. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/tag-invalidation/page.tsx +127 -0
  47. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-nondeterministic/page.tsx +110 -0
  48. package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/src/app/layout.tsx +0 -2
  49. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/update-tag-test/page.tsx +22 -0
  50. package/test/nextjs-test-projects/next-pages-16-2-6/README.md +16 -0
  51. package/test/nextjs-test-projects/next-pages-16-2-6/eslint.config.mjs +18 -0
  52. package/test/nextjs-test-projects/next-pages-16-2-6/next.config.ts +7 -0
  53. package/test/nextjs-test-projects/next-pages-16-2-6/package.json +26 -0
  54. package/test/nextjs-test-projects/next-pages-16-2-6/pnpm-lock.yaml +3896 -0
  55. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/api/revalidate.ts +24 -0
  56. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/index.tsx +11 -0
  57. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/isr/[slug].tsx +49 -0
  58. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/static-forever.tsx +20 -0
  59. package/test/nextjs-test-projects/next-pages-16-2-6/tsconfig.json +34 -0
  60. package/{tests → test/playwright}/update-tag.spec.ts +1 -1
  61. package/test/{integration → vitest/integration}/build-id-prefix.integration.test.ts +2 -2
  62. package/test/{cache-components/cache-components.integration.spec.ts → vitest/integration/cache-components/cache-components.integration.test.ts} +7 -3
  63. package/test/{cache-components/redis-kill-reconnect.spec.ts → vitest/integration/cache-components/redis-kill-reconnect.test.ts} +28 -7
  64. package/test/vitest/integration/cache-components/redis-quit-vs-disconnect.test.ts +59 -0
  65. package/test/vitest/integration/cache-components/redis-subscriber-outage.test.ts +100 -0
  66. package/test/{cache-components → vitest/integration/cache-components}/scripts/redis-kill-reconnect.ts +31 -8
  67. package/test/vitest/integration/cache-components/scripts/redis-quit-vs-disconnect.ts +113 -0
  68. package/test/vitest/integration/cache-components/scripts/redis-subscriber-outage.ts +379 -0
  69. package/test/vitest/integration/cache-components/scripts/redis-test-helpers.ts +155 -0
  70. package/test/{integration → vitest/integration}/nextjs-cache-handler.integration.test.ts +15 -9
  71. package/test/vitest/integration/pages-router.integration.test.ts +420 -0
  72. package/test/vitest/unit/SyncedMap-reconnect-repro.test.ts +154 -0
  73. package/{src → test/vitest/unit}/index.test.ts +3 -3
  74. package/test/vitest/unit/pages-router-kinds.test.ts +292 -0
  75. package/test/{cache-components/reconnect-socket-already-opened.spec.ts → vitest/unit/reconnect-socket-already-opened.test.ts} +1 -1
  76. package/{src → test/vitest/unit}/serializer.test.ts +7 -3
  77. package/{src → test/vitest/unit}/utils/prefix.test.ts +1 -1
  78. package/vite.config.ts +4 -3
  79. package/vitest.cache-components.config.ts +1 -1
  80. package/test/browser/update-tag.browser.test.ts +0 -41
  81. package/test/integration/next-app-15-0-3/next.config.js +0 -6
  82. package/test/integration/next-app-15-0-3/package-lock.json +0 -5833
  83. package/test/integration/next-app-15-0-3/pnpm-lock.yaml +0 -3710
  84. package/test/integration/next-app-15-3-2/next.config.js +0 -6
  85. package/test/integration/next-app-15-3-2/package.json +0 -33
  86. package/test/integration/next-app-15-3-2/pnpm-lock.yaml +0 -3720
  87. package/test/integration/next-app-15-3-2/postcss.config.mjs +0 -5
  88. package/test/integration/next-app-15-3-2/src/app/api/revalidateTag/route.ts +0 -15
  89. package/test/integration/next-app-15-4-7/eslint.config.mjs +0 -16
  90. package/test/integration/next-app-15-4-7/package-lock.json +0 -5969
  91. package/test/integration/next-app-15-4-7/package.json +0 -33
  92. package/test/integration/next-app-15-4-7/postcss.config.mjs +0 -5
  93. package/test/integration/next-app-15-4-7/src/app/api/revalidateTag/route.ts +0 -15
  94. package/test/integration/next-app-15-4-7/tsconfig.json +0 -27
  95. package/test/integration/next-app-16-0-3/src/app/api/cached-static-fetch/route.ts +0 -18
  96. package/test/integration/next-app-16-0-3/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +0 -27
  97. package/test/integration/next-app-16-0-3/src/app/api/revalidatePath/route.ts +0 -15
  98. package/test/integration/next-app-16-0-3/src/app/api/revalidated-fetch/route.ts +0 -17
  99. package/test/integration/next-app-16-0-3/src/app/api/uncached-fetch/route.ts +0 -15
  100. package/test/integration/next-app-16-0-3/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +0 -19
  101. package/test/integration/next-app-16-0-3/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +0 -34
  102. package/test/integration/next-app-16-0-3/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +0 -25
  103. package/test/integration/next-app-16-0-3/src/app/pages/no-fetch/default-page/page.tsx +0 -55
  104. package/test/integration/next-app-16-0-3/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +0 -19
  105. package/test/integration/next-app-16-0-3/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +0 -35
  106. package/test/integration/next-app-16-0-3/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +0 -25
  107. package/test/integration/next-app-16-0-3/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +0 -19
  108. package/test/integration/next-app-16-0-3/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +0 -32
  109. package/test/integration/next-app-16-0-3/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +0 -25
  110. package/test/integration/next-app-16-2-3/src/app/api/cached-static-fetch/route.ts +0 -18
  111. package/test/integration/next-app-16-2-3/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +0 -27
  112. package/test/integration/next-app-16-2-3/src/app/api/revalidatePath/route.ts +0 -15
  113. package/test/integration/next-app-16-2-3/src/app/api/revalidated-fetch/route.ts +0 -17
  114. package/test/integration/next-app-16-2-3/src/app/api/uncached-fetch/route.ts +0 -15
  115. package/test/integration/next-app-16-2-3/src/app/globals.css +0 -26
  116. package/test/integration/next-app-16-2-3/src/app/layout.tsx +0 -59
  117. package/test/integration/next-app-16-2-3/src/app/page.tsx +0 -755
  118. package/test/integration/next-app-16-2-3/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +0 -19
  119. package/test/integration/next-app-16-2-3/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +0 -34
  120. package/test/integration/next-app-16-2-3/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +0 -25
  121. package/test/integration/next-app-16-2-3/src/app/pages/no-fetch/default-page/page.tsx +0 -55
  122. package/test/integration/next-app-16-2-3/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +0 -19
  123. package/test/integration/next-app-16-2-3/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +0 -35
  124. package/test/integration/next-app-16-2-3/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +0 -25
  125. package/test/integration/next-app-16-2-3/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +0 -19
  126. package/test/integration/next-app-16-2-3/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +0 -32
  127. package/test/integration/next-app-16-2-3/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +0 -25
  128. package/test/integration/next-app-16-2-3/src/app/revalidation-interface.tsx +0 -267
  129. package/test/integration/next-app-16-2-3-cache-components/public/file.svg +0 -1
  130. package/test/integration/next-app-16-2-3-cache-components/public/globe.svg +0 -1
  131. package/test/integration/next-app-16-2-3-cache-components/public/next.svg +0 -1
  132. package/test/integration/next-app-16-2-3-cache-components/public/vercel.svg +0 -1
  133. package/test/integration/next-app-16-2-3-cache-components/public/window.svg +0 -1
  134. package/test/integration/next-app-customized/README.md +0 -36
  135. package/test/integration/next-app-customized/eslint.config.mjs +0 -16
  136. package/vitest.browser.config.ts +0 -10
  137. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/README.md +0 -0
  138. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/eslint.config.mjs +0 -0
  139. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-15-4-11}/next.config.js +0 -0
  140. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-15-4-11}/package-lock.json +0 -0
  141. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/postcss.config.mjs +0 -0
  142. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/public/file.svg +0 -0
  143. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/public/globe.svg +0 -0
  144. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/public/next.svg +0 -0
  145. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/public/vercel.svg +0 -0
  146. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/public/window.svg +0 -0
  147. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/api/cached-static-fetch/route.ts +0 -0
  148. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +0 -0
  149. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/api/revalidatePath/route.ts +0 -0
  150. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/api/revalidateTag/route.ts +0 -0
  151. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/api/revalidated-fetch/route.ts +0 -0
  152. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/api/uncached-fetch/route.ts +0 -0
  153. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/globals.css +0 -0
  154. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/layout.tsx +0 -0
  155. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/page.tsx +0 -0
  156. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +0 -0
  157. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +0 -0
  158. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  159. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/no-fetch/default-page/page.tsx +0 -0
  160. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +0 -0
  161. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +0 -0
  162. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  163. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +0 -0
  164. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +0 -0
  165. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  166. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/src/app/revalidation-interface.tsx +0 -0
  167. /package/test/{integration/next-app-15-0-3 → nextjs-test-projects/next-app-15-4-11}/tsconfig.json +0 -0
  168. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-0-11}/README.md +0 -0
  169. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11}/eslint.config.mjs +0 -0
  170. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11}/next.config.ts +0 -0
  171. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11}/postcss.config.mjs +0 -0
  172. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/api/cached-static-fetch/route.ts +0 -0
  173. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +0 -0
  174. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/api/revalidatePath/route.ts +0 -0
  175. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11}/src/app/api/revalidateTag/route.ts +0 -0
  176. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/api/revalidated-fetch/route.ts +0 -0
  177. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/api/uncached-fetch/route.ts +0 -0
  178. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11}/src/app/favicon.ico +0 -0
  179. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-0-11}/src/app/globals.css +0 -0
  180. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/layout.tsx +0 -0
  181. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-0-11}/src/app/page.tsx +0 -0
  182. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +0 -0
  183. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +0 -0
  184. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  185. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/no-fetch/default-page/page.tsx +0 -0
  186. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +0 -0
  187. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +0 -0
  188. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  189. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +0 -0
  190. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +0 -0
  191. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11}/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  192. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-0-11}/src/app/revalidation-interface.tsx +0 -0
  193. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11}/src/app/update-tag-test/page.tsx +0 -0
  194. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11}/tsconfig.json +0 -0
  195. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/README.md +0 -0
  196. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/cache-handler.js +0 -0
  197. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11-cache-components}/eslint.config.mjs +0 -0
  198. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/next.config.ts +0 -0
  199. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11-cache-components}/postcss.config.mjs +0 -0
  200. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/public/file.svg +0 -0
  201. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/public/globe.svg +0 -0
  202. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/public/next.svg +0 -0
  203. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/public/vercel.svg +0 -0
  204. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/public/window.svg +0 -0
  205. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/api/cached-static-fetch/route.ts +0 -0
  206. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/api/cached-with-cachelife/route.ts +0 -0
  207. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/api/cached-with-tag/route.ts +0 -0
  208. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/api/revalidate-tag/route.ts +0 -0
  209. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/api/revalidated-fetch/route.ts +0 -0
  210. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/cache-lab/cachelife-short/page.tsx +0 -0
  211. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/cache-lab/page.tsx +0 -0
  212. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/cache-lab/runtime-data-suspense/page.tsx +0 -0
  213. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/cache-lab/stale-while-revalidate/page.tsx +0 -0
  214. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/cache-lab/tag-invalidation/page.tsx +0 -0
  215. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/cache-lab/use-cache-nondeterministic/page.tsx +0 -0
  216. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/favicon.ico +0 -0
  217. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/globals.css +0 -0
  218. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/layout.tsx +0 -0
  219. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/page.tsx +0 -0
  220. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/revalidation-interface.tsx +0 -0
  221. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-0-11-cache-components}/src/app/update-tag-test/page.tsx +0 -0
  222. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-0-11-cache-components}/tsconfig.json +0 -0
  223. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-2-6}/README.md +0 -0
  224. /package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6}/next.config.ts +0 -0
  225. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/api/cached-static-fetch/route.ts +0 -0
  226. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +0 -0
  227. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/api/revalidatePath/route.ts +0 -0
  228. /package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6}/src/app/api/revalidateTag/route.ts +0 -0
  229. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/api/revalidated-fetch/route.ts +0 -0
  230. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/api/uncached-fetch/route.ts +0 -0
  231. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-2-6}/src/app/globals.css +0 -0
  232. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/layout.tsx +0 -0
  233. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-2-6}/src/app/page.tsx +0 -0
  234. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +0 -0
  235. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +0 -0
  236. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  237. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/no-fetch/default-page/page.tsx +0 -0
  238. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +0 -0
  239. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +0 -0
  240. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  241. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +0 -0
  242. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +0 -0
  243. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6}/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +0 -0
  244. /package/test/{integration/next-app-16-2-3-cache-components → nextjs-test-projects/next-app-16-2-6}/src/app/revalidation-interface.tsx +0 -0
  245. /package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6}/src/app/update-tag-test/page.tsx +0 -0
  246. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/README.md +0 -0
  247. /package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/eslint.config.mjs +0 -0
  248. /package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/postcss.config.mjs +0 -0
  249. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6-cache-components}/public/file.svg +0 -0
  250. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6-cache-components}/public/globe.svg +0 -0
  251. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6-cache-components}/public/next.svg +0 -0
  252. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6-cache-components}/public/vercel.svg +0 -0
  253. /package/test/{integration/next-app-15-4-7 → nextjs-test-projects/next-app-16-2-6-cache-components}/public/window.svg +0 -0
  254. /package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/src/app/favicon.ico +0 -0
  255. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/src/app/globals.css +0 -0
  256. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/src/app/page.tsx +0 -0
  257. /package/test/{integration/next-app-16-0-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/src/app/revalidation-interface.tsx +0 -0
  258. /package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-16-2-6-cache-components}/tsconfig.json +0 -0
  259. /package/test/{integration/next-app-16-2-3 → nextjs-test-projects/next-app-customized}/README.md +0 -0
  260. /package/test/{integration → nextjs-test-projects}/next-app-customized/customized-cache-handler.js +0 -0
  261. /package/test/{integration/next-app-15-3-2 → nextjs-test-projects/next-app-customized}/eslint.config.mjs +0 -0
  262. /package/test/{integration → nextjs-test-projects}/next-app-customized/next.config.js +0 -0
  263. /package/{tests → test/playwright}/cache-lab.spec.ts +0 -0
package/package.json CHANGED
@@ -1,30 +1,29 @@
1
1
  {
2
2
  "name": "@trieb.work/nextjs-turbo-redis-cache",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "homepage": "https://trwk.de/case-studies/nextjs-turbo-redis-cache",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/trieb-work/nextjs-turbo-redis-cache.git"
8
8
  },
9
9
  "scripts": {
10
- "dev": "pnpm test",
11
- "run-dev-server": "pnpm run-dev-server-15-4-7",
12
- "run-dev-server-15-3-2": "cd ./test/integration/next-app-15-3-2 && pnpm dev",
13
- "run-dev-server-15-0-3": "cd ./test/integration/next-app-15-0-3 && pnpm dev",
14
- "run-dev-server-15-4-7": "cd ./test/integration/next-app-15-4-7 && pnpm dev",
10
+ "dev": "pnpm test:unit",
15
11
  "build": "tsup",
16
12
  "lint": "eslint -c eslint.config.mjs --fix",
17
13
  "format": "prettier --write 'src/**/*.ts' 'src/*.ts'",
18
- "test": "vitest --coverage --config vite.config.ts",
19
- "test:ci": "vitest --run --coverage --config vite.config.ts src/**/*.test.ts src/**/*.test.tsx test/integration/nextjs-cache-handler.integration.test.ts",
20
- "test:integration:build-id-prefix": "vitest --run --coverage --config vite.config.ts test/integration/build-id-prefix.integration.test.ts",
21
- "test:ui": "vitest --ui --config vite.config.ts",
22
- "test:unit": "vitest --config vite.config.ts src/**/*.test.ts src/**/*.test.tsx",
23
- "test:integration": "vitest --config vite.config.ts ./test/integration/nextjs-cache-handler.integration.test.ts",
24
- "test:cache-components": "vitest --run --config vitest.cache-components.config.ts",
25
14
  "prepare": "./scripts/prepare.sh",
26
- "test:browser": "vitest --config=vitest.browser.config.ts",
27
- "test:e2e": "playwright test"
15
+ "test": "vitest --coverage --config vite.config.ts",
16
+ "test:ui": "vitest --ui --config vite.config.ts",
17
+ "test:unit": "vitest --run --config vite.config.ts test/vitest/unit",
18
+ "test:unit:watch": "vitest --config vite.config.ts test/vitest/unit",
19
+ "test:unit:coverage": "vitest --run --coverage --config vite.config.ts test/vitest/unit",
20
+ "test:integration": "vitest --run --config vite.config.ts test/vitest/integration/nextjs-cache-handler.integration.test.ts",
21
+ "test:integration:build-id-prefix": "vitest --run --config vite.config.ts test/vitest/integration/build-id-prefix.integration.test.ts",
22
+ "test:integration:pages": "vitest --run --config vite.config.ts test/vitest/integration/pages-router.integration.test.ts",
23
+ "test:integration:cache-components": "vitest --run --config vitest.cache-components.config.ts",
24
+ "test:e2e": "playwright test",
25
+ "run-dev-server": "pnpm run-dev-server-15-4-11",
26
+ "run-dev-server-15-4-11": "cd ./test/nextjs-test-projects/next-app-15-4-11 && pnpm dev"
28
27
  },
29
28
  "main": "dist/index.js",
30
29
  "module": "dist/index.mjs",
@@ -78,7 +77,7 @@
78
77
  "devDependencies": {
79
78
  "@commitlint/cli": "^19.6.0",
80
79
  "@commitlint/config-conventional": "^19.6.0",
81
- "@playwright/test": "^1.56.1",
80
+ "@playwright/test": "^1.62.0",
82
81
  "@semantic-release/changelog": "^6.0.3",
83
82
  "@semantic-release/git": "^10.0.1",
84
83
  "@semantic-release/github": "^12.0.2",
@@ -1,14 +1,16 @@
1
1
  import { defineConfig } from '@playwright/test';
2
2
 
3
+ const testApp =
4
+ process.env.PLAYWRIGHT_TEST_APP || 'next-app-16-2-6-cache-components';
5
+
3
6
  export default defineConfig({
4
- testDir: 'tests',
7
+ testDir: 'test/playwright',
5
8
  use: {
6
9
  baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3101',
7
10
  trace: 'on-first-retry',
8
11
  },
9
12
  webServer: {
10
- command:
11
- 'pnpm -C test/integration/next-app-16-2-3-cache-components dev -p 3101',
13
+ command: `pnpm -C test/nextjs-test-projects/${testApp} dev -p 3101`,
12
14
  url: 'http://localhost:3101',
13
15
  reuseExistingServer: true,
14
16
  timeout: 120_000,
@@ -6,6 +6,7 @@ import {
6
6
  redisErrorHandler,
7
7
  } from './RedisStringsHandler';
8
8
  import { SyncedMap } from './SyncedMap';
9
+ import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
9
10
  import { debug } from './utils/debug';
10
11
  import { resolveKeyPrefix } from './utils/prefix';
11
12
 
@@ -87,8 +88,19 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
87
88
  private client: Client;
88
89
  private revalidatedTagsMap: SyncedMap<number>;
89
90
  private sharedTagsMap: SyncedMap<string[]>;
91
+ private inMemoryDeduplicationCache: SyncedMap<
92
+ Promise<ReturnType<Client['get']>>
93
+ >;
90
94
  private keyPrefix: string;
91
95
  private getTimeoutMs: number;
96
+ private redisGet: Client['get'];
97
+ private redisDeduplicationHandler: DeduplicatedRequestHandler<
98
+ Client['get'],
99
+ string | Buffer | null
100
+ >;
101
+ private deduplicatedRedisGet: (key: string) => Client['get'];
102
+ private redisGetDeduplication: boolean;
103
+ private inMemoryCachingTime: number;
92
104
 
93
105
  constructor({
94
106
  redisUrl = process.env.REDIS_URL
@@ -103,6 +115,8 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
103
115
  : 500,
104
116
  revalidateTagQuerySize = 250,
105
117
  avgResyncIntervalMs = 60 * 60 * 1_000,
118
+ redisGetDeduplication = true,
119
+ inMemoryCachingTime = 10_000,
106
120
  socketOptions,
107
121
  clientOptions,
108
122
  killContainerOnErrorThreshold = process.env
@@ -118,6 +132,8 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
118
132
  env: process.env,
119
133
  });
120
134
  this.getTimeoutMs = getTimeoutMs;
135
+ this.redisGetDeduplication = redisGetDeduplication;
136
+ this.inMemoryCachingTime = inMemoryCachingTime;
121
137
 
122
138
  this.client = createClient({
123
139
  url: redisUrl,
@@ -208,6 +224,29 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
208
224
  avgResyncIntervalMs / 10 +
209
225
  Math.random() * (avgResyncIntervalMs / 10),
210
226
  });
227
+
228
+ this.inMemoryDeduplicationCache = new SyncedMap({
229
+ client: this.client,
230
+ keyPrefix: this.keyPrefix,
231
+ redisKey: '__cacheComponents_inMemoryDeduplicationCache__',
232
+ database,
233
+ querySize: revalidateTagQuerySize,
234
+ filterKeys,
235
+ customizedSync: {
236
+ withoutRedisHashmap: true,
237
+ withoutSetSync: true,
238
+ },
239
+ });
240
+
241
+ const redisGet: Client['get'] = this.client.get.bind(this.client);
242
+ this.redisDeduplicationHandler = new DeduplicatedRequestHandler(
243
+ redisGet,
244
+ inMemoryCachingTime,
245
+ this.inMemoryDeduplicationCache,
246
+ );
247
+ this.redisGet = redisGet;
248
+ this.deduplicatedRedisGet =
249
+ this.redisDeduplicationHandler.deduplicatedFunction;
211
250
  } catch (error) {
212
251
  console.error('RedisCacheComponentsHandler constructor error', error);
213
252
  throw error;
@@ -253,12 +292,18 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
253
292
  try {
254
293
  await this.assertClientIsReady();
255
294
 
295
+ const clientGet = this.redisGetDeduplication
296
+ ? this.deduplicatedRedisGet(cacheKey)
297
+ : this.redisGet;
298
+
256
299
  const serialized = await redisErrorHandler(
257
- 'RedisCacheComponentsHandler.get(), operation: get ' +
300
+ 'RedisCacheComponentsHandler.get(), operation: get' +
301
+ (this.redisGetDeduplication ? ' deduplicated' : '') +
302
+ ' ' +
258
303
  this.getTimeoutMs +
259
304
  'ms ' +
260
305
  redisKey,
261
- this.client.get(
306
+ clientGet(
262
307
  commandOptions({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
263
308
  redisKey,
264
309
  ),
@@ -355,6 +400,10 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
355
400
  throw jsonError;
356
401
  }
357
402
 
403
+ if (this.redisGetDeduplication) {
404
+ this.redisDeduplicationHandler.seedRequestReturn(cacheKey, serialized);
405
+ }
406
+
358
407
  // expire is already a duration in seconds, use it directly
359
408
  const ttlSeconds =
360
409
  Number.isFinite(stored.expire) && stored.expire > 0
@@ -450,6 +499,12 @@ class RedisCacheComponentsHandler implements CacheComponentsHandler {
450
499
  this.client.unlink(fullRedisKeys),
451
500
  );
452
501
 
502
+ if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
503
+ for (const key of keysToDelete) {
504
+ this.inMemoryDeduplicationCache.delete(key);
505
+ }
506
+ }
507
+
453
508
  // Delete from sharedTagsMap
454
509
  const deleteTagsOperation = this.sharedTagsMap.delete(cacheKeys);
455
510
  await deleteTagsOperation;
@@ -13,6 +13,84 @@ export type CacheEntry = {
13
13
  tags: string[];
14
14
  };
15
15
 
16
+ /** Discriminated union of the `ctx` argument Next.js passes to
17
+ * {@link RedisStringsHandler.get}. The Pages Router (`PAGES`) has no PPR
18
+ * concept, so `isRoutePPREnabled` is optional for it.
19
+ *
20
+ * Note: `REDIRECT` is intentionally absent. Next.js always calls `get()` with
21
+ * `kind: 'PAGES'` for Pages Router routes, even when the stored value has
22
+ * `kind: 'REDIRECT'`. The handler returns whatever value is cached and Next.js
23
+ * interprets it accordingly. */
24
+ export type GetContext =
25
+ | {
26
+ kind: 'APP_ROUTE' | 'APP_PAGE';
27
+ isRoutePPREnabled: boolean;
28
+ isFallback: boolean;
29
+ }
30
+ | {
31
+ kind: 'PAGES';
32
+ isRoutePPREnabled?: boolean;
33
+ isFallback: boolean;
34
+ }
35
+ | {
36
+ kind: 'FETCH';
37
+ revalidate: number;
38
+ fetchUrl: string;
39
+ fetchIdx: number;
40
+ tags: string[];
41
+ softTags: string[];
42
+ isFallback: boolean;
43
+ };
44
+
45
+ /** Discriminated union of cache payloads accepted by
46
+ * {@link RedisStringsHandler.set}. `null` is a valid payload: the Pages Router
47
+ * stores `notFound: true` results as a cache entry with a null value. */
48
+ export type SetCacheValue =
49
+ | {
50
+ kind: 'APP_PAGE';
51
+ status?: number;
52
+ headers: {
53
+ 'x-nextjs-stale-time': string; // timestamp in ms
54
+ 'x-next-cache-tags': string; // comma separated paths (tags)
55
+ };
56
+ html: string;
57
+ rscData: Buffer;
58
+ segmentData: unknown;
59
+ postboned: unknown;
60
+ }
61
+ | {
62
+ kind: 'APP_ROUTE';
63
+ status: number;
64
+ headers: {
65
+ 'cache-control'?: string;
66
+ 'x-nextjs-stale-time': string; // timestamp in ms
67
+ 'x-next-cache-tags': string; // comma separated paths (tags)
68
+ };
69
+ body: Buffer;
70
+ }
71
+ | {
72
+ kind: 'PAGES';
73
+ html: string;
74
+ pageData: Record<string, unknown>;
75
+ headers?: Record<string, number | string | string[] | undefined>;
76
+ status?: number;
77
+ }
78
+ | {
79
+ kind: 'REDIRECT';
80
+ props: Record<string, unknown>;
81
+ }
82
+ | {
83
+ kind: 'FETCH';
84
+ data: {
85
+ headers: Record<string, string>;
86
+ body: string; // base64 encoded
87
+ status: number;
88
+ url: string;
89
+ };
90
+ revalidate: number | false;
91
+ }
92
+ | null;
93
+
16
94
  export function redisErrorHandler<T extends Promise<unknown>>(
17
95
  debugInfo: string,
18
96
  redisCommandResult: T,
@@ -144,6 +222,24 @@ const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';
144
222
  // This helps track when specific tags were last invalidated
145
223
  const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
146
224
 
225
+ // Cache kinds this handler is designed and tested for. Kept as the single
226
+ // source of truth for both payload validation and the warning message, so
227
+ // adding a kind is a one-line change here instead of edits scattered across
228
+ // the type union, the guard chain, and the log string.
229
+ const SUPPORTED_GET_KINDS = [
230
+ 'APP_ROUTE',
231
+ 'APP_PAGE',
232
+ 'PAGES',
233
+ 'FETCH',
234
+ ] as const;
235
+ const SUPPORTED_SET_KINDS = [
236
+ 'APP_ROUTE',
237
+ 'APP_PAGE',
238
+ 'PAGES',
239
+ 'REDIRECT',
240
+ 'FETCH',
241
+ ] as const;
242
+
147
243
  let killContainerOnErrorCount: number = 0;
148
244
  export default class RedisStringsHandler {
149
245
  private client: Client;
@@ -371,35 +467,14 @@ export default class RedisStringsHandler {
371
467
  }
372
468
  }
373
469
 
374
- public async get(
375
- key: string,
376
- ctx:
377
- | {
378
- kind: 'APP_ROUTE' | 'APP_PAGE';
379
- isRoutePPREnabled: boolean;
380
- isFallback: boolean;
381
- }
382
- | {
383
- kind: 'FETCH';
384
- revalidate: number;
385
- fetchUrl: string;
386
- fetchIdx: number;
387
- tags: string[];
388
- softTags: string[];
389
- isFallback: boolean;
390
- },
391
- ): Promise<CacheEntry | null> {
470
+ public async get(key: string, ctx: GetContext): Promise<CacheEntry | null> {
392
471
  try {
393
- if (
394
- ctx.kind !== 'APP_ROUTE' &&
395
- ctx.kind !== 'APP_PAGE' &&
396
- ctx.kind !== 'FETCH'
397
- ) {
472
+ if (!(SUPPORTED_GET_KINDS as readonly string[]).includes(ctx.kind)) {
398
473
  console.warn(
399
474
  'RedisStringsHandler.get() called with',
400
475
  key,
401
476
  ctx,
402
- ' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
477
+ `this cache handler is only designed and tested for kinds ${SUPPORTED_GET_KINDS.join(', ')} and not for kind`,
403
478
  (ctx as { kind: string })?.kind,
404
479
  );
405
480
  }
@@ -479,7 +554,10 @@ export default class RedisStringsHandler {
479
554
  'cacheEntry is mall formed (missing tags)',
480
555
  );
481
556
  }
482
- if (!cacheEntry?.value) {
557
+ // value === null is a legitimate entry: the Pages Router stores
558
+ // `notFound: true` results as a cache entry with a null value.
559
+ // Only an absent value indicates a malformed entry.
560
+ if (cacheEntry?.value === undefined) {
483
561
  console.warn(
484
562
  'RedisStringsHandler.get() called with',
485
563
  key,
@@ -589,71 +667,49 @@ export default class RedisStringsHandler {
589
667
  }
590
668
  public async set(
591
669
  key: string,
592
- data:
593
- | {
594
- kind: 'APP_PAGE';
595
- status?: number;
596
- headers: {
597
- 'x-nextjs-stale-time': string; // timestamp in ms
598
- 'x-next-cache-tags': string; // comma separated paths (tags)
599
- };
600
- html: string;
601
- rscData: Buffer;
602
- segmentData: unknown;
603
- postboned: unknown;
604
- }
605
- | {
606
- kind: 'APP_ROUTE';
607
- status: number;
608
- headers: {
609
- 'cache-control'?: string;
610
- 'x-nextjs-stale-time': string; // timestamp in ms
611
- 'x-next-cache-tags': string; // comma separated paths (tags)
612
- };
613
- body: Buffer;
614
- }
615
- | {
616
- kind: 'FETCH';
617
- data: {
618
- headers: Record<string, string>;
619
- body: string; // base64 encoded
620
- status: number;
621
- url: string;
622
- };
623
- revalidate: number | false;
624
- },
670
+ data: SetCacheValue,
625
671
  ctx: {
626
672
  isRoutePPREnabled: boolean;
627
673
  isFallback: boolean;
628
674
  tags?: string[];
629
675
  // Different versions of Next.js use different arguments for the same functionality
630
676
  revalidate?: number | false; // Version 15.0.3
631
- cacheControl?: { revalidate: 5; expire: undefined }; // Version 15.0.3
677
+ cacheControl?: { revalidate: number | false; expire: number | undefined }; // Version 15.0.3+
632
678
  },
633
679
  ) {
634
680
  try {
635
681
  if (
636
- data.kind !== 'APP_ROUTE' &&
637
- data.kind !== 'APP_PAGE' &&
638
- data.kind !== 'FETCH'
682
+ data !== null &&
683
+ !(SUPPORTED_SET_KINDS as readonly string[]).includes(data.kind)
639
684
  ) {
640
685
  console.warn(
641
686
  'RedisStringsHandler.set() called with',
642
687
  key,
643
688
  ctx,
644
689
  data,
645
- ' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
690
+ `this cache handler is only designed and tested for kinds ${SUPPORTED_SET_KINDS.join(', ')} and not for kind`,
646
691
  (data as { kind: string })?.kind,
647
692
  );
648
693
  }
649
694
 
650
695
  await this.assertClientIsReady();
651
696
 
652
- if (data.kind === 'APP_PAGE' || data.kind === 'APP_ROUTE') {
697
+ if (data?.kind === 'APP_PAGE' || data?.kind === 'APP_ROUTE') {
653
698
  const tags = data.headers['x-next-cache-tags']?.split(',');
654
699
  ctx.tags = [...(ctx.tags || []), ...(tags || [])];
655
700
  }
656
701
 
702
+ // Pages Router entries (PAGES, REDIRECT and null/notFound results) do not
703
+ // carry an x-next-cache-tags header. Attach the implicit path tag
704
+ // (_N_T_/<path>) so that revalidatePath()/revalidateTag('_N_T_/<path>')
705
+ // invalidates them the same way as App Router entries.
706
+ if (data === null || data.kind === 'PAGES' || data.kind === 'REDIRECT') {
707
+ const implicitTag = NEXT_CACHE_IMPLICIT_TAG_ID + key;
708
+ if (!ctx.tags?.includes(implicitTag)) {
709
+ ctx.tags = [...(ctx.tags || []), implicitTag];
710
+ }
711
+ }
712
+
657
713
  // Constructing and serializing the value for storing it in redis
658
714
  const cacheEntry: CacheEntry = {
659
715
  lastModified: Date.now(),
@@ -676,10 +732,13 @@ export default class RedisStringsHandler {
676
732
  // Constructing the expire time for the cache entry
677
733
  const revalidate =
678
734
  // 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
679
- (data.kind === 'FETCH' && data.revalidate) ||
735
+ (data?.kind === 'FETCH' && data.revalidate) ||
680
736
  ctx.revalidate ||
681
737
  ctx.cacheControl?.revalidate ||
682
- (data as { revalidate?: number | false })?.revalidate;
738
+ // Legacy fallback: older Next.js versions attached `revalidate` directly
739
+ // to the data payload. FETCH is already handled above, so this only
740
+ // matters for those older, non-discriminated shapes.
741
+ (data as { revalidate?: number | false } | null)?.revalidate;
683
742
  const expireAt =
684
743
  revalidate && Number.isSafeInteger(revalidate) && revalidate > 0
685
744
  ? this.estimateExpireAge(revalidate)
package/src/SyncedMap.ts CHANGED
@@ -41,6 +41,7 @@ export class SyncedMap<V> {
41
41
 
42
42
  private setupLock: Promise<void>;
43
43
  private setupLockResolve!: () => void;
44
+ private isReconnecting = false;
44
45
 
45
46
  constructor(options: SyncedMapOptions) {
46
47
  this.client = options.client;
@@ -209,6 +210,16 @@ export class SyncedMap<V> {
209
210
  };
210
211
 
211
212
  try {
213
+ // Attach error handler BEFORE subscribing so that errors during
214
+ // subscribe or after connection loss are always caught and trigger
215
+ // a reconnection. Previously this was attached after the subscribe()
216
+ // calls, which meant if subscribe threw during an outage the handler
217
+ // was never attached and the subscriber was permanently dead.
218
+ this.subscriberClient.on('error', async (err) => {
219
+ console.error('Subscriber client error:', err);
220
+ await this.reconnectSubscriber();
221
+ });
222
+
212
223
  const connectIfNeeded = async () => {
213
224
  // Avoid overlapping connect() calls which can lead to
214
225
  // "Socket already opened" errors when a connect is already in progress
@@ -241,7 +252,9 @@ export class SyncedMap<V> {
241
252
  throw new Error(
242
253
  'Keyspace event configuration is set to "' +
243
254
  keyspaceEventConfig +
244
- "\" but has to include 'E' for Keyevent events, published with __keyevent@<db>__ prefix. We recommend to set it to 'Exe' like so `redis-cli -h localhost config set notify-keyspace-events Exe`",
255
+ " but has to include 'E' for Keyevent events, " +
256
+ 'published with __keyevent@<db>__ prefix. We recommend to set it to ' +
257
+ "'Exe' like so `redis-cli -h localhost config set notify-keyspace-events Exe`",
245
258
  );
246
259
  }
247
260
  if (
@@ -254,7 +267,9 @@ export class SyncedMap<V> {
254
267
  throw new Error(
255
268
  'Keyspace event configuration is set to "' +
256
269
  keyspaceEventConfig +
257
- "\" but has to include 'A' or 'x' and 'e' for expired and evicted events. We recommend to set it to 'Exe' like so `redis-cli -h localhost config set notify-keyspace-events Exe`",
270
+ " but has to include 'A' or 'x' and 'e' for expired and " +
271
+ 'evicted events. We recommend to set it to ' +
272
+ "'Exe' like so `redis-cli -h localhost config set notify-keyspace-events Exe`",
258
273
  );
259
274
  }
260
275
  }
@@ -275,24 +290,58 @@ export class SyncedMap<V> {
275
290
  keyEventHandler,
276
291
  ),
277
292
  ]);
293
+ } catch (error) {
294
+ console.error('Error setting up pub/sub client:', error);
295
+ throw error;
296
+ }
297
+ }
278
298
 
279
- // Error handling for reconnection
280
- this.subscriberClient.on('error', async (err) => {
281
- console.error('Subscriber client error:', err);
299
+ /**
300
+ * Reconnect the subscriber client with exponential backoff.
301
+ * Guarded by `isReconnecting` to prevent concurrent reconnection
302
+ * attempts when multiple error events fire during an outage.
303
+ * Retries indefinitely (with capped delay) so the subscriber
304
+ * eventually recovers once Redis is available again.
305
+ */
306
+ private async reconnectSubscriber(): Promise<void> {
307
+ if (this.isReconnecting) return;
308
+ this.isReconnecting = true;
309
+
310
+ try {
311
+ let attempt = 0;
312
+ while (true) {
282
313
  try {
283
- await this.subscriberClient.quit();
314
+ // Forcibly tear down the old subscriber so its internal reconnect
315
+ // loop cannot come back to life once Redis recovers. `quit()` waits
316
+ // for the server and can silently fail during an outage, leaving a
317
+ // zombie client that still fires our error handler. Remove that
318
+ // handler first so a delayed error from the old socket cannot tear
319
+ // down the replacement subscriber we are about to create.
320
+ try {
321
+ this.subscriberClient.removeAllListeners('error');
322
+ await this.subscriberClient.disconnect();
323
+ } catch {
324
+ // expected during outage
325
+ }
326
+ // Create a fresh subscriber client
284
327
  this.subscriberClient = this.client.duplicate();
285
328
  await this.setupPubSub();
286
- } catch (reconnectError) {
329
+ return; // success
330
+ } catch (error) {
331
+ const delay = Math.min(
332
+ 500 * Math.pow(2, Math.min(attempt, 5)),
333
+ 10_000,
334
+ );
335
+ attempt++;
287
336
  console.error(
288
- 'Failed to reconnect subscriber client:',
289
- reconnectError,
337
+ `Subscriber reconnect attempt ${attempt} failed, retrying in ${delay}ms`,
338
+ error,
290
339
  );
340
+ await new Promise((r) => setTimeout(r, delay));
291
341
  }
292
- });
293
- } catch (error) {
294
- console.error('Error setting up pub/sub client:', error);
295
- throw error;
342
+ }
343
+ } finally {
344
+ this.isReconnecting = false;
296
345
  }
297
346
  }
298
347