@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,24 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+
3
+ // On-demand revalidation endpoint: res.revalidate(path) re-renders the page
4
+ // and writes the fresh entry through the cache handler, so all instances
5
+ // sharing the same Redis pick it up.
6
+ export default async function handler(
7
+ req: NextApiRequest,
8
+ res: NextApiResponse,
9
+ ) {
10
+ const path = req.query.path;
11
+ if (typeof path !== 'string') {
12
+ return res
13
+ .status(400)
14
+ .json({ revalidated: false, error: 'Missing ?path= query parameter' });
15
+ }
16
+ try {
17
+ await res.revalidate(path);
18
+ return res.json({ revalidated: true, path });
19
+ } catch (err) {
20
+ return res
21
+ .status(500)
22
+ .json({ revalidated: false, path, error: String(err) });
23
+ }
24
+ }
@@ -0,0 +1,11 @@
1
+ export default function Home() {
2
+ return (
3
+ <main>
4
+ <h1>Pages Router test app</h1>
5
+ <p>
6
+ Fixture for testing the Redis cache handler with the Next.js Pages
7
+ Router (PAGES, REDIRECT and notFound cache entries).
8
+ </p>
9
+ </main>
10
+ );
11
+ }
@@ -0,0 +1,49 @@
1
+ import type { GetStaticPaths, GetStaticProps } from 'next';
2
+
3
+ type Props = { slug: string; timestamp: number; counter: number };
4
+
5
+ // Per-process regeneration counter. Two server instances of the same build
6
+ // have independent counters, while the timestamp proves cross-instance
7
+ // freshness after on-demand revalidation.
8
+ let regenerationCounter = 0;
9
+
10
+ export default function IsrPage({ slug, timestamp, counter }: Props) {
11
+ return (
12
+ <main>
13
+ <h1>ISR page</h1>
14
+ <p>Slug: {slug}</p>
15
+ <p>Timestamp: {timestamp}</p>
16
+ <p>Counter: {counter}</p>
17
+ </main>
18
+ );
19
+ }
20
+
21
+ export const getStaticPaths: GetStaticPaths = async () => {
22
+ return {
23
+ paths: [{ params: { slug: 'prebuilt' } }],
24
+ fallback: 'blocking',
25
+ };
26
+ };
27
+
28
+ // revalidate is set high (300s) so that natural ISR regeneration does not
29
+ // interfere with the on-demand revalidation (res.revalidate) tests.
30
+ export const getStaticProps: GetStaticProps<Props> = async (context) => {
31
+ const slug = context.params?.slug as string;
32
+
33
+ if (slug === 'not-found') {
34
+ return { notFound: true, revalidate: 300 };
35
+ }
36
+
37
+ if (slug === 'redirect') {
38
+ return {
39
+ redirect: { destination: '/static-forever', permanent: false },
40
+ revalidate: 300,
41
+ };
42
+ }
43
+
44
+ regenerationCounter += 1;
45
+ return {
46
+ props: { slug, timestamp: Date.now(), counter: regenerationCounter },
47
+ revalidate: 300,
48
+ };
49
+ };
@@ -0,0 +1,20 @@
1
+ import type { GetStaticProps } from 'next';
2
+
3
+ type Props = { timestamp: number };
4
+
5
+ export default function StaticForever({ timestamp }: Props) {
6
+ return (
7
+ <main>
8
+ <h1>Static forever</h1>
9
+ <p>Timestamp: {timestamp}</p>
10
+ </main>
11
+ );
12
+ }
13
+
14
+ // revalidate is intentionally omitted (revalidate: false): the cache entry
15
+ // must fall through to the defaultStaleAge based TTL in the cache handler.
16
+ export const getStaticProps: GetStaticProps<Props> = async () => {
17
+ return {
18
+ props: { timestamp: Date.now() },
19
+ };
20
+ };
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }
@@ -1,7 +1,7 @@
1
1
  import { test, expect } from '@playwright/test';
