@nifrajs/web 3.5.0 → 4.0.2

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 (390) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +18 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +82 -34
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +4 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +68 -13
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +57 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +356 -85
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +3 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +136 -35
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/dev-client.d.ts +76 -0
  40. package/dist/dev-client.d.ts.map +1 -0
  41. package/dist/dev-client.js +280 -0
  42. package/dist/dev-client.js.map +1 -0
  43. package/dist/dev-diagnostics.d.ts +26 -2
  44. package/dist/dev-diagnostics.d.ts.map +1 -1
  45. package/dist/dev-diagnostics.js +38 -7
  46. package/dist/dev-diagnostics.js.map +1 -1
  47. package/dist/dev-error.d.ts +4 -13
  48. package/dist/dev-error.d.ts.map +1 -1
  49. package/dist/dev-error.js +46 -4
  50. package/dist/dev-error.js.map +1 -1
  51. package/dist/dev-feed.d.ts +298 -0
  52. package/dist/dev-feed.d.ts.map +1 -0
  53. package/dist/dev-feed.js +797 -0
  54. package/dist/dev-feed.js.map +1 -0
  55. package/dist/dev-indicator.d.ts +38 -0
  56. package/dist/dev-indicator.d.ts.map +1 -0
  57. package/dist/dev-indicator.js +93 -0
  58. package/dist/dev-indicator.js.map +1 -0
  59. package/dist/dev-session.d.ts +77 -0
  60. package/dist/dev-session.d.ts.map +1 -0
  61. package/dist/dev-session.js +580 -0
  62. package/dist/dev-session.js.map +1 -0
  63. package/dist/dev-sourcemap.d.ts +27 -0
  64. package/dist/dev-sourcemap.d.ts.map +1 -0
  65. package/dist/dev-sourcemap.js +235 -0
  66. package/dist/dev-sourcemap.js.map +1 -0
  67. package/dist/dev-ssr-graph.d.ts.map +1 -1
  68. package/dist/dev-ssr-graph.js +13 -5
  69. package/dist/dev-ssr-graph.js.map +1 -1
  70. package/dist/dev.d.ts +15 -2
  71. package/dist/dev.d.ts.map +1 -1
  72. package/dist/dev.js +69 -44
  73. package/dist/dev.js.map +1 -1
  74. package/dist/diagnostic-catalog.d.ts +35 -0
  75. package/dist/diagnostic-catalog.d.ts.map +1 -0
  76. package/dist/diagnostic-catalog.js +203 -0
  77. package/dist/diagnostic-catalog.js.map +1 -0
  78. package/dist/diagnostic-prompt.d.ts +49 -0
  79. package/dist/diagnostic-prompt.d.ts.map +1 -0
  80. package/dist/diagnostic-prompt.js +303 -0
  81. package/dist/diagnostic-prompt.js.map +1 -0
  82. package/dist/diagnostic.d.ts +17 -23
  83. package/dist/diagnostic.d.ts.map +1 -1
  84. package/dist/diagnostic.js +31 -39
  85. package/dist/diagnostic.js.map +1 -1
  86. package/dist/draft.d.ts.map +1 -1
  87. package/dist/draft.js +4 -1
  88. package/dist/draft.js.map +1 -1
  89. package/dist/fn.d.ts +5 -0
  90. package/dist/fn.d.ts.map +1 -1
  91. package/dist/fn.js +13 -3
  92. package/dist/fn.js.map +1 -1
  93. package/dist/fs.d.ts.map +1 -1
  94. package/dist/fs.js +4 -2
  95. package/dist/fs.js.map +1 -1
  96. package/dist/index.d.ts +13 -15
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +11 -12
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  101. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  102. package/dist/internal/cdn/cloudflare.js +94 -0
  103. package/dist/internal/cdn/cloudflare.js.map +1 -0
  104. package/dist/internal/cdn/core.d.ts +91 -0
  105. package/dist/internal/cdn/core.d.ts.map +1 -0
  106. package/dist/internal/cdn/core.js +220 -0
  107. package/dist/internal/cdn/core.js.map +1 -0
  108. package/dist/internal/cdn/fastly.d.ts +17 -0
  109. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  110. package/dist/internal/cdn/fastly.js +43 -0
  111. package/dist/internal/cdn/fastly.js.map +1 -0
  112. package/dist/internal/cdn/vercel.d.ts +31 -0
  113. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  114. package/dist/internal/cdn/vercel.js +56 -0
  115. package/dist/internal/cdn/vercel.js.map +1 -0
  116. package/dist/internal/code-unit-order.d.ts +4 -0
  117. package/dist/internal/code-unit-order.d.ts.map +1 -0
  118. package/dist/internal/code-unit-order.js +6 -0
  119. package/dist/internal/code-unit-order.js.map +1 -0
  120. package/dist/internal/codegen.d.ts.map +1 -1
  121. package/dist/internal/codegen.js +141 -25
  122. package/dist/internal/codegen.js.map +1 -1
  123. package/dist/internal/dev-csp.d.ts +24 -0
  124. package/dist/internal/dev-csp.d.ts.map +1 -0
  125. package/dist/internal/dev-csp.js +160 -0
  126. package/dist/internal/dev-csp.js.map +1 -0
  127. package/dist/internal/dev-reserved.d.ts +25 -0
  128. package/dist/internal/dev-reserved.d.ts.map +1 -0
  129. package/dist/internal/dev-reserved.js +39 -0
  130. package/dist/internal/dev-reserved.js.map +1 -0
  131. package/dist/internal/html-spans.d.ts +10 -0
  132. package/dist/internal/html-spans.d.ts.map +1 -0
  133. package/dist/internal/html-spans.js +46 -0
  134. package/dist/internal/html-spans.js.map +1 -0
  135. package/dist/internal/loading-runtime.d.ts +44 -0
  136. package/dist/internal/loading-runtime.d.ts.map +1 -0
  137. package/dist/internal/loading-runtime.js +206 -0
  138. package/dist/internal/loading-runtime.js.map +1 -0
  139. package/dist/internal/matches-runtime.d.ts +8 -0
  140. package/dist/internal/matches-runtime.d.ts.map +1 -0
  141. package/dist/internal/matches-runtime.js +61 -0
  142. package/dist/internal/matches-runtime.js.map +1 -0
  143. package/dist/internal/mount-shadow.d.ts +36 -0
  144. package/dist/internal/mount-shadow.d.ts.map +1 -0
  145. package/dist/internal/mount-shadow.js +65 -0
  146. package/dist/internal/mount-shadow.js.map +1 -0
  147. package/dist/internal/node-builtins.d.ts +3 -0
  148. package/dist/internal/node-builtins.d.ts.map +1 -0
  149. package/dist/internal/node-builtins.js +79 -0
  150. package/dist/internal/node-builtins.js.map +1 -0
  151. package/dist/internal/output-guard.d.ts +65 -0
  152. package/dist/internal/output-guard.d.ts.map +1 -0
  153. package/dist/internal/output-guard.js +630 -0
  154. package/dist/internal/output-guard.js.map +1 -0
  155. package/dist/internal/page-execution.d.ts +20 -8
  156. package/dist/internal/page-execution.d.ts.map +1 -1
  157. package/dist/internal/page-execution.js +571 -105
  158. package/dist/internal/page-execution.js.map +1 -1
  159. package/dist/internal/parity.d.ts +21 -0
  160. package/dist/internal/parity.d.ts.map +1 -1
  161. package/dist/internal/parity.js +104 -22
  162. package/dist/internal/parity.js.map +1 -1
  163. package/dist/internal/private-env.d.ts +12 -0
  164. package/dist/internal/private-env.d.ts.map +1 -0
  165. package/dist/internal/private-env.js +335 -0
  166. package/dist/internal/private-env.js.map +1 -0
  167. package/dist/internal/public-url.d.ts +3 -0
  168. package/dist/internal/public-url.d.ts.map +1 -0
  169. package/dist/internal/public-url.js +18 -0
  170. package/dist/internal/public-url.js.map +1 -0
  171. package/dist/internal/render-document.d.ts +25 -19
  172. package/dist/internal/render-document.d.ts.map +1 -1
  173. package/dist/internal/render-document.js +77 -61
  174. package/dist/internal/render-document.js.map +1 -1
  175. package/dist/internal/response-controls.d.ts +75 -0
  176. package/dist/internal/response-controls.d.ts.map +1 -0
  177. package/dist/internal/response-controls.js +256 -0
  178. package/dist/internal/response-controls.js.map +1 -0
  179. package/dist/internal/runtime-contract.d.ts +11 -16
  180. package/dist/internal/runtime-contract.d.ts.map +1 -1
  181. package/dist/internal/runtime-contract.js.map +1 -1
  182. package/dist/internal/secret-scan.d.ts +104 -0
  183. package/dist/internal/secret-scan.d.ts.map +1 -0
  184. package/dist/internal/secret-scan.js +503 -0
  185. package/dist/internal/secret-scan.js.map +1 -0
  186. package/dist/internal/server-boundary.d.ts +6 -29
  187. package/dist/internal/server-boundary.d.ts.map +1 -1
  188. package/dist/internal/server-boundary.js +6 -66
  189. package/dist/internal/server-boundary.js.map +1 -1
  190. package/dist/internal/source-map.d.ts +47 -0
  191. package/dist/internal/source-map.d.ts.map +1 -0
  192. package/dist/internal/source-map.js +225 -0
  193. package/dist/internal/source-map.js.map +1 -0
  194. package/dist/internal/target-compat.d.ts +27 -0
  195. package/dist/internal/target-compat.d.ts.map +1 -0
  196. package/dist/internal/target-compat.js +77 -0
  197. package/dist/internal/target-compat.js.map +1 -0
  198. package/dist/internal/timing-safe-equal.d.ts +6 -0
  199. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  200. package/dist/internal/timing-safe-equal.js +10 -0
  201. package/dist/internal/timing-safe-equal.js.map +1 -1
  202. package/dist/internal/vitals-runtime.d.ts +18 -0
  203. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  204. package/dist/internal/vitals-runtime.js +38 -0
  205. package/dist/internal/vitals-runtime.js.map +1 -0
  206. package/dist/internal/web-app.d.ts +38 -9
  207. package/dist/internal/web-app.d.ts.map +1 -1
  208. package/dist/internal/web-app.js +199 -59
  209. package/dist/internal/web-app.js.map +1 -1
  210. package/dist/internal/zone-graph.d.ts +89 -0
  211. package/dist/internal/zone-graph.d.ts.map +1 -0
  212. package/dist/internal/zone-graph.js +492 -0
  213. package/dist/internal/zone-graph.js.map +1 -0
  214. package/dist/isr.d.ts +83 -6
  215. package/dist/isr.d.ts.map +1 -1
  216. package/dist/isr.js +263 -36
  217. package/dist/isr.js.map +1 -1
  218. package/dist/llms-txt.d.ts.map +1 -1
  219. package/dist/llms-txt.js +31 -6
  220. package/dist/llms-txt.js.map +1 -1
  221. package/dist/manifest.d.ts +220 -13
  222. package/dist/manifest.d.ts.map +1 -1
  223. package/dist/manifest.js +394 -9
  224. package/dist/manifest.js.map +1 -1
  225. package/dist/module-graph.d.ts +26 -5
  226. package/dist/module-graph.d.ts.map +1 -1
  227. package/dist/module-graph.js +17 -7
  228. package/dist/module-graph.js.map +1 -1
  229. package/dist/navigation.d.ts +17 -3
  230. package/dist/navigation.d.ts.map +1 -1
  231. package/dist/navigation.js +11 -5
  232. package/dist/navigation.js.map +1 -1
  233. package/dist/nonce.d.ts +47 -0
  234. package/dist/nonce.d.ts.map +1 -0
  235. package/dist/nonce.js +63 -0
  236. package/dist/nonce.js.map +1 -0
  237. package/dist/plugins/css-modules.d.ts.map +1 -1
  238. package/dist/plugins/css-modules.js +4 -3
  239. package/dist/plugins/css-modules.js.map +1 -1
  240. package/dist/plugins/kit.d.ts +9 -0
  241. package/dist/plugins/kit.d.ts.map +1 -1
  242. package/dist/plugins/kit.js +18 -0
  243. package/dist/plugins/kit.js.map +1 -1
  244. package/dist/plugins/svg.d.ts +22 -3
  245. package/dist/plugins/svg.d.ts.map +1 -1
  246. package/dist/plugins/svg.js +112 -1
  247. package/dist/plugins/svg.js.map +1 -1
  248. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  249. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  250. package/dist/plugins/vite-leak-guard.js +378 -17
  251. package/dist/plugins/vite-leak-guard.js.map +1 -1
  252. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  253. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  254. package/dist/plugins/vite-zone-guard.js +208 -0
  255. package/dist/plugins/vite-zone-guard.js.map +1 -0
  256. package/dist/plugins/zone-guard.d.ts +27 -0
  257. package/dist/plugins/zone-guard.d.ts.map +1 -0
  258. package/dist/plugins/zone-guard.js +80 -0
  259. package/dist/plugins/zone-guard.js.map +1 -0
  260. package/dist/prerender.d.ts +10 -0
  261. package/dist/prerender.d.ts.map +1 -1
  262. package/dist/prerender.js +35 -3
  263. package/dist/prerender.js.map +1 -1
  264. package/dist/public-dir.d.ts +9 -0
  265. package/dist/public-dir.d.ts.map +1 -1
  266. package/dist/public-dir.js +67 -9
  267. package/dist/public-dir.js.map +1 -1
  268. package/dist/pwa-manifest.d.ts +68 -0
  269. package/dist/pwa-manifest.d.ts.map +1 -0
  270. package/dist/pwa-manifest.js +178 -0
  271. package/dist/pwa-manifest.js.map +1 -0
  272. package/dist/query.js +4 -4
  273. package/dist/query.js.map +1 -1
  274. package/dist/render-seam.d.ts +42 -2
  275. package/dist/render-seam.d.ts.map +1 -1
  276. package/dist/render-seam.js +6 -0
  277. package/dist/render-seam.js.map +1 -1
  278. package/dist/route-manifest.d.ts +3 -0
  279. package/dist/route-manifest.d.ts.map +1 -1
  280. package/dist/route-manifest.js +26 -3
  281. package/dist/route-manifest.js.map +1 -1
  282. package/dist/route-types.d.ts +24 -0
  283. package/dist/route-types.d.ts.map +1 -0
  284. package/dist/route-types.js +195 -0
  285. package/dist/route-types.js.map +1 -0
  286. package/dist/router.d.ts +28 -6
  287. package/dist/router.d.ts.map +1 -1
  288. package/dist/router.js +129 -40
  289. package/dist/router.js.map +1 -1
  290. package/dist/vitals.d.ts +33 -0
  291. package/dist/vitals.d.ts.map +1 -0
  292. package/dist/vitals.js +16 -0
  293. package/dist/vitals.js.map +1 -0
  294. package/dist/vite.d.ts +16 -1
  295. package/dist/vite.d.ts.map +1 -1
  296. package/dist/vite.js +248 -43
  297. package/dist/vite.js.map +1 -1
  298. package/dist/zones.d.ts +70 -0
  299. package/dist/zones.d.ts.map +1 -0
  300. package/dist/zones.js +365 -0
  301. package/dist/zones.js.map +1 -0
  302. package/package.json +67 -14
  303. package/src/backend-only.ts +16 -0
  304. package/src/boundary.ts +18 -5
  305. package/src/build-plan.ts +90 -35
  306. package/src/build-vite.ts +77 -11
  307. package/src/build.ts +466 -96
  308. package/src/bun-dev-entry.ts +1 -1
  309. package/src/cdn.ts +216 -0
  310. package/src/client.ts +135 -36
  311. package/src/conformance.ts +16 -3
  312. package/src/csp.ts +194 -0
  313. package/src/dev-client.ts +349 -0
  314. package/src/dev-diagnostics.ts +60 -7
  315. package/src/dev-error.ts +49 -5
  316. package/src/dev-feed.ts +1140 -0
  317. package/src/dev-indicator.ts +113 -0
  318. package/src/dev-session.ts +729 -0
  319. package/src/dev-sourcemap.ts +281 -0
  320. package/src/dev-ssr-graph.ts +16 -5
  321. package/src/dev.ts +86 -43
  322. package/src/diagnostic-catalog.ts +243 -0
  323. package/src/diagnostic-prompt.ts +355 -0
  324. package/src/diagnostic.ts +63 -66
  325. package/src/draft.ts +4 -1
  326. package/src/fn.ts +19 -3
  327. package/src/fs.ts +13 -2
  328. package/src/index.ts +42 -10
  329. package/src/internal/cdn/cloudflare.ts +146 -0
  330. package/src/internal/cdn/core.ts +316 -0
  331. package/src/internal/cdn/fastly.ts +62 -0
  332. package/src/internal/cdn/vercel.ts +95 -0
  333. package/src/internal/code-unit-order.ts +5 -0
  334. package/src/internal/codegen.ts +161 -24
  335. package/src/internal/dev-csp.ts +176 -0
  336. package/src/internal/dev-reserved.ts +52 -0
  337. package/src/internal/html-spans.ts +50 -0
  338. package/src/internal/loading-runtime.ts +244 -0
  339. package/src/internal/matches-runtime.ts +62 -0
  340. package/src/internal/mount-shadow.ts +88 -0
  341. package/src/internal/node-builtins.ts +79 -0
  342. package/src/internal/output-guard.ts +701 -0
  343. package/src/internal/page-execution.ts +795 -185
  344. package/src/internal/parity.ts +150 -36
  345. package/src/internal/private-env.ts +334 -0
  346. package/src/internal/public-url.ts +22 -0
  347. package/src/internal/render-document.ts +114 -67
  348. package/src/internal/response-controls.ts +284 -0
  349. package/src/internal/runtime-contract.ts +10 -15
  350. package/src/internal/secret-scan.ts +638 -0
  351. package/src/internal/server-boundary.ts +6 -69
  352. package/src/internal/source-map.ts +256 -0
  353. package/src/internal/target-compat.ts +92 -0
  354. package/src/internal/timing-safe-equal.ts +13 -0
  355. package/src/internal/vitals-runtime.ts +53 -0
  356. package/src/internal/web-app.ts +262 -84
  357. package/src/internal/zone-graph.ts +593 -0
  358. package/src/isr.ts +325 -37
  359. package/src/llms-txt.ts +28 -4
  360. package/src/manifest.ts +682 -16
  361. package/src/module-graph.ts +53 -9
  362. package/src/navigation.ts +29 -6
  363. package/src/nonce.ts +104 -0
  364. package/src/plugins/css-modules.ts +5 -3
  365. package/src/plugins/kit.ts +24 -0
  366. package/src/plugins/svg.ts +135 -4
  367. package/src/plugins/vite-leak-guard.ts +529 -16
  368. package/src/plugins/vite-zone-guard.ts +263 -0
  369. package/src/plugins/zone-guard.ts +119 -0
  370. package/src/prerender.ts +49 -3
  371. package/src/public-dir.ts +69 -8
  372. package/src/pwa-manifest.ts +226 -0
  373. package/src/query.ts +4 -4
  374. package/src/render-seam.ts +46 -1
  375. package/src/route-manifest.ts +15 -3
  376. package/src/route-types.ts +264 -0
  377. package/src/router.ts +163 -63
  378. package/src/vitals.ts +44 -0
  379. package/src/vite.ts +257 -42
  380. package/src/zones.ts +430 -0
  381. package/dist/plugins/vite-server-only.d.ts +0 -49
  382. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  383. package/dist/plugins/vite-server-only.js +0 -57
  384. package/dist/plugins/vite-server-only.js.map +0 -1
  385. package/dist/server-only.d.ts +0 -21
  386. package/dist/server-only.d.ts.map +0 -1
  387. package/dist/server-only.js +0 -25
  388. package/dist/server-only.js.map +0 -1
  389. package/src/plugins/vite-server-only.ts +0 -75
  390. package/src/server-only.ts +0 -25
