@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,420 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
3
+ import fetch from 'node-fetch';
4
+ import { createClient, RedisClientType } from 'redis';
5
+ import { join } from 'path';
6
+ import { readFileSync } from 'fs';
7
+ import { CacheEntry } from '../../../src/RedisStringsHandler';
8
+
9
+ // Select which Pages Router test app to use. Can be overridden via NEXT_PAGES_TEST_APP env var
10
+ const NEXT_PAGES_TEST_APP =
11
+ process.env.NEXT_PAGES_TEST_APP || 'next-pages-16-2-6';
12
+ const NEXT_APP_DIR = join(
13
+ __dirname,
14
+ '..',
15
+ '..',
16
+ 'nextjs-test-projects',
17
+ NEXT_PAGES_TEST_APP,
18
+ );
19
+ console.log('NEXT_APP_DIR', NEXT_APP_DIR);
20
+
21
+ // Two instances of the same build sharing one Redis. This mirrors a
22
+ // load-balanced multi-instance deployment and is used to prove that
23
+ // on-demand revalidation (res.revalidate) on one instance is picked up
24
+ // by the other instance.
25
+ const INSTANCE_A_PORT = 3061;
26
+ const INSTANCE_B_PORT = 3062;
27
+ const INSTANCE_A_URL = `http://localhost:${INSTANCE_A_PORT}`;
28
+ const INSTANCE_B_URL = `http://localhost:${INSTANCE_B_PORT}`;
29
+
30
+ const REDIS_BACKGROUND_SYNC_DELAY = 250; //ms delay to prevent flaky tests in slow CI environments
31
+
32
+ // Default inMemoryCachingTime of the handler is 10s. After a get(), an
33
+ // instance may serve the entry from its in-memory deduplication cache for up
34
+ // to this long, so cross-instance freshness tests wait it out first.
35
+ const IN_MEMORY_CACHE_EXPIRY_DELAY = 11_000;
36
+
37
+ // revalidate value of /isr/[slug] in the test app
38
+ const ISR_REVALIDATE_SECONDS = 300;
39
+ // default stale age of the cache handler (14 days)
40
+ const DEFAULT_STALE_AGE = 60 * 60 * 24 * 14;
41
+
42
+ let instanceA: ChildProcessWithoutNullStreams;
43
+ let instanceB: ChildProcessWithoutNullStreams;
44
+ let redisClient: RedisClientType;
45
+ let buildId: string;
46
+
47
+ async function delay(ms: number) {
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ async function runCommand(cmd: string, args: string[], cwd: string) {
52
+ return new Promise((resolve, reject) => {
53
+ let stderr = '';
54
+ let stdout = '';
55
+ const proc = spawn(cmd, args, { cwd, stdio: 'pipe' });
56
+
57
+ proc.stdout.on('data', (data) => {
58
+ if (process.env.DEBUG_INTEGRATION) {
59
+ console.log(data.toString());
60
+ }
61
+ stdout += data.toString();
62
+ });
63
+
64
+ proc.stderr.on('data', (data) => {
65
+ if (process.env.DEBUG_INTEGRATION) {
66
+ console.error(data.toString());
67
+ }
68
+ stderr += data.toString();
69
+ });
70
+
71
+ proc.on('exit', (code) => {
72
+ if (code === 0) resolve(undefined);
73
+ else {
74
+ reject(
75
+ new Error(
76
+ `${cmd} ${args.join(' ')} failed with code ${code}\n` +
77
+ `stdout: ${stdout}\n` +
78
+ `stderr: ${stderr}`,
79
+ ),
80
+ );
81
+ }
82
+ });
83
+ });
84
+ }
85
+
86
+ function startInstance(port: number): ChildProcessWithoutNullStreams {
87
+ const proc = spawn('npx', ['next', 'start', '-p', String(port)], {
88
+ cwd: NEXT_APP_DIR,
89
+ env: {
90
+ ...process.env,
91
+ },
92
+ stdio: 'pipe',
93
+ });
94
+ if (process.env.DEBUG_INTEGRATION) {
95
+ proc.stdout.on('data', (data) => {
96
+ console.log(`stdout(:${port}): ${data}`);
97
+ });
98
+ }
99
+ proc.stderr.on('data', (data) => {
100
+ console.error(`stderr(:${port}): ${data}`);
101
+ });
102
+ return proc;
103
+ }
104
+
105
+ async function waitForServer(url: string, timeout = 20000) {
106
+ const start = Date.now();
107
+ while (Date.now() - start < timeout) {
108
+ try {
109
+ const res = await fetch(url);
110
+ if (res.ok) return;
111
+ } catch {}
112
+ await new Promise((r) => setTimeout(r, 300));
113
+ }
114
+ throw new Error(`Next.js server at ${url} did not start in time`);
115
+ }
116
+
117
+ function extractTimestamp(html: string): number {
118
+ const match = html.match(/Timestamp: (?:<!-- -->)?(\d+)/)?.[1];
119
+ expect(match).toBeDefined();
120
+ return Number(match);
121
+ }
122
+
123
+ function extractCounter(html: string): number {
124
+ const match = html.match(/Counter: (?:<!-- -->)?(\d+)/)?.[1];
125
+ expect(match).toBeDefined();
126
+ return Number(match);
127
+ }
128
+
129
+ describe('Pages Router Redis cache integration (two instances)', () => {
130
+ beforeAll(async () => {
131
+ // If old servers from a previous run are still around, kill them
132
+ for (const port of [INSTANCE_A_PORT, INSTANCE_B_PORT]) {
133
+ try {
134
+ const res = await fetch(`http://localhost:${port}`);
135
+ if (res.ok) {
136
+ await runCommand('pkill', ['-f', `next start -p ${port}`], '.');
137
+ }
138
+ } catch {}
139
+ }
140
+
141
+ // Set up environment variables
142
+ process.env.VERCEL_ENV = 'production';
143
+ process.env.VERCEL_URL =
144
+ 'pages-integration-test-' + Math.random().toString(36).substring(2, 15);
145
+ console.log('redis key prefix is:', process.env.VERCEL_URL);
146
+
147
+ // Only override redis env vars if not set. This can be set in the CI env.
148
+ process.env.REDISHOST = process.env.REDISHOST || 'localhost';
149
+ process.env.REDISPORT = process.env.REDISPORT || '6379';
150
+
151
+ if (process.env.SKIP_BUILD === 'true') {
152
+ console.log('skipping build');
153
+ } else {
154
+ await runCommand('pnpm', ['i'], NEXT_APP_DIR);
155
+ console.log('pnpm i done');
156
+ await runCommand('pnpm', ['build'], NEXT_APP_DIR);
157
+ console.log('pnpm build done');
158
+ }
159
+
160
+ buildId = readFileSync(join(NEXT_APP_DIR, '.next', 'BUILD_ID'), 'utf-8')
161
+ .toString()
162
+ .trim();
163
+
164
+ // Start two Next.js instances of the same build sharing one Redis
165
+ instanceA = startInstance(INSTANCE_A_PORT);
166
+ instanceB = startInstance(INSTANCE_B_PORT);
167
+ await Promise.all([
168
+ waitForServer(INSTANCE_A_URL),
169
+ waitForServer(INSTANCE_B_URL),
170
+ ]);
171
+ console.log('both next instances started');
172
+
173
+ // Connect to Redis
174
+ redisClient = createClient({
175
+ url: `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`,
176
+ });
177
+ await redisClient.connect();
178
+ }, 240_000);
179
+
180
+ afterAll(async () => {
181
+ if (process.env.KEEP_SERVER_RUNNING === 'true') {
182
+ console.log('keeping servers running');
183
+ } else {
184
+ if (instanceA) instanceA.kill();
185
+ if (instanceB) instanceB.kill();
186
+ }
187
+ if (redisClient) await redisClient.quit();
188
+ });
189
+
190
+ describe('PAGES entries (getStaticProps + revalidate)', () => {
191
+ let firstTimestamp: number;
192
+ let firstCounter: number;
193
+
194
+ it('first request on instance A renders the page and stores a PAGES entry in Redis', async () => {
195
+ const res = await fetch(INSTANCE_A_URL + '/isr/prebuilt');
196
+ expect(res.status).toBe(200);
197
+ const html = await res.text();
198
+ firstTimestamp = extractTimestamp(html);
199
+ firstCounter = extractCounter(html);
200
+
201
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
202
+
203
+ const value = (await redisClient.get(
204
+ process.env.VERCEL_URL + '/isr/prebuilt',
205
+ )) as string;
206
+ expect(value).toBeDefined();
207
+ const cacheEntry: CacheEntry = JSON.parse(value);
208
+ expect(cacheEntry).toMatchObject({
209
+ value: {
210
+ kind: 'PAGES',
211
+ html: expect.any(String),
212
+ pageData: {
213
+ pageProps: expect.objectContaining({
214
+ slug: 'prebuilt',
215
+ timestamp: firstTimestamp,
216
+ }),
217
+ },
218
+ },
219
+ lastModified: expect.any(Number),
220
+ tags: ['_N_T_/isr/prebuilt'],
221
+ });
222
+
223
+ // The implicit path tag is registered in the shared tags hashmap so
224
+ // that revalidatePath()/revalidateTag() can invalidate the page
225
+ const hashmap = (await redisClient.hGet(
226
+ process.env.VERCEL_URL + '__sharedTags__',
227
+ '/isr/prebuilt',
228
+ )) as string;
229
+ expect(JSON.parse(hashmap)).toEqual(['_N_T_/isr/prebuilt']);
230
+ });
231
+
232
+ it('the TTL is derived from the getStaticProps revalidate value', async () => {
233
+ const ttl = await redisClient.ttl(
234
+ process.env.VERCEL_URL + '/isr/prebuilt',
235
+ );
236
+ // VERCEL_ENV=production -> expire age is 2 * revalidate
237
+ expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
238
+ expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
239
+ });
240
+
241
+ it('instance B serves the identical cached HTML from the shared Redis', async () => {
242
+ const res = await fetch(INSTANCE_B_URL + '/isr/prebuilt');
243
+ expect(res.status).toBe(200);
244
+ const html = await res.text();
245
+ expect(extractTimestamp(html)).toBe(firstTimestamp);
246
+ expect(extractCounter(html)).toBe(firstCounter);
247
+ });
248
+
249
+ it('the pageData JSON (client navigation) is served from the same entry', async () => {
250
+ const res = await fetch(
251
+ `${INSTANCE_B_URL}/_next/data/${buildId}/isr/prebuilt.json`,
252
+ );
253
+ expect(res.status).toBe(200);
254
+ const data: any = await res.json();
255
+ expect(data.pageProps.slug).toBe('prebuilt');
256
+ expect(data.pageProps.timestamp).toBe(firstTimestamp);
257
+ });
258
+
259
+ describe('two-instance on-demand revalidation (res.revalidate)', () => {
260
+ it('res.revalidate on instance A updates Redis and instance B serves the fresh HTML and pageData', async () => {
261
+ // Wait until instance B's in-memory deduplication cache entry for
262
+ // the page has expired, so its next get() hits Redis again
263
+ await delay(IN_MEMORY_CACHE_EXPIRY_DELAY);
264
+
265
+ const revalidateRes = await fetch(
266
+ INSTANCE_A_URL + '/api/revalidate?path=/isr/prebuilt',
267
+ );
268
+ const revalidateJson: any = await revalidateRes.json();
269
+ expect(revalidateJson).toEqual({
270
+ revalidated: true,
271
+ path: '/isr/prebuilt',
272
+ });
273
+
274
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
275
+
276
+ // Instance B must serve the fresh HTML rendered by instance A
277
+ const resB = await fetch(INSTANCE_B_URL + '/isr/prebuilt');
278
+ expect(resB.status).toBe(200);
279
+ const htmlB = await resB.text();
280
+ const timestampB = extractTimestamp(htmlB);
281
+ const counterB = extractCounter(htmlB);
282
+ expect(timestampB).toBeGreaterThan(firstTimestamp);
283
+
284
+ // Instance A serves the same regenerated page - both instances
285
+ // return the exact same render, proving it came through Redis and
286
+ // not from an independent re-render per instance
287
+ const resA = await fetch(INSTANCE_A_URL + '/isr/prebuilt');
288
+ const htmlA = await resA.text();
289
+ expect(extractTimestamp(htmlA)).toBe(timestampB);
290
+ expect(extractCounter(htmlA)).toBe(counterB);
291
+
292
+ // The updated pageData JSON for client-side navigation is also
293
+ // served fresh on instance B
294
+ const dataRes = await fetch(
295
+ `${INSTANCE_B_URL}/_next/data/${buildId}/isr/prebuilt.json`,
296
+ );
297
+ expect(dataRes.status).toBe(200);
298
+ const data: any = await dataRes.json();
299
+ expect(data.pageProps.timestamp).toBe(timestampB);
300
+ }, 30_000);
301
+ });
302
+ });
303
+
304
+ describe('fallback: "blocking" (page not prerendered at build time)', () => {
305
+ it('first hit renders the page (blocking) and stores it in Redis', async () => {
306
+ const res = await fetch(INSTANCE_A_URL + '/isr/fallback-test');
307
+ expect(res.status).toBe(200);
308
+ const html = await res.text();
309
+ const timestamp = extractTimestamp(html);
310
+
311
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
312
+
313
+ const value = (await redisClient.get(
314
+ process.env.VERCEL_URL + '/isr/fallback-test',
315
+ )) as string;
316
+ expect(value).toBeDefined();
317
+ const cacheEntry: CacheEntry = JSON.parse(value);
318
+ expect((cacheEntry.value as { kind: string }).kind).toBe('PAGES');
319
+
320
+ // The other instance serves the same render from Redis
321
+ const resB = await fetch(INSTANCE_B_URL + '/isr/fallback-test');
322
+ expect(resB.status).toBe(200);
323
+ expect(extractTimestamp(await resB.text())).toBe(timestamp);
324
+ });
325
+ });
326
+
327
+ describe('notFound: true (null cache entry)', () => {
328
+ it('renders a 404 and stores a null-value entry in Redis', async () => {
329
+ const res = await fetch(INSTANCE_A_URL + '/isr/not-found');
330
+ expect(res.status).toBe(404);
331
+
332
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
333
+
334
+ const value = (await redisClient.get(
335
+ process.env.VERCEL_URL + '/isr/not-found',
336
+ )) as string;
337
+ expect(value).toBeDefined();
338
+ const cacheEntry: CacheEntry = JSON.parse(value);
339
+ expect(cacheEntry).toEqual({
340
+ value: null,
341
+ lastModified: expect.any(Number),
342
+ tags: ['_N_T_/isr/not-found'],
343
+ });
344
+
345
+ // TTL is derived from the revalidate value returned with notFound
346
+ const ttl = await redisClient.ttl(
347
+ process.env.VERCEL_URL + '/isr/not-found',
348
+ );
349
+ expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
350
+ expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
351
+ });
352
+
353
+ it('instance B serves the 404 from the shared cache', async () => {
354
+ const res = await fetch(INSTANCE_B_URL + '/isr/not-found');
355
+ expect(res.status).toBe(404);
356
+ });
357
+ });
358
+
359
+ describe('redirect (getStaticProps redirect return)', () => {
360
+ it('responds with a redirect and stores a REDIRECT entry in Redis', async () => {
361
+ const res = await fetch(INSTANCE_A_URL + '/isr/redirect', {
362
+ redirect: 'manual',
363
+ });
364
+ expect(res.status).toBe(307);
365
+ expect(res.headers.get('location')).toContain('/static-forever');
366
+
367
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
368
+
369
+ const value = (await redisClient.get(
370
+ process.env.VERCEL_URL + '/isr/redirect',
371
+ )) as string;
372
+ expect(value).toBeDefined();
373
+ const cacheEntry: CacheEntry = JSON.parse(value);
374
+ expect(cacheEntry).toMatchObject({
375
+ value: {
376
+ kind: 'REDIRECT',
377
+ props: expect.objectContaining({
378
+ pageProps: expect.objectContaining({
379
+ __N_REDIRECT: '/static-forever',
380
+ __N_REDIRECT_STATUS: 307,
381
+ }),
382
+ }),
383
+ },
384
+ lastModified: expect.any(Number),
385
+ tags: ['_N_T_/isr/redirect'],
386
+ });
387
+ });
388
+
389
+ it('instance B serves the redirect from the shared cache', async () => {
390
+ const res = await fetch(INSTANCE_B_URL + '/isr/redirect', {
391
+ redirect: 'manual',
392
+ });
393
+ expect(res.status).toBe(307);
394
+ expect(res.headers.get('location')).toContain('/static-forever');
395
+ });
396
+ });
397
+
398
+ describe('revalidate: false (fully static page)', () => {
399
+ it('falls back to the defaultStaleAge based TTL', async () => {
400
+ const res = await fetch(INSTANCE_A_URL + '/static-forever');
401
+ expect(res.status).toBe(200);
402
+
403
+ await delay(REDIS_BACKGROUND_SYNC_DELAY);
404
+
405
+ const value = (await redisClient.get(
406
+ process.env.VERCEL_URL + '/static-forever',
407
+ )) as string;
408
+ expect(value).toBeDefined();
409
+ const cacheEntry: CacheEntry = JSON.parse(value);
410
+ expect((cacheEntry.value as { kind: string }).kind).toBe('PAGES');
411
+
412
+ const ttl = await redisClient.ttl(
413
+ process.env.VERCEL_URL + '/static-forever',
414
+ );
415
+ // VERCEL_ENV=production -> expire age is 2 * defaultStaleAge (14 days)
416
+ expect(ttl).toBeGreaterThan(2 * DEFAULT_STALE_AGE - 30);
417
+ expect(ttl).toBeLessThanOrEqual(2 * DEFAULT_STALE_AGE);
418
+ });
419
+ });
420
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { SyncedMap } from '../../../src/SyncedMap';
3
+
4
+ // Module-level knobs for the subscribe() mock so that behaviour persists
5
+ // across the duplicate() clients created inside SyncedMap.
6
+ let globalSubscribeCalls = 0;
7
+ let globalSubscribeFailUntil = 0;
8
+
9
+ class MockClient {
10
+ static nextId = 1;
11
+ id = MockClient.nextId++;
12
+ private errorHandlers: Set<(err: Error) => void | Promise<void>> = new Set();
13
+ quitShouldThrow = true;
14
+
15
+ isOpen = true;
16
+ isReady = true;
17
+
18
+ on(event: string, handler: (err: Error) => void | Promise<void>) {
19
+ if (event === 'error') this.errorHandlers.add(handler);
20
+ return this as any;
21
+ }
22
+
23
+ emit(event: string, err: Error) {
24
+ if (event === 'error') {
25
+ for (const h of this.errorHandlers) {
26
+ void h(err);
27
+ }
28
+ }
29
+ return true;
30
+ }
31
+
32
+ removeAllListeners(event?: string) {
33
+ if (!event || event === 'error') this.errorHandlers.clear();
34
+ return this as any;
35
+ }
36
+
37
+ async connect() {
38
+ this.isOpen = true;
39
+ this.isReady = true;
40
+ }
41
+
42
+ async configGet() {
43
+ return { 'notify-keyspace-events': 'Exe' };
44
+ }
45
+
46
+ async subscribe() {
47
+ const attempt = globalSubscribeCalls++;
48
+ if (attempt < globalSubscribeFailUntil) {
49
+ throw new Error(`Mock subscribe failure ${attempt}`);
50
+ }
51
+ }
52
+
53
+ async quit() {
54
+ if (this.quitShouldThrow)
55
+ throw new Error('Mock quit failure (disconnected)');
56
+ }
57
+
58
+ async disconnect() {
59
+ this.isOpen = false;
60
+ this.isReady = false;
61
+ this.errorHandlers.clear();
62
+ }
63
+
64
+ duplicate() {
65
+ return new MockClient();
66
+ }
67
+
68
+ hScan() {
69
+ return { cursor: 0, tuples: [] };
70
+ }
71
+
72
+ scan() {
73
+ return { cursor: 0, keys: [] };
74
+ }
75
+ }
76
+
77
+ function createMap(rootClient = new MockClient()) {
78
+ return new SyncedMap<string>({
79
+ client: rootClient as any,
80
+ keyPrefix: 'repro:',
81
+ redisKey: '__sharedTags__',
82
+ database: 0,
83
+ querySize: 250,
84
+ filterKeys: () => true,
85
+ customizedSync: {
86
+ withoutRedisHashmap: true,
87
+ withoutSetSync: true,
88
+ },
89
+ });
90
+ }
91
+
92
+ describe('SyncedMap.reconnectSubscriber() repro tests', () => {
93
+ let originalSetTimeout: typeof global.setTimeout;
94
+
95
+ beforeEach(() => {
96
+ globalSubscribeCalls = 0;
97
+ globalSubscribeFailUntil = 0;
98
+ originalSetTimeout = global.setTimeout;
99
+ });
100
+
101
+ afterEach(() => {
102
+ global.setTimeout = originalSetTimeout;
103
+ });
104
+
105
+ it('Issue 2: first backoff delay should be 500ms, not 1000ms', async () => {
106
+ const root = new MockClient();
107
+ const map = createMap(root);
108
+ // wait for initial setup to succeed (attempt 0)
109
+ await (map as any).waitUntilReady().catch(() => undefined);
110
+
111
+ const capturedDelays: number[] = [];
112
+ global.setTimeout = ((callback: () => void, delay?: number) => {
113
+ capturedDelays.push(Number(delay ?? 0));
114
+ return originalSetTimeout(callback, 0) as any;
115
+ }) as any;
116
+
117
+ // initial subscribe consumed attempt 0; force the next 3 reconnection
118
+ // attempts to fail, then succeed on the 4th
119
+ globalSubscribeFailUntil = 4;
120
+
121
+ await (map as any).reconnectSubscriber();
122
+
123
+ expect(capturedDelays.length).toBeGreaterThanOrEqual(1);
124
+ // Bug: delay is computed with attempt already incremented, so the first
125
+ // retry waits 500 * 2^1 = 1000 ms. After the fix it should be 500 ms.
126
+ expect(capturedDelays[0]).toBe(500);
127
+ });
128
+
129
+ it('Issue 1: stale error on old subscriber must not kill the healthy new subscriber', async () => {
130
+ const root = new MockClient();
131
+ const map = createMap(root);
132
+ await (map as any).waitUntilReady().catch(() => undefined);
133
+
134
+ const oldSubscriber = (map as any).subscriberClient as MockClient;
135
+ expect(oldSubscriber).toBeDefined();
136
+
137
+ await (map as any).reconnectSubscriber();
138
+
139
+ const newSubscriber = (map as any).subscriberClient as MockClient;
140
+ expect(newSubscriber.id).not.toBe(oldSubscriber.id);
141
+
142
+ // Simulate a delayed error from the old (now replaced) subscriber.
143
+ // With the current code the old 'error' listener is still attached,
144
+ // so it triggers reconnectSubscriber() and tears down the healthy client.
145
+ oldSubscriber.emit('error', new Error('delayed zombie error'));
146
+
147
+ // Give the async error handler a chance to run.
148
+ await new Promise((r) => originalSetTimeout(r, 50));
149
+
150
+ const afterStaleError = (map as any).subscriberClient as MockClient;
151
+
152
+ expect(afterStaleError.id).toBe(newSubscriber.id);
153
+ });
154
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
- import type { CreateRedisStringsHandlerOptions } from './index';
4
- import RedisStringsHandler from './RedisStringsHandler';
3
+ import type { CreateRedisStringsHandlerOptions } from '../../../src/index';
4
+ import RedisStringsHandler from '../../../src/RedisStringsHandler';
5
5
 
6
6
  vi.mock('redis', () => {
7
7
  const createClient = () => {
@@ -38,7 +38,7 @@ vi.mock('redis', () => {
38
38
  };
39
39
  });
40
40
 
41
- vi.mock('./SyncedMap', () => {
41
+ vi.mock('../../src/SyncedMap', () => {
42
42
  class SyncedMap {
43
43
  waitUntilReady = vi.fn(async () => undefined);
44
44
  get = vi.fn(() => undefined);