@nifrajs/web 4.0.2 → 5.0.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 (384) hide show
  1. package/README.md +11 -0
  2. package/dist/build-vite.d.ts +19 -0
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +31 -10
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +8 -10
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +90 -31
  9. package/dist/build.js.map +1 -1
  10. package/dist/cdn.d.ts +8 -0
  11. package/dist/cdn.d.ts.map +1 -1
  12. package/dist/cdn.js +14 -15
  13. package/dist/cdn.js.map +1 -1
  14. package/dist/client.d.ts +1 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +25 -7
  17. package/dist/client.js.map +1 -1
  18. package/dist/deferred.d.ts +2 -2
  19. package/dist/deferred.d.ts.map +1 -1
  20. package/dist/deferred.js +28 -7
  21. package/dist/deferred.js.map +1 -1
  22. package/dist/dev-feed.d.ts.map +1 -1
  23. package/dist/dev-feed.js +24 -5
  24. package/dist/dev-feed.js.map +1 -1
  25. package/dist/dev-session.d.ts.map +1 -1
  26. package/dist/dev-session.js +4 -14
  27. package/dist/dev-session.js.map +1 -1
  28. package/dist/dev.d.ts +7 -17
  29. package/dist/dev.d.ts.map +1 -1
  30. package/dist/dev.js +13 -1
  31. package/dist/dev.js.map +1 -1
  32. package/dist/draft.d.ts +8 -5
  33. package/dist/draft.d.ts.map +1 -1
  34. package/dist/draft.js +30 -13
  35. package/dist/draft.js.map +1 -1
  36. package/dist/fonts-google.d.ts.map +1 -1
  37. package/dist/fonts-google.js +7 -0
  38. package/dist/fonts-google.js.map +1 -1
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -2
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/codegen.d.ts +1 -0
  44. package/dist/internal/codegen.d.ts.map +1 -1
  45. package/dist/internal/codegen.js +56 -29
  46. package/dist/internal/codegen.js.map +1 -1
  47. package/dist/internal/disposable-dir.d.ts +8 -0
  48. package/dist/internal/disposable-dir.d.ts.map +1 -0
  49. package/dist/internal/disposable-dir.js +25 -0
  50. package/dist/internal/disposable-dir.js.map +1 -0
  51. package/dist/internal/entry-chunks.d.ts +9 -0
  52. package/dist/internal/entry-chunks.d.ts.map +1 -0
  53. package/dist/internal/entry-chunks.js +15 -0
  54. package/dist/internal/entry-chunks.js.map +1 -0
  55. package/dist/internal/evidence-traversal.d.ts +12 -0
  56. package/dist/internal/evidence-traversal.d.ts.map +1 -0
  57. package/dist/internal/evidence-traversal.js +48 -0
  58. package/dist/internal/evidence-traversal.js.map +1 -0
  59. package/dist/internal/head-merge.d.ts +6 -0
  60. package/dist/internal/head-merge.d.ts.map +1 -1
  61. package/dist/internal/head-merge.js +9 -0
  62. package/dist/internal/head-merge.js.map +1 -1
  63. package/dist/internal/js-string.d.ts +2 -0
  64. package/dist/internal/js-string.d.ts.map +1 -1
  65. package/dist/internal/js-string.js +5 -1
  66. package/dist/internal/js-string.js.map +1 -1
  67. package/dist/internal/mount-shadow.js +1 -1
  68. package/dist/internal/mount-shadow.js.map +1 -1
  69. package/dist/internal/output-guard.d.ts +7 -0
  70. package/dist/internal/output-guard.d.ts.map +1 -1
  71. package/dist/internal/output-guard.js +80 -8
  72. package/dist/internal/output-guard.js.map +1 -1
  73. package/dist/internal/page-execution.d.ts +6 -0
  74. package/dist/internal/page-execution.d.ts.map +1 -1
  75. package/dist/internal/page-execution.js +377 -108
  76. package/dist/internal/page-execution.js.map +1 -1
  77. package/dist/internal/progressive-cookies.d.ts +14 -0
  78. package/dist/internal/progressive-cookies.d.ts.map +1 -0
  79. package/dist/internal/progressive-cookies.js +43 -0
  80. package/dist/internal/progressive-cookies.js.map +1 -0
  81. package/dist/internal/public-env.d.ts +2 -0
  82. package/dist/internal/public-env.d.ts.map +1 -0
  83. package/dist/internal/public-env.js +13 -0
  84. package/dist/internal/public-env.js.map +1 -0
  85. package/dist/internal/render-document.d.ts +1 -0
  86. package/dist/internal/render-document.d.ts.map +1 -1
  87. package/dist/internal/render-document.js +28 -5
  88. package/dist/internal/render-document.js.map +1 -1
  89. package/dist/internal/response-controls.d.ts +2 -0
  90. package/dist/internal/response-controls.d.ts.map +1 -1
  91. package/dist/internal/response-controls.js +26 -16
  92. package/dist/internal/response-controls.js.map +1 -1
  93. package/dist/internal/revive-dates.d.ts +16 -0
  94. package/dist/internal/revive-dates.d.ts.map +1 -0
  95. package/dist/internal/revive-dates.js +32 -0
  96. package/dist/internal/revive-dates.js.map +1 -0
  97. package/dist/internal/route-path.d.ts +8 -0
  98. package/dist/internal/route-path.d.ts.map +1 -0
  99. package/dist/internal/route-path.js +8 -0
  100. package/dist/internal/route-path.js.map +1 -0
  101. package/dist/internal/secret-scan.d.ts.map +1 -1
  102. package/dist/internal/secret-scan.js +88 -20
  103. package/dist/internal/secret-scan.js.map +1 -1
  104. package/dist/internal/server-boundary.d.ts.map +1 -1
  105. package/dist/internal/server-boundary.js +33 -5
  106. package/dist/internal/server-boundary.js.map +1 -1
  107. package/dist/internal/web-app.d.ts +21 -16
  108. package/dist/internal/web-app.d.ts.map +1 -1
  109. package/dist/internal/web-app.js +73 -21
  110. package/dist/internal/web-app.js.map +1 -1
  111. package/dist/internal/wire-date.d.ts +3 -0
  112. package/dist/internal/wire-date.d.ts.map +1 -0
  113. package/dist/internal/wire-date.js +14 -0
  114. package/dist/internal/wire-date.js.map +1 -0
  115. package/dist/internal/zone-graph.d.ts +4 -0
  116. package/dist/internal/zone-graph.d.ts.map +1 -1
  117. package/dist/internal/zone-graph.js +54 -14
  118. package/dist/internal/zone-graph.js.map +1 -1
  119. package/dist/isr.d.ts +58 -2
  120. package/dist/isr.d.ts.map +1 -1
  121. package/dist/isr.js +296 -39
  122. package/dist/isr.js.map +1 -1
  123. package/dist/llms-txt.d.ts +2 -0
  124. package/dist/llms-txt.d.ts.map +1 -1
  125. package/dist/llms-txt.js +29 -3
  126. package/dist/llms-txt.js.map +1 -1
  127. package/dist/manifest.d.ts +28 -4
  128. package/dist/manifest.d.ts.map +1 -1
  129. package/dist/manifest.js +3 -0
  130. package/dist/manifest.js.map +1 -1
  131. package/dist/open-resolved.d.ts +18 -0
  132. package/dist/open-resolved.d.ts.map +1 -0
  133. package/dist/open-resolved.js +62 -0
  134. package/dist/open-resolved.js.map +1 -0
  135. package/dist/plugins/kit.d.ts +1 -0
  136. package/dist/plugins/kit.d.ts.map +1 -1
  137. package/dist/plugins/kit.js +26 -11
  138. package/dist/plugins/kit.js.map +1 -1
  139. package/dist/plugins/vite-leak-guard.d.ts +3 -1
  140. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  141. package/dist/plugins/vite-leak-guard.js +4 -0
  142. package/dist/plugins/vite-leak-guard.js.map +1 -1
  143. package/dist/public-dir.d.ts +8 -1
  144. package/dist/public-dir.d.ts.map +1 -1
  145. package/dist/public-dir.js +17 -6
  146. package/dist/public-dir.js.map +1 -1
  147. package/dist/query.d.ts +13 -4
  148. package/dist/query.d.ts.map +1 -1
  149. package/dist/query.js +89 -26
  150. package/dist/query.js.map +1 -1
  151. package/dist/render-seam.d.ts +2 -0
  152. package/dist/render-seam.d.ts.map +1 -1
  153. package/dist/render-seam.js +1 -0
  154. package/dist/render-seam.js.map +1 -1
  155. package/dist/route-types.d.ts.map +1 -1
  156. package/dist/route-types.js +20 -9
  157. package/dist/route-types.js.map +1 -1
  158. package/dist/router.d.ts +9 -2
  159. package/dist/router.d.ts.map +1 -1
  160. package/dist/router.js +39 -46
  161. package/dist/router.js.map +1 -1
  162. package/dist/server-components/build-info.d.ts +3 -0
  163. package/dist/server-components/build-info.d.ts.map +1 -0
  164. package/dist/server-components/build-info.js +7 -0
  165. package/dist/server-components/build-info.js.map +1 -0
  166. package/dist/server-components/build-target.d.ts +12 -0
  167. package/dist/server-components/build-target.d.ts.map +1 -0
  168. package/dist/server-components/build-target.js +153 -0
  169. package/dist/server-components/build-target.js.map +1 -0
  170. package/dist/server-components/build.d.ts +24 -0
  171. package/dist/server-components/build.d.ts.map +1 -0
  172. package/dist/server-components/build.js +366 -0
  173. package/dist/server-components/build.js.map +1 -0
  174. package/dist/server-components/bundle-proof.d.ts +3 -0
  175. package/dist/server-components/bundle-proof.d.ts.map +1 -0
  176. package/dist/server-components/bundle-proof.js +23 -0
  177. package/dist/server-components/bundle-proof.js.map +1 -0
  178. package/dist/server-components/client.d.ts +46 -0
  179. package/dist/server-components/client.d.ts.map +1 -0
  180. package/dist/server-components/client.js +594 -0
  181. package/dist/server-components/client.js.map +1 -0
  182. package/dist/server-components/definition.d.ts +11 -0
  183. package/dist/server-components/definition.d.ts.map +1 -0
  184. package/dist/server-components/definition.js +94 -0
  185. package/dist/server-components/definition.js.map +1 -0
  186. package/dist/server-components/dev-http.d.ts +5 -0
  187. package/dist/server-components/dev-http.d.ts.map +1 -0
  188. package/dist/server-components/dev-http.js +71 -0
  189. package/dist/server-components/dev-http.js.map +1 -0
  190. package/dist/server-components/error.d.ts +9 -0
  191. package/dist/server-components/error.d.ts.map +1 -0
  192. package/dist/server-components/error.js +24 -0
  193. package/dist/server-components/error.js.map +1 -0
  194. package/dist/server-components/frames.d.ts +7 -0
  195. package/dist/server-components/frames.d.ts.map +1 -0
  196. package/dist/server-components/frames.js +169 -0
  197. package/dist/server-components/frames.js.map +1 -0
  198. package/dist/server-components/html-policy.d.ts +6 -0
  199. package/dist/server-components/html-policy.d.ts.map +1 -0
  200. package/dist/server-components/html-policy.js +75 -0
  201. package/dist/server-components/html-policy.js.map +1 -0
  202. package/dist/server-components/html.d.ts +4 -0
  203. package/dist/server-components/html.d.ts.map +1 -0
  204. package/dist/server-components/html.js +67 -0
  205. package/dist/server-components/html.js.map +1 -0
  206. package/dist/server-components/islands.d.ts +44 -0
  207. package/dist/server-components/islands.d.ts.map +1 -0
  208. package/dist/server-components/islands.js +42 -0
  209. package/dist/server-components/islands.js.map +1 -0
  210. package/dist/server-components/native.d.ts +4 -0
  211. package/dist/server-components/native.d.ts.map +1 -0
  212. package/dist/server-components/native.js +81 -0
  213. package/dist/server-components/native.js.map +1 -0
  214. package/dist/server-components/portal.d.ts +6 -0
  215. package/dist/server-components/portal.d.ts.map +1 -0
  216. package/dist/server-components/portal.js +35 -0
  217. package/dist/server-components/portal.js.map +1 -0
  218. package/dist/server-components/props.d.ts +3 -0
  219. package/dist/server-components/props.d.ts.map +1 -0
  220. package/dist/server-components/props.js +14 -0
  221. package/dist/server-components/props.js.map +1 -0
  222. package/dist/server-components/protocol.d.ts +44 -0
  223. package/dist/server-components/protocol.d.ts.map +1 -0
  224. package/dist/server-components/protocol.js +139 -0
  225. package/dist/server-components/protocol.js.map +1 -0
  226. package/dist/server-components/range.d.ts +24 -0
  227. package/dist/server-components/range.d.ts.map +1 -0
  228. package/dist/server-components/range.js +263 -0
  229. package/dist/server-components/range.js.map +1 -0
  230. package/dist/server-components/registry.d.ts +6 -0
  231. package/dist/server-components/registry.d.ts.map +1 -0
  232. package/dist/server-components/registry.js +30 -0
  233. package/dist/server-components/registry.js.map +1 -0
  234. package/dist/server-components/response.d.ts +4 -0
  235. package/dist/server-components/response.d.ts.map +1 -0
  236. package/dist/server-components/response.js +32 -0
  237. package/dist/server-components/response.js.map +1 -0
  238. package/dist/server-components/router-integration.d.ts +9 -0
  239. package/dist/server-components/router-integration.d.ts.map +1 -0
  240. package/dist/server-components/router-integration.js +29 -0
  241. package/dist/server-components/router-integration.js.map +1 -0
  242. package/dist/server-components/runtime.d.ts +14 -0
  243. package/dist/server-components/runtime.d.ts.map +1 -0
  244. package/dist/server-components/runtime.js +276 -0
  245. package/dist/server-components/runtime.js.map +1 -0
  246. package/dist/server-components/scheduler.d.ts +14 -0
  247. package/dist/server-components/scheduler.d.ts.map +1 -0
  248. package/dist/server-components/scheduler.js +74 -0
  249. package/dist/server-components/scheduler.js.map +1 -0
  250. package/dist/server-components/schema.d.ts +4 -0
  251. package/dist/server-components/schema.d.ts.map +1 -0
  252. package/dist/server-components/schema.js +77 -0
  253. package/dist/server-components/schema.js.map +1 -0
  254. package/dist/server-components/server-entry.d.ts +12 -0
  255. package/dist/server-components/server-entry.d.ts.map +1 -0
  256. package/dist/server-components/server-entry.js +90 -0
  257. package/dist/server-components/server-entry.js.map +1 -0
  258. package/dist/server-components/source.d.ts +15 -0
  259. package/dist/server-components/source.d.ts.map +1 -0
  260. package/dist/server-components/source.js +269 -0
  261. package/dist/server-components/source.js.map +1 -0
  262. package/dist/server-components/state.d.ts +4 -0
  263. package/dist/server-components/state.d.ts.map +1 -0
  264. package/dist/server-components/state.js +123 -0
  265. package/dist/server-components/state.js.map +1 -0
  266. package/dist/server-components/stream.d.ts +6 -0
  267. package/dist/server-components/stream.d.ts.map +1 -0
  268. package/dist/server-components/stream.js +70 -0
  269. package/dist/server-components/stream.js.map +1 -0
  270. package/dist/server-components/trusted-html.d.ts +3 -0
  271. package/dist/server-components/trusted-html.d.ts.map +1 -0
  272. package/dist/server-components/trusted-html.js +14 -0
  273. package/dist/server-components/trusted-html.js.map +1 -0
  274. package/dist/server-components/types.d.ts +107 -0
  275. package/dist/server-components/types.d.ts.map +1 -0
  276. package/dist/server-components/types.js +2 -0
  277. package/dist/server-components/types.js.map +1 -0
  278. package/dist/server-components/typescript.d.ts +11 -0
  279. package/dist/server-components/typescript.d.ts.map +1 -0
  280. package/dist/server-components/typescript.js +28 -0
  281. package/dist/server-components/typescript.js.map +1 -0
  282. package/dist/server-components/value.d.ts +5 -0
  283. package/dist/server-components/value.d.ts.map +1 -0
  284. package/dist/server-components/value.js +77 -0
  285. package/dist/server-components/value.js.map +1 -0
  286. package/dist/server-components/vite.d.ts +12 -0
  287. package/dist/server-components/vite.d.ts.map +1 -0
  288. package/dist/server-components/vite.js +197 -0
  289. package/dist/server-components/vite.js.map +1 -0
  290. package/dist/server-components.d.ts +5 -0
  291. package/dist/server-components.d.ts.map +1 -0
  292. package/dist/server-components.js +4 -0
  293. package/dist/server-components.js.map +1 -0
  294. package/dist/service-worker.d.ts +8 -1
  295. package/dist/service-worker.d.ts.map +1 -1
  296. package/dist/service-worker.js +20 -6
  297. package/dist/service-worker.js.map +1 -1
  298. package/dist/vite.d.ts +12 -0
  299. package/dist/vite.d.ts.map +1 -1
  300. package/dist/vite.js +7 -1
  301. package/dist/vite.js.map +1 -1
  302. package/dist/zones.d.ts +40 -0
  303. package/dist/zones.d.ts.map +1 -1
  304. package/dist/zones.js +170 -6
  305. package/dist/zones.js.map +1 -1
  306. package/package.json +97 -4
  307. package/src/build-vite.ts +31 -9
  308. package/src/build.ts +109 -31
  309. package/src/cdn.ts +18 -12
  310. package/src/client.ts +25 -6
  311. package/src/deferred.ts +27 -9
  312. package/src/dev-feed.ts +24 -5
  313. package/src/dev-session.ts +4 -13
  314. package/src/dev.ts +29 -1
  315. package/src/draft.ts +35 -14
  316. package/src/fonts-google.ts +10 -0
  317. package/src/index.ts +3 -2
  318. package/src/internal/codegen.ts +60 -29
  319. package/src/internal/disposable-dir.ts +24 -0
  320. package/src/internal/entry-chunks.ts +18 -0
  321. package/src/internal/evidence-traversal.ts +57 -0
  322. package/src/internal/head-merge.ts +16 -0
  323. package/src/internal/js-string.ts +6 -1
  324. package/src/internal/mount-shadow.ts +1 -1
  325. package/src/internal/output-guard.ts +88 -7
  326. package/src/internal/page-execution.ts +506 -108
  327. package/src/internal/progressive-cookies.ts +60 -0
  328. package/src/internal/public-env.ts +14 -0
  329. package/src/internal/render-document.ts +26 -11
  330. package/src/internal/response-controls.ts +31 -14
  331. package/src/internal/revive-dates.ts +35 -0
  332. package/src/internal/route-path.ts +8 -0
  333. package/src/internal/secret-scan.ts +90 -18
  334. package/src/internal/server-boundary.ts +29 -5
  335. package/src/internal/web-app.ts +116 -37
  336. package/src/internal/wire-date.ts +16 -0
  337. package/src/internal/zone-graph.ts +59 -19
  338. package/src/isr.ts +351 -43
  339. package/src/llms-txt.ts +32 -3
  340. package/src/manifest.ts +28 -4
  341. package/src/open-resolved.ts +64 -0
  342. package/src/plugins/kit.ts +31 -11
  343. package/src/plugins/vite-leak-guard.ts +5 -1
  344. package/src/public-dir.ts +27 -8
  345. package/src/query.ts +107 -26
  346. package/src/render-seam.ts +2 -0
  347. package/src/route-types.ts +34 -7
  348. package/src/router.ts +58 -55
  349. package/src/server-components/build-info.ts +11 -0
  350. package/src/server-components/build-target.ts +194 -0
  351. package/src/server-components/build.ts +419 -0
  352. package/src/server-components/bundle-proof.ts +33 -0
  353. package/src/server-components/client.ts +663 -0
  354. package/src/server-components/definition.ts +125 -0
  355. package/src/server-components/dev-http.ts +75 -0
  356. package/src/server-components/error.ts +24 -0
  357. package/src/server-components/frames.ts +181 -0
  358. package/src/server-components/html-policy.ts +94 -0
  359. package/src/server-components/html.ts +81 -0
  360. package/src/server-components/islands.ts +111 -0
  361. package/src/server-components/native.ts +101 -0
  362. package/src/server-components/portal.ts +42 -0
  363. package/src/server-components/props.ts +19 -0
  364. package/src/server-components/protocol.ts +167 -0
  365. package/src/server-components/range.ts +313 -0
  366. package/src/server-components/registry.ts +34 -0
  367. package/src/server-components/response.ts +44 -0
  368. package/src/server-components/router-integration.ts +40 -0
  369. package/src/server-components/runtime.ts +376 -0
  370. package/src/server-components/scheduler.ts +84 -0
  371. package/src/server-components/schema.ts +69 -0
  372. package/src/server-components/server-entry.ts +98 -0
  373. package/src/server-components/source.ts +324 -0
  374. package/src/server-components/state.ts +143 -0
  375. package/src/server-components/stream.ts +71 -0
  376. package/src/server-components/trusted-html.ts +21 -0
  377. package/src/server-components/types.ts +138 -0
  378. package/src/server-components/typescript.ts +39 -0
  379. package/src/server-components/value.ts +82 -0
  380. package/src/server-components/vite.ts +242 -0
  381. package/src/server-components.ts +32 -0
  382. package/src/service-worker.ts +24 -7
  383. package/src/vite.ts +8 -1
  384. package/src/zones.ts +208 -7