@@ -1,4 +1,9 @@
1
- import type { ResponseResult } from "@nifrajs/core/server"
1
+ import {
2
+ type BackendPlatformBinder,
3
+ NIFRA_BACKEND_BIND_PLATFORM,
4
+ NIFRA_PLATFORM_CLIENT_IP_DERIVED,
5
+ } from "@nifrajs/core/mount"
6
+ import { type Platform, type ResponseResult, status as statusResult } from "@nifrajs/core/server"
2
7
  import {
3
8
  type BoundaryRequestCtx,
4
9
  type BoundaryStates,
@@ -7,23 +12,29 @@ import {
7
12
  type StaticBoundaryCache,
8
13
  startDynamicBoundaries,
9
14
  } from "../boundary.ts"
15
+ import type { CspPolicy } from "../csp.ts"
10
16
  import { type CssLoadingMode, DEFAULT_CSS_LOADING, normalizeCssLoading } from "../css-contract.ts"
11
17
  import { defer, ndjsonStream, prepareDeferred } from "../deferred.ts"
12
18
  import { isDraftEnabled } from "../draft.ts"
19
+ import { routeTags } from "../isr.ts"
13
20
  import type {
14
21
  LayoutEntry,
15
22
  LoaderContext,
16
23
  Manifest,
17
24
  Meta,
18
25
  MetaArgs,
26
+ NotFoundEntry,
19
27
  RouteEntry,
20
28
  RouteModule,
29
+ ShouldRevalidate,
21
30
  } from "../manifest.ts"
22
- import type { RenderAdapter } from "../render-seam.ts"
31
+ import type { NonceResolver } from "../nonce.ts"
32
+ import type { MatchChain, RenderAdapter } from "../render-seam.ts"
23
33
  import {
24
34
  createMatcher,
25
35
  DATA_HEADER,
26
36
  NAV_FROM_HEADER,
37
+ REDIRECT_HEADER,
27
38
  RETAIN_HEADER,
28
39
  REVALIDATE_HEADER,
29
40
  STATUS_HEADER,
@@ -31,10 +42,10 @@ import {
31
42
  import { searchOf, searchOfChain } from "../search.ts"
32
43
  import { mergeHeads, resolveMeta } from "./head-merge.ts"
33
44
  import {
34
- actionResponse,
35
45
  EMPTY_RETAIN,
36
46
  type HeadersLike,
37
47
  isControlFlow,
48
+ isResponseResult,
38
49
  isStatusSignal,
39
50
  type LoadedLayoutModules,
40
51
  layoutErrorId,
@@ -50,18 +61,114 @@ import {
50
61
  withDuplicateInstanceHint,
51
62
  } from "./render-document.ts"
52
63
  import { urlPartsFor } from "./request-url.ts"
64
+ import {
65
+ ACTION_SCOPE,
66
+ type CoreResponseControls,
67
+ DATA_RESPONSE_HEADERS,
68
+ MIDDLEWARE_SCOPE,
69
+ PAGE_SCOPE,
70
+ PageResponseControls,
71
+ PRIVATE_NO_STORE,
72
+ PRIVATE_PAGE_HEADERS,
73
+ privateOutcome,
74
+ varyOnDataHeader,
75
+ } from "./response-controls.ts"
53
76
 
54
- /** Resolve the CSP nonce for one document request. Return `undefined` to omit nonce attributes. */
55
- export type NonceResolver<Env = unknown> = (ctx: {
56
- readonly request: Request
57
- readonly env: Env
58
- }) => string | undefined | Promise<string | undefined>
77
+ export type { NonceResolver } from "../nonce.ts"
59
78
 
60
79
  /** Structural context supplied by the core server to one page route handler. */
61
80
  export interface PageRouteContext<Env = unknown> {
62
81
  readonly params: Record<string, string>
63
82
  readonly req: Request
64
83
  readonly env: Env
84
+ /** The serving app's response controls; a loader's `ctx.set` queues its cookies through them. */
85
+ readonly set: CoreResponseControls
86
+ /** The visitor's IP as the serving app derived it; forwarded to a request-bound `ctx.api`. */
87
+ readonly clientIp?: string | undefined
88
+ readonly waitUntil?: (promise: Promise<unknown>) => void
89
+ }
90
+
91
+ /** The page slot of an `ssr = false` route with no `HydrateFallback`: a component that renders nothing. */
92
+ const EMPTY_LEAF = (): null => null
93
+
94
+ /** The request-scoping seam of an in-process `api` (see `inProcessClient`), if it carries one. */
95
+ function platformBinderOf(api: unknown): BackendPlatformBinder | undefined {
96
+ if ((typeof api !== "object" && typeof api !== "function") || api === null) return undefined
97
+ const bind = (api as { [NIFRA_BACKEND_BIND_PLATFORM]?: unknown })[NIFRA_BACKEND_BIND_PLATFORM]
98
+ return typeof bind === "function" ? (bind as BackendPlatformBinder) : undefined
99
+ }
100
+
101
+ /**
102
+ * The page request's platform identity for loader calls: `clientIp` (read lazily, so a backend that
103
+ * never reads `c.clientIp` never pays the socket lookup), `env` and `waitUntil` - and nothing else. The
104
+ * page request's headers (cookie, authorization) never travel; a loader passes them explicitly if it
105
+ * wants an authenticated call. The prototype marker tells the backend its `clientIp` is already derived,
106
+ * so its own trust declaration does not re-read forwarding headers the synthesized request does not
107
+ * carry; a spread copy drops both the getter and the marker, so it falls back to re-deriving.
108
+ */
109
+ class LoaderPlatform {
110
+ readonly env: unknown
111
+ readonly waitUntil: ((promise: Promise<unknown>) => void) | undefined
112
+ readonly #c: PageRouteContext<unknown>
113
+ constructor(c: PageRouteContext<unknown>) {
114
+ this.#c = c
115
+ this.env = c.env
116
+ this.waitUntil = c.waitUntil
117
+ }
118
+ get clientIp(): string | undefined {
119
+ return this.#c.clientIp
120
+ }
121
+ }
122
+ ;(LoaderPlatform.prototype as { [NIFRA_PLATFORM_CLIENT_IP_DERIVED]?: true })[
123
+ NIFRA_PLATFORM_CLIENT_IP_DERIVED
124
+ ] = true
125
+
126
+ /**
127
+ * A redirect answering a data request (a client navigation or form submit), as the client router reads
128
+ * one: fetch would follow a 3xx and hand the router another page's data, so the target rides
129
+ * `x-nifra-redirect` on a 204 that keeps the redirect's own headers. `undefined` for any other outcome
130
+ * or request.
131
+ */
132
+ function dataRedirect(req: Request, outcome: unknown): Response | ResponseResult | undefined {
133
+ if (req.headers.get(DATA_HEADER) === null) return undefined
134
+ if (isResponseResult(outcome)) {
135
+ const plain = outcome.plain
136
+ if (plain === undefined) return dataRedirect(req, outcome.toResponse())
137
+ if (plain.status < 300 || plain.status >= 400) return undefined
138
+ const { location, ...own } = plain.headers ?? {}
139
+ if (location === undefined) return undefined
140
+ return statusResult(204, undefined, {
141
+ headers: {
142
+ ...own,
143
+ [REDIRECT_HEADER]: appTarget(location, req.url),
144
+ ...DATA_RESPONSE_HEADERS,
145
+ },
146
+ })
147
+ }
148
+ if (!(outcome instanceof Response) || outcome.status < 300 || outcome.status >= 400) {
149
+ return undefined
150
+ }
151
+ const location = outcome.headers.get("location")
152
+ if (location === null) return undefined
153
+ const headers = new Headers(outcome.headers)
154
+ headers.delete("location")
155
+ headers.delete("content-length")
156
+ headers.set(REDIRECT_HEADER, appTarget(location, req.url))
157
+ for (const [name, value] of Object.entries(DATA_RESPONSE_HEADERS)) headers.set(name, value)
158
+ return new Response(null, { status: 204, headers })
159
+ }
160
+
161
+ /** A same-origin target as a path, which the client router can follow without loading a document. */
162
+ function appTarget(location: string, requestUrl: string): string {
163
+ let target: URL
164
+ try {
165
+ target = new URL(location, requestUrl)
166
+ } catch {
167
+ return location // the browser fails on it the same way when it loads it
168
+ }
169
+ return target.origin === new URL(requestUrl).origin
170
+ ? target.pathname + target.search + target.hash
171
+ : location
65
172
  }
66
173
 
67
174
  export interface PageExecutionOptions<Env = unknown> {
@@ -76,10 +183,16 @@ export interface PageExecutionOptions<Env = unknown> {
76
183
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
77
184
  readonly cssLoading?: CssLoadingMode
78
185
  readonly prerenderedPaths?: readonly string[]
186
+ /** Where pages fetch {@link prerenderedPaths} from when the list is too large to inline. */
187
+ readonly prerenderedListUrl?: string
79
188
  readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
80
189
  readonly staticBoundaryCache?: StaticBoundaryCache
81
190
  /** Per-request CSP nonce for framework-owned executable document scripts. */
82
191
  readonly nonce?: NonceResolver<Env>
192
+ /** A hash-based CSP policy: every document is rendered under it, carrying `nonce` only when needed. */
193
+ readonly csp?: CspPolicy
194
+ /** Set once a cache wrapper attaches; until then a route's freshness and tags stay off the response. */
195
+ readonly cacheChannel?: { readonly enabled: boolean }
83
196
  readonly onLoaderError?: (
84
197
  error: unknown,
85
198
  ctx: {
@@ -95,7 +208,34 @@ type PageResponse = Response | ResponseResult | RenderedPage
95
208
  export interface PageRequestExecutor<Env = unknown> {
96
209
  get(route: RouteEntry): (ctx: PageRouteContext<Env>) => Promise<PageResponse>
97
210
  post(route: RouteEntry): (ctx: PageRouteContext<Env>) => Promise<PageResponse>
98
- notFound(request: Request, env: Env, path?: string): Promise<PageResponse>
211
+ /** Answer a URL no route matched: the nearest nested `_404` for it, else the root one. */
212
+ fallback(ctx: PageRouteContext<Env>): Promise<PageResponse>
213
+ }
214
+
215
+ /** One URL pattern a nested `_404` answers, as the executor indexes it. */
216
+ interface NotFoundTarget {
217
+ readonly id: string
218
+ readonly page: NotFoundEntry
219
+ /** The pattern's param names in order; the index holds them by position. */
220
+ readonly names: readonly string[]
221
+ /** The page's layout chain as a route, so layouts, gates and boundaries run as they do for one. */
222
+ readonly route: RouteEntry
223
+ }
224
+
225
+ /** The browser's side of a client navigation: the page it is leaving and the layout slots it holds. */
226
+ type RetainContext = {
227
+ readonly requested: ReadonlySet<number>
228
+ readonly from: RouteEntry
229
+ readonly fromParams: Readonly<Record<string, string>>
230
+ readonly fromUrl: URL
231
+ readonly toUrl: URL
232
+ }
233
+
234
+ type LayoutRun = {
235
+ readonly modules: LoadedLayoutModules
236
+ readonly layoutData: readonly unknown[] | undefined
237
+ readonly retained: readonly number[]
238
+ readonly pending: Promise<unknown> | undefined
99
239
  }
100
240
 
101
241
  /**
@@ -111,14 +251,66 @@ export function createPageRequestExecutor<Env = unknown>(
111
251
  options: PageExecutionOptions<Env>,
112
252
  ): PageRequestExecutor<Env> {
113
253
  const { adapter, manifest, clientEntry, api } = options
254
+ // Bound once per render, so every loader, action and boundary in that render shares one view of the
255
+ // in-process client carrying the visitor's platform. An `api` without the seam passes through as-is.
256
+ const bindApi = platformBinderOf(api)
257
+ // One view per render, shared by every loader, action and boundary it runs.
258
+ const apiFor = (c: PageRouteContext<Env>): unknown =>
259
+ bindApi === undefined ? api : bindApi.call(api, new LoaderPlatform(c) as Platform)
114
260
  const cssLoading = normalizeCssLoading(options.cssLoading ?? DEFAULT_CSS_LOADING)
115
261
  const titleOption = options.title === undefined ? {} : { title: options.title }
262
+ const cspOption = options.csp === undefined ? {} : { csp: options.csp }
116
263
  const prerenderedSet = new Set(options.prerenderedPaths ?? [])
264
+ const prerenderedHandover =
265
+ options.prerenderedListUrl === undefined
266
+ ? { prerenderedPaths: options.prerenderedPaths ?? [] }
267
+ : { prerenderedListUrl: options.prerenderedListUrl }
117
268
  const matchManifestRoute = createMatcher(
118
269
  manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
119
270
  )
120
271
  const routeById = new Map(manifest.routes.map((route) => [route.id, route]))
121
272
 
273
+ // A nested `_404` answers an unmatched URL from inside the catch-all, never as a route of its own:
274
+ // a registered `/admin/*` would take URLs from a route the router only reaches by backtracking.
275
+ // Param names are indexed by position, so two directories that name one segment differently share
276
+ // the index; each target keeps its own names to read a match back.
277
+ const notFoundTargets: NotFoundTarget[] = []
278
+ const notFoundPatterns: Array<{ routeId: string; pattern: string }> = []
279
+ for (const [id, page] of Object.entries(manifest.notFounds ?? {})) {
280
+ for (const scope of page.scopes) {
281
+ const names: string[] = []
282
+ const pattern = scope.pattern.replace(
283
+ /([:*])([A-Za-z_][A-Za-z0-9_]*)/g,
284
+ (_match, sigil: string, name: string) => `${sigil}p${names.push(name) - 1}`,
285
+ )
286
+ notFoundPatterns.push({ routeId: String(notFoundTargets.length), pattern })
287
+ notFoundTargets.push({
288
+ id,
289
+ page,
290
+ names,
291
+ route: {
292
+ id,
293
+ pattern: scope.pattern,
294
+ layoutIds: page.layoutIds,
295
+ layoutParams: scope.layoutParams,
296
+ ...(page.middlewareIds === undefined
297
+ ? {}
298
+ : {
299
+ middlewareIds: page.middlewareIds,
300
+ ...(scope.middlewareParams === undefined
301
+ ? {}
302
+ : { middlewareParams: scope.middlewareParams }),
303
+ }),
304
+ errorIds: page.errorIds,
305
+ file: page.file,
306
+ load: page.load,
307
+ },
308
+ })
309
+ }
310
+ }
311
+ const matchNotFoundScope =
312
+ notFoundPatterns.length === 0 ? undefined : createMatcher(notFoundPatterns)
313
+
122
314
  const draftFlag = (req: Request): Promise<boolean> =>
123
315
  options.draftSecret === undefined
124
316
  ? Promise.resolve(false)
@@ -161,34 +353,49 @@ export function createPageRequestExecutor<Env = unknown>(
161
353
  return modules
162
354
  }
163
355
 
356
+ // No modules, data, or per-request state: reuse the settled result for page-only routes.
357
+ const emptyLayoutRun: LayoutRun = {
358
+ modules: [],
359
+ layoutData: undefined,
360
+ retained: [],
361
+ pending: undefined,
362
+ }
363
+
164
364
  /** Run layout loaders, awaiting gates before starting the page loader and retaining only safe data. */
165
365
  const runLayoutChain = async (
166
366
  route: RouteEntry,
167
367
  ctx: LoaderContext,
168
- retain: ReadonlySet<number> = new Set(),
169
- ): Promise<{
170
- readonly modules: LoadedLayoutModules
171
- readonly layoutData: readonly unknown[] | undefined
172
- readonly retained: readonly number[]
173
- readonly pending: Promise<unknown>
174
- }> => {
368
+ controls: PageResponseControls,
369
+ retain?: RetainContext,
370
+ ): Promise<LayoutRun> => {
371
+ if (route.layoutIds.length === 0) return emptyLayoutRun
175
372
  const modules = await loadLayoutModules(route)
176
373
  if (!modules.some((m) => m.loader !== undefined)) {
177
- return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() }
374
+ return { modules, layoutData: undefined, retained: [], pending: undefined }
178
375
  }
179
376
  const results: unknown[] = new Array(modules.length).fill(null)
180
377
  const retained: number[] = []
181
378
  const pending: Array<Promise<void>> = []
182
379
  for (let i = 0; i < modules.length; i++) {
183
- const loader = modules[i]?.loader
380
+ const layout = modules[i] as LoadedLayoutModules[number]
381
+ const loader = layout.loader
184
382
  if (loader === undefined) continue
185
- if (retain.has(i) && modules[i]?.gate !== true) {
186
- retained.push(i)
187
- continue
383
+ if (retain !== undefined && layout.gate !== true) {
384
+ let keep: boolean
385
+ try {
386
+ keep = keepsLayoutData(route, i, layout, ctx.params, retain)
387
+ } catch (err) {
388
+ throw tagLayoutError(err, route.layoutIds[i] as string)
389
+ }
390
+ if (keep) {
391
+ retained.push(i)
392
+ continue
393
+ }
188
394
  }
189
395
  const scoped: LoaderContext = {
190
396
  ...ctx,
191
397
  params: scopeParams(ctx.params, route.layoutParams?.[i]),
398
+ set: controls.scope(i),
192
399
  }
193
400
  if (modules[i]?.gate === true) {
194
401
  try {
@@ -200,23 +407,40 @@ export function createPageRequestExecutor<Env = unknown>(
200
407
  }
201
408
  const layoutId = route.layoutIds[i] as string
202
409
  const index = i
203
- pending.push(
204
- (async () => {
205
- try {
206
- results[index] = await loader(scoped)
207
- } catch (err) {
208
- throw tagLayoutError(err, layoutId)
209
- }
210
- })(),
211
- )
410
+ try {
411
+ const value = loader(scoped)
412
+ // Scalars settle synchronously. Objects keep await's intrinsic promise/thenable
413
+ // assimilation: property-presence and prototype probes are unsafe for proxies.
414
+ if (value !== null && (typeof value === "object" || typeof value === "function")) {
415
+ pending.push(
416
+ (async () => {
417
+ try {
418
+ results[index] = await value
419
+ } catch (err) {
420
+ throw tagLayoutError(err, layoutId)
421
+ }
422
+ })(),
423
+ )
424
+ } else results[index] = value
425
+ } catch (err) {
426
+ // Preserve a synchronous throw as a pending layout failure, just like the async lane.
427
+ pending.push(Promise.reject(tagLayoutError(err, layoutId)))
428
+ }
429
+ }
430
+ return {
431
+ modules,
432
+ layoutData: results,
433
+ retained,
434
+ pending:
435
+ pending.length === 0 ? undefined : pending.length === 1 ? pending[0] : Promise.all(pending),
212
436
  }
213
- return { modules, layoutData: results, retained, pending: Promise.all(pending) }
214
437
  }
215
438
 
216
439
  /** Run only authorization gates before an action; ordinary layout data runs after a native action. */
217
440
  const runLayoutGates = async (
218
441
  route: RouteEntry,
219
442
  ctx: LoaderContext,
443
+ controls: PageResponseControls,
220
444
  ): Promise<LoadedLayoutModules> => {
221
445
  const modules = await loadLayoutModules(route)
222
446
  for (let i = 0; i < modules.length; i++) {
@@ -226,6 +450,7 @@ export function createPageRequestExecutor<Env = unknown>(
226
450
  await mod.loader({
227
451
  ...ctx,
228
452
  params: scopeParams(ctx.params, route.layoutParams?.[i]),
453
+ set: controls.scope(i),
229
454
  })
230
455
  } catch (err) {
231
456
  throw tagLayoutError(err, route.layoutIds[i] as string)
@@ -234,6 +459,47 @@ export function createPageRequestExecutor<Env = unknown>(
234
459
  return modules
235
460
  }
236
461
 
462
+ /** Outermost first; a failure carries its directory so the nearest `_error` above it renders. */
463
+ const runMiddleware = async (
464
+ route: RouteEntry,
465
+ ctx: LoaderContext,
466
+ controls: PageResponseControls,
467
+ ): Promise<void> => {
468
+ const ids = route.middlewareIds ?? []
469
+ for (let i = 0; i < ids.length; i++) {
470
+ const id = ids[i] as string
471
+ const entry = manifest.middlewares?.[id] as LayoutEntry
472
+ const dirTag = `${id.slice(0, id.length - "_middleware".length)}_layout`
473
+ let outcome: unknown
474
+ try {
475
+ const middleware = (await entry.load()).default
476
+ // A `_layout.backend.ts` that exports no middleware is a layout data module, nothing more.
477
+ if (middleware === undefined) continue
478
+ if (typeof middleware !== "function") {
479
+ throw new Error(
480
+ `[nifra/web] "${entry.file}" exports a middleware that is not a function.`,
481
+ )
482
+ }
483
+ outcome = await middleware({
484
+ ...ctx,
485
+ params: scopeParams(ctx.params, route.middlewareParams?.[i]),
486
+ set: controls.scope(MIDDLEWARE_SCOPE + i),
487
+ })
488
+ } catch (err) {
489
+ throw tagLayoutError(err, dirTag)
490
+ }
491
+ if (outcome === undefined) continue
492
+ throw tagLayoutError(
493
+ isControlFlow(outcome)
494
+ ? outcome
495
+ : new Error(
496
+ `[nifra/web] the middleware in "${entry.file}" returned a value. Middleware returns nothing to let the request through, or a redirect(), a status such as notFound(), or a Response to answer it.`,
497
+ ),
498
+ dirTag,
499
+ )
500
+ }
501
+ }
502
+
237
503
  const retainedIndices = (header: string | null): ReadonlySet<number> => {
238
504
  if (header === null || header === "") return EMPTY_RETAIN
239
505
  const out = new Set<number>()
@@ -244,43 +510,61 @@ export function createPageRequestExecutor<Env = unknown>(
244
510
  return out
245
511
  }
246
512
 
247
- const validatedRetainedIndices = (
248
- req: Request,
249
- route: RouteEntry,
250
- params: Readonly<Record<string, string>>,
251
- ): ReadonlySet<number> => {
252
- if (req.headers.get(DATA_HEADER) === null) return EMPTY_RETAIN
513
+ /**
514
+ * The browser's side of a client navigation: the page it is leaving and the layout slots it asked
515
+ * to keep. Undefined unless this is a data request whose `from` is same-origin and routable.
516
+ */
517
+ const retainContextOf = (req: Request): RetainContext | undefined => {
518
+ if (req.headers.get(DATA_HEADER) === null) return undefined
253
519
  const requested = retainedIndices(req.headers.get(RETAIN_HEADER))
254
520
  const from = req.headers.get(NAV_FROM_HEADER)
255
- if (requested.size === 0 || from === null) return EMPTY_RETAIN
256
- let fromPath: string
521
+ if (requested.size === 0 || from === null) return undefined
522
+ let toUrl: URL
523
+ let fromUrl: URL
257
524
  try {
258
- const current = new URL(req.url)
259
- const source = new URL(from, current)
260
- if (source.origin !== current.origin) return EMPTY_RETAIN
261
- fromPath = source.pathname + source.search
525
+ toUrl = new URL(req.url)
526
+ fromUrl = new URL(from, toUrl)
262
527
  } catch {
263
- return EMPTY_RETAIN
528
+ return undefined
264
529
  }
265
- const previousMatch = matchManifestRoute(fromPath)
266
- if (previousMatch === null) return EMPTY_RETAIN
530
+ if (fromUrl.origin !== toUrl.origin) return undefined
531
+ const previousMatch = matchManifestRoute(fromUrl.pathname + fromUrl.search)
532
+ if (previousMatch === null) return undefined
267
533
  const previous = routeById.get(previousMatch.routeId)
268
- if (previous === undefined) return EMPTY_RETAIN
269
- const valid = new Set<number>()
270
- for (const index of requested) {
271
- if (route.layoutIds[index] !== previous.layoutIds[index]) continue
272
- const owned = route.layoutParams?.[index] ?? []
273
- const previouslyOwned = previous.layoutParams?.[index] ?? []
274
- if (
275
- owned.length !== previouslyOwned.length ||
276
- owned.some((name, i) => name !== previouslyOwned[i]) ||
277
- owned.some((name) => params[name] !== previousMatch.params[name])
278
- ) {
279
- continue
280
- }
281
- valid.add(index)
282
- }
283
- return valid
534
+ if (previous === undefined) return undefined
535
+ return { requested, from: previous, fromParams: previousMatch.params, fromUrl, toUrl }
536
+ }
537
+
538
+ /** A query change re-runs layout loaders by default: any can read it, and none declares keys. */
539
+ const keepsLayoutData = (
540
+ route: RouteEntry,
541
+ index: number,
542
+ layout: LoadedLayoutModules[number],
543
+ params: Readonly<Record<string, string>>,
544
+ retain: RetainContext,
545
+ ): boolean => {
546
+ if (!retain.requested.has(index)) return false
547
+ const previous = retain.from
548
+ if (route.layoutIds[index] !== previous.layoutIds[index]) return false
549
+ const owned = route.layoutParams?.[index] ?? []
550
+ const previouslyOwned = previous.layoutParams?.[index] ?? []
551
+ const defaultShouldRevalidate =
552
+ retain.fromUrl.search !== retain.toUrl.search ||
553
+ owned.length !== previouslyOwned.length ||
554
+ owned.some((name, i) => name !== previouslyOwned[i]) ||
555
+ owned.some((name) => params[name] !== retain.fromParams[name])
556
+ const decide = layout.shouldRevalidate
557
+ if (typeof decide !== "function") return !defaultShouldRevalidate
558
+ // Copies, so a hook that mutates its arguments cannot reach the request's params or the next
559
+ // layout's view of the navigation.
560
+ const answer = (decide as ShouldRevalidate)({
561
+ currentUrl: new URL(retain.fromUrl),
562
+ nextUrl: new URL(retain.toUrl),
563
+ currentParams: { ...retain.fromParams },
564
+ nextParams: { ...params },
565
+ defaultShouldRevalidate,
566
+ })
567
+ return answer === false
284
568
  }
285
569
 
286
570
  const loaderSearch = (searchSchema: RouteModule["searchSchema"], request: Request) =>
@@ -296,12 +580,25 @@ export function createPageRequestExecutor<Env = unknown>(
296
580
  urlPartsFor(request).search,
297
581
  )
298
582
 
583
+ // `ssr = false`: the slot holds `HydrateFallback`, which the browser hydrates before the page.
584
+ const serverLeafOf = (route: RouteEntry, mod: RouteModule): unknown => {
585
+ if (mod.ssr !== false) return mod.default
586
+ if (mod.hydrate === false) {
587
+ throw new Error(
588
+ `[nifra/web] route "${route.id}" sets both \`ssr = false\` and \`hydrate = false\`: the ` +
589
+ "server skips its component and no client would ever render it. Remove one of the two.",
590
+ )
591
+ }
592
+ return mod.HydrateFallback ?? EMPTY_LEAF
593
+ }
594
+
299
595
  const resolveChainAndHead = (
300
596
  layoutModules: LoadedLayoutModules,
301
597
  page: RouteModule,
302
598
  metaArgs: MetaArgs,
599
+ leaf: unknown = page.default,
303
600
  ): { chain: unknown[]; head: Meta } => {
304
- const chain = [...layoutModules.map((m) => m.default), page.default]
601
+ const chain = [...layoutModules.map((m) => m.default), leaf]
305
602
  const heads = [
306
603
  ...layoutModules.map((m) => resolveMeta(m.meta, metaArgs)),
307
604
  resolveMeta(page.meta, metaArgs),
@@ -309,6 +606,17 @@ export function createPageRequestExecutor<Env = unknown>(
309
606
  return { chain, head: mergeHeads(heads) }
310
607
  }
311
608
 
609
+ /** What `useMatches` reports for a chain: the layouts' ids and `handle`s, then the page's. */
610
+ const matchChainOf = (
611
+ layoutIds: readonly string[],
612
+ layoutModules: LoadedLayoutModules,
613
+ pageId: string,
614
+ page: { readonly handle?: unknown },
615
+ ): MatchChain => ({
616
+ ids: [...layoutIds, pageId],
617
+ handles: [...layoutModules.map((layout) => layout.handle), page.handle],
618
+ })
619
+
312
620
  const dirOfId = (id: string, suffix: string): string =>
313
621
  id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
314
622
 
@@ -345,6 +653,7 @@ export function createPageRequestExecutor<Env = unknown>(
345
653
  route: RouteEntry,
346
654
  errorId: string,
347
655
  err: unknown,
656
+ personalized: boolean,
348
657
  nonce?: string,
349
658
  ): Promise<PageResponse> => {
350
659
  const errDir = dirOfId(errorId, "_error")
@@ -359,15 +668,21 @@ export function createPageRequestExecutor<Env = unknown>(
359
668
  )
360
669
  const { default: errComp } = await (manifest.errors?.[errorId] as LayoutEntry).load()
361
670
  const e = err instanceof Error ? err : new Error(String(err))
671
+ // An error's text can carry a connection string or a query, so a production page gets neither.
672
+ const production = typeof process === "undefined" || process.env.NODE_ENV === "production"
362
673
  return renderPageResult({
363
674
  adapter,
364
675
  chain: [...layouts, errComp],
365
- data: { name: e.name, message: e.message },
676
+ data: production
677
+ ? { name: "Error", message: "Internal Server Error" }
678
+ : { name: e.name, message: e.message },
366
679
  clientEntry,
367
680
  routeId: errorId,
368
681
  status: 500,
369
682
  hydrate: false,
683
+ ...(personalized ? { headers: PRIVATE_PAGE_HEADERS } : {}),
370
684
  ...(nonce === undefined ? {} : { nonce }),
685
+ ...cspOption,
371
686
  ...titleOption,
372
687
  })
373
688
  }
@@ -388,6 +703,7 @@ export function createPageRequestExecutor<Env = unknown>(
388
703
  const loaded = (await page.load()) as {
389
704
  readonly default: unknown
390
705
  readonly searchSchema?: RouteModule["searchSchema"]
706
+ readonly handle?: unknown
391
707
  }
392
708
  return renderPageResult({
393
709
  adapter,
@@ -395,6 +711,7 @@ export function createPageRequestExecutor<Env = unknown>(
395
711
  data: null,
396
712
  clientEntry,
397
713
  routeId,
714
+ matchChain: { ids: [routeId], handles: [loaded.handle] },
398
715
  ...(path !== undefined
399
716
  ? {
400
717
  search: searchOf(
@@ -407,9 +724,10 @@ export function createPageRequestExecutor<Env = unknown>(
407
724
  status,
408
725
  ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
409
726
  ...(nonce === undefined ? {} : { nonce }),
727
+ ...cspOption,
410
728
  ...preloadOf(routeId),
411
729
  ...stylesOf(routeId),
412
- prerenderedPaths: options.prerenderedPaths ?? [],
730
+ ...prerenderedHandover,
413
731
  ...titleOption,
414
732
  })
415
733
  }
@@ -418,14 +736,29 @@ export function createPageRequestExecutor<Env = unknown>(
418
736
  req: Request,
419
737
  env: Env,
420
738
  signal: StatusSignal,
739
+ personalized: boolean,
421
740
  ): Promise<PageResponse> => {
422
741
  const { status, headers } = signal[STATUS_SIGNAL]
423
742
  if (req.headers.get(DATA_HEADER) !== null) {
743
+ // Same URL as the status page, so it is never storable: a cache keyed on the URL alone would
744
+ // otherwise answer a document request with this empty body.
424
745
  const responseHeaders = new Headers(headers)
425
746
  responseHeaders.set(STATUS_HEADER, String(status))
747
+ responseHeaders.set("cache-control", PRIVATE_NO_STORE)
748
+ responseHeaders.set("vary", varyOnDataHeader(responseHeaders.get("vary")))
426
749
  return new Response(null, { status, headers: responseHeaders })
427
750
  }
428
- return renderTerminalStatus(status, pathOf(req), headers, await resolveNonce(req, env))
751
+ const nonce = await resolveNonce(req, env)
752
+ if (headers === undefined && !personalized) {
753
+ return renderTerminalStatus(status, pathOf(req), undefined, nonce)
754
+ }
755
+ const documentHeaders = new Headers(headers)
756
+ if (headers !== undefined) {
757
+ documentHeaders.set("vary", varyOnDataHeader(documentHeaders.get("vary")))
758
+ }
759
+ // A cookie queued before the signal makes the status page specific to this visitor.
760
+ if (personalized) documentHeaders.set("cache-control", PRIVATE_NO_STORE)
761
+ return renderTerminalStatus(status, pathOf(req), documentHeaders, nonce)
429
762
  }
430
763
 
431
764
  const reportLoaderError = (
@@ -454,8 +787,10 @@ export function createPageRequestExecutor<Env = unknown>(
454
787
  layoutRetained: readonly number[],
455
788
  boundaryStates: BoundaryStates | undefined,
456
789
  assemblyCache: RenderAssemblyCache | undefined,
790
+ responseHeaders: Record<string, string> | undefined,
791
+ personalized: boolean,
457
792
  ): Promise<PageResponse> => {
458
- if (isStatusSignal(data)) return renderStatusSignal(req, env, data)
793
+ if (isStatusSignal(data)) return renderStatusSignal(req, env, data, personalized)
459
794
  if (isControlFlow(data)) return data
460
795
  if (req.headers.get(DATA_HEADER) !== null) {
461
796
  const pageSplit = prepareDeferred(data)
@@ -483,18 +818,29 @@ export function createPageRequestExecutor<Env = unknown>(
483
818
  retained: layoutRetained,
484
819
  ...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
485
820
  }
486
- if (allDeferred.length === 0) return Response.json(payload ?? null)
821
+ if (allDeferred.length === 0) {
822
+ return Response.json(payload ?? null, { headers: DATA_RESPONSE_HEADERS })
823
+ }
487
824
  return new Response(ndjsonStream(payload, allDeferred), {
488
- headers: { "content-type": "application/x-ndjson; charset=utf-8" },
825
+ headers: {
826
+ "content-type": "application/x-ndjson; charset=utf-8",
827
+ ...DATA_RESPONSE_HEADERS,
828
+ },
489
829
  })
490
830
  }
491
831
 
492
- const { chain, head } = resolveChainAndHead(layoutModules, mod, {
493
- data,
494
- params,
495
- origin: originOf(req),
496
- })
497
832
  const nonce = options.nonce === undefined ? undefined : await resolveNonce(req, env)
833
+ const { chain, head } = resolveChainAndHead(
834
+ layoutModules,
835
+ mod,
836
+ {
837
+ data,
838
+ params,
839
+ origin: originOf(req),
840
+ ...(nonce === undefined ? {} : { nonce }),
841
+ },
842
+ serverLeafOf(route, mod),
843
+ )
498
844
  try {
499
845
  return await renderPageResult({
500
846
  adapter,
@@ -506,27 +852,169 @@ export function createPageRequestExecutor<Env = unknown>(
506
852
  params,
507
853
  path: pathOf(req),
508
854
  search: searchChainOf(layoutModules, mod, req),
855
+ matchChain: matchChainOf(route.layoutIds, layoutModules, route.id, mod),
509
856
  hydrate: mod.hydrate !== false,
510
857
  ...(layoutData !== undefined ? { layoutData } : {}),
511
858
  ...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
512
859
  ...preloadOf(route.id),
513
860
  ...stylesOf(route.id),
514
- prerenderedPaths: options.prerenderedPaths ?? [],
515
- ...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
516
- ...(mod.revalidateTags !== undefined ? { revalidateTags: mod.revalidateTags } : {}),
861
+ ...prerenderedHandover,
862
+ ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
863
+ // A response that sets a cookie is one visitor's: it never advertises ISR freshness.
864
+ ...(mod.revalidate !== undefined && !personalized && options.cacheChannel?.enabled === true
865
+ ? { revalidate: mod.revalidate }
866
+ : {}),
867
+ ...(mod.revalidateTags !== undefined &&
868
+ !personalized &&
869
+ options.cacheChannel?.enabled === true
870
+ ? {
871
+ revalidateTags: routeTags(
872
+ mod.revalidateTags,
873
+ { params, url: new URL(req.url) },
874
+ route.id,
875
+ ),
876
+ }
877
+ : {}),
517
878
  ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
518
879
  ...(nonce === undefined ? {} : { nonce }),
880
+ ...cspOption,
519
881
  ...titleOption,
520
882
  ...(assemblyCache === undefined ? {} : { assemblyCache }),
521
883
  })
522
884
  } catch (err) {
523
- if (isStatusSignal(err)) return renderStatusSignal(req, env, err)
885
+ if (isStatusSignal(err)) return renderStatusSignal(req, env, err, personalized)
524
886
  if (isControlFlow(err)) throw err
525
887
  reportLoaderError(route, req, { ...params }, err)
526
888
  const errorId = boundaryFor(route, err)
527
889
  if (errorId === undefined) throw err
528
- return renderError(route, errorId, withDuplicateInstanceHint(err), nonce)
890
+ return renderError(route, errorId, withDuplicateInstanceHint(err), personalized, nonce)
891
+ }
892
+ }
893
+
894
+ /** What a failed loader answers with, once a status signal has been ruled out. */
895
+ const renderLoaderFailure = async (
896
+ route: RouteEntry,
897
+ req: Request,
898
+ env: Env,
899
+ params: Record<string, string>,
900
+ err: unknown,
901
+ personalized: boolean,
902
+ ): Promise<PageResponse> => {
903
+ if (isControlFlow(err)) throw err
904
+ reportLoaderError(route, req, params, err)
905
+ const errorId = boundaryFor(route, err)
906
+ if (errorId === undefined) throw err
907
+ if (req.headers.get(DATA_HEADER) !== null) {
908
+ return new Response("Internal Server Error", {
909
+ status: 500,
910
+ headers: { "content-type": "text/plain; charset=utf-8" },
911
+ })
529
912
  }
913
+ return renderError(
914
+ route,
915
+ errorId,
916
+ withDuplicateInstanceHint(err),
917
+ personalized,
918
+ options.nonce === undefined ? undefined : await resolveNonce(req, env),
919
+ )
920
+ }
921
+
922
+ /**
923
+ * Render a nested `_404` inside its layouts. `layoutModules` and `layoutData` are exactly the
924
+ * layouts at or above the page, their gates already passed.
925
+ */
926
+ const renderNestedNotFound = async (
927
+ req: Request,
928
+ env: Env,
929
+ id: string,
930
+ page: NotFoundEntry,
931
+ params: Readonly<Record<string, string>>,
932
+ layoutModules: LoadedLayoutModules,
933
+ layoutData: readonly unknown[] | undefined,
934
+ personalized: boolean,
935
+ signalHeaders?: HeadersLike,
936
+ ): Promise<PageResponse> => {
937
+ // The page can show what a layout loaded for this visitor, and a 404 is storable by default.
938
+ const isPrivate = personalized || layoutData !== undefined
939
+ if (req.headers.get(DATA_HEADER) !== null) {
940
+ // No status header: the page has no client chunk, so the navigation falls back to loading the
941
+ // URL as a document, which renders it here.
942
+ const headers = new Headers(signalHeaders)
943
+ headers.set("cache-control", PRIVATE_NO_STORE)
944
+ headers.set("vary", varyOnDataHeader(headers.get("vary")))
945
+ return new Response(null, { status: 404, headers })
946
+ }
947
+ const mod = await page.load()
948
+ const nonce = options.nonce === undefined ? undefined : await resolveNonce(req, env)
949
+ const { chain, head } = resolveChainAndHead(layoutModules, mod, {
950
+ data: null,
951
+ params,
952
+ origin: originOf(req),
953
+ ...(nonce === undefined ? {} : { nonce }),
954
+ })
955
+ let headers: Headers | undefined
956
+ if (signalHeaders !== undefined || isPrivate) {
957
+ headers = new Headers(signalHeaders)
958
+ if (signalHeaders !== undefined) headers.set("vary", varyOnDataHeader(headers.get("vary")))
959
+ if (isPrivate) headers.set("cache-control", PRIVATE_NO_STORE)
960
+ }
961
+ return renderPageResult({
962
+ adapter,
963
+ chain,
964
+ data: null,
965
+ head,
966
+ clientEntry,
967
+ routeId: id,
968
+ params,
969
+ path: pathOf(req),
970
+ search: searchChainOf(layoutModules, mod, req),
971
+ matchChain: matchChainOf(page.layoutIds, layoutModules, id, mod),
972
+ status: 404,
973
+ hydrate: false,
974
+ ...(layoutData !== undefined ? { layoutData } : {}),
975
+ ...stylesOf(id),
976
+ ...(headers === undefined ? {} : { headers }),
977
+ ...(nonce === undefined ? {} : { nonce }),
978
+ ...cspOption,
979
+ ...titleOption,
980
+ })
981
+ }
982
+
983
+ /** The nearest nested `_404` for the route's own `notFound()`, else `undefined` (root page). */
984
+ const renderRouteNotFound = async (
985
+ req: Request,
986
+ env: Env,
987
+ route: RouteEntry,
988
+ params: Readonly<Record<string, string>>,
989
+ signal: StatusSignal,
990
+ run: LayoutRun | undefined,
991
+ personalized: boolean,
992
+ ): Promise<PageResponse | undefined> => {
993
+ const id = route.notFoundIds?.at(-1)
994
+ if (id === undefined || run === undefined) return undefined
995
+ if (signal[STATUS_SIGNAL].status !== 404 || layoutErrorId(signal) !== undefined) {
996
+ return undefined
997
+ }
998
+ const page = manifest.notFounds?.[id]
999
+ if (page === undefined) return undefined
1000
+ try {
1001
+ await run.pending
1002
+ } catch {
1003
+ return undefined
1004
+ }
1005
+ // The page's layouts are the leading ones of the route's: its directory contains the route.
1006
+ const kept = page.layoutIds.length
1007
+ return renderNestedNotFound(
1008
+ req,
1009
+ env,
1010
+ id,
1011
+ page,
1012
+ params,
1013
+ run.modules.slice(0, kept),
1014
+ run.layoutData?.slice(0, kept),
1015
+ personalized,
1016
+ signal[STATUS_SIGNAL].headers,
1017
+ )
530
1018
  }
531
1019
 
532
1020
  const renderNotFound = async (
@@ -544,6 +1032,33 @@ export function createPageRequestExecutor<Env = unknown>(
544
1032
  )
545
1033
  }
546
1034
 
1035
+ /**
1036
+ * Run a page handler with the request's `ctx.set`, sealed once the handler settles - whatever it
1037
+ * answered with - so a write that arrives after the response was decided throws. A request that
1038
+ * queued a cookie answers with a response no shared cache may store, returned or thrown. A redirect
1039
+ * answering a data request, returned or thrown, reaches the client router intact.
1040
+ */
1041
+ const withResponseControls =
1042
+ (run: (c: PageRouteContext<Env>, controls: PageResponseControls) => Promise<PageResponse>) =>
1043
+ async (c: PageRouteContext<Env>): Promise<PageResponse> => {
1044
+ const controls = new PageResponseControls(c)
1045
+ try {
1046
+ const outcome = await run(c, controls)
1047
+ const answer = dataRedirect(c.req, outcome) ?? outcome
1048
+ return controls.personalized ? (privateOutcome(answer) as PageResponse) : answer
1049
+ } catch (err) {
1050
+ if (!isControlFlow(err)) throw err
1051
+ const answer = dataRedirect(c.req, err)
1052
+ // Returned rather than rethrown: the serving app sends a thrown `Response` exactly as it is,
1053
+ // without the cookies the request queued.
1054
+ if (controls.personalized) return privateOutcome(answer ?? err) as PageResponse
1055
+ if (answer !== undefined) return answer
1056
+ throw err
1057
+ } finally {
1058
+ controls.seal()
1059
+ }
1060
+ }
1061
+
547
1062
  return {
548
1063
  get: (route) => {
549
1064
  const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
@@ -568,32 +1083,35 @@ export function createPageRequestExecutor<Env = unknown>(
568
1083
  return assemblySlot
569
1084
  }
570
1085
 
571
- return async (c: PageRouteContext<Env>) => {
1086
+ return withResponseControls(async (c, controls) => {
572
1087
  if (is404Fallback && !prerenderedSet.has(urlPartsFor(c.req).pathname)) {
573
1088
  return renderNotFound(c.req, c.env, pathOf(c.req))
574
1089
  }
575
1090
  const mod = await route.load()
576
1091
  const draft = await draftFlag(c.req)
1092
+ // Unpublished content is the editor's alone: whatever cache-control a loader sets, no cache keeps it.
1093
+ if (draft) controls.personalize()
577
1094
  let data: unknown
578
1095
  let layoutData: readonly unknown[] | undefined
579
1096
  let boundaryStates: BoundaryStates | undefined
580
1097
  let layoutModules: LoadedLayoutModules | undefined
581
1098
  let layoutRetained: readonly number[] = []
1099
+ let responseHeaders: Record<string, string> | undefined
1100
+ const requestApi = apiFor(c)
1101
+ let run: LayoutRun | undefined
582
1102
  try {
583
1103
  const ctx: LoaderContext = {
584
1104
  params: c.params,
585
1105
  request: c.req,
586
1106
  req: c.req,
587
- api,
1107
+ api: requestApi,
588
1108
  env: c.env,
589
1109
  draft,
590
1110
  search: loaderSearch(mod.searchSchema, c.req),
1111
+ set: controls.scope(PAGE_SCOPE),
591
1112
  }
592
- const run = await runLayoutChain(
593
- route,
594
- ctx,
595
- validatedRetainedIndices(c.req, route, c.params),
596
- )
1113
+ if (route.middlewareIds !== undefined) await runMiddleware(route, ctx, controls)
1114
+ run = await runLayoutChain(route, ctx, controls, retainContextOf(c.req))
597
1115
  layoutModules = run.modules
598
1116
  layoutRetained = run.retained
599
1117
  const boundaryDefinitions = [
@@ -608,7 +1126,7 @@ export function createPageRequestExecutor<Env = unknown>(
608
1126
  : startDynamicBoundaries(boundaryDefinitions, {
609
1127
  request: c.req,
610
1128
  params: c.params,
611
- api,
1129
+ api: requestApi,
612
1130
  env: c.env,
613
1131
  draft,
614
1132
  search: effectiveSearch,
@@ -616,17 +1134,19 @@ export function createPageRequestExecutor<Env = unknown>(
616
1134
  } satisfies BoundaryRequestCtx)
617
1135
  const staticBoundaryPromise =
618
1136
  boundaryDefinitions.length === 0
619
- ? Promise.resolve(undefined)
1137
+ ? undefined
620
1138
  : resolveStaticBoundaries(
621
1139
  boundaryDefinitions,
622
- { phase: "build", origin: originOf(c.req) },
1140
+ { phase: "build" },
623
1141
  options.staticBoundaryCache,
624
1142
  )
625
- const [pageData, , staticBoundaries] = await Promise.all([
626
- mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
627
- run.pending,
628
- staticBoundaryPromise,
629
- ])
1143
+ const pageResult = mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null
1144
+ const [pageData, , staticBoundaries] =
1145
+ run.pending === undefined && staticBoundaryPromise === undefined
1146
+ ? ([await pageResult, undefined, undefined] as const)
1147
+ : staticBoundaryPromise === undefined
1148
+ ? await Promise.all([pageResult, run.pending])
1149
+ : await Promise.all([pageResult, run.pending, staticBoundaryPromise])
630
1150
  data = pageData
631
1151
  layoutData = run.layoutData
632
1152
  if (boundaryBatch !== undefined) {
@@ -640,24 +1160,36 @@ export function createPageRequestExecutor<Env = unknown>(
640
1160
  } else if (staticBoundaries !== undefined) {
641
1161
  boundaryStates = staticBoundaries
642
1162
  }
1163
+ // Inside the loader `try`: a header the response cannot carry is a loader error, rendered
1164
+ // by the same boundary as one.
1165
+ responseHeaders = controls.documentHeaders()
643
1166
  } catch (err) {
644
- if (isStatusSignal(err)) return renderStatusSignal(c.req, c.env, err)
645
- if (isControlFlow(err)) throw err
646
- reportLoaderError(route, c.req, c.params, err)
647
- const errorId = boundaryFor(route, err)
648
- if (errorId === undefined) throw err
649
- if (c.req.headers.get(DATA_HEADER) !== null) {
650
- return new Response("Internal Server Error", {
651
- status: 500,
652
- headers: { "content-type": "text/plain; charset=utf-8" },
653
- })
1167
+ if (isStatusSignal(err)) {
1168
+ return (
1169
+ (await renderRouteNotFound(
1170
+ c.req,
1171
+ c.env,
1172
+ route,
1173
+ c.params,
1174
+ err,
1175
+ run,
1176
+ controls.personalized,
1177
+ )) ?? renderStatusSignal(c.req, c.env, err, controls.personalized)
1178
+ )
654
1179
  }
655
- return renderError(
1180
+ return renderLoaderFailure(route, c.req, c.env, c.params, err, controls.personalized)
1181
+ }
1182
+ if (isStatusSignal(data)) {
1183
+ const nested = await renderRouteNotFound(
1184
+ c.req,
1185
+ c.env,
656
1186
  route,
657
- errorId,
658
- withDuplicateInstanceHint(err),
659
- options.nonce === undefined ? undefined : await resolveNonce(c.req, c.env),
1187
+ c.params,
1188
+ data,
1189
+ run,
1190
+ controls.personalized,
660
1191
  )
1192
+ if (nested !== undefined) return nested
661
1193
  }
662
1194
  return handleDataOrDocument(
663
1195
  c.req,
@@ -671,91 +1203,169 @@ export function createPageRequestExecutor<Env = unknown>(
671
1203
  layoutRetained,
672
1204
  boundaryStates,
673
1205
  layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules),
1206
+ responseHeaders,
1207
+ controls.personalized,
674
1208
  )
675
- }
1209
+ })
676
1210
  },
677
1211
 
678
- post: (route) => async (c) => {
679
- const mod = await route.load()
680
- const draft = await draftFlag(c.req)
681
- if (mod.action === undefined) {
682
- return new Response("Method Not Allowed", {
683
- status: 405,
684
- headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
1212
+ post: (route) =>
1213
+ withResponseControls(async (c, controls) => {
1214
+ const mod = await route.load()
1215
+ const draft = await draftFlag(c.req)
1216
+ if (draft) controls.personalize()
1217
+ if (mod.action === undefined) {
1218
+ return new Response("Method Not Allowed", {
1219
+ status: 405,
1220
+ headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
1221
+ })
1222
+ }
1223
+ const requestApi = apiFor(c)
1224
+ const actionContext: LoaderContext = {
1225
+ params: c.params,
1226
+ request: c.req,
1227
+ req: c.req,
1228
+ api: requestApi,
1229
+ env: c.env,
1230
+ draft,
1231
+ search: loaderSearch(mod.searchSchema, c.req),
1232
+ set: controls.scope(ACTION_SCOPE),
1233
+ }
1234
+ const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
1235
+ let layoutModules: LoadedLayoutModules
1236
+ let result: unknown
1237
+ try {
1238
+ if (route.middlewareIds !== undefined) await runMiddleware(route, actionContext, controls)
1239
+ layoutModules = await runLayoutGates(route, actionContext, controls)
1240
+ result = await mod.action(actionContext)
1241
+ } catch (err) {
1242
+ if (isControlFlow(err)) return err
1243
+ throw err
1244
+ }
1245
+ const isRevalidate =
1246
+ result !== null && typeof result === "object" && "__nifraRevalidate" in result
1247
+ const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
1248
+ const revalidateHeader: Record<string, string> = isRevalidate
1249
+ ? {
1250
+ [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(
1251
+ ",",
1252
+ ),
1253
+ }
1254
+ : {}
1255
+ if (isControlFlow(actionResult)) return actionResult
1256
+ if (isDataRequest) {
1257
+ // Headers shape the document only, but a bad one fails here too, so a navigation and a full
1258
+ // page load agree on whether the action's response is valid.
1259
+ controls.commit()
1260
+ const { forClient, deferred } = prepareDeferred(actionResult)
1261
+ if (deferred.length === 0) {
1262
+ return Response.json(actionResult ?? null, {
1263
+ headers: { ...DATA_RESPONSE_HEADERS, ...revalidateHeader },
1264
+ })
1265
+ }
1266
+ return new Response(ndjsonStream(forClient, deferred), {
1267
+ headers: {
1268
+ "content-type": "application/x-ndjson; charset=utf-8",
1269
+ ...DATA_RESPONSE_HEADERS,
1270
+ ...revalidateHeader,
1271
+ },
1272
+ })
1273
+ }
1274
+ const search = searchChainOf(layoutModules, mod, c.req)
1275
+ const data = mod.loader
1276
+ ? await mod.loader({
1277
+ params: c.params,
1278
+ request: c.req,
1279
+ req: c.req,
1280
+ api: requestApi,
1281
+ env: c.env,
1282
+ draft,
1283
+ search,
1284
+ set: controls.scope(PAGE_SCOPE),
1285
+ })
1286
+ : null
1287
+ const responseHeaders = controls.documentHeaders()
1288
+ const nonce = options.nonce === undefined ? undefined : await resolveNonce(c.req, c.env)
1289
+ const { chain, head } = resolveChainAndHead(
1290
+ layoutModules,
1291
+ mod,
1292
+ {
1293
+ data,
1294
+ params: c.params,
1295
+ origin: originOf(c.req),
1296
+ ...(nonce === undefined ? {} : { nonce }),
1297
+ },
1298
+ serverLeafOf(route, mod),
1299
+ )
1300
+ return renderPageResult({
1301
+ adapter,
1302
+ chain,
1303
+ data,
1304
+ actionData: actionResult,
1305
+ head,
1306
+ clientEntry,
1307
+ routeId: route.id,
1308
+ params: c.params,
1309
+ path: pathOf(c.req),
1310
+ search,
1311
+ matchChain: matchChainOf(route.layoutIds, layoutModules, route.id, mod),
1312
+ hydrate: mod.hydrate !== false,
1313
+ ...preloadOf(route.id),
1314
+ ...stylesOf(route.id),
1315
+ ...prerenderedHandover,
1316
+ ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
1317
+ ...(nonce === undefined ? {} : { nonce }),
1318
+ ...cspOption,
1319
+ ...titleOption,
685
1320
  })
1321
+ }),
1322
+
1323
+ fallback: withResponseControls(async (c, controls) => {
1324
+ const match = matchNotFoundScope?.(pathOf(c.req)) ?? null
1325
+ const target = match === null ? undefined : notFoundTargets[Number(match.routeId)]
1326
+ if (match === null || target === undefined) {
1327
+ return renderNotFound(c.req, c.env, pathOf(c.req))
686
1328
  }
687
- const actionContext: LoaderContext = {
688
- params: c.params,
689
- request: c.req,
690
- req: c.req,
691
- api,
692
- env: c.env,
693
- draft,
694
- search: loaderSearch(mod.searchSchema, c.req),
1329
+ const params: Record<string, string> = {}
1330
+ for (let i = 0; i < target.names.length; i++) {
1331
+ params[target.names[i] as string] = match.params[`p${i}`] as string
695
1332
  }
696
- const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
697
- let layoutModules: LoadedLayoutModules
698
- let result: unknown
1333
+ const mod = await target.page.load()
1334
+ const draft = await draftFlag(c.req)
1335
+ if (draft) controls.personalize()
1336
+ let run: LayoutRun
699
1337
  try {
700
- layoutModules = await runLayoutGates(route, actionContext)
701
- result = await mod.action(actionContext)
1338
+ const ctx: LoaderContext = {
1339
+ params,
1340
+ request: c.req,
1341
+ req: c.req,
1342
+ api: apiFor(c),
1343
+ env: c.env,
1344
+ draft,
1345
+ search: loaderSearch(mod.searchSchema, c.req),
1346
+ set: controls.scope(PAGE_SCOPE),
1347
+ }
1348
+ if (target.route.middlewareIds !== undefined) {
1349
+ await runMiddleware(target.route, ctx, controls)
1350
+ }
1351
+ run = await runLayoutChain(target.route, ctx, controls)
1352
+ await run.pending
702
1353
  } catch (err) {
703
- if (isControlFlow(err)) return actionResponse(err, isDataRequest)
704
- throw err
705
- }
706
- const isRevalidate =
707
- result !== null && typeof result === "object" && "__nifraRevalidate" in result
708
- const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
709
- const revalidateHeader: Record<string, string> = isRevalidate
710
- ? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
711
- : {}
712
- if (isControlFlow(actionResult)) return actionResponse(actionResult, isDataRequest)
713
- if (isDataRequest) {
714
- const { forClient, deferred } = prepareDeferred(actionResult)
715
- if (deferred.length === 0)
716
- return Response.json(actionResult ?? null, { headers: revalidateHeader })
717
- return new Response(ndjsonStream(forClient, deferred), {
718
- headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
719
- })
1354
+ if (isStatusSignal(err)) {
1355
+ return renderStatusSignal(c.req, c.env, err, controls.personalized)
1356
+ }
1357
+ return renderLoaderFailure(target.route, c.req, c.env, params, err, controls.personalized)
720
1358
  }
721
- const search = searchChainOf(layoutModules, mod, c.req)
722
- const data = mod.loader
723
- ? await mod.loader({
724
- params: c.params,
725
- request: c.req,
726
- req: c.req,
727
- api,
728
- env: c.env,
729
- draft,
730
- search,
731
- })
732
- : null
733
- const { chain, head } = resolveChainAndHead(layoutModules, mod, {
734
- data,
735
- params: c.params,
736
- origin: originOf(c.req),
737
- })
738
- const nonce = options.nonce === undefined ? undefined : await resolveNonce(c.req, c.env)
739
- return renderPageResult({
740
- adapter,
741
- chain,
742
- data,
743
- actionData: actionResult,
744
- head,
745
- clientEntry,
746
- routeId: route.id,
747
- params: c.params,
748
- path: pathOf(c.req),
749
- search,
750
- hydrate: mod.hydrate !== false,
751
- ...preloadOf(route.id),
752
- ...stylesOf(route.id),
753
- prerenderedPaths: options.prerenderedPaths ?? [],
754
- ...(nonce === undefined ? {} : { nonce }),
755
- ...titleOption,
756
- })
757
- },
758
-
759
- notFound: renderNotFound,
1359
+ return renderNestedNotFound(
1360
+ c.req,
1361
+ c.env,
1362
+ target.id,
1363
+ target.page,
1364
+ params,
1365
+ run.modules,
1366
+ run.layoutData,
1367
+ controls.personalized,
1368
+ )
1369
+ }),
760
1370
  }
761
1371
  }