2
2
 
3
3
  // This test assumes the Next 16 app is already running, e.g.:
4
- // cd test/integration/next-app-16-0-3 && pnpm dev
4
+ // cd test/nextjs-test-projects/next-app-16-0-11 && pnpm dev
5
5
  // on the same origin as baseURL.
6
6
 
7
7
  test('button click triggers Server Action using updateTag', async ({
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import fs from 'fs';
6
6
  import fetch from 'node-fetch';
7
7
 
8
- const NEXT_APP = 'next-app-15-4-7';
8
+ const NEXT_APP = 'next-app-15-4-11';
9
9
  const PORT = 3075;
10
10
  const BASE_URL = `http://localhost:${PORT}`;
11
11
 
@@ -28,7 +28,7 @@ describe('BUILD_ID-based key prefix (Next handlers)', () => {
28
28
  let buildId: string;
29
29
 
30
30
  beforeAll(async () => {
31
- appDir = path.join(__dirname, NEXT_APP);
31
+ appDir = path.join(__dirname, '..', '..', 'nextjs-test-projects', NEXT_APP);
32
32
 
33
33
  // Ensure clean env so BUILD_ID precedence kicks in
34
34
  delete (process.env as Record<string, string | undefined>).KEY_PREFIX;
@@ -27,12 +27,16 @@ describe('Next.js 16 Cache Components Integration', () => {
27
27
  keyPrefix = `cache-components-test-${Math.random().toString(36).substring(7)}`;
28
28
  process.env.VERCEL_URL = keyPrefix;
29
29
 
30
- // Build and start Next.js app
30
+ const cacheComponentsApp =
31
+ process.env.CACHE_COMPONENTS_APP || 'next-app-16-2-6-cache-components';
32
+
31
33
  const appDir = path.join(
32
34
  __dirname,
33
35
  '..',
34
- 'integration',
35
- 'next-app-16-2-3-cache-components',
36
+ '..',
37
+ '..',
38
+ 'nextjs-test-projects',
39
+ cacheComponentsApp,
36
40
  );
37
41
 
38
42
  console.log('Installing Next.js app dependencies...');
@@ -1,6 +1,21 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { spawn } from 'child_process';
3
3
  import path from 'path';
4
+ import { spawnSync } from 'child_process';
5
+
6
+ function hasContainerRuntime() {
7
+ const podman = spawnSync('podman', ['--version'], {
8
+ encoding: 'utf8',
9
+ timeout: 3_000,
10
+ });
11
+ if (!podman.error && (podman.status ?? 1) === 0) return true;
12
+
13
+ const docker = spawnSync('docker', ['--version'], {
14
+ encoding: 'utf8',
15
+ timeout: 3_000,
16
+ });
17
+ return !docker.error && (docker.status ?? 1) === 0;
18
+ }
4
19
 
5
20
  function runNode(script: string, timeoutMs = 120_000) {
6
21
  return new Promise<{ code: number; stdout: string; stderr: string }>(
@@ -26,13 +41,19 @@ function runNode(script: string, timeoutMs = 120_000) {
26
41
  }
27
42
 
28
43
  describe('redis kill/reconnect end-to-end (both handlers)', () => {
29
- it('survives redis restart without Socket already opened', async () => {
30
- const script = path.join(__dirname, 'scripts', 'redis-kill-reconnect.ts');
44
+ const runOrSkip = hasContainerRuntime() ? it : it.skip;
31
45
 
32
- const res = await runNode(script, 180_000);
46
+ runOrSkip(
47
+ 'survives redis restart without Socket already opened',
48
+ async () => {
49
+ const script = path.join(__dirname, 'scripts', 'redis-kill-reconnect.ts');
33
50
 
34
- expect(res.code).toBe(0);
35
- expect(res.stdout).toContain('OK');
36
- expect(res.stderr).not.toContain('Socket already opened');
37
- }, 180_000);
51
+ const res = await runNode(script, 180_000);
52
+
53
+ expect(res.code).toBe(0);
54
+ expect(res.stdout).toContain('OK');
55
+ expect(res.stderr).not.toContain('Socket already opened');
56
+ },
57
+ 180_000,
58
+ );
38
59
  });
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import path from 'path';
3
+ import {
4
+ hasContainerRuntime,
5
+ runNode,
6
+ parseResults,
7
+ type TestResult,
8
+ } from './scripts/redis-test-helpers';
9
+
10
+ const describeOrSkip = hasContainerRuntime() ? describe : describe.skip;
11
+
12
+ describeOrSkip('subscriber teardown: quit() vs disconnect()', () => {
13
+ let results: Map<string, TestResult>;
14
+ let exitCode: number;
15
+ let stderr: string;
16
+
17
+ beforeAll(async () => {
18
+ const script = path.join(
19
+ __dirname,
20
+ 'scripts',
21
+ 'redis-quit-vs-disconnect.ts',
22
+ );
23
+ const res = await runNode(script, 60_000);
24
+ exitCode = res.code;
25
+ stderr = res.stderr;
26
+ results = parseResults(res.stdout);
27
+ }, 60_000);
28
+
29
+ it('script exits successfully', () => {
30
+ if (exitCode !== 0) {
31
+ process.stderr.write(stderr);
32
+ }
33
+ expect(exitCode).toBe(0);
34
+ });
35
+
36
+ it('subscriber clients are ready before outage', () => {
37
+ const r = results.get('subscribers-ready');
38
+ expect(r).toBeDefined();
39
+ expect(r!.pass, r!.detail).toBe(true);
40
+ });
41
+
42
+ it('quit() does not immediately close a subscriber during outage', () => {
43
+ const r = results.get('quit-does-not-immediately-close');
44
+ expect(r).toBeDefined();
45
+ expect(r!.pass, r!.detail).toBe(true);
46
+ });
47
+
48
+ it('disconnect() closes the subscriber immediately during outage', () => {
49
+ const r = results.get('disconnect-closes-immediately');
50
+ expect(r).toBeDefined();
51
+ expect(r!.pass, r!.detail).toBe(true);
52
+ });
53
+
54
+ it('disconnected subscriber stays dead after Redis restarts', () => {
55
+ const r = results.get('disconnected-client-stays-dead');
56
+ expect(r).toBeDefined();
57
+ expect(r!.pass, r!.detail).toBe(true);
58
+ });
59
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import path from 'path';
3
+ import {
4
+ hasContainerRuntime,
5
+ runNode,
6
+ parseResults,
7
+ type TestResult,
8
+ } from './scripts/redis-test-helpers';
9
+
10
+ const describeOrSkip = hasContainerRuntime() ? describe : describe.skip;
11
+
12
+ describeOrSkip('issue #86: subscriber outage recovery', () => {
13
+ let results: Map<string, TestResult>;
14
+
15
+ beforeAll(async () => {
16
+ const script = path.join(
17
+ __dirname,
18
+ 'scripts',
19
+ 'redis-subscriber-outage.ts',
20
+ );
21
+ const res = await runNode(script, 300_000);
22
+ // Script always exits 0 (it records pass/fail per sub-test)
23
+ if (res.code !== 0) {
24
+ throw new Error(
25
+ `Script exited with code ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`,
26
+ );
27
+ }
28
+ results = parseResults(res.stdout);
29
+ }, 300_000);
30
+
31
+ // --- Tests that should PASS (verify correct behavior before outage) ---
32
+
33
+ it('PubSub sync works before outage', () => {
34
+ const r = results.get('pubsub-initial-strings');
35
+ expect(r).toBeDefined();
36
+ expect(r!.pass, r!.detail).toBe(true);
37
+ });
38
+
39
+ it('subscriber clients have error listeners before outage', () => {
40
+ const r = results.get('error-listener-initial');
41
+ expect(r).toBeDefined();
42
+ expect(r!.pass, r!.detail).toBe(true);
43
+ });
44
+
45
+ // --- Tests that verify symptoms during outage ---
46
+
47
+ it('get() returns null during outage', () => {
48
+ const r = results.get('get-during-outage');
49
+ expect(r).toBeDefined();
50
+ expect(r!.pass, r!.detail).toBe(true);
51
+ });
52
+
53
+ it('heap does not grow excessively during 50 get() calls in outage', () => {
54
+ const r = results.get('heap-growth-during-outage');
55
+ expect(r).toBeDefined();
56
+ expect(r!.pass, r!.detail).toBe(true);
57
+ });
58
+
59
+ it('main client reconnects after Redis restart', () => {
60
+ const r = results.get('main-client-reconnects');
61
+ expect(r).toBeDefined();
62
+ expect(r!.pass, r!.detail).toBe(true);
63
+ });
64
+
65
+ it('get() works after Redis restart (main client path)', () => {
66
+ const r = results.get('get-after-outage');
67
+ expect(r).toBeDefined();
68
+ expect(r!.pass, r!.detail).toBe(true);
69
+ });
70
+
71
+ // --- Tests that reproduce the bug (EXPECTED TO FAIL with current code) ---
72
+
73
+ it('PubSub sync works after outage (issue #86 — subscriber never recovers)', () => {
74
+ const r = results.get('pubsub-after-outage-strings');
75
+ expect(r).toBeDefined();
76
+ expect(r!.pass, r!.detail).toBe(true);
77
+ });
78
+
79
+ it('subscriber error listener state after outage (informational)', () => {
80
+ const r = results.get('error-listener-after-outage');
81
+ expect(r).toBeDefined();
82
+ // Informational — always passes. Detail shows whether the listener
83
+ // survived and whether the subscriber client object was replaced.
84
+ expect(r!.pass, r!.detail).toBe(true);
85
+ });
86
+
87
+ it('duplicate() call count during outage (informational)', () => {
88
+ const r = results.get('duplicate-count-during-outage');
89
+ expect(r).toBeDefined();
90
+ // Informational — always passes. Detail has the actual count.
91
+ expect(r!.pass, r!.detail).toBe(true);
92
+ });
93
+
94
+ it('total duplicate() call count (informational)', () => {
95
+ const r = results.get('duplicate-count-total');
96
+ expect(r).toBeDefined();
97
+ // Informational — always passes. Detail has the actual count.
98
+ expect(r!.pass, r!.detail).toBe(true);
99
+ });
100
+ });
@@ -1,5 +1,7 @@
1
1
  import { spawnSync } from 'child_process';
2
2
 
3
+ type ContainerRuntime = 'podman' | 'docker';
4
+
3
5
  function sh(cmd: string, args: string[], opts: { timeoutMs?: number } = {}) {
4
6
  const res = spawnSync(cmd, args, {
5
7
  encoding: 'utf8',
@@ -13,6 +15,26 @@ function sh(cmd: string, args: string[], opts: { timeoutMs?: number } = {}) {
13
15
  };
14
16
  }
15
17
 
18
+ function canRun(cmd: string) {
19
+ const res = spawnSync(cmd, ['--version'], {
20
+ encoding: 'utf8',
21
+ timeout: 3_000,
22
+ });
23
+ return !res.error && (res.status ?? 1) === 0;
24
+ }
25
+
26
+ function detectContainerRuntime(): ContainerRuntime {
27
+ if (process.env.CONTAINER_RUNTIME === 'podman') return 'podman';
28
+ if (process.env.CONTAINER_RUNTIME === 'docker') return 'docker';
29
+
30
+ if (canRun('podman')) return 'podman';
31
+ if (canRun('docker')) return 'docker';
32
+
33
+ throw new Error(
34
+ 'Neither podman nor docker is available. Install one of them or set CONTAINER_RUNTIME.',
35
+ );
36
+ }
37
+
16
38
  async function sleep(ms: number) {
17
39
  await new Promise((r) => setTimeout(r, ms));
18
40
  }
@@ -28,6 +50,7 @@ async function waitUntil(fn: () => Promise<boolean>, timeoutMs = 20_000) {
28
50
  }
29
51
 
30
52
  async function main() {
53
+ const runtime = detectContainerRuntime();
31
54
  const name = `redis-e2e-${Math.random().toString(36).slice(2, 8)}`;
32
55
 
33
56
  // Pick a free localhost port
@@ -45,12 +68,12 @@ async function main() {
45
68
  });
46
69
 
47
70
  // cleanup
48
- sh('podman', ['rm', '-f', name]);
71
+ sh(runtime, ['rm', '-f', name]);
49
72
 
50
73
  // Start redis with keyspace notifications enabled (required by SyncedMap)
51
74
  {
52
75
  const r = sh(
53
- 'podman',
76
+ runtime,
54
77
  [
55
78
  'run',
56
79
  '-d',
@@ -75,10 +98,10 @@ async function main() {
75
98
  process.env.VERCEL_URL = `e2e-${name}-`;
76
99
 
77
100
  const { getRedisCacheComponentsHandler } = await import(
78
- '../../../src/CacheComponentsHandler'
101
+ '../../../../../src/CacheComponentsHandler'
79
102
  );
80
103
  const { default: RedisStringsHandler } = await import(
81
- '../../../src/RedisStringsHandler'
104
+ '../../../../../src/RedisStringsHandler'
82
105
  );
83
106
 
84
107
  // Cache Components handler
@@ -153,14 +176,14 @@ async function main() {
153
176
  if (!got) throw new Error('expected cache hit (regular handler)');
154
177
 
155
178
  // Kill redis
156
- sh('podman', ['stop', '-t', '0', name], { timeoutMs: 30_000 });
179
+ sh(runtime, ['stop', '-t', '0', name], { timeoutMs: 30_000 });
157
180
 
158
181
  // Wait a bit, then start again
159
182
  await sleep(500);
160
183
 
161
184
  {
162
185
  const r = sh(
163
- 'podman',
186
+ runtime,
164
187
  [
165
188
  'run',
166
189
  '-d',
@@ -176,7 +199,7 @@ async function main() {
176
199
  ],
177
200
  { timeoutMs: 60_000 },
178
201
  );
179
- if (r.code !== 0) throw new Error(`podman run2 failed: ${r.stderr}`);
202
+ if (r.code !== 0) throw new Error(`${runtime} run2 failed: ${r.stderr}`);
180
203
  }
181
204
 
182
205
  // Should recover (both)
@@ -205,7 +228,7 @@ async function main() {
205
228
  } catch {}
206
229
 
207
230
  // cleanup best effort
208
- sh('podman', ['rm', '-f', name]);
231
+ sh(runtime, ['rm', '-f', name]);
209
232
 
210
233
  // If we reach here without crashing due to Socket already opened, we're good.
211
234
  // (Vitest will check stdout for this line.)
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Demonstrates the difference between quit() and disconnect() on a
3
+ * node-redis subscriber client while Redis is down.
4
+ *
5
+ * Scenario:
6
+ * 1. Start Redis.
7
+ * 2. Connect two subscriber clients and subscribe them.
8
+ * 3. Kill Redis and wait for the reconnect loop to start.
9
+ * 4. Subscriber A: call quit(). It should NOT resolve quickly while Redis is down.
10
+ * 5. Subscriber B: call disconnect(). It must close the socket immediately.
11
+ * 6. Restart Redis and verify that the disconnected subscriber stays dead.
12
+ *
13
+ * This shows why disconnect() is the correct teardown method during an outage.
14
+ */
15
+
16
+ import { createClient } from 'redis';
17
+ import {
18
+ detectContainerRuntime,
19
+ getFreePort,
20
+ record,
21
+ sh,
22
+ sleep,
23
+ startRedis,
24
+ } from './redis-test-helpers';
25
+
26
+ async function createSubscriber(url: string) {
27
+ const client = createClient({
28
+ url,
29
+ socket: {
30
+ connectTimeout: 2_000,
31
+ reconnectStrategy: (retries: number) => Math.min(50 + retries * 50, 500),
32
+ },
33
+ });
34
+ client.on('error', () => {});
35
+ await client.connect();
36
+ await client.subscribe('test-channel', () => {});
37
+ return client;
38
+ }
39
+
40
+ async function main() {
41
+ const runtime = detectContainerRuntime();
42
+ const name = `redis-quit-disc-${Math.random().toString(36).slice(2, 8)}`;
43
+ const port = await getFreePort();
44
+
45
+ sh(runtime, ['rm', '-f', name]);
46
+ startRedis(runtime, name, port);
47
+
48
+ const url = `redis://127.0.0.1:${port}`;
49
+
50
+ const quitSubscriber = await createSubscriber(url);
51
+ const disconnectSubscriber = await createSubscriber(url);
52
+
53
+ record(
54
+ 'subscribers-ready',
55
+ quitSubscriber.isReady && disconnectSubscriber.isReady,
56
+ `quitSub isReady=${quitSubscriber.isReady}, disconnectSub isReady=${disconnectSubscriber.isReady}`,
57
+ );
58
+
59
+ // Kill Redis and let both clients enter their reconnect loops
60
+ sh(runtime, ['stop', '-t', '0', name], { timeoutMs: 30_000 });
61
+ await sleep(1_500);
62
+
63
+ // Test quit(): it should not resolve while Redis is unreachable.
64
+ const quitResult = await Promise.race([
65
+ quitSubscriber
66
+ .quit()
67
+ .then(() => 'resolved')
68
+ .catch((e) => `error:${e.message ?? e}`),
69
+ sleep(3_000).then(() => 'timeout'),
70
+ ]);
71
+
72
+ record(
73
+ 'quit-does-not-immediately-close',
74
+ quitResult !== 'resolved',
75
+ `quit() result during outage: ${quitResult}`,
76
+ );
77
+
78
+ // Test disconnect(): it must close the client immediately, even during outage.
79
+ const disconnectStart = Date.now();
80
+ try {
81
+ await disconnectSubscriber.disconnect();
82
+ } catch (e: any) {
83
+ // If the client is already closed, that's still a successful teardown.
84
+ }
85
+ const disconnectDuration = Date.now() - disconnectStart;
86
+
87
+ record(
88
+ 'disconnect-closes-immediately',
89
+ !disconnectSubscriber.isOpen && !disconnectSubscriber.isReady,
90
+ `disconnect() took ${disconnectDuration}ms, isOpen=${disconnectSubscriber.isOpen}, isReady=${disconnectSubscriber.isReady}`,
91
+ );
92
+
93
+ // Restart Redis and make sure the disconnected client does not come back.
94
+ startRedis(runtime, name, port);
95
+ await sleep(1_500);
96
+
97
+ record(
98
+ 'disconnected-client-stays-dead',
99
+ !disconnectSubscriber.isOpen && !disconnectSubscriber.isReady,
100
+ `after restart: isOpen=${disconnectSubscriber.isOpen}, isReady=${disconnectSubscriber.isReady}`,
101
+ );
102
+
103
+ // Also clean up the quit subscriber so we don't leak the process.
104
+ try {
105
+ await quitSubscriber.disconnect();
106
+ } catch {}
107
+ sh(runtime, ['rm', '-f', name]);
108
+ }
109
+
110
+ main().catch((err) => {
111
+ process.stderr.write(String(err) + '\n');
112
+ process.exit(1);
113
+ });