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