package/src/isr.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
8
8
  */
9
+ import { isAuthenticatedRequest, writableResponse } from "@nifrajs/core/server"
9
10
  import { isDraftEnabled } from "./draft.ts"
10
11
  import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
11
12
 
@@ -22,6 +23,9 @@ export interface CachedResponse {
22
23
  readonly revalidate: number
23
24
  /** Public, bounded invalidation labels. A tag purge makes matching entries miss immediately. */
24
25
  readonly tags?: readonly string[]
26
+ /** The store's {@link CacheStore.fence} as the render behind this entry began. A store that keeps
27
+ * purge order skips the write when this key or one of its tags was purged after it. */
28
+ readonly fence?: number | undefined
25
29
  }
26
30
 
27
31
  /**
@@ -39,6 +43,13 @@ export interface CacheStore {
39
43
  delete(key: string): Promise<void>
40
44
  /** Drop every cached response carrying `tag`, when the backend supports tag invalidation. */
41
45
  readonly invalidateTag?: (tag: string) => Promise<void>
46
+ /**
47
+ * The current point in this store's purge order. `withISR` takes one before each render and hands it
48
+ * back as {@link CachedResponse.fence}, and `set` skips that entry when its key or one of its tags
49
+ * was purged after the point. Without it, a purge that lands while a page renders is undone when the
50
+ * render stores the page it built from data read before the purge.
51
+ */
52
+ readonly fence?: () => number | Promise<number>
42
53
  }
