@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
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Reproduces issue #86: Memory grows unbounded and process never recovers
3
+ * after a short Redis outage — subscriber reconnect loop.
4
+ *
5
+ * This script creates two RedisStringsHandler instances (A and B) sharing
6
+ * the same keyPrefix so their SyncedMaps communicate via Redis PubSub.
7
+ * It also creates a CacheComponentsHandler singleton (C).
8
+ *
9
+ * Test sequence:
10
+ * 1. Verify PubSub sync works before outage (A.set → B.sharedTagsMap.get)
11
+ * 2. Verify subscriber clients have error listeners before outage
12
+ * 3. Kill Redis, call get() 50× during outage (heap-growth probe)
13
+ * 4. Wait 3 s for error handlers to fire and fail
14
+ * 5. Count duplicate() calls during outage (client-leak probe)
15
+ * 6. Restart Redis, wait for main clients to reconnect
16
+ * 7. Verify PubSub sync works after outage (EXPECTED TO FAIL — the bug)
17
+ * 8. Verify subscriber clients still have error listeners (EXPECTED TO FAIL)
18
+ * 9. Verify get() works after Redis restart
19
+ *
20
+ * Output: lines of `RESULT|<name>|PASS|<detail>` or `RESULT|<name>|FAIL|<detail>`
21
+ * followed by `RESULTS_JSON|<json>` on the last line.
22
+ */
23
+
24
+ import {
25
+ detectContainerRuntime,
26
+ getFreePort,
27
+ sh,
28
+ sleep,
29
+ startRedis,
30
+ waitUntil,
31
+ type TestResult,
32
+ } from './redis-test-helpers';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Result tracking
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const results: TestResult[] = [];
39
+
40
+ function record(name: string, pass: boolean, detail: string) {
41
+ results.push({ name, pass, detail });
42
+ process.stdout.write(`RESULT|${name}|${pass ? 'PASS' : 'FAIL'}|${detail}\n`);
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Main
47
+ // ---------------------------------------------------------------------------
48
+
49
+ async function main() {
50
+ const runtime = detectContainerRuntime();
51
+ const name = `redis-e2e-${Math.random().toString(36).slice(2, 8)}`;
52
+ const port = await getFreePort();
53
+
54
+ // cleanup any stale container
55
+ sh(runtime, ['rm', '-f', name]);
56
+
57
+ // 1. Start Redis
58
+ startRedis(runtime, name, port);
59
+
60
+ // Set env vars BEFORE importing so the CacheComponentsHandler singleton
61
+ // picks up the correct Redis URL.
62
+ process.env.REDIS_URL = `redis://127.0.0.1:${port}`;
63
+ process.env.VERCEL_URL = `e2e-${name}-`;
64
+
65
+ // Import handlers (dynamic import ensures env vars are set first)
66
+ const { getRedisCacheComponentsHandler } = await import(
67
+ '../../../../../src/CacheComponentsHandler'
68
+ );
69
+ const { default: RedisStringsHandler } = await import(
70
+ '../../../../../src/RedisStringsHandler'
71
+ );
72
+
73
+ const socketOpts = {
74
+ connectTimeout: 2_000,
75
+ reconnectStrategy: (retries: number) => Math.min(50 + retries * 50, 500),
76
+ };
77
+
78
+ // 2. Create handler A (writer) and B (reader) with same keyPrefix
79
+ const handlerA = new RedisStringsHandler({
80
+ redisUrl: `redis://127.0.0.1:${port}`,
81
+ socketOptions: socketOpts,
82
+ keyPrefix: `e2e-${name}-`,
83
+ killContainerOnErrorThreshold: 0, // disabled — same as issue #86 config
84
+ } as any);
85
+
86
+ const handlerB = new RedisStringsHandler({
87
+ redisUrl: `redis://127.0.0.1:${port}`,
88
+ socketOptions: socketOpts,
89
+ keyPrefix: `e2e-${name}-`,
90
+ killContainerOnErrorThreshold: 0,
91
+ } as any);
92
+
93
+ // CacheComponentsHandler singleton (created on import via redisCacheHandler)
94
+ const handlerC = getRedisCacheComponentsHandler();
95
+
96
+ // 3. Monkey-patch A's client.duplicate to count calls (client-leak probe)
97
+ const clientA = (handlerA as any).client;
98
+ const originalDuplicate = clientA.duplicate.bind(clientA);
99
+ let duplicateCount = 0;
100
+ (clientA as any).duplicate = function (...args: any[]) {
101
+ duplicateCount++;
102
+ return originalDuplicate(...args);
103
+ };
104
+
105
+ // 4. Wait for all main clients to be ready
106
+ await waitUntil(async () => (handlerA as any).client.isReady, 20_000);
107
+ await waitUntil(async () => (handlerB as any).client.isReady, 20_000);
108
+ await waitUntil(async () => (handlerC as any).client.isReady, 20_000);
109
+
110
+ // Record initial duplicate count (3 SyncedMaps × 1 duplicate each = 3)
111
+ const initialDuplicateCount = duplicateCount;
112
+
113
+ // 5. Test: PubSub sync works before outage
114
+ await handlerA.set(
115
+ 'key1',
116
+ {
117
+ kind: 'FETCH',
118
+ data: {
119
+ headers: {},
120
+ body: Buffer.from('hello').toString('base64'),
121
+ status: 200,
122
+ url: 'https://example.com/e2e',
123
+ },
124
+ revalidate: 10,
125
+ },
126
+ { isRoutePPREnabled: false, isFallback: false, tags: ['tag1'] },
127
+ );
128
+
129
+ const pubsubInitial = await waitUntil(async () => {
130
+ const tags = (handlerB as any).sharedTagsMap.get('key1');
131
+ return !!tags && tags.length === 1 && tags[0] === 'tag1';
132
+ }, 5_000);
133
+
134
+ record(
135
+ 'pubsub-initial-strings',
136
+ pubsubInitial,
137
+ pubsubInitial
138
+ ? 'sync received — B.sharedTagsMap has key1'
139
+ : 'B.sharedTagsMap never received sync for key1',
140
+ );
141
+
142
+ // 6. Test: Error listeners present before outage
143
+ const subB = (handlerB as any).sharedTagsMap.subscriberClient;
144
+ const subC = (handlerC as any).revalidatedTagsMap.subscriberClient;
145
+ const initListenerB = subB?.listenerCount?.('error') ?? -1;
146
+ const initListenerC = subC?.listenerCount?.('error') ?? -1;
147
+
148
+ record(
149
+ 'error-listener-initial',
150
+ initListenerB >= 1 && initListenerC >= 1,
151
+ `B.sharedTagsMap.subscriber listenerCount(error)=${initListenerB}, C.revalidatedTagsMap.subscriber listenerCount(error)=${initListenerC}`,
152
+ );
153
+
154
+ // 7. Kill Redis
155
+ sh(runtime, ['stop', '-t', '0', name], { timeoutMs: 30_000 });
156
+
157
+ // 8. Test: get() returns null during outage (symptom verification)
158
+ // Use a key that was NOT just set() — the dedup cache seeds the return
159
+ // value on set(), so get('key1') would return the cached value without
160
+ // hitting Redis. Using a different key forces a real Redis GET which fails.
161
+ let getDuringOutage = false;
162
+ let getDuringOutageDetail = '';
163
+ try {
164
+ const result = await handlerA.get('nonexistent-during-outage', {
165
+ kind: 'FETCH',
166
+ revalidate: 10,
167
+ fetchUrl: 'https://example.com/e2e',
168
+ fetchIdx: 0,
169
+ tags: ['tag1'],
170
+ softTags: [],
171
+ isFallback: false,
172
+ });
173
+ getDuringOutage = result === null;
174
+ getDuringOutageDetail = getDuringOutage
175
+ ? 'returned null (expected during outage)'
176
+ : `returned ${typeof result}`;
177
+ } catch (err: any) {
178
+ getDuringOutageDetail = `threw: ${err?.message ?? err}`;
179
+ }
180
+ record('get-during-outage', getDuringOutage, getDuringOutageDetail);
181
+
182
+ // 9. Test: Heap growth during outage
183
+ const heapBefore = process.memoryUsage().heapUsed;
184
+ for (let i = 0; i < 50; i++) {
185
+ try {
186
+ await handlerA.get(`nonexistent-${i}`, {
187
+ kind: 'FETCH',
188
+ revalidate: 10,
189
+ fetchUrl: 'https://example.com/e2e',
190
+ fetchIdx: 0,
191
+ tags: ['tag1'],
192
+ softTags: [],
193
+ isFallback: false,
194
+ });
195
+ } catch {
196
+ // ignore
197
+ }
198
+ }
199
+ // Force a GC pass if --expose-gc is available so we measure retained memory
200
+ if (typeof (globalThis as any).gc === 'function') {
201
+ (globalThis as any).gc();
202
+ }
203
+ const heapAfter = process.memoryUsage().heapUsed;
204
+ const heapGrowthMB = (heapAfter - heapBefore) / 1024 / 1024;
205
+ record(
206
+ 'heap-growth-during-outage',
207
+ heapGrowthMB < 50,
208
+ `heap grew ${heapGrowthMB.toFixed(2)} MB during 50 get() calls`,
209
+ );
210
+
211
+ // 10. Wait for subscriber error handlers to fire and fail
212
+ await sleep(3_000);
213
+
214
+ // 11. Test: Duplicate count during outage (client-leak probe)
215
+ // This is informational: in some scenarios quit() throws before
216
+ // duplicate() is reached (no leak), in others duplicate() runs but
217
+ // setupPubSub() throws (leaked client). Either way the subscriber
218
+ // is dead — the PubSub test below is the definitive check.
219
+ const outageDuplicates = duplicateCount - initialDuplicateCount;
220
+ record(
221
+ 'duplicate-count-during-outage',
222
+ true, // informational — always passes, detail has the count
223
+ `${outageDuplicates} duplicate() calls during outage (initial=${initialDuplicateCount}, total=${duplicateCount})`,
224
+ );
225
+
226
+ // 12. Restart Redis
227
+ startRedis(runtime, name, port);
228
+
229
+ // 13. Wait for main clients to reconnect
230
+ const reconnectedA = await waitUntil(async () => {
231
+ try {
232
+ return (await (handlerA as any).client.ping()) === 'PONG';
233
+ } catch {
234
+ return false;
235
+ }
236
+ }, 30_000);
237
+
238
+ const reconnectedB = await waitUntil(async () => {
239
+ try {
240
+ return (await (handlerB as any).client.ping()) === 'PONG';
241
+ } catch {
242
+ return false;
243
+ }
244
+ }, 30_000);
245
+
246
+ record(
247
+ 'main-client-reconnects',
248
+ reconnectedA && reconnectedB,
249
+ `A reconnected=${reconnectedA}, B reconnected=${reconnectedB}`,
250
+ );
251
+
252
+ // Wait a bit for subscriber reconnection to complete
253
+ await sleep(2_000);
254
+
255
+ // 14. Test: PubSub sync after outage (was the bug — should now PASS)
256
+ if (reconnectedA) {
257
+ await handlerA.set(
258
+ 'key2',
259
+ {
260
+ kind: 'FETCH',
261
+ data: {
262
+ headers: {},
263
+ body: Buffer.from('world').toString('base64'),
264
+ status: 200,
265
+ url: 'https://example.com/e2e2',
266
+ },
267
+ revalidate: 10,
268
+ },
269
+ { isRoutePPREnabled: false, isFallback: false, tags: ['tag2'] },
270
+ );
271
+
272
+ // Give the subscriber reconnect loop time to succeed after Redis
273
+ // restarts. The backoff delays are 500ms, 1s, 2s, 4s, 8s, capped at 10s.
274
+ // After a 3s outage + restart, the subscriber may need up to ~10s to
275
+ // reconnect depending on which backoff slot it's in.
276
+ const pubsubAfter = await waitUntil(async () => {
277
+ const tags = (handlerB as any).sharedTagsMap.get('key2');
278
+ return !!tags && tags.length === 1 && tags[0] === 'tag2';
279
+ }, 20_000);
280
+
281
+ record(
282
+ 'pubsub-after-outage-strings',
283
+ pubsubAfter,
284
+ pubsubAfter
285
+ ? 'sync received — B.sharedTagsMap has key2'
286
+ : 'B.sharedTagsMap never received sync for key2 (subscriber is dead)',
287
+ );
288
+ } else {
289
+ record(
290
+ 'pubsub-after-outage-strings',
291
+ false,
292
+ 'skipped — main client A did not reconnect',
293
+ );
294
+ }
295
+
296
+ // 15. Test: Error listener on subscriber after outage
297
+ // Informational: the listener may still be present on the old dead client
298
+ // (if quit() threw before duplicate()), or may be missing (if duplicate()
299
+ // ran but setupPubSub() threw). Either way, the subscriber is non-functional.
300
+ // The definitive test is pubsub-after-outage-strings below.
301
+ const subBAfter = (handlerB as any).sharedTagsMap.subscriberClient;
302
+ const subCAfter = (handlerC as any).revalidatedTagsMap.subscriberClient;
303
+ const afterListenerB = subBAfter?.listenerCount?.('error') ?? -1;
304
+ const afterListenerC = subCAfter?.listenerCount?.('error') ?? -1;
305
+ const sameClientB = subBAfter === subB;
306
+ const sameClientC = subCAfter === subC;
307
+
308
+ record(
309
+ 'error-listener-after-outage',
310
+ true, // informational — always passes, detail has the counts
311
+ `B: listenerCount(error)=${afterListenerB} (was ${initListenerB}), sameClient=${sameClientB}; C: listenerCount(error)=${afterListenerC} (was ${initListenerC}), sameClient=${sameClientC}`,
312
+ );
313
+
314
+ // 16. Test: get() works after Redis restart (main client path)
315
+ // Redis was restarted with --rm (data lost), so re-seed key1 first
316
+ // to verify the main client can write and read after restart.
317
+ let getAfter = false;
318
+ let getAfterDetail = '';
319
+ try {
320
+ await handlerA.set(
321
+ 'key1',
322
+ {
323
+ kind: 'FETCH',
324
+ data: {
325
+ headers: {},
326
+ body: Buffer.from('hello').toString('base64'),
327
+ status: 200,
328
+ url: 'https://example.com/e2e',
329
+ },
330
+ revalidate: 10,
331
+ },
332
+ { isRoutePPREnabled: false, isFallback: false, tags: ['tag1'] },
333
+ );
334
+ const result = await handlerA.get('key1', {
335
+ kind: 'FETCH',
336
+ revalidate: 10,
337
+ fetchUrl: 'https://example.com/e2e',
338
+ fetchIdx: 0,
339
+ tags: ['tag1'],
340
+ softTags: [],
341
+ isFallback: false,
342
+ });
343
+ getAfter = result !== null;
344
+ getAfterDetail = getAfter
345
+ ? 'returned cached value'
346
+ : 'returned null (cache miss or stale)';
347
+ } catch (err: any) {
348
+ getAfterDetail = `threw: ${err?.message ?? err}`;
349
+ }
350
+ record('get-after-outage', getAfter, getAfterDetail);
351
+
352
+ // 17. Test: Total duplicate count (informational)
353
+ record(
354
+ 'duplicate-count-total',
355
+ true, // informational — always passes, detail has the count
356
+ `total duplicate() calls=${duplicateCount} (initial=${initialDuplicateCount}, extra=${duplicateCount - initialDuplicateCount})`,
357
+ );
358
+
359
+ // 18. Cleanup
360
+ try {
361
+ await (handlerA as any).client?.quit?.();
362
+ } catch {}
363
+ try {
364
+ await (handlerB as any).client?.quit?.();
365
+ } catch {}
366
+ try {
367
+ await (handlerC as any).client?.quit?.();
368
+ } catch {}
369
+ sh(runtime, ['rm', '-f', name]);
370
+
371
+ // Output JSON summary for the vitest wrapper to parse
372
+ process.stdout.write(`RESULTS_JSON|${JSON.stringify(results)}\n`);
373
+ process.exit(0);
374
+ }
375
+
376
+ main().catch((err) => {
377
+ console.error(err);
378
+ process.exit(1);
379
+ });
@@ -0,0 +1,155 @@
1
+ import { spawn, spawnSync } from 'child_process';
2
+
3
+ export type ContainerRuntime = 'podman' | 'docker';
4
+
5
+ export function canRun(cmd: string): boolean {
6
+ const res = spawnSync(cmd, ['--version'], {
7
+ encoding: 'utf8',
8
+ timeout: 3_000,
9
+ });
10
+ return !res.error && (res.status ?? 1) === 0;
11
+ }
12
+
13
+ export function hasContainerRuntime(): boolean {
14
+ return canRun('podman') || canRun('docker');
15
+ }
16
+
17
+ export function detectContainerRuntime(): ContainerRuntime {
18
+ if (process.env.CONTAINER_RUNTIME === 'podman') return 'podman';
19
+ if (process.env.CONTAINER_RUNTIME === 'docker') return 'docker';
20
+ if (canRun('podman')) return 'podman';
21
+ if (canRun('docker')) return 'docker';
22
+ throw new Error(
23
+ 'Neither podman nor docker is available. Install one of them or set CONTAINER_RUNTIME.',
24
+ );
25
+ }
26
+
27
+ export interface ShResult {
28
+ code: number;
29
+ stdout: string;
30
+ stderr: string;
31
+ }
32
+
33
+ export function sh(
34
+ cmd: string,
35
+ args: string[],
36
+ opts: { timeoutMs?: number } = {},
37
+ ): ShResult {
38
+ const res = spawnSync(cmd, args, {
39
+ encoding: 'utf8',
40
+ timeout: opts.timeoutMs ?? 30_000,
41
+ });
42
+ if (res.error) throw res.error;
43
+ return {
44
+ code: res.status ?? -1,
45
+ stdout: res.stdout ?? '',
46
+ stderr: res.stderr ?? '',
47
+ };
48
+ }
49
+
50
+ export async function sleep(ms: number): Promise<void> {
51
+ await new Promise((r) => setTimeout(r, ms));
52
+ }
53
+
54
+ export async function waitUntil(
55
+ fn: () => Promise<boolean>,
56
+ timeoutMs = 20_000,
57
+ intervalMs = 200,
58
+ ): Promise<boolean> {
59
+ const start = Date.now();
60
+ // eslint-disable-next-line no-constant-condition
61
+ while (true) {
62
+ if (await fn()) return true;
63
+ if (Date.now() - start > timeoutMs) return false;
64
+ await sleep(intervalMs);
65
+ }
66
+ }
67
+
68
+ export async function getFreePort(): Promise<number> {
69
+ const net = await import('net');
70
+ return new Promise((resolve, reject) => {
71
+ const srv = net.createServer();
72
+ srv.on('error', reject);
73
+ srv.listen(0, '127.0.0.1', () => {
74
+ const addr = srv.address();
75
+ if (!addr || typeof addr === 'string')
76
+ return reject(new Error('bad addr'));
77
+ const p = addr.port;
78
+ srv.close(() => resolve(p));
79
+ });
80
+ });
81
+ }
82
+
83
+ export function startRedis(
84
+ runtime: ContainerRuntime,
85
+ name: string,
86
+ port: number,
87
+ ): void {
88
+ const r = sh(
89
+ runtime,
90
+ [
91
+ 'run',
92
+ '-d',
93
+ '--rm',
94
+ '--name',
95
+ name,
96
+ '-p',
97
+ `${port}:6379`,
98
+ 'docker.io/redis:7-alpine',
99
+ 'redis-server',
100
+ '--notify-keyspace-events',
101
+ 'Exe',
102
+ ],
103
+ { timeoutMs: 60_000 },
104
+ );
105
+ if (r.code !== 0) throw new Error(`${runtime} run failed: ${r.stderr}`);
106
+ }
107
+
108
+ export interface TestResult {
109
+ name: string;
110
+ pass: boolean;
111
+ detail: string;
112
+ }
113
+
114
+ export function record(name: string, pass: boolean, detail: string): void {
115
+ process.stdout.write(`RESULT|${name}|${pass ? 'PASS' : 'FAIL'}|${detail}\n`);
116
+ }
117
+
118
+ export function parseResults(stdout: string): Map<string, TestResult> {
119
+ const map = new Map<string, TestResult>();
120
+ for (const line of stdout.split('\n')) {
121
+ const m = line.match(/^RESULT\|([^|]+)\|(PASS|FAIL)\|(.*)$/);
122
+ if (m) {
123
+ map.set(m[1], {
124
+ name: m[1],
125
+ pass: m[2] === 'PASS',
126
+ detail: m[3],
127
+ });
128
+ }
129
+ }
130
+ return map;
131
+ }
132
+
133
+ export function runNode(
134
+ script: string,
135
+ timeoutMs = 300_000,
136
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
137
+ return new Promise((resolve, reject) => {
138
+ const p = spawn('pnpm', ['-s', 'tsx', script], {
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ env: { ...process.env },
141
+ });
142
+ let stdout = '';
143
+ let stderr = '';
144
+ p.stdout.on('data', (d) => (stdout += d.toString()));
145
+ p.stderr.on('data', (d) => (stderr += d.toString()));
146
+ const t = setTimeout(() => {
147
+ p.kill('SIGKILL');
148
+ reject(new Error('timeout'));
149
+ }, timeoutMs);
150
+ p.on('close', (code) => {
151
+ clearTimeout(t);
152
+ resolve({ code: code ?? -1, stdout, stderr });
153
+ });
154
+ });
155
+ }
@@ -3,13 +3,19 @@ import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
3
3
  import fetch from 'node-fetch';
4
4
  import { createClient, RedisClientType } from 'redis';
5
5
  import { join } from 'path';
6
- import { CacheEntry } from '../../src/RedisStringsHandler';
7
- import { revalidate as next1503_revalidatedFetch_route } from './next-app-15-0-3/src/app/api/revalidated-fetch/route';
6
+ import { CacheEntry } from '../../../src/RedisStringsHandler';
7
+ import { revalidate as revalidatedFetchRouteRevalidate } from '../../nextjs-test-projects/next-app-15-4-11/src/app/api/revalidated-fetch/route';
8
8
 
9
9
  // Select which Next.js test app to use. Can be overridden via NEXT_TEST_APP env var
10
- // Examples: next-app-15-0-3, next-app-15-3-2, next-app-15-4-7
11
- const NEXT_TEST_APP = process.env.NEXT_TEST_APP || 'next-app-15-4-7';
12
- const NEXT_APP_DIR = join(__dirname, NEXT_TEST_APP);
10
+ // Examples: next-app-15-4-11, next-app-16-0-11, next-app-16-2-6
11
+ const NEXT_TEST_APP = process.env.NEXT_TEST_APP || 'next-app-15-4-11';
12
+ const NEXT_APP_DIR = join(
13
+ __dirname,
14
+ '..',
15
+ '..',
16
+ 'nextjs-test-projects',
17
+ NEXT_TEST_APP,
18
+ );
13
19
  console.log('NEXT_APP_DIR', NEXT_APP_DIR);
14
20
  const NEXT_START_PORT = 3055;
15
21
  const NEXT_START_URL = `http://localhost:${NEXT_START_PORT}`;
@@ -291,9 +297,9 @@ describe('Next.js Turbo Redis Cache Integration', () => {
291
297
  const ttl = await redisClient.ttl(
292
298
  process.env.VERCEL_URL + '/api/revalidated-fetch',
293
299
  );
294
- expect(ttl).toBeLessThan(2 * next1503_revalidatedFetch_route);
300
+ expect(ttl).toBeLessThan(2 * revalidatedFetchRouteRevalidate);
295
301
  expect(ttl).toBeGreaterThan(
296
- 2 * next1503_revalidatedFetch_route -
302
+ 2 * revalidatedFetchRouteRevalidate -
297
303
  FIRST_DELAY -
298
304
  SECOND_DELAY -
299
305
  REDIS_BACKGROUND_SYNC_DELAY,
@@ -743,7 +749,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
743
749
  });
744
750
 
745
751
  // describe('With a cached static fetch request inside a page', () => {
746
- // // TODO: implement test for `test/integration/next-app/src/app/pages/cached-static-fetch`
752
+ // // TODO: implement test for `test/nextjs-test-projects/next-app/src/app/pages/cached-static-fetch`
747
753
  // });
748
754
 
749
755
  describe('With a cached revalidation fetch request inside a page', () => {
@@ -905,7 +911,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
905
911
  });
906
912
 
907
913
  // describe('With a uncached fetch request inside a page', () => {
908
- // // TODO: implement test for `test/integration/next-app/src/app/pages/uncached-fetch`
914
+ // // TODO: implement test for `test/nextjs-test-projects/next-app/src/app/pages/uncached-fetch`
909
915
  //
910
916
  // });
911
917