43
54
 
44
55
  const ISR_TAG_PATTERN = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/
@@ -145,6 +156,10 @@ export class MemoryCacheStore implements CacheStore {
145
156
  >()
146
157
  private readonly max: number
147
158
  private readonly tagEpoch = new Map<string, string>()
159
+ // Purge order: `fence` reads it; each purge advances it and records where, per key and per tag.
160
+ private order = 0
161
+ private readonly purgedKeys = new Map<string, number>()
162
+ private readonly purgedTags = new Map<string, number>()
148
163
 
149
164
  constructor(options: MemoryCacheStoreOptions = {}) {
150
165
  const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
@@ -188,8 +203,14 @@ export class MemoryCacheStore implements CacheStore {
188
203
  return Promise.resolve(value)
189
204
  }
190
205
 
206
+ fence(): number {
207
+ return this.order
208
+ }
209
+
191
210
  set(key: string, value: CachedResponse): Promise<void> {
192
211
  const tags = normalizeTags(value.tags)
212
+ if (value.fence !== undefined && this.purgedAfter(key, tags, value.fence))
213
+ return Promise.resolve()
193
214
  const tagEpochs: Record<string, string> = {}
194
215
  for (const tag of tags) tagEpochs[tag] = this.tagEpoch.get(tag) ?? "0"
195
216
  this.cache.delete(key) // re-insert at the tail so Map order tracks recency (with get) for the LRU evict
@@ -205,6 +226,7 @@ export class MemoryCacheStore implements CacheStore {
205
226
 
206
227
  delete(key: string): Promise<void> {
207
228
  this.cache.delete(key)
229
+ this.markPurged(this.purgedKeys, key)
208
230
  this.pruneTagEpochs()
209
231
  return Promise.resolve()
210
232
  }
@@ -212,6 +234,7 @@ export class MemoryCacheStore implements CacheStore {
212
234
  invalidateTag(tag: string): Promise<void> {
213
235
  normalizeTag(tag)
214
236
  this.tagEpoch.set(tag, crypto.randomUUID())
237
+ this.markPurged(this.purgedTags, tag)
215
238
  // This store is bounded and local, so eagerly remove matching rows. It keeps invalidation
216
239
  // immediate and lets us discard epoch keys that no retained entry references anymore.
217
240
  for (const [key, row] of this.cache) {
@@ -221,6 +244,22 @@ export class MemoryCacheStore implements CacheStore {
221
244
  return Promise.resolve()
222
245
  }
223
246
 
247
+ private purgedAfter(key: string, tags: readonly string[], fence: number): boolean {
248
+ if ((this.purgedKeys.get(key) ?? 0) > fence) return true
249
+ return tags.some((tag) => (this.purgedTags.get(tag) ?? 0) > fence)
250
+ }
251
+
252
+ private markPurged(marks: Map<string, number>, name: string): void {
253
+ marks.delete(name)
254
+ marks.set(name, ++this.order)
255
+ // Bounded like the entries: a mark matters only to a render still in flight.
256
+ while (marks.size > this.max) {
257
+ const oldest = marks.keys().next().value
258
+ if (oldest === undefined) break
259
+ marks.delete(oldest)
260
+ }
261
+ }
262
+
224
263
  private pruneTagEpochs(): void {
225
264
  if (this.tagEpoch.size === 0) return
226
265
  const used = new Set<string>()
@@ -276,13 +315,22 @@ export interface KVCacheStoreOptions {
276
315
  const isCachedResponse = (value: unknown): value is CachedResponse => {
277
316
  if (typeof value !== "object" || value === null) return false
278
317
  const v = value as Record<string, unknown>
318
+ // Everything a replay hands to `new Response` must be legal there, or a corrupt record would throw
319
+ // on every hit instead of reading as a miss.
279
320
  if (
280
321
  typeof v.body !== "string" ||
281
322
  typeof v.status !== "number" ||
323
+ !Number.isInteger(v.status) ||
324
+ v.status < 200 ||
325
+ v.status > 599 ||
282
326
  typeof v.storedAt !== "number" ||
327
+ !Number.isFinite(v.storedAt) ||
283
328
  typeof v.revalidate !== "number" ||
329
+ !Number.isFinite(v.revalidate) ||
330
+ v.revalidate < 0 ||
284
331
  typeof v.headers !== "object" ||
285
- v.headers === null
332
+ v.headers === null ||
333
+ Array.isArray(v.headers)
286
334
  ) {
287
335
  return false
288
336
  }
@@ -294,13 +342,54 @@ const isCachedResponse = (value: unknown): value is CachedResponse => {
294
342
  return false
295
343
  }
296
344
  }
297
- for (const headerValue of Object.values(v.headers as Record<string, unknown>)) {
345
+ const headers: Record<string, string> = {}
346
+ for (const [name, headerValue] of Object.entries(v.headers)) {
298
347
  if (typeof headerValue !== "string") return false
348
+ headers[name] = headerValue
349
+ }
350
+ try {
351
+ new Headers(headers)
352
+ } catch {
353
+ return false
299
354
  }
300
355
  return true
301
356
  }
302
357
 
303
358
  const KV_TAG_EPOCH_PREFIX = "__nifra_isr_tag_v1:"
359
+ const KV_PURGED_AT = "__nifraPurgedAt"
360
+
361
+ /** When a KV page key was purged, from the marker `delete` leaves; `-Infinity` for a page or nothing. */
362
+ function kvPurgedAt(raw: string | null): number {
363
+ if (raw === null || !raw.includes(KV_PURGED_AT)) return Number.NEGATIVE_INFINITY
364
+ try {
365
+ const parsed: unknown = JSON.parse(raw)
366
+ if (typeof parsed === "object" && parsed !== null && KV_PURGED_AT in parsed) {
367
+ const at = parsed[KV_PURGED_AT]
368
+ if (typeof at === "number") return at
369
+ }
370
+ } catch {
371
+ // Not a marker.
372
+ }
373
+ return Number.NEGATIVE_INFINITY
374
+ }
375
+
376
+ /** When a tag epoch was written (`<time>:<uuid>`); an epoch from before epochs carried a time is older
377
+ * than any render. */
378
+ function kvEpochTime(epoch: string): number {
379
+ const at = epoch.indexOf(":")
380
+ const time = at === -1 ? Number.NaN : Number(epoch.slice(0, at))
381
+ return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY
382
+ }
383
+
384
+ let lastKvTime = 0
385
+ /** Wall-clock ms, strictly increasing per process so a purge orders after a render it follows. Not
386
+ * `timeOrigin + performance.now()`: that clock stops while the host sleeps, so a purge from a process
387
+ * that slept would order before renders other instances began earlier. */
388
+ const kvClock = (): number => {
389
+ const now = Date.now()
390
+ lastKvTime = now > lastKvTime ? now : lastKvTime + 0.001
391
+ return lastKvTime
392
+ }
304
393
 
305
394
  type StoredTaggedResponse = CachedResponse & {
306
395
  readonly __nifraTagEpochs?: Readonly<Record<string, string>>
@@ -373,10 +462,18 @@ export class KVCacheStore implements CacheStore {
373
462
  return value
374
463
  }
375
464
 
465
+ /** Wall-clock time, so purges on any instance order against it. KV is eventually consistent, so the
466
+ * fence holds only for purges this location has already seen. */
467
+ fence(): number {
468
+ return kvClock()
469
+ }
470
+
376
471
  async set(key: string, value: CachedResponse): Promise<void> {
377
- const tags = normalizeTags(value.tags)
472
+ const { fence, ...entry } = value
473
+ if (fence !== undefined && kvPurgedAt(await this.kv.get(key)) > fence) return
474
+ const tags = normalizeTags(entry.tags)
378
475
  if (tags.length === 0) {
379
- await this.kv.put(key, JSON.stringify(value), this.putOptions)
476
+ await this.kv.put(key, JSON.stringify(entry), this.putOptions)
380
477
  return
381
478
  }
382
479
  const epochs = Object.fromEntries(
@@ -384,24 +481,29 @@ export class KVCacheStore implements CacheStore {
384
481
  tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
385
482
  ),
386
483
  )
484
+ if (fence !== undefined && Object.values(epochs).some((epoch) => kvEpochTime(epoch) > fence)) {
485
+ return
486
+ }
387
487
  const stored: StoredTaggedResponse = {
388
- ...value,
488
+ ...entry,
389
489
  tags,
390
490
  __nifraTagEpochs: epochs,
391
491
  }
392
492
  await this.kv.put(key, JSON.stringify(stored), this.putOptions)
393
493
  }
394
494
 
495
+ /** Leaves a marker rather than removing the key, so `set` can refuse a render that began before
496
+ * the purge. The marker reads as a miss. */
395
497
  async delete(key: string): Promise<void> {
396
- await this.kv.delete(key)
498
+ await this.kv.put(key, JSON.stringify({ [KV_PURGED_AT]: kvClock() }), this.putOptions)
397
499
  }
398
500
 
399
501
  async invalidateTag(tag: string): Promise<void> {
400
502
  normalizeTag(tag)
401
503
  // Epoch invalidation avoids KV key enumeration and remains bounded for a tag with millions of
402
504
  // pages. Epoch keys intentionally have no expiration: an expired epoch would resurrect entries
403
- // written before the invalidation when their page key still exists.
404
- await this.kv.put(this.tagKey(tag), crypto.randomUUID())
505
+ // written before the invalidation when their page key still exists. The time orders it for `set`.
506
+ await this.kv.put(this.tagKey(tag), `${kvClock()}:${crypto.randomUUID()}`)
405
507
  }
406
508
 
407
509
  private tagKey(tag: string): string {
@@ -450,17 +552,10 @@ export function withoutCacheChannel(res: Response): Response {
450
552
  if (!res.headers.has(ISR_REVALIDATE_HEADER) && !res.headers.has(ISR_REVALIDATE_TAGS_HEADER)) {
451
553
  return res
452
554
  }
453
- try {
454
- res.headers.delete(ISR_REVALIDATE_HEADER)
455
- res.headers.delete(ISR_REVALIDATE_TAGS_HEADER)
456
- return res
457
- } catch {
458
- // A `fetch()` response has immutable headers: rebuild it around the same body instead.
459
- const headers = new Headers(res.headers)
460
- headers.delete(ISR_REVALIDATE_HEADER)
461
- headers.delete(ISR_REVALIDATE_TAGS_HEADER)
462
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
463
- }
555
+ const target = writableResponse(res)
556
+ target.headers.delete(ISR_REVALIDATE_HEADER)
557
+ target.headers.delete(ISR_REVALIDATE_TAGS_HEADER)
558
+ return target
464
559
  }
465
560
 
466
561
  export interface ISROptions {
@@ -473,8 +568,17 @@ export interface ISROptions {
473
568
  readonly now: () => number
474
569
  /** Cache key for a request. Default: `origin + pathname`, plus the query parameters `query` admits,
475
570
  * so host-routed apps do not share entries across tenants. Return `null` to bypass the cache for
476
- * this request (it goes straight to the app, uncached). Replaces `query` entirely. */
571
+ * this request (it goes straight to the app, uncached). Replaces `query` entirely. Under the default
572
+ * key a request carrying `Authorization` or a cookie outside `anonymousCookies` never reads the
573
+ * store; a custom key takes that decision over, so include whatever identifies the visitor in it. */
477
574
  readonly key?: (req: Request) => string | null
575
+ /**
576
+ * Cookie names that never change the page, such as a consent or analytics cookie. Under the default
577
+ * key a request carrying `Authorization` or any other cookie skips the stored page and renders fresh,
578
+ * because a signed-in visitor's page can differ from the anonymous one; a request whose cookies are
579
+ * all listed here is served from the cache like one with none. Default none.
580
+ */
581
+ readonly anonymousCookies?: readonly string[]
478
582
  /**
479
583
  * How the query string reaches the default key. Default `"bypass"`: a request carrying any query
480
584
  * parameter skips the cache - rendered fresh, never stored - so `?a=1`, `?a=2`, ... cannot each
@@ -488,6 +592,41 @@ export interface ISROptions {
488
592
  * set, a request carrying a valid signed draft cookie **bypasses the cache** - editors always render
489
593
  * fresh, and a draft render is never written to the store (it can't poison the public cache). */
490
594
  readonly draftSecret?: string
595
+ /**
596
+ * Called when a store call fails: it throws, rejects, or outlives {@link storeTimeoutMs}. The request
597
+ * is served without the cache either way - a failed read is a miss, a failed write still returns the
598
+ * rendered page, a failed regeneration keeps the stale entry - so a store outage never changes a
599
+ * status. Default: one `console.error` line per operation per minute, never naming the cache key.
600
+ */
601
+ readonly onStoreError?: (error: unknown, info: { readonly op: ISRStoreOp }) => void
602
+ /**
603
+ * Longest a store call may take (ms) before it counts as failed. Default none, so a hung store holds
604
+ * the request; set it when the store is a network service.
605
+ */
606
+ readonly storeTimeoutMs?: number
607
+ }
608
+
609
+ /** The store operation an {@link ISROptions.onStoreError} report is about. */
610
+ export type ISRStoreOp = "get" | "set" | "delete" | "fence"
611
+
612
+ const STORE_FAILED = Symbol("nifra.isr.store-failed")
613
+ const STORE_REPORT_INTERVAL_MS = 60_000
614
+ const STORE_REPORT_DETAIL_CHARS = 200
615
+
616
+ /** The error's own words with the cache key cut out, since a store may name the key it failed on. */
617
+ function storeErrorDetail(error: unknown, key: string): string {
618
+ const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
619
+ let path: string | undefined
620
+ try {
621
+ path = new URL(key).pathname
622
+ } catch {
623
+ path = undefined
624
+ }
625
+ let redacted = text.split(key).join("<key>")
626
+ if (path !== undefined && path.length > 1) redacted = redacted.split(path).join("<key>")
627
+ return redacted.length > STORE_REPORT_DETAIL_CHARS
628
+ ? `${redacted.slice(0, STORE_REPORT_DETAIL_CHARS)}...`
629
+ : redacted
491
630
  }
492
631
 
493
632
  /** `createWebApp`'s marker for how its documents meet a CSP (see `DOCUMENT_POLICY` in ./csp.ts). Read
@@ -554,8 +693,19 @@ const cacheControlHas = (headers: Headers, names: readonly string[]): boolean =>
554
693
  const hasSetCookie = (headers: Headers): boolean =>
555
694
  headers.has("set-cookie") || (headers.getSetCookie?.().length ?? 0) > 0
556
695
 
696
+ /** Whether every cookie in a `Cookie` header is named in `names`. */
697
+ const cookieNamesWithin = (header: string, names: ReadonlySet<string>): boolean => {
698
+ for (const pair of header.split(";")) {
699
+ const name = pair.split("=", 1)[0]?.trim() ?? ""
700
+ if (name !== "" && !names.has(name)) return false
701
+ }
702
+ return true
703
+ }
704
+
705
+ // An authentication stage may read any header, so a request it accepted is private whatever carried
706
+ // the credential. The mark is set on the request the app was handed, which is why this runs after it.
557
707
  const requestCarriesPrivateState = (req: Request): boolean =>
558
- req.headers.has("authorization") || req.headers.has("cookie")
708
+ req.headers.has("authorization") || req.headers.has("cookie") || isAuthenticatedRequest(req)
559
709
 
560
710
  const responseIsExplicitlyPublic = (res: Response): boolean =>
561
711
  cacheControlHas(res.headers, ["public"])
@@ -575,7 +725,8 @@ const responseDeclaresVary = (res: Response): boolean => {
575
725
  /**
576
726
  * Whether `res` may be stored by a shared cache and served to anyone requesting `req`'s URL: a
577
727
  * full-document `GET` 200 `text/html` with no Set-Cookie, no `private`/`no-store`, no `Vary` beyond
578
- * the data header, and, for a request carrying a cookie or Authorization, an explicit `public`.
728
+ * the data header, and, for a request carrying a cookie or Authorization or accepted by an
729
+ * authentication stage, an explicit `public`.
579
730
  * The one rule both `withISR` and `@nifrajs/web/cdn` cache by.
580
731
  */
581
732
  export const isCacheablePage = (req: Request, res: Response): boolean => {
@@ -653,8 +804,22 @@ function sanitizeCachedHeaders(headers: Readonly<Record<string, string>>): Recor
653
804
  return out
654
805
  }
655
806
 
656
- const isInvalidationResponse = (res: Response): boolean =>
657
- res.status === 404 || res.status === 410 || (res.status >= 300 && res.status < 400)
807
+ /**
808
+ * Whether a regeneration's answer withdraws the stored page: it is gone or moved, or, rendered for a
809
+ * request with no cookie or Authorization, refused (401, 403) or declared `private`/`no-store`. A
810
+ * signed-in visitor's own 403 or private copy says nothing about the shared page. A 5xx or 429 keeps
811
+ * serving the stale copy.
812
+ */
813
+ const isInvalidationResponse = (req: Request, res: Response): boolean => {
814
+ if (res.status === 404 || res.status === 410 || (res.status >= 300 && res.status < 400))
815
+ return true
816
+ if (requestCarriesPrivateState(req)) return false
817
+ return (
818
+ res.status === 401 ||
819
+ res.status === 403 ||
820
+ (res.status >= 200 && res.status < 300 && cacheControlHas(res.headers, ["private", "no-store"]))
821
+ )
822
+ }
658
823
 
659
824
  /**
660
825
  * Wrap a nifra app with **Incremental Static Regeneration**: a cacheable page is served from
@@ -673,8 +838,77 @@ export function withISR(
673
838
  ): (req: Request, platform?: ISRPlatform) => Promise<Response> {
674
839
  const { store, now } = options
675
840
  assertRevalidate(options.revalidate, "revalidate")
841
+ const storeTimeoutMs = options.storeTimeoutMs
842
+ if (storeTimeoutMs !== undefined && !(Number.isFinite(storeTimeoutMs) && storeTimeoutMs > 0)) {
843
+ throw new RangeError("[nifra/web] ISR storeTimeoutMs must be a positive finite number")
844
+ }
845
+ const lastReport = new Map<ISRStoreOp, number>()
846
+ const reportStoreError = (op: ISRStoreOp, key: string, error: unknown): void => {
847
+ try {
848
+ if (options.onStoreError !== undefined) {
849
+ options.onStoreError(error, { op })
850
+ return
851
+ }
852
+ const last = lastReport.get(op)
853
+ const at = now()
854
+ if (last !== undefined && at - last < STORE_REPORT_INTERVAL_MS) return
855
+ lastReport.set(op, at)
856
+ console.error(
857
+ `[nifra/web] ISR store ${op} failed; serving without the cache (${storeErrorDetail(error, key)})`,
858
+ )
859
+ } catch {
860
+ // A failing reporter must not turn a served page into an error.
861
+ }
862
+ }
863
+ // Runs one store call; a throw, a rejection or a timeout is reported and answered with STORE_FAILED.
864
+ const attempt = async <T>(
865
+ op: ISRStoreOp,
866
+ key: string,
867
+ call: () => Promise<T>,
868
+ ): Promise<T | typeof STORE_FAILED> => {
869
+ let timer: ReturnType<typeof setTimeout> | undefined
870
+ try {
871
+ const pending = call()
872
+ if (storeTimeoutMs === undefined) return await pending
873
+ const timeout = new Promise<never>((_, reject) => {
874
+ timer = setTimeout(() => {
875
+ const error = new Error(`ISR store ${op} exceeded storeTimeoutMs (${storeTimeoutMs} ms)`)
876
+ error.name = "TimeoutError"
877
+ reject(error)
878
+ }, storeTimeoutMs)
879
+ })
880
+ return await Promise.race([pending, timeout])
881
+ } catch (error) {
882
+ reportStoreError(op, key, error)
883
+ return STORE_FAILED
884
+ } finally {
885
+ if (timer !== undefined) clearTimeout(timer)
886
+ }
887
+ }
888
+ // A store without purge order, or one whose fence failed, stores the render unfenced.
889
+ const fenceFor = async (key: string): Promise<number | undefined> => {
890
+ const { fence } = store
891
+ if (fence === undefined) return undefined
892
+ const taken = await attempt("fence", key, async () => fence.call(store))
893
+ return taken === STORE_FAILED ? undefined : taken
894
+ }
676
895
  const keyOf = options.key ?? requestKeyOf(urlKeyOf(options.query))
677
896
  const draftSecret = options.draftSecret
897
+ const anonymousCookies = new Set(options.anonymousCookies ?? [])
898
+ // A request whose cookies are all anonymous is judged for storage as if it carried none.
899
+ const judged = (req: Request): Request => {
900
+ if (
901
+ anonymousCookies.size === 0 ||
902
+ req.headers.has("authorization") ||
903
+ isAuthenticatedRequest(req)
904
+ )
905
+ return req
906
+ const cookie = req.headers.get("cookie")
907
+ if (cookie === null || !cookieNamesWithin(cookie, anonymousCookies)) return req
908
+ const headers = new Headers(req.headers)
909
+ headers.delete("cookie")
910
+ return new Request(req.url, { method: req.method, headers })
911
+ }
678
912
  const regenerating = new Set<string>()
679
913
  openCacheChannel(app)
680
914
  // An outer cache layer (`withCdn`) opens this wrapper's own channel: cached responses then carry how
@@ -727,10 +961,13 @@ export function withISR(
727
961
  req: Request,
728
962
  platform: ISRPlatform | undefined,
729
963
  key: string,
964
+ remember: boolean,
965
+ stored?: (entry: CachedResponse) => void,
730
966
  ): Promise<Response> => {
967
+ const fence = await fenceFor(key)
731
968
  const res = await app.fetch(req, platform)
732
- if (!isCacheablePage(req, res)) {
733
- rememberUncacheable(key, res)
969
+ if (!isCacheablePage(judged(req), res)) {
970
+ if (remember) rememberUncacheable(key, res)
734
971
  return withoutCacheChannel(res)
735
972
  }
736
973
  uncacheable.delete(key)
@@ -743,14 +980,49 @@ export function withISR(
743
980
  storedAt: now(),
744
981
  revalidate: ttlMs(res),
745
982
  ...(tags.length === 0 ? {} : { tags }),
983
+ fence,
746
984
  }
747
- await store.set(key, entry)
748
- const fresh = new Response(body, {
749
- status: res.status,
985
+ // A failed write still answers with the page, and still hands it to concurrent misses: whether it
986
+ // may be shared was decided above, not by the store.
987
+ await attempt("set", key, () => store.set(key, entry))
988
+ stored?.(entry)
989
+ return fresh(entry)
990
+ }
991
+
992
+ const fresh = (entry: CachedResponse): Response => {
993
+ const res = new Response(entry.body, {
994
+ status: entry.status,
750
995
  headers: { ...entry.headers, [ISR_STATUS_HEADER]: "miss" },
751
996
  })
752
- advertise(fresh.headers, entry.revalidate, tags)
753
- return fresh
997
+ advertise(res.headers, entry.revalidate, entry.tags ?? [])
998
+ return res
999
+ }
1000
+
1001
+ // Cold misses in flight per key. Concurrent misses wait for the first one's stored entry instead
1002
+ // of each rendering the page; a render that is not stored (private, failed) sends each waiter to
1003
+ // render for itself, so a waiter never receives a page that was not safe to share.
1004
+ const coldRenders = new Map<string, Promise<CachedResponse | undefined>>()
1005
+ const renderCold = async (
1006
+ req: Request,
1007
+ platform: ISRPlatform | undefined,
1008
+ key: string,
1009
+ ): Promise<Response> => {
1010
+ const pending = coldRenders.get(key)
1011
+ if (pending !== undefined) {
1012
+ const entry = await pending
1013
+ return entry === undefined ? render(req, platform, key, true) : fresh(entry)
1014
+ }
1015
+ const shared = Promise.withResolvers<CachedResponse | undefined>()
1016
+ coldRenders.set(key, shared.promise)
1017
+ let entry: CachedResponse | undefined
1018
+ try {
1019
+ return await render(req, platform, key, true, (stored) => {
1020
+ entry = stored
1021
+ })
1022
+ } finally {
1023
+ coldRenders.delete(key)
1024
+ shared.resolve(entry)
1025
+ }
754
1026
  }
755
1027
 
756
1028
  // Background regeneration (single-flight per key) - a failed regen keeps the stale entry; the next
@@ -763,22 +1035,26 @@ export function withISR(
763
1035
  if (regenerating.has(key)) return
764
1036
  regenerating.add(key)
765
1037
  try {
1038
+ const fence = await fenceFor(key)
766
1039
  const res = await app.fetch(req, platform)
767
- if (isCacheablePage(req, res)) {
1040
+ if (isCacheablePage(judged(req), res)) {
768
1041
  const tags = tagsOf(res)
769
- await store.set(key, {
1042
+ const entry: CachedResponse = {
770
1043
  body: await res.text(),
771
1044
  status: res.status,
772
1045
  headers: headersOf(res),
773
1046
  storedAt: now(),
774
1047
  revalidate: ttlMs(res),
775
1048
  ...(tags.length === 0 ? {} : { tags }),
776
- })
777
- } else if (isInvalidationResponse(res)) {
778
- // A successful deletion or redirect is authoritative. Keeping the old entry would make a
779
- // withdrawn page stay publicly visible forever because every stale request would retry the same
780
- // non-cacheable response while continuing to serve the old body.
781
- await store.delete(key)
1049
+ fence,
1050
+ }
1051
+ await attempt("set", key, () => store.set(key, entry))
1052
+ } else if (isInvalidationResponse(judged(req), res)) {
1053
+ // A withdrawal is authoritative. Keeping the old entry would make a withdrawn page stay
1054
+ // publicly visible forever, because every stale request would retry the same non-cacheable
1055
+ // response while continuing to serve the old body.
1056
+ await res.body?.cancel()
1057
+ await attempt("delete", key, () => store.delete(key))
782
1058
  } else {
783
1059
  await res.body?.cancel()
784
1060
  }
@@ -793,7 +1069,15 @@ export function withISR(
793
1069
  // A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML documents keyed
794
1070
  // by URL, and serving one to a loader-data fetch would hand the client HTML where it expects
795
1071
  // the loader payload. (The write path already refuses to cache data-mode responses.)
796
- const key = req.method === "GET" && req.headers.get("x-nifra-data") === null ? keyOf(req) : null
1072
+ const privateRoute: unknown = Reflect.get(app, Symbol.for("nifra.web.private-route"))
1073
+ const ownsPrivate = typeof privateRoute === "function" && privateRoute(req) === true
1074
+ const key =
1075
+ req.method === "GET" &&
1076
+ req.headers.get("x-nifra-data") === null &&
1077
+ !req.headers.has("x-nifra-component") &&
1078
+ !ownsPrivate
1079
+ ? keyOf(req)
1080
+ : null
797
1081
  if (key === null) return withoutCacheChannel(await app.fetch(req, platform))
798
1082
 
799
1083
  // Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
@@ -802,13 +1086,18 @@ export function withISR(
802
1086
  return withoutCacheChannel(await app.fetch(req, platform))
803
1087
  }
804
1088
 
1089
+ // A cookie or Authorization request may be personalized: under the default key it never reads
1090
+ // the store, and its private render never marks the URL uncacheable for everyone else.
1091
+ const personal = options.key === undefined && requestCarriesPrivateState(judged(req))
1092
+
805
1093
  const until = uncacheable.get(key)
806
1094
  if (until !== undefined) {
807
- if (now() < until) return render(req, platform, key)
1095
+ if (now() < until) return render(req, platform, key, !personal)
808
1096
  uncacheable.delete(key)
809
1097
  }
810
1098
 
811
- const hit = await store.get(key)
1099
+ const read = personal ? undefined : await attempt("get", key, () => store.get(key))
1100
+ const hit = read === STORE_FAILED ? undefined : read
812
1101
  if (hit !== undefined) {
813
1102
  if (now() - hit.storedAt < hit.revalidate) return served(hit, "hit")
814
1103
  // Stale: serve it now, regenerate behind it (waitUntil keeps the edge worker alive for the regen).
@@ -817,7 +1106,7 @@ export function withISR(
817
1106
  else void task.catch(() => {})
818
1107
  return served(hit, "stale")
819
1108
  }
820
- return render(req, platform, key)
1109
+ return personal ? render(req, platform, key, false) : renderCold(req, platform, key)
821
1110
  }
822
1111
  Object.defineProperty(handler, CACHE_CHANNEL, {
823
1112
  value: () => {
@@ -870,6 +1159,13 @@ export interface RevalidateEndpointOptions {
870
1159
  * the purge, `202` when it is queued (rate limit or retry), and `502` when the CDN refused it.
871
1160
  */
872
1161
  readonly cdn?: CdnPurgeTarget
1162
+ /**
1163
+ * Caches below the pages whose entries carry the same tags, such as a `@nifrajs/cache` holding
1164
+ * loader data. A tag purge invalidates each of them first, then the page store, then the CDN, so a
1165
+ * page regenerated mid-purge cannot be rebuilt from stale data. A failure answers `500` before the
1166
+ * page store is touched, so a retry purges everything again. Path purges do not reach them.
1167
+ */
1168
+ readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[]
873
1169
  }
874
1170
 
875
1171
  const stringList = (value: unknown): string[] | null | undefined => {
@@ -953,6 +1249,18 @@ export function revalidateEndpoint(
953
1249
  return jsonError(501, "tag_invalidation_not_supported")
954
1250
  }
955
1251
 
1252
+ if (tags.length > 0 && options.dataCaches !== undefined) {
1253
+ try {
1254
+ for (const cache of options.dataCaches) {
1255
+ for (const candidate of tags) await cache.invalidateTag(candidate)
1256
+ }
1257
+ } catch {
1258
+ return Response.json(
1259
+ { ok: false, error: "data_cache_invalidation_failed" },
1260
+ { status: 500 },
1261
+ )
1262
+ }
1263
+ }
956
1264
  for (const key of keys) await options.store.delete(key)
957
1265
  for (const candidate of tags) await invalidateTag?.call(options.store, candidate)
958
1266
  const done =