@nifrajs/web 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (407) 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 +35 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +89 -35
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +18 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +97 -17
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +72 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +411 -98
  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 +21 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +226 -15
  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/css-contract.d.ts +21 -0
  40. package/dist/css-contract.d.ts.map +1 -0
  41. package/dist/css-contract.js +28 -0
  42. package/dist/css-contract.js.map +1 -0
  43. package/dist/dev-client.d.ts +76 -0
  44. package/dist/dev-client.d.ts.map +1 -0
  45. package/dist/dev-client.js +280 -0
  46. package/dist/dev-client.js.map +1 -0
  47. package/dist/dev-diagnostics.d.ts +26 -2
  48. package/dist/dev-diagnostics.d.ts.map +1 -1
  49. package/dist/dev-diagnostics.js +38 -7
  50. package/dist/dev-diagnostics.js.map +1 -1
  51. package/dist/dev-error.d.ts +4 -13
  52. package/dist/dev-error.d.ts.map +1 -1
  53. package/dist/dev-error.js +46 -4
  54. package/dist/dev-error.js.map +1 -1
  55. package/dist/dev-feed.d.ts +298 -0
  56. package/dist/dev-feed.d.ts.map +1 -0
  57. package/dist/dev-feed.js +797 -0
  58. package/dist/dev-feed.js.map +1 -0
  59. package/dist/dev-indicator.d.ts +38 -0
  60. package/dist/dev-indicator.d.ts.map +1 -0
  61. package/dist/dev-indicator.js +93 -0
  62. package/dist/dev-indicator.js.map +1 -0
  63. package/dist/dev-session.d.ts +77 -0
  64. package/dist/dev-session.d.ts.map +1 -0
  65. package/dist/dev-session.js +580 -0
  66. package/dist/dev-session.js.map +1 -0
  67. package/dist/dev-sourcemap.d.ts +27 -0
  68. package/dist/dev-sourcemap.d.ts.map +1 -0
  69. package/dist/dev-sourcemap.js +235 -0
  70. package/dist/dev-sourcemap.js.map +1 -0
  71. package/dist/dev-ssr-graph.d.ts.map +1 -1
  72. package/dist/dev-ssr-graph.js +13 -5
  73. package/dist/dev-ssr-graph.js.map +1 -1
  74. package/dist/dev.d.ts +15 -2
  75. package/dist/dev.d.ts.map +1 -1
  76. package/dist/dev.js +69 -44
  77. package/dist/dev.js.map +1 -1
  78. package/dist/diagnostic-catalog.d.ts +35 -0
  79. package/dist/diagnostic-catalog.d.ts.map +1 -0
  80. package/dist/diagnostic-catalog.js +203 -0
  81. package/dist/diagnostic-catalog.js.map +1 -0
  82. package/dist/diagnostic-prompt.d.ts +49 -0
  83. package/dist/diagnostic-prompt.d.ts.map +1 -0
  84. package/dist/diagnostic-prompt.js +303 -0
  85. package/dist/diagnostic-prompt.js.map +1 -0
  86. package/dist/diagnostic.d.ts +17 -23
  87. package/dist/diagnostic.d.ts.map +1 -1
  88. package/dist/diagnostic.js +31 -39
  89. package/dist/diagnostic.js.map +1 -1
  90. package/dist/draft.d.ts.map +1 -1
  91. package/dist/draft.js +4 -1
  92. package/dist/draft.js.map +1 -1
  93. package/dist/fn.d.ts +5 -0
  94. package/dist/fn.d.ts.map +1 -1
  95. package/dist/fn.js +13 -3
  96. package/dist/fn.js.map +1 -1
  97. package/dist/fs.d.ts.map +1 -1
  98. package/dist/fs.js +4 -2
  99. package/dist/fs.js.map +1 -1
  100. package/dist/index.d.ts +15 -15
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +13 -12
  103. package/dist/index.js.map +1 -1
  104. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  105. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  106. package/dist/internal/cdn/cloudflare.js +94 -0
  107. package/dist/internal/cdn/cloudflare.js.map +1 -0
  108. package/dist/internal/cdn/core.d.ts +91 -0
  109. package/dist/internal/cdn/core.d.ts.map +1 -0
  110. package/dist/internal/cdn/core.js +220 -0
  111. package/dist/internal/cdn/core.js.map +1 -0
  112. package/dist/internal/cdn/fastly.d.ts +17 -0
  113. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  114. package/dist/internal/cdn/fastly.js +43 -0
  115. package/dist/internal/cdn/fastly.js.map +1 -0
  116. package/dist/internal/cdn/vercel.d.ts +31 -0
  117. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  118. package/dist/internal/cdn/vercel.js +56 -0
  119. package/dist/internal/cdn/vercel.js.map +1 -0
  120. package/dist/internal/code-unit-order.d.ts +4 -0
  121. package/dist/internal/code-unit-order.d.ts.map +1 -0
  122. package/dist/internal/code-unit-order.js +6 -0
  123. package/dist/internal/code-unit-order.js.map +1 -0
  124. package/dist/internal/codegen.d.ts +3 -0
  125. package/dist/internal/codegen.d.ts.map +1 -1
  126. package/dist/internal/codegen.js +151 -30
  127. package/dist/internal/codegen.js.map +1 -1
  128. package/dist/internal/dev-csp.d.ts +24 -0
  129. package/dist/internal/dev-csp.d.ts.map +1 -0
  130. package/dist/internal/dev-csp.js +160 -0
  131. package/dist/internal/dev-csp.js.map +1 -0
  132. package/dist/internal/dev-reserved.d.ts +25 -0
  133. package/dist/internal/dev-reserved.d.ts.map +1 -0
  134. package/dist/internal/dev-reserved.js +39 -0
  135. package/dist/internal/dev-reserved.js.map +1 -0
  136. package/dist/internal/html-spans.d.ts +10 -0
  137. package/dist/internal/html-spans.d.ts.map +1 -0
  138. package/dist/internal/html-spans.js +46 -0
  139. package/dist/internal/html-spans.js.map +1 -0
  140. package/dist/internal/loading-runtime.d.ts +44 -0
  141. package/dist/internal/loading-runtime.d.ts.map +1 -0
  142. package/dist/internal/loading-runtime.js +206 -0
  143. package/dist/internal/loading-runtime.js.map +1 -0
  144. package/dist/internal/matches-runtime.d.ts +8 -0
  145. package/dist/internal/matches-runtime.d.ts.map +1 -0
  146. package/dist/internal/matches-runtime.js +61 -0
  147. package/dist/internal/matches-runtime.js.map +1 -0
  148. package/dist/internal/mount-shadow.d.ts +36 -0
  149. package/dist/internal/mount-shadow.d.ts.map +1 -0
  150. package/dist/internal/mount-shadow.js +65 -0
  151. package/dist/internal/mount-shadow.js.map +1 -0
  152. package/dist/internal/node-builtins.d.ts +3 -0
  153. package/dist/internal/node-builtins.d.ts.map +1 -0
  154. package/dist/internal/node-builtins.js +79 -0
  155. package/dist/internal/node-builtins.js.map +1 -0
  156. package/dist/internal/output-guard.d.ts +65 -0
  157. package/dist/internal/output-guard.d.ts.map +1 -0
  158. package/dist/internal/output-guard.js +630 -0
  159. package/dist/internal/output-guard.js.map +1 -0
  160. package/dist/internal/page-execution.d.ts +69 -0
  161. package/dist/internal/page-execution.d.ts.map +1 -0
  162. package/dist/internal/page-execution.js +1021 -0
  163. package/dist/internal/page-execution.js.map +1 -0
  164. package/dist/internal/parity.d.ts +21 -0
  165. package/dist/internal/parity.d.ts.map +1 -1
  166. package/dist/internal/parity.js +104 -22
  167. package/dist/internal/parity.js.map +1 -1
  168. package/dist/internal/private-env.d.ts +12 -0
  169. package/dist/internal/private-env.d.ts.map +1 -0
  170. package/dist/internal/private-env.js +335 -0
  171. package/dist/internal/private-env.js.map +1 -0
  172. package/dist/internal/public-url.d.ts +3 -0
  173. package/dist/internal/public-url.d.ts.map +1 -0
  174. package/dist/internal/public-url.js +18 -0
  175. package/dist/internal/public-url.js.map +1 -0
  176. package/dist/internal/render-document.d.ts +37 -21
  177. package/dist/internal/render-document.d.ts.map +1 -1
  178. package/dist/internal/render-document.js +104 -65
  179. package/dist/internal/render-document.js.map +1 -1
  180. package/dist/internal/request-url.d.ts +3 -0
  181. package/dist/internal/request-url.d.ts.map +1 -0
  182. package/dist/internal/request-url.js +14 -0
  183. package/dist/internal/request-url.js.map +1 -0
  184. package/dist/internal/response-controls.d.ts +75 -0
  185. package/dist/internal/response-controls.d.ts.map +1 -0
  186. package/dist/internal/response-controls.js +256 -0
  187. package/dist/internal/response-controls.js.map +1 -0
  188. package/dist/internal/runtime-contract.d.ts +11 -16
  189. package/dist/internal/runtime-contract.d.ts.map +1 -1
  190. package/dist/internal/runtime-contract.js.map +1 -1
  191. package/dist/internal/secret-scan.d.ts +104 -0
  192. package/dist/internal/secret-scan.d.ts.map +1 -0
  193. package/dist/internal/secret-scan.js +485 -0
  194. package/dist/internal/secret-scan.js.map +1 -0
  195. package/dist/internal/server-boundary.d.ts +6 -29
  196. package/dist/internal/server-boundary.d.ts.map +1 -1
  197. package/dist/internal/server-boundary.js +6 -66
  198. package/dist/internal/server-boundary.js.map +1 -1
  199. package/dist/internal/source-map.d.ts +47 -0
  200. package/dist/internal/source-map.d.ts.map +1 -0
  201. package/dist/internal/source-map.js +225 -0
  202. package/dist/internal/source-map.js.map +1 -0
  203. package/dist/internal/target-compat.d.ts +27 -0
  204. package/dist/internal/target-compat.d.ts.map +1 -0
  205. package/dist/internal/target-compat.js +77 -0
  206. package/dist/internal/target-compat.js.map +1 -0
  207. package/dist/internal/timing-safe-equal.d.ts +6 -0
  208. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  209. package/dist/internal/timing-safe-equal.js +10 -0
  210. package/dist/internal/timing-safe-equal.js.map +1 -1
  211. package/dist/internal/vitals-runtime.d.ts +18 -0
  212. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  213. package/dist/internal/vitals-runtime.js +38 -0
  214. package/dist/internal/vitals-runtime.js.map +1 -0
  215. package/dist/internal/web-app.d.ts +56 -8
  216. package/dist/internal/web-app.d.ts.map +1 -1
  217. package/dist/internal/web-app.js +215 -772
  218. package/dist/internal/web-app.js.map +1 -1
  219. package/dist/internal/zone-graph.d.ts +89 -0
  220. package/dist/internal/zone-graph.d.ts.map +1 -0
  221. package/dist/internal/zone-graph.js +492 -0
  222. package/dist/internal/zone-graph.js.map +1 -0
  223. package/dist/isr.d.ts +83 -6
  224. package/dist/isr.d.ts.map +1 -1
  225. package/dist/isr.js +301 -39
  226. package/dist/isr.js.map +1 -1
  227. package/dist/llms-txt.d.ts +3 -0
  228. package/dist/llms-txt.d.ts.map +1 -1
  229. package/dist/llms-txt.js +37 -9
  230. package/dist/llms-txt.js.map +1 -1
  231. package/dist/manifest.d.ts +221 -14
  232. package/dist/manifest.d.ts.map +1 -1
  233. package/dist/manifest.js +386 -9
  234. package/dist/manifest.js.map +1 -1
  235. package/dist/module-graph.d.ts +26 -5
  236. package/dist/module-graph.d.ts.map +1 -1
  237. package/dist/module-graph.js +17 -7
  238. package/dist/module-graph.js.map +1 -1
  239. package/dist/navigation.d.ts +17 -3
  240. package/dist/navigation.d.ts.map +1 -1
  241. package/dist/navigation.js +11 -5
  242. package/dist/navigation.js.map +1 -1
  243. package/dist/nonce.d.ts +47 -0
  244. package/dist/nonce.d.ts.map +1 -0
  245. package/dist/nonce.js +63 -0
  246. package/dist/nonce.js.map +1 -0
  247. package/dist/plugins/css-modules.d.ts.map +1 -1
  248. package/dist/plugins/css-modules.js +4 -3
  249. package/dist/plugins/css-modules.js.map +1 -1
  250. package/dist/plugins/kit.d.ts +9 -0
  251. package/dist/plugins/kit.d.ts.map +1 -1
  252. package/dist/plugins/kit.js +18 -0
  253. package/dist/plugins/kit.js.map +1 -1
  254. package/dist/plugins/svg.d.ts +22 -3
  255. package/dist/plugins/svg.d.ts.map +1 -1
  256. package/dist/plugins/svg.js +112 -1
  257. package/dist/plugins/svg.js.map +1 -1
  258. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  259. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  260. package/dist/plugins/vite-leak-guard.js +378 -17
  261. package/dist/plugins/vite-leak-guard.js.map +1 -1
  262. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  263. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  264. package/dist/plugins/vite-zone-guard.js +208 -0
  265. package/dist/plugins/vite-zone-guard.js.map +1 -0
  266. package/dist/plugins/zone-guard.d.ts +27 -0
  267. package/dist/plugins/zone-guard.d.ts.map +1 -0
  268. package/dist/plugins/zone-guard.js +72 -0
  269. package/dist/plugins/zone-guard.js.map +1 -0
  270. package/dist/prerender.d.ts +10 -0
  271. package/dist/prerender.d.ts.map +1 -1
  272. package/dist/prerender.js +35 -3
  273. package/dist/prerender.js.map +1 -1
  274. package/dist/public-dir.d.ts +9 -0
  275. package/dist/public-dir.d.ts.map +1 -1
  276. package/dist/public-dir.js +131 -48
  277. package/dist/public-dir.js.map +1 -1
  278. package/dist/pwa-manifest.d.ts +68 -0
  279. package/dist/pwa-manifest.d.ts.map +1 -0
  280. package/dist/pwa-manifest.js +178 -0
  281. package/dist/pwa-manifest.js.map +1 -0
  282. package/dist/query.js +4 -4
  283. package/dist/query.js.map +1 -1
  284. package/dist/render-seam.d.ts +42 -2
  285. package/dist/render-seam.d.ts.map +1 -1
  286. package/dist/render-seam.js +6 -0
  287. package/dist/render-seam.js.map +1 -1
  288. package/dist/route-manifest.d.ts +3 -0
  289. package/dist/route-manifest.d.ts.map +1 -1
  290. package/dist/route-manifest.js +26 -3
  291. package/dist/route-manifest.js.map +1 -1
  292. package/dist/route-types.d.ts +24 -0
  293. package/dist/route-types.d.ts.map +1 -0
  294. package/dist/route-types.js +195 -0
  295. package/dist/route-types.js.map +1 -0
  296. package/dist/router.d.ts +28 -6
  297. package/dist/router.d.ts.map +1 -1
  298. package/dist/router.js +129 -40
  299. package/dist/router.js.map +1 -1
  300. package/dist/trusted-html.d.ts +34 -0
  301. package/dist/trusted-html.d.ts.map +1 -0
  302. package/dist/trusted-html.js +35 -0
  303. package/dist/trusted-html.js.map +1 -0
  304. package/dist/vitals.d.ts +33 -0
  305. package/dist/vitals.d.ts.map +1 -0
  306. package/dist/vitals.js +16 -0
  307. package/dist/vitals.js.map +1 -0
  308. package/dist/vite.d.ts +16 -1
  309. package/dist/vite.d.ts.map +1 -1
  310. package/dist/vite.js +187 -36
  311. package/dist/vite.js.map +1 -1
  312. package/dist/zones.d.ts +70 -0
  313. package/dist/zones.d.ts.map +1 -0
  314. package/dist/zones.js +365 -0
  315. package/dist/zones.js.map +1 -0
  316. package/package.json +67 -14
  317. package/src/backend-only.ts +16 -0
  318. package/src/boundary.ts +18 -5
  319. package/src/build-plan.ts +113 -36
  320. package/src/build-vite.ts +124 -15
  321. package/src/build.ts +535 -102
  322. package/src/bun-dev-entry.ts +1 -1
  323. package/src/cdn.ts +216 -0
  324. package/src/client.ts +251 -18
  325. package/src/conformance.ts +16 -3
  326. package/src/csp.ts +194 -0
  327. package/src/css-contract.ts +44 -0
  328. package/src/dev-client.ts +349 -0
  329. package/src/dev-diagnostics.ts +60 -7
  330. package/src/dev-error.ts +49 -5
  331. package/src/dev-feed.ts +1140 -0
  332. package/src/dev-indicator.ts +113 -0
  333. package/src/dev-session.ts +729 -0
  334. package/src/dev-sourcemap.ts +281 -0
  335. package/src/dev-ssr-graph.ts +16 -5
  336. package/src/dev.ts +86 -43
  337. package/src/diagnostic-catalog.ts +243 -0
  338. package/src/diagnostic-prompt.ts +355 -0
  339. package/src/diagnostic.ts +63 -66
  340. package/src/draft.ts +4 -1
  341. package/src/fn.ts +19 -3
  342. package/src/fs.ts +13 -2
  343. package/src/index.ts +57 -10
  344. package/src/internal/cdn/cloudflare.ts +146 -0
  345. package/src/internal/cdn/core.ts +316 -0
  346. package/src/internal/cdn/fastly.ts +62 -0
  347. package/src/internal/cdn/vercel.ts +95 -0
  348. package/src/internal/code-unit-order.ts +5 -0
  349. package/src/internal/codegen.ts +180 -29
  350. package/src/internal/dev-csp.ts +176 -0
  351. package/src/internal/dev-reserved.ts +52 -0
  352. package/src/internal/html-spans.ts +50 -0
  353. package/src/internal/loading-runtime.ts +244 -0
  354. package/src/internal/matches-runtime.ts +62 -0
  355. package/src/internal/mount-shadow.ts +88 -0
  356. package/src/internal/node-builtins.ts +79 -0
  357. package/src/internal/output-guard.ts +701 -0
  358. package/src/internal/page-execution.ts +1371 -0
  359. package/src/internal/parity.ts +150 -36
  360. package/src/internal/private-env.ts +334 -0
  361. package/src/internal/public-url.ts +22 -0
  362. package/src/internal/render-document.ts +153 -73
  363. package/src/internal/request-url.ts +14 -0
  364. package/src/internal/response-controls.ts +284 -0
  365. package/src/internal/runtime-contract.ts +10 -15
  366. package/src/internal/secret-scan.ts +623 -0
  367. package/src/internal/server-boundary.ts +6 -69
  368. package/src/internal/source-map.ts +256 -0
  369. package/src/internal/target-compat.ts +92 -0
  370. package/src/internal/timing-safe-equal.ts +13 -0
  371. package/src/internal/vitals-runtime.ts +53 -0
  372. package/src/internal/web-app.ts +290 -879
  373. package/src/internal/zone-graph.ts +593 -0
  374. package/src/isr.ts +366 -39
  375. package/src/llms-txt.ts +44 -8
  376. package/src/manifest.ts +671 -17
  377. package/src/module-graph.ts +53 -9
  378. package/src/navigation.ts +29 -6
  379. package/src/nonce.ts +104 -0
  380. package/src/plugins/css-modules.ts +5 -3
  381. package/src/plugins/kit.ts +24 -0
  382. package/src/plugins/svg.ts +135 -4
  383. package/src/plugins/vite-leak-guard.ts +529 -16
  384. package/src/plugins/vite-zone-guard.ts +263 -0
  385. package/src/plugins/zone-guard.ts +110 -0
  386. package/src/prerender.ts +49 -3
  387. package/src/public-dir.ts +135 -49
  388. package/src/pwa-manifest.ts +226 -0
  389. package/src/query.ts +4 -4
  390. package/src/render-seam.ts +46 -1
  391. package/src/route-manifest.ts +15 -3
  392. package/src/route-types.ts +264 -0
  393. package/src/router.ts +163 -63
  394. package/src/trusted-html.ts +44 -0
  395. package/src/vitals.ts +44 -0
  396. package/src/vite.ts +207 -36
  397. package/src/zones.ts +430 -0
  398. package/dist/plugins/vite-server-only.d.ts +0 -49
  399. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  400. package/dist/plugins/vite-server-only.js +0 -57
  401. package/dist/plugins/vite-server-only.js.map +0 -1
  402. package/dist/server-only.d.ts +0 -21
  403. package/dist/server-only.d.ts.map +0 -1
  404. package/dist/server-only.js +0 -25
  405. package/dist/server-only.js.map +0 -1
  406. package/src/plugins/vite-server-only.ts +0 -75
  407. package/src/server-only.ts +0 -25
@@ -1,58 +1,26 @@
1
1
  import {
2
+ type BackendEvidenceProvider,
2
3
  type BackendMount,
3
- type BackendMountHandler,
4
+ type BackendWebSocketMountHandler,
5
+ NIFRA_BACKEND_EVIDENCE,
4
6
  NIFRA_BACKEND_MOUNT,
7
+ NIFRA_BACKEND_WS_MOUNT,
8
+ NIFRA_BACKEND_WS_RUNTIME,
9
+ preRouteMountPaths,
5
10
  } from "@nifrajs/core/mount"
6
- import { type ServerOptions, server, type UrlParts, urlPartsOf } from "@nifrajs/core/server"
7
- import {
8
- type BoundaryRequestCtx,
9
- type BoundaryStates,
10
- boundaryDescriptors,
11
- resolveStaticBoundaries,
12
- startDynamicBoundaries,
13
- } from "../boundary.ts"
14
- import { defer, ndjsonStream, prepareDeferred } from "../deferred.ts"
15
- import { isDraftEnabled } from "../draft.ts"
11
+ import type { MountableApp, MountOptions } from "@nifrajs/core/server"
12
+ import { type ServerOptions, server } from "@nifrajs/core/server"
13
+ import { type CspPolicy, cspNonceResolver, DOCUMENT_POLICY, isCspPolicy } from "../csp.ts"
14
+ import type { CssLoadingMode } from "../css-contract.ts"
15
+ import { CACHE_CHANNEL } from "../isr.ts"
16
16
  import { generateLlmsTxt } from "../llms-txt.ts"
17
- import type {
18
- LayoutEntry,
19
- LoaderContext,
20
- Manifest,
21
- Meta,
22
- MetaArgs,
23
- RouteEntry,
24
- RouteModule,
25
- } from "../manifest.ts"
17
+ import type { Manifest } from "../manifest.ts"
26
18
  import type { RenderAdapter } from "../render-seam.ts"
27
- import {
28
- createMatcher,
29
- DATA_HEADER,
30
- NAV_FROM_HEADER,
31
- RETAIN_HEADER,
32
- REVALIDATE_HEADER,
33
- STATUS_HEADER,
34
- } from "../router.ts"
35
- import { searchOf, searchOfChain } from "../search.ts"
36
- import { mergeHeads, resolveMeta } from "./head-merge.ts"
37
- import {
38
- actionResponse,
39
- EMPTY_RETAIN,
40
- type HeadersLike,
41
- isControlFlow,
42
- isStatusSignal,
43
- type LoadedLayoutModules,
44
- layoutErrorId,
45
- type RenderAssemblyCache,
46
- type RenderedPage,
47
- type RevalidateResult,
48
- renderPageResult,
49
- STATUS_SIGNAL,
50
- STATUS_TEXT,
51
- type StatusSignal,
52
- scopeParams,
53
- tagLayoutError,
54
- withDuplicateInstanceHint,
55
- } from "./render-document.ts"
19
+ import { PRERENDERED_LIST_PATH } from "../router.ts"
20
+ import { formatShadowedPages, shadowedPages } from "./mount-shadow.ts"
21
+ import { createPageRequestExecutor, type NonceResolver } from "./page-execution.ts"
22
+
23
+ export type { NonceResolver } from "./page-execution.ts"
56
24
  export interface CreateWebAppOptions<Env = unknown> {
57
25
  readonly adapter: RenderAdapter
58
26
  readonly manifest: Manifest
@@ -60,6 +28,21 @@ export interface CreateWebAppOptions<Env = unknown> {
60
28
  readonly clientEntry: string
61
29
  /** Default document title for all pages. */
62
30
  readonly title?: string
31
+ /**
32
+ * Resolve a fresh CSP nonce for each document request. The value is applied to every
33
+ * framework-owned executable script; return `undefined` to keep the default nonce-free output.
34
+ * Nonce-bearing documents are marked `private, no-store` so the request-specific value is not
35
+ * replayed by browser, CDN, or ISR caches. The resolver does not declare a CSP header because the
36
+ * policy's allowed sources are app-specific.
37
+ */
38
+ readonly nonce?: NonceResolver<Env>
39
+ /**
40
+ * A hash-based Content-Security-Policy from `createCspPolicy`, instead of `nonce`. A document then
41
+ * carries a nonce only when it needs one (it `defer()`s a value, or `meta` names the nonce in
42
+ * `unsafeInlineScript`); every other page is nonce-free, gets the same CSP header on every request,
43
+ * and stays cacheable by `withISR` or a CDN. `meta` still receives a nonce to name.
44
+ */
45
+ readonly csp?: CspPolicy
63
46
  /**
64
47
  * Options for the underlying `server()` - `requestTimeoutMs`, `admission`, `gracefulSignals`, and
65
48
  * the rest of {@link ServerOptions}.
@@ -121,14 +104,26 @@ export interface CreateWebAppOptions<Env = unknown> {
121
104
  * mount interface from `@nifrajs/core/mount`; `createWebApp` also serves that backend over HTTP at
122
105
  * {@link apiPrefix} (default `/api`): a request whose pathname starts with the prefix is dispatched
123
106
  * before page routing with the same `env`/`waitUntil` platform context, and the backend's `Response`
124
- * is returned untouched. The mount runs in `nifra dev` too. Pass `apiPrefix: ""` to disable it. */
107
+ * is returned untouched. The mount runs in `nifra dev` too. Pass `apiPrefix: ""` to disable it.
108
+ *
109
+ * **Request-scoped loader calls.** While a page renders, its loaders, actions and boundaries get a
110
+ * `ctx.api` bound to that request's platform identity: a backend handler reached through it sees the
111
+ * visitor's `c.clientIp` (derived under this app's `server.clientIp` trust declaration), `c.env` and
112
+ * `c.waitUntil`, so a per-caller rate limit or audit field keys on the real visitor. `c.clientIp` is
113
+ * the supported way to read the caller - identity travels in the platform, so rebuilding a `Request`
114
+ * does not lose it. Headers do NOT travel: the page request's `cookie` and `authorization` are never
115
+ * copied, so a loader call is anonymous unless the loader passes headers on the call itself. */
125
116
  readonly api?: unknown
126
117
  /** HTTP path prefix the {@link api} backend is auto-mounted at (default `"/api"`). A request whose
127
118
  * pathname is exactly the prefix or starts with `prefix + "/"` is dispatched to the backend before
128
119
  * page routing; the backend therefore defines its routes at the **full** path (`server().post("/api/
129
120
  * sync", …)`), matching the in-process `inProcessClient` call sites. Set to `""` to disable the
130
121
  * auto-mount entirely (the app serves pages only and `api` stays a loader-only `ctx.api`). Mounting
131
- * is also a no-op when `api` does not expose the symbol mount. */
122
+ * is also a no-op when `api` does not expose the symbol mount.
123
+ *
124
+ * A page file under the prefix (`routes/api/report.tsx`) could never render - the backend answers
125
+ * first and its 404 is final - so `createWebApp` throws, naming the file. The same holds for every
126
+ * other pre-route mount. */
132
127
  readonly apiPrefix?: string
133
128
  /**
134
129
  * Strip {@link apiPrefix} from the pathname before dispatching to `api` (default `false`).
@@ -150,12 +145,16 @@ export interface CreateWebAppOptions<Env = unknown> {
150
145
  *
151
146
  * Tried longest-path-first and BEFORE the `api` mount, so a mount at `/api/auth` wins over a backend
152
147
  * at `/api` no matter which was declared first. `stripPrefix` is the per-mount form of {@link apiStrip}:
153
- * leave it off to pass the full path through.
148
+ * leave it off to pass the full path through. `opaque` states why a mount whose routes nifra cannot
149
+ * read (better-auth's handler) is outside assurance; it is then listed as a known gap.
154
150
  */
155
151
  readonly mounts?: ReadonlyArray<{
156
152
  readonly path: string
157
- readonly app: { fetch(request: Request): Response | Promise<Response> }
153
+ readonly app: MountableApp<Env>
158
154
  readonly stripPrefix?: boolean
155
+ readonly priority?: number
156
+ readonly fallbackOn?: 404
157
+ readonly opaque?: string
159
158
  }>
160
159
  /** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
161
160
  * signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
@@ -174,15 +173,27 @@ export interface CreateWebAppOptions<Env = unknown> {
174
173
  * of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
175
174
  * page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
176
175
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
176
+ /**
177
+ * Activation policy for framework-owned stylesheet links (default `"blocking"`). `"deferred"`
178
+ * emits `media="print"` links; the generated client waits for them and switches them to `all` before
179
+ * mounting. Pair with an aggregate Vite stylesheet (`cssCodeSplit: false`) to prevent lazy-prefetch
180
+ * CSS insertion. Non-hydrated pages always keep their stylesheet links blocking.
181
+ */
182
+ readonly cssLoading?: CssLoadingMode
177
183
  /** SSG: the prerendered-path set (e.g. `enumerateStaticRoutes(routes).paths` or the build's
178
- * `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
179
- * into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
184
+ * `prerendered.json`). Handed over as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
185
+ * into a prerendered route fetches its static `_data.json` instead of hitting the worker. A set over
186
+ * 4 KB of JSON is served once from `/__nifra/prerendered.json?v=<version>`, cacheable for good, and
187
+ * pages hand over only that URL; `prerenderRoutes` writes the file into a static output. */
180
188
  readonly prerenderedPaths?: readonly string[]
181
189
  /** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
182
190
  * those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
183
191
  * team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
184
192
  * routinely. Turn it on only for a repo whose guidelines you would publish as a page. */
185
193
  readonly publishLocalGuidelines?: boolean
194
+ /** Serve `/llms.txt` and `/llms-full.txt` (default `true`): the page routes, and the backend routes
195
+ * when the backend is mounted over HTTP at {@link apiPrefix}. `false` registers neither path. */
196
+ readonly llmsTxt?: boolean
186
197
  /** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
187
198
  * the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
188
199
  * `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
@@ -207,14 +218,6 @@ export interface CreateWebAppOptions<Env = unknown> {
207
218
  ) => void
208
219
  }
209
220
 
210
- /** The handler context fields createWebApp uses - a structural subset of nifra's `Context`. */
211
- interface RouteContext {
212
- readonly params: Record<string, string>
213
- readonly req: Request
214
- /** Platform bindings (Workers env), forwarded to each route's loader/action as `args.env`. */
215
- readonly env: unknown
216
- }
217
-
218
221
  /**
219
222
  * Re-issue `request` with `prefix` removed from its pathname.
220
223
  *
@@ -228,35 +231,47 @@ interface RouteContext {
228
231
  * The `Request` is rebuilt rather than mutated (`url` is read-only), preserving method, headers, body,
229
232
  * and duplex streaming - the body is passed through unread, so a large or streamed upload is untouched.
230
233
  */
231
- function stripMountPrefix(request: Request, prefix: string): Request {
232
- const url = new URL(request.url)
233
- const rest = url.pathname.slice(prefix.length)
234
- url.pathname = rest === "" ? "/" : rest
235
- // `url.href`, not the `URL`: this type set only declares the string and Request overloads.
236
- return new Request(url.href, request)
237
- }
238
-
239
234
  /**
240
235
  * Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
241
236
  * context without making web depend on client.
242
237
  */
243
- function backendMountOf(api: unknown): BackendMountHandler | undefined {
238
+ function backendMountOf<Env>(api: unknown): MountableApp<Env> | undefined {
244
239
  if ((typeof api !== "object" && typeof api !== "function") || api === null) return undefined
245
- const explicit = (api as Partial<BackendMount>)[NIFRA_BACKEND_MOUNT]
240
+ const explicit = (api as Partial<BackendMount<Env>>)[NIFRA_BACKEND_MOUNT]
246
241
  if (typeof explicit !== "function") return undefined
247
- return (request, platform) => explicit.call(api, request, platform)
242
+ const websocket = (api as Partial<BackendMount<Env>>)[NIFRA_BACKEND_WS_MOUNT]
243
+ const runtime = (api as Partial<BackendMount<Env>>)[NIFRA_BACKEND_WS_RUNTIME]
244
+ const evidence = (api as Partial<BackendMount<Env>>)[NIFRA_BACKEND_EVIDENCE]
245
+ const mount: {
246
+ fetch: MountableApp<Env>["fetch"]
247
+ resolveWebSocketUpgrade?: BackendWebSocketMountHandler<Env>
248
+ [NIFRA_BACKEND_WS_RUNTIME]?: NonNullable<BackendMount<Env>[typeof NIFRA_BACKEND_WS_RUNTIME]>
249
+ [NIFRA_BACKEND_EVIDENCE]?: BackendEvidenceProvider
250
+ } = {
251
+ fetch: (request, platform) => explicit.call(api, request, platform),
252
+ }
253
+ if (typeof websocket === "function") {
254
+ mount.resolveWebSocketUpgrade = (request, platform) => websocket.call(api, request, platform)
255
+ }
256
+ if (typeof runtime === "function") mount[NIFRA_BACKEND_WS_RUNTIME] = runtime
257
+ if (typeof evidence === "function") mount[NIFRA_BACKEND_EVIDENCE] = evidence
258
+ return mount
259
+ }
260
+
261
+ function evidenceProviderOf(value: unknown): BackendEvidenceProvider | undefined {
262
+ if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined
263
+ const provider = (value as { [NIFRA_BACKEND_EVIDENCE]?: unknown })[NIFRA_BACKEND_EVIDENCE]
264
+ return typeof provider === "function" ? (provider as BackendEvidenceProvider) : undefined
248
265
  }
249
266
 
250
- // `createWebApp` reaches the same Request object through the mount hook and the page route. Cache the
251
- // allocation-light core split once so API mounting, path hydration, search parsing, and SSG fallback
252
- // checks do not each rescan the absolute URL. WeakMap keeps this bounded to requests that are alive.
253
- const REQUEST_URL_PARTS = new WeakMap<Request, UrlParts>()
254
- const urlPartsFor = (request: Request): UrlParts => {
255
- const cached = REQUEST_URL_PARTS.get(request)
256
- if (cached !== undefined) return cached
257
- const parts = urlPartsOf(request.url)
258
- REQUEST_URL_PARTS.set(request, parts)
259
- return parts
267
+ function mountPathPrefix(path: string): string {
268
+ const withoutWildcard = path.endsWith("/*") ? path.slice(0, -2) : path
269
+ if (withoutWildcard === "" || withoutWildcard === "/") return ""
270
+ // Index-scan instead of `/\/+$/`: mount paths can be library input, and the
271
+ // unanchored-start trailing-run replacement can backtrack quadratically.
272
+ let end = withoutWildcard.length
273
+ while (end > 0 && withoutWildcard.charCodeAt(end - 1) === 47 /* '/' */) end--
274
+ return withoutWildcard.slice(0, end)
260
275
  }
261
276
 
262
277
  /**
@@ -277,25 +292,11 @@ export function createWebApp<Env = unknown>(
277
292
  options: CreateWebAppOptions<Env>,
278
293
  ): ReturnType<typeof server<Env>> {
279
294
  const { adapter, manifest, clientEntry, title, api } = options
280
- const titleOption = title === undefined ? {} : { title }
281
- // Draft/preview: when a `draftSecret` is configured, each request's signed `__nifra_draft` cookie is
282
- // verified once and surfaced to loaders/actions as `ctx.draft`. No secret ⇒ always `false` (sync, free).
283
- const draftFlag = (req: Request): Promise<boolean> =>
284
- options.draftSecret === undefined
285
- ? Promise.resolve(false)
286
- : isDraftEnabled(req, options.draftSecret)
287
- // Per-route preload chunks (spread into renderPage; omitted when unmapped, for exactOptionalPropertyTypes).
288
- const preloadOf = (id: string): { preload?: readonly string[] } => {
289
- const chunks = options.routePreload?.[id]
290
- return chunks ? { preload: chunks } : {}
295
+ if (options.csp !== undefined && options.nonce !== undefined) {
296
+ throw new TypeError("[nifra/web] createWebApp takes `csp` or `nonce`, not both")
291
297
  }
292
- // The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
293
- // (`routeStyles[id]` - only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
294
- // `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
295
- const stylesOf = (id: string): { styles?: readonly string[] } => {
296
- const perRoute = options.routeStyles?.[id]
297
- if (perRoute !== undefined) return { styles: perRoute }
298
- return options.styles && options.styles.length > 0 ? { styles: options.styles } : {}
298
+ if (options.csp !== undefined && !isCspPolicy(options.csp)) {
299
+ throw new TypeError("[nifra/web] createWebApp `csp` must come from createCspPolicy()")
299
300
  }
300
301
  // Seed the context with the declared `Env` so `app.fetch(req, { env })` / `toFetchHandler(app)` type
301
302
  // the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
@@ -305,810 +306,220 @@ export function createWebApp<Env = unknown>(
305
306
  // `beforeHandle`, assurance evidence) actually bind to them - see the `use` option. A caller
306
307
  // cannot do this after the fact: by the time this function returns, the routes are declared.
307
308
  options.use?.(app)
309
+ // A resolver created by `createNonceResolver({ header })` carries a paired response hook. Register it
310
+ // after caller middleware so its request-specific CSP value is the last default applied to a page.
311
+ const nonceResponse = options.nonce?.onResponse
312
+ if (nonceResponse !== undefined) app.onResponse(nonceResponse)
313
+ // How this app's documents meet a CSP, for wrappers that cache them (`withISR` warns on "nonce").
314
+ const documentPolicy =
315
+ options.csp !== undefined ? "hash" : options.nonce !== undefined ? "nonce" : undefined
316
+ if (documentPolicy !== undefined) {
317
+ Object.defineProperty(app, DOCUMENT_POLICY, { value: documentPolicy })
318
+ }
319
+ const cacheChannel = { enabled: false }
320
+ Object.defineProperty(app, CACHE_CHANNEL, {
321
+ value: () => {
322
+ cacheChannel.enabled = true
323
+ },
324
+ })
308
325
  // Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
309
- // `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
310
- // for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
311
- // surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
312
- // (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
313
- // at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
314
- // sites. `apiPrefix: ""` opts out; a non-mountable `api` (no symbol mount) is left pages-only. The hook
315
- // is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
326
+ // Core's pre-route mount seam runs before the page wildcard `/*`; parent request hooks run first,
327
+ // child request hooks run inside the mount, and parent response hooks still wrap the result.
328
+ // The mount handles every HTTP method and preserves backend 404 responses instead of letting the
329
+ // page catch-all render them. We dispatch the same `Request` object (its body unread), so
330
+ // streamed/large bodies pass through untouched. The backend defines full prefixed paths
331
+ // (`server().post("/api/sync", …)`) unless `apiStrip` is enabled. `apiPrefix: ""` opts out;
332
+ // a non-mountable `api` remains loader-only and pages-only apps keep the no-mount fast path.
316
333
  const apiPrefix = options.apiPrefix ?? "/api"
317
334
  const apiStrip = options.apiStrip === true
318
- const mountedApi = backendMountOf(api)
335
+ const mountedApi = backendMountOf<Env>(api)
319
336
  // Longest path first, so a more specific mount (`/api/auth`) is tried before a broader one (`/api`)
320
337
  // regardless of the order they were declared in.
321
- const mounts = [...(options.mounts ?? [])].sort((a, b) => b.path.length - a.path.length)
322
- const underPrefix = (pathname: string, prefix: string): boolean =>
323
- pathname === prefix || pathname.startsWith(`${prefix}/`)
324
-
325
- if (mounts.length > 0 || (apiPrefix !== "" && mountedApi !== undefined)) {
326
- app.onRequest((req, platform) => {
327
- const { pathname } = urlPartsFor(req)
328
- // Sub-app mounts win over the backend prefix: they are the more specific declaration, and an
329
- // auth handler mounted at `/api/auth` must not be swallowed by the backend mounted at `/api`.
330
- for (const mount of mounts) {
331
- if (!underPrefix(pathname, mount.path)) continue
332
- return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req)
333
- }
334
- // Exactly the prefix (`/api`) or a sub-path (`/api/…`) - NOT a sibling like `/apixyz` that merely
335
- // shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
336
- if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
337
- return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform)
338
- }
339
- return undefined // not an API path → continue to page routing
340
- })
341
- }
342
- // SSG `fallback: "404"`: the set of concrete paths that actually exist for those routes. An unlisted
343
- // path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
344
- // SSR on demand). O(1) membership via a Set.
345
- const prerenderedSet = new Set(options.prerenderedPaths ?? [])
346
- const matchManifestRoute = createMatcher(
347
- manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
338
+ const configuredMounts: readonly MountOptions<Env>[] = Object.freeze(
339
+ (options.mounts ?? []).map(
340
+ (mount): MountOptions<Env> => ({
341
+ path: mount.path,
342
+ app: mount.app,
343
+ ...(mount.stripPrefix === undefined ? {} : { stripPrefix: mount.stripPrefix }),
344
+ ...(mount.priority === undefined ? {} : { priority: mount.priority }),
345
+ ...(mount.fallbackOn === undefined ? {} : { fallbackOn: mount.fallbackOn }),
346
+ ...(mount.opaque === undefined ? {} : { opaque: mount.opaque }),
347
+ }),
348
+ ),
348
349
  )
349
- const routeById = new Map(manifest.routes.map((route) => [route.id, route]))
350
-
351
- // Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
352
- // render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
353
- // contributes). Loaded lazily - the data-only and 405 branches return before any layout is needed.
354
- const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
355
- const modules = await Promise.all(
356
- // layoutIds only reference layouts present in the manifest (buildManifest invariant).
357
- route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).load()),
358
- )
359
- // A layout is not a form target, so an `action` there is a mistake with no meaning to give it.
360
- // A `loader`, by contrast, now runs (see `runLayoutChain`).
361
- for (let i = 0; i < modules.length; i++) {
362
- if ((modules[i] as { action?: unknown }).action === undefined) continue
363
- const id = route.layoutIds[i] as string
364
- throw new Error(
365
- `[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one - only route files do. Move the mutation into the route that submits to it.`,
366
- )
367
- }
368
- return modules
350
+ const mounts: Array<MountOptions<Env>> = [...configuredMounts]
351
+ if (mountedApi !== undefined && apiPrefix !== "") {
352
+ mounts.push({ path: apiPrefix, app: mountedApi, stripPrefix: apiStrip })
369
353
  }
354
+ for (const mount of mounts) app.mount(mount)
355
+ // Read back the server's own mount table, so a mount added in `use` counts as well: a page under any
356
+ // pre-route mount is unreachable, and serving it as a silent 404 hides that.
357
+ const shadowed = shadowedPages(manifest, preRouteMountPaths(app))
358
+ if (shadowed.length > 0) throw new Error(`[nifra/web] ${formatShadowedPages(shadowed)}`)
359
+ const ownPatterns = new Set(manifest.routes.map((route) => route.pattern))
360
+ const prerenderedList = ownPatterns.has(PRERENDERED_LIST_PATH)
361
+ ? undefined
362
+ : servedPrerenderedList(options.prerenderedPaths)
363
+ const pageExecutor = createPageRequestExecutor<Env>({
364
+ adapter,
365
+ manifest,
366
+ clientEntry,
367
+ ...(title === undefined ? {} : { title }),
368
+ ...(api === undefined ? {} : { api }),
369
+ ...(options.draftSecret === undefined ? {} : { draftSecret: options.draftSecret }),
370
+ ...(options.routePreload === undefined ? {} : { routePreload: options.routePreload }),
371
+ ...(options.styles === undefined ? {} : { styles: options.styles }),
372
+ ...(options.routeStyles === undefined ? {} : { routeStyles: options.routeStyles }),
373
+ ...(options.cssLoading === undefined ? {} : { cssLoading: options.cssLoading }),
374
+ ...(options.prerenderedPaths === undefined
375
+ ? {}
376
+ : { prerenderedPaths: options.prerenderedPaths }),
377
+ ...(prerenderedList === undefined ? {} : { prerenderedListUrl: prerenderedList.url }),
378
+ ...(options.staticFallbacks === undefined ? {} : { staticFallbacks: options.staticFallbacks }),
379
+ ...(options.staticBoundaryCache === undefined
380
+ ? {}
381
+ : { staticBoundaryCache: options.staticBoundaryCache }),
382
+ ...(options.nonce === undefined ? {} : { nonce: options.nonce }),
383
+ ...(options.csp === undefined
384
+ ? {}
385
+ : { nonce: cspNonceResolver(options.csp) as NonceResolver<Env>, csp: options.csp }),
386
+ ...(options.onLoaderError === undefined ? {} : { onLoaderError: options.onLoaderError }),
387
+ cacheChannel,
388
+ })
370
389
 
371
- /**
372
- * Run a route's layout-chain loaders, honouring declared gates.
373
- *
374
- * Returns once every **gate** has resolved, with the non-gate loaders still in flight, so the caller
375
- * can start the page loader at exactly the right moment: after anything that might reject the
376
- * request, alongside anything that merely fetches.
377
- *
378
- * The ordering is the security-relevant part. Parallel loaders are NOT an authorization boundary -
379
- * a page loader running concurrently with a guard has already queried by the time the guard says no.
380
- * Remix documents that as a footgun; here it is a declaration instead. `export const gate = true`
381
- * makes a layout blocking for everything beneath it, and nothing beneath a rejected gate runs.
382
- *
383
- * Each layout receives only the params it OWNS ({@link RouteEntry.layoutParams}), not the route's
384
- * full set, so a layout at `orgs/[org]/` cannot read a param belonging to a route below it.
385
- */
386
- /**
387
- * Layout indices a client navigation asked to keep, from {@link RETAIN_HEADER}.
388
- *
389
- * The CLIENT decides retainability: it knows each layout's scoped params (shipped in the bootstrap)
390
- * and both the old and new match, so it can tell whether a layout's own prefix changed - without the
391
- * server re-matching a path the client already matched.
392
- *
393
- * It is a HINT, not an instruction. The client is asking to keep data it already holds, which is
394
- * harmless for data and unacceptable for a guard, so `runLayoutChain` refuses to skip a gate no
395
- * matter what arrives here. Anything unparseable is ignored rather than rejected: the worst case of
396
- * a bad hint is a loader that runs when it need not.
397
- */
398
- const retainedIndices = (header: string | null): ReadonlySet<number> => {
399
- if (header === null || header === "") return EMPTY_RETAIN
400
- const out = new Set<number>()
401
- for (const part of header.split(",")) {
402
- // Digits only. `Number("")` is 0 and `Number(" ")` is 0, so a stray comma would otherwise read
403
- // as "retain index 0" and silently skip the outermost layout.
404
- if (!/^\d+$/.test(part)) continue
405
- out.add(Number(part))
406
- }
407
- return out
408
- }
409
-
410
- /**
411
- * Validate a data-navigation's retain request against the manifest and its declared source path.
412
- * A client may ask to retain every slot it currently has; the server only honours indices whose
413
- * layout identity and owned params are unchanged. Hard document GETs never retain anything.
414
- */
415
- const validatedRetainedIndices = (
416
- req: Request,
417
- route: RouteEntry,
418
- params: Readonly<Record<string, string>>,
419
- ): ReadonlySet<number> => {
420
- if (req.headers.get(DATA_HEADER) === null) return EMPTY_RETAIN
421
- const requested = retainedIndices(req.headers.get(RETAIN_HEADER))
422
- const from = req.headers.get(NAV_FROM_HEADER)
423
- if (requested.size === 0 || from === null) return EMPTY_RETAIN
424
- let fromPath: string
425
- try {
426
- const current = new URL(req.url)
427
- const source = new URL(from, current)
428
- if (source.origin !== current.origin) return EMPTY_RETAIN
429
- fromPath = source.pathname + source.search
430
- } catch {
431
- return EMPTY_RETAIN
432
- }
433
- const previousMatch = matchManifestRoute(fromPath)
434
- if (previousMatch === null) return EMPTY_RETAIN
435
- const previous = routeById.get(previousMatch.routeId)
436
- if (previous === undefined) return EMPTY_RETAIN
437
- const valid = new Set<number>()
438
- for (const index of requested) {
439
- if (route.layoutIds[index] !== previous.layoutIds[index]) continue
440
- const owned = route.layoutParams?.[index] ?? []
441
- const previouslyOwned = previous.layoutParams?.[index] ?? []
442
- if (
443
- owned.length !== previouslyOwned.length ||
444
- owned.some((name, i) => name !== previouslyOwned[i]) ||
445
- owned.some((name) => params[name] !== previousMatch.params[name])
446
- ) {
447
- continue
448
- }
449
- valid.add(index)
450
- }
451
- return valid
452
- }
453
-
454
- // The validated (or raw-parsed) search a route's loader context receives; `searchOf` fails closed to
455
- // the schema's defaults on hostile input, so this never throws on an attacker-picked query.
456
- const loaderSearch = (searchSchema: RouteModule["searchSchema"], request: Request) =>
457
- searchOf(searchSchema, urlPartsFor(request).search)
458
-
459
- // The route's EFFECTIVE search once its layout chain is loaded: the layout schemas merged with the
460
- // page's (page-wins), the same chain the client mount builds for `useSearch`. Used for the page loader's
461
- // `ctx.search` and the render's `RenderProps.search`, so both agree with the client and with each other.
462
- const searchChainOf = (
463
- layoutMods: LoadedLayoutModules,
464
- mod: RouteModule,
465
- request: Request,
466
- ): Record<string, unknown> =>
467
- searchOfChain(
468
- [...layoutMods.map((m) => m.searchSchema), mod.searchSchema],
469
- urlPartsFor(request).search,
470
- )
471
-
472
- const runLayoutChain = async (
473
- route: RouteEntry,
474
- ctx: LoaderContext,
475
- retain: ReadonlySet<number> = new Set(),
476
- ): Promise<{
477
- readonly modules: LoadedLayoutModules
478
- readonly layoutData: readonly unknown[] | undefined
479
- readonly retained: readonly number[]
480
- readonly pending: Promise<unknown>
481
- }> => {
482
- const modules = await loadLayoutModules(route)
483
- if (!modules.some((m) => m.loader !== undefined)) {
484
- // Nothing to run: no array is produced, so a page-only app serializes nothing extra and its
485
- // HTML is byte-identical to before this feature.
486
- return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() }
487
- }
488
- const results: unknown[] = new Array(modules.length).fill(null)
489
- const retained: number[] = []
490
- const pending: Array<Promise<void>> = []
491
- for (let i = 0; i < modules.length; i++) {
492
- const loader = modules[i]?.loader
493
- if (loader === undefined) continue
494
- // Unchanged scope: the client keeps what it has. A GATE is never skipped - it is a guard, and a
495
- // guard that only runs when the URL prefix changed is not a guard.
496
- if (retain.has(i) && modules[i]?.gate !== true) {
497
- retained.push(i)
498
- continue
499
- }
500
- const scoped: LoaderContext = {
501
- ...ctx,
502
- params: scopeParams(ctx.params, route.layoutParams?.[i]),
503
- }
504
- if (modules[i]?.gate === true) {
505
- // Blocking: awaited here, so a throw propagates before anything deeper is started.
506
- try {
507
- results[i] = await loader(scoped)
508
- } catch (err) {
509
- throw tagLayoutError(err, route.layoutIds[i] as string)
510
- }
511
- continue
512
- }
513
- const layoutId = route.layoutIds[i] as string
514
- const index = i
515
- // The loader is invoked INSIDE the async function, not before it. `Promise.resolve(loader(x))`
516
- // would let a synchronously-throwing loader escape past the rejection handler entirely - the
517
- // exception happens while evaluating the argument, before any promise exists to reject.
518
- pending.push(
519
- (async () => {
520
- try {
521
- results[index] = await loader(scoped)
522
- } catch (err) {
523
- throw tagLayoutError(err, layoutId)
524
- }
525
- })(),
526
- )
527
- }
528
- return { modules, layoutData: results, retained, pending: Promise.all(pending) }
390
+ for (const route of manifest.routes) {
391
+ app.register("GET", route.pattern, undefined, pageExecutor.get(route))
392
+ app.register("POST", route.pattern, undefined, pageExecutor.post(route))
529
393
  }
530
-
531
- /** Run only blocking layout gates before a route action. Non-gate loaders fetch render data and
532
- * therefore run after the mutation when a native POST needs a fresh document. */
533
- const runLayoutGates = async (
534
- route: RouteEntry,
535
- ctx: LoaderContext,
536
- ): Promise<LoadedLayoutModules> => {
537
- const modules = await loadLayoutModules(route)
538
- for (let i = 0; i < modules.length; i++) {
539
- const mod = modules[i]
540
- if (mod?.gate !== true || mod.loader === undefined) continue
541
- try {
542
- await mod.loader({
543
- ...ctx,
544
- params: scopeParams(ctx.params, route.layoutParams?.[i]),
394
+ // llms.txt & llms-full.txt describe what a visitor can reach: the pages, and the backend only when it
395
+ // is served over HTTP - a backend the loaders call in-process has no public surface to describe. An
396
+ // app route at either path takes precedence, and each text is built once per app.
397
+ if (options.llmsTxt !== false) {
398
+ const llmsOptions = { includeLocalGuidelines: options.publishLocalGuidelines === true }
399
+ const publicApi = mountedApi !== undefined && apiPrefix !== "" ? api : undefined
400
+ for (const [path, full] of [
401
+ ["/llms.txt", false],
402
+ ["/llms-full.txt", true],
403
+ ] as const) {
404
+ if (ownPatterns.has(path)) continue
405
+ let text: Promise<string> | undefined
406
+ app.register("GET", path, undefined, async () => {
407
+ text ??= generateLlmsTxt(full, manifest.routes, publicApi, llmsOptions).catch((error) => {
408
+ text = undefined
409
+ throw error
545
410
  })
546
- } catch (err) {
547
- throw tagLayoutError(err, route.layoutIds[i] as string)
548
- }
549
- }
550
- return modules
551
- }
552
-
553
- // Build a route's render chain + its merged `<head>` from the already-loaded layout modules + the
554
- // page module. The chain is `[…layout components, page]`; the head merges each layout's `meta`
555
- // export (outermost→innermost) with the page's (last), so a `head`/`meta` on `_layout.tsx`
556
- // contributes sitewide tags. `metaArgs` (loader data + params) feed function-form `meta`s.
557
- const resolveChainAndHead = (
558
- layoutModules: LoadedLayoutModules,
559
- page: RouteModule,
560
- metaArgs: MetaArgs,
561
- ): { chain: unknown[]; head: Meta } => {
562
- const chain = [...layoutModules.map((m) => m.default), page.default]
563
- const heads = [
564
- ...layoutModules.map((m) => resolveMeta(m.meta, metaArgs)),
565
- resolveMeta(page.meta, metaArgs),
566
- ]
567
- return { chain, head: mergeHeads(heads) }
568
- }
569
-
570
- // Dir a special-file id lives in: `_error`→"" , `a/b/_error`→"a/b" (and likewise for `_layout`).
571
- const dirOfId = (id: string, suffix: string): string =>
572
- id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
573
-
574
- /**
575
- * Choose the `_error` boundary for a failure, respecting where a LAYOUT loader failed.
576
- *
577
- * A route error uses the nearest boundary, as it always has. A layout error must not: the nearest
578
- * boundary can sit BELOW the layout that threw, and rendering there would wrap the boundary in that
579
- * very layout - the one whose loader just failed and whose data therefore never arrived.
580
- */
581
- const boundaryFor = (route: RouteEntry, err: unknown): string | undefined => {
582
- const ids = route.errorIds ?? []
583
- const failing = layoutErrorId(err)
584
- if (failing === undefined) return ids.at(-1)
585
- const layoutDir = dirOfId(failing, "_layout")
586
- // Deepest boundary at or ABOVE the failing layout's directory.
587
- for (let i = ids.length - 1; i >= 0; i--) {
588
- const errDir = dirOfId(ids[i] as string, "_error")
589
- if (errDir === "" || errDir === layoutDir || layoutDir.startsWith(`${errDir}/`)) return ids[i]
590
- }
591
- return undefined
592
- }
593
-
594
- // The request's origin (scheme + host + port, NO trailing slash) - the one server fact `meta()` needs
595
- // for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
596
- // `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
597
- // a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
598
- // (shouldn't happen on a real request) degrades to `""` - the documented unknown-origin default -
599
- // rather than throwing during render.
600
- const originOf = (req: Request): string => {
601
- try {
602
- return new URL(req.url).origin
603
- } catch {
604
- return ""
605
- }
606
- }
607
-
608
- // The request's `pathname + search` - threaded into `renderPage` so an adapter's `useLocation`/
609
- // `useSearchParams` render the current URL server-side and hydrate against the client's initial state
610
- // (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
611
- // the server. A malformed `req.url` degrades to "/" rather than throwing during render.
612
- const pathOf = (req: Request): string => {
613
- try {
614
- const parts = urlPartsFor(req)
615
- return parts.pathname + parts.search
616
- } catch {
617
- return "/"
411
+ return new Response(await text, {
412
+ headers: { "content-type": "text/plain; charset=utf-8" },
413
+ })
414
+ })
618
415
  }
619
416
  }
620
417
 
621
- /**
622
- * Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
623
- * half of error UI - works on every adapter, no client takeover). The boundary renders in place of
624
- * the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
625
- * served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
626
- * client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
627
- * message }` - never the stack (no internals leak into HTML).
628
- */
629
- const renderError = async (
630
- route: RouteEntry,
631
- errorId: string,
632
- err: unknown,
633
- ): Promise<Response | RenderedPage> => {
634
- const errDir = dirOfId(errorId, "_error")
635
- const keptLayoutIds = route.layoutIds.filter((id) => {
636
- const ld = dirOfId(id, "_layout")
637
- return ld === "" || ld === errDir || errDir.startsWith(`${ld}/`)
638
- })
639
- const layouts = await Promise.all(
640
- keptLayoutIds.map((id) =>
641
- (manifest.layouts[id] as LayoutEntry).load().then((m) => m.default),
642
- ),
418
+ if (prerenderedList !== undefined) {
419
+ const { body, version } = prerenderedList
420
+ app.register(
421
+ "GET",
422
+ PRERENDERED_LIST_PATH,
423
+ undefined,
424
+ (c: { readonly req: Request }) =>
425
+ new Response(body, {
426
+ headers: {
427
+ "content-type": "application/json",
428
+ // Only the versioned URL pages hand over is immutable; a bare request gets today's list.
429
+ "cache-control":
430
+ new URL(c.req.url).searchParams.get("v") === version
431
+ ? "public, max-age=31536000, immutable"
432
+ : "no-cache",
433
+ },
434
+ }),
643
435
  )
644
- const { default: errComp } = await (manifest.errors?.[errorId] as LayoutEntry).load()
645
- const e = err instanceof Error ? err : new Error(String(err))
646
- return renderPageResult({
647
- adapter,
648
- chain: [...layouts, errComp],
649
- data: { name: e.name, message: e.message },
650
- clientEntry,
651
- routeId: errorId,
652
- status: 500,
653
- hydrate: false,
654
- ...titleOption,
655
- })
656
- }
657
-
658
- // The 404 response - the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
659
- // catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
660
- // that opted out of on-demand SSR).
661
- const renderNotFound = (path?: string): Promise<Response | RenderedPage> =>
662
- renderTerminalStatus(404, path)
663
-
664
- /**
665
- * Render a terminal-status page: the `_<status>` page if the app authored one, else `_404`, else
666
- * plain text. Shared by the wildcard catch-all, `fallback: "404"` enforcement, and a loader's
667
- * `notFound()` / `gone()` / `statusPage()`, so all of them resolve the page and set the status
668
- * through one path rather than forking.
669
- *
670
- * Hydrates, following the `_404` precedent rather than `_error`'s non-hydrated 500: these are
671
- * ordinary pages the client router can own, and a 404 arrived at by clicking a link should behave
672
- * like any other route.
673
- */
674
- const renderTerminalStatus = async (
675
- status: number,
676
- path?: string,
677
- extraHeaders?: HeadersLike,
678
- ): Promise<Response | RenderedPage> => {
679
- const page = manifest.statusPages?.[String(status)] ?? manifest.notFound
680
- if (page === undefined) {
681
- const headers = new Headers(extraHeaders)
682
- headers.set("content-type", "text/plain; charset=utf-8")
683
- return new Response(STATUS_TEXT[status] ?? "Error", { status, headers })
684
- }
685
- // The routeId decides which preload/style bundle and which client component this maps to, so it
686
- // must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
687
- const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404"
688
- const loaded = (await page.load()) as {
689
- readonly default: unknown
690
- readonly searchSchema?: RouteModule["searchSchema"]
691
- }
692
- const component = loaded.default
693
- return renderPageResult({
694
- adapter,
695
- chain: [component],
696
- data: null,
697
- clientEntry,
698
- routeId,
699
- // The page hydrates, so besides `path` its SSR `search` must equal the client mount's derivation for
700
- // this routeId (`searchOf(searchSchema, url.search)`), or a status page reading `useSearch` drifts.
701
- // Only when `path` is present (a real URL); a schema-less status page then gets the raw parsed query.
702
- ...(path !== undefined
703
- ? {
704
- search: searchOf(
705
- loaded.searchSchema,
706
- path.includes("?") ? path.slice(path.indexOf("?")) : "",
707
- ),
708
- }
709
- : {}),
710
- // The page hydrates, so its SSR `path` must equal the client's initial
711
- // `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
712
- // on it would drift.
713
- ...(path !== undefined ? { path } : {}),
714
- status,
715
- ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
716
- ...preloadOf(routeId),
717
- ...stylesOf(routeId),
718
- prerenderedPaths: options.prerenderedPaths ?? [],
719
- ...titleOption,
720
- })
721
436
  }
722
437
 
723
- /**
724
- * Answer a loader's terminal-status signal, in whichever form the caller asked for.
725
- *
726
- * A hard navigation wants the document, so it gets the rendered boundary. A client-side navigation
727
- * fetched DATA, not a document, so it gets an empty body plus the status on `X-Nifra-Status`, and
728
- * the client router renders the same boundary from its own manifest. Sending the HTML back to a
729
- * soft-nav would be answering a different question than the one asked - and skipping the header
730
- * entirely is what leaves a crawler seeing a correct 404 while a user who clicked a link sees a
731
- * blank page.
732
- */
733
- const renderStatusSignal = (
734
- req: Request,
735
- signal: StatusSignal,
736
- ): Promise<Response | RenderedPage> | Response => {
737
- const { status, headers } = signal[STATUS_SIGNAL]
738
- if (req.headers.get(DATA_HEADER) !== null) {
739
- const responseHeaders = new Headers(headers)
740
- responseHeaders.set(STATUS_HEADER, String(status))
741
- return new Response(null, { status, headers: responseHeaders })
742
- }
743
- return renderTerminalStatus(status, pathOf(req), headers)
744
- }
438
+ // Wildcard catch-all: unmatched paths render the nearest `_404` (404), or a plain text 404 if
439
+ // absent.
440
+ app.register("GET", "/*", undefined, pageExecutor.fallback)
745
441
 
746
- for (const route of manifest.routes) {
747
- // A dynamic route whose `getStaticPaths` declared `fallback: "404"` - only its prerendered paths
748
- // exist; anything else 404s (computed once per route, not per request).
749
- const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
750
- // Per-route document-assembly cache (see RenderAssemblyCache): filled on the first GET render and
751
- // reused while the route + layout MODULE IDENTITIES are unchanged. Attached only when every meta
752
- // in the chain is static - a `meta(data)` function makes the head per-request, so those routes
753
- // always assemble fresh. A module reload (dev HMR) yields new module objects, dropping the slot.
754
- let assemblySlot: RenderAssemblyCache | undefined
755
- let assemblySlotMod: unknown
756
- let assemblySlotLayouts: LoadedLayoutModules | undefined
757
- const assemblyCacheFor = (
758
- mod: RouteModule,
759
- lms: LoadedLayoutModules,
760
- ): { assemblyCache: RenderAssemblyCache } | Record<string, never> => {
761
- if (typeof mod.meta === "function") return {}
762
- for (const m of lms) if (typeof m.meta === "function") return {}
763
- const layoutsUnchanged =
764
- assemblySlotLayouts !== undefined &&
765
- assemblySlotLayouts.length === lms.length &&
766
- assemblySlotLayouts.every((m, i) => m === lms[i])
767
- if (assemblySlotMod !== mod || !layoutsUnchanged) {
768
- assemblySlot = {}
769
- assemblySlotMod = mod
770
- assemblySlotLayouts = lms
771
- }
772
- return { assemblyCache: assemblySlot as RenderAssemblyCache }
442
+ let evidenceComposing = false
443
+ const evidenceProvider: BackendEvidenceProvider = async () => {
444
+ if (evidenceComposing) {
445
+ throw new Error("[nifra/web] cyclic composed assurance evidence mount")
773
446
  }
774
- app.register("GET", route.pattern, undefined, async (c: RouteContext) => {
775
- // Enforce `fallback: "404"` before any work: an unlisted path under this route doesn't exist.
776
- // Covers hard navigation directly; a client soft-nav's data fetch gets the 404, throws, and the
777
- // history layer falls back to a full-page navigation (which lands here again, as a document).
778
- if (is404Fallback && !prerenderedSet.has(urlPartsFor(c.req).pathname)) {
779
- return renderNotFound(pathOf(c.req))
780
- }
781
- const mod = await route.load()
782
- const draft = await draftFlag(c.req)
783
- let data: unknown
784
- let layoutData: readonly unknown[] | undefined
785
- let boundaryStates: BoundaryStates | undefined
786
- let layoutModules: LoadedLayoutModules | undefined
787
- let layoutRetained: readonly number[] = []
788
- try {
789
- const ctx = {
790
- params: c.params,
791
- request: c.req,
792
- req: c.req,
793
- api,
794
- env: c.env,
795
- draft,
796
- search: loaderSearch(mod.searchSchema, c.req),
797
- }
798
- // Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
799
- // the document path would be bypassed by adding the data header, which is exactly the request
800
- // a client navigation makes - a guard has to hold on the path an attacker can pick.
801
- //
802
- // `runLayoutChain` returns once every GATE has resolved, with the non-gate loaders still in
803
- // flight. So the page loader starts as late as correctness requires and as early as speed
804
- // allows, and both are awaited together below.
805
- const run = await runLayoutChain(
806
- route,
807
- ctx,
808
- validatedRetainedIndices(c.req, route, c.params),
809
- )
810
- layoutModules = run.modules
811
- layoutRetained = run.retained
812
- const boundaryDefinitions = [
813
- ...run.modules.flatMap((layout) => layout.boundaries ?? []),
814
- ...(mod.boundaries ?? []),
815
- ]
816
- if (boundaryDefinitions.length > 0) boundaryDescriptors(boundaryDefinitions)
817
- const effectiveSearch = searchChainOf(run.modules, mod, c.req)
818
- const boundaryBatch =
819
- boundaryDefinitions.length === 0
820
- ? undefined
821
- : startDynamicBoundaries(boundaryDefinitions, {
822
- request: c.req,
823
- params: c.params,
824
- api,
825
- env: c.env,
826
- draft,
827
- search: effectiveSearch,
828
- signal: c.req.signal,
829
- } satisfies BoundaryRequestCtx)
830
- const staticBoundaryPromise =
831
- boundaryDefinitions.length === 0
832
- ? Promise.resolve(undefined)
833
- : resolveStaticBoundaries(
834
- boundaryDefinitions,
835
- { phase: "build", origin: originOf(c.req) },
836
- options.staticBoundaryCache,
837
- )
838
- // Boundary data is attached as one Deferred marker per slot. The shell can render
839
- // immediately with each slot pending, while the existing deferred protocol emits the first
840
- // completed sibling independently instead of waiting for the slowest boundary.
841
- // The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
842
- // modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
843
- const [pageData, , staticBoundaries] = await Promise.all([
844
- mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
845
- run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
846
- staticBoundaryPromise,
847
- ])
848
- data = pageData
849
- layoutData = run.layoutData
850
- if (boundaryBatch !== undefined) {
851
- const streamed = { ...boundaryBatch.initial, ...(staticBoundaries ?? {}) }
852
- for (const entry of boundaryBatch.pending) {
853
- const initial = streamed[entry.name]
854
- if (initial !== undefined)
855
- streamed[entry.name] = { ...initial, data: defer(entry.promise) }
856
- }
857
- boundaryStates = streamed
858
- } else if (staticBoundaries !== undefined) {
859
- boundaryStates = staticBoundaries
860
- }
861
- } catch (err) {
862
- // A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
863
- // that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
864
- // is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
865
- if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
866
- // A thrown control-flow signal (a guard's `redirect(...)`, an explicit error response) - let
867
- // it propagate to core, which renders it as-is, whether it is a plain render or a
868
- // hand-rolled `Response`. Real errors render the nearest `_error` boundary, if any; with
869
- // none, rethrow (unchanged 500 behavior).
870
- if (isControlFlow(err)) throw err
871
- // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
872
- if (options.onLoaderError !== undefined) {
873
- try {
874
- options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern })
875
- } catch {
876
- // A faulty reporter must never break error rendering.
877
- }
878
- }
879
- const errorId = boundaryFor(route, err)
880
- if (errorId === undefined) throw err
881
- // A soft-nav data fetch can't render a boundary - 500 so the client falls back to a full-page
882
- // navigation, which lands here as a document and renders the `_error` page.
883
- if (c.req.headers.get(DATA_HEADER) !== null) {
884
- return new Response("Internal Server Error", {
885
- status: 500,
886
- headers: { "content-type": "text/plain; charset=utf-8" },
887
- })
888
- }
889
- return renderError(route, errorId, withDuplicateInstanceHint(err))
890
- }
891
- // A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
892
- // reads naturally and must not silently serialize the signal as loader data. Mirror the catch
893
- // above exactly: a status signal renders its boundary, any other signal (a redirect, a
894
- // hand-rolled response) passes through to core verbatim - so return and throw are
895
- // interchangeable.
896
- if (isStatusSignal(data)) return renderStatusSignal(c.req, data)
897
- if (isControlFlow(data)) return data
898
- // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
899
- // document, no layout chain. A route with deferred data streams NDJSON (critical data first,
900
- // then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
901
- // same auth - only the transport differs.
902
- if (c.req.headers.get(DATA_HEADER) !== null) {
903
- const pageSplit = prepareDeferred(data)
904
- const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
905
- let offset = pageSplit.deferred.length
906
- for (const entry of layoutData ?? []) {
907
- const split = prepareDeferred(entry, offset)
908
- layoutSplits.push(split)
909
- offset += split.deferred.length
910
- }
911
- const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)]
912
- const boundarySplit =
913
- boundaryStates === undefined
914
- ? undefined
915
- : prepareDeferred(boundaryStates, deferred.length)
916
- const allDeferred = [
917
- ...deferred,
918
- ...(boundarySplit === undefined ? [] : boundarySplit.deferred),
919
- ]
920
- const payload =
921
- layoutData === undefined && boundarySplit === undefined
922
- ? pageSplit.forClient
923
- : {
924
- v: 1 as const,
925
- data: pageSplit.forClient,
926
- layoutData: layoutSplits.map((split) => split.forClient),
927
- retained: layoutRetained,
928
- ...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
929
- }
930
- // Bare value when there is no layout data - the pre-envelope shape, so a client running older
931
- // code against a newer server keeps working.
932
- if (allDeferred.length === 0) {
933
- return Response.json(payload ?? null)
447
+ evidenceComposing = true
448
+ try {
449
+ const { composeProjectEvidence, snapshotProjectEvidence } = await import(
450
+ "@nifrajs/core/evidence"
451
+ )
452
+ const parts: Array<{
453
+ readonly evidence: import("@nifrajs/core/evidence").ProjectEvidenceSnapshot
454
+ readonly pathPrefix?: string
455
+ }> = [{ evidence: snapshotProjectEvidence(app) }]
456
+ for (const mount of configuredMounts) {
457
+ const provider = evidenceProviderOf(mount.app)
458
+ if (provider === undefined) {
459
+ // A declared opaque mount reaches the snapshot as a known gap, through this app's own
460
+ // mount reflection above.
461
+ if (mount.opaque !== undefined) continue
462
+ throw new Error(
463
+ `[nifra/web] cannot compose assurance evidence: mount "${mount.path}" has no token-only evidence provider - declare why it is not analyzed with { opaque: "<reason>" }`,
464
+ )
934
465
  }
935
- return new Response(ndjsonStream(payload, allDeferred), {
936
- headers: { "content-type": "application/x-ndjson; charset=utf-8" },
466
+ parts.push({
467
+ evidence: await provider.call(mount.app),
468
+ ...(mount.stripPrefix === true ? { pathPrefix: mountPathPrefix(mount.path) } : {}),
937
469
  })
938
470
  }
939
- const chainLayoutModules = layoutModules ?? (await loadLayoutModules(route))
940
- const { chain, head } = resolveChainAndHead(chainLayoutModules, mod, {
941
- data,
942
- params: c.params,
943
- origin: originOf(c.req),
944
- })
945
- const hydrateRoute = mod.hydrate !== false
946
- try {
947
- // `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
948
- // can render the `_error` page - not just a loader throw. Mid-stream (post-shell) throws can't
949
- // be recovered to a full page; the per-adapter client boundary catches client render errors.
950
- return await renderPageResult({
951
- adapter,
952
- chain,
953
- data,
954
- head,
955
- clientEntry,
956
- routeId: route.id,
957
- params: c.params,
958
- path: pathOf(c.req),
959
- // The effective (layout chain + page) search, matching the page loader's `ctx.search` above and
960
- // the client mount - so `useSearch` renders SSR-correct and hydrates with no drift. `layoutModules`
961
- // is loaded by now (the loader try succeeded to reach here).
962
- search: searchChainOf(layoutModules ?? [], mod, c.req),
963
- hydrate: hydrateRoute,
964
- ...(layoutData !== undefined ? { layoutData } : {}),
965
- ...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
966
- ...preloadOf(route.id),
967
- ...stylesOf(route.id),
968
- prerenderedPaths: options.prerenderedPaths ?? [],
969
- ...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
970
- ...(mod.revalidateTags !== undefined ? { revalidateTags: mod.revalidateTags } : {}),
971
- ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
972
- ...titleOption,
973
- ...assemblyCacheFor(mod, chainLayoutModules),
974
- })
975
- } catch (err) {
976
- // Same precedence as the loader catch above - a `meta()` may signal too.
977
- if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
978
- if (isControlFlow(err)) throw err
979
- // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
980
- if (options.onLoaderError !== undefined) {
981
- try {
982
- options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern })
983
- } catch {
984
- // A faulty reporter must never break error rendering.
985
- }
471
+ if (mountedApi !== undefined && apiPrefix !== "") {
472
+ const provider = evidenceProviderOf(api)
473
+ if (provider === undefined) {
474
+ throw new Error(
475
+ `[nifra/web] cannot compose assurance evidence: auto-mounted API at "${apiPrefix}" has no token-only evidence provider`,
476
+ )
986
477
  }
987
- const errorId = boundaryFor(route, err)
988
- if (errorId === undefined) throw err
989
- return renderError(route, errorId, withDuplicateInstanceHint(err))
990
- }
991
- })
992
-
993
- // POST runs the route's `action` (mutation). A control-flow return (e.g. a `redirect(...)`)
994
- // passes straight through; a data return re-renders the page (the loader re-runs for fresh
995
- // data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
996
- app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
997
- const mod = await route.load()
998
- const draft = await draftFlag(c.req)
999
- if (mod.action === undefined) {
1000
- return new Response("Method Not Allowed", {
1001
- status: 405,
1002
- headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
1003
- })
1004
- }
1005
- const actionContext = {
1006
- params: c.params,
1007
- request: c.req,
1008
- req: c.req,
1009
- api,
1010
- env: c.env,
1011
- draft,
1012
- search: loaderSearch(mod.searchSchema, c.req),
1013
- }
1014
- const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
1015
- let layoutModules: LoadedLayoutModules
1016
- let result: unknown
1017
- try {
1018
- // A gate is an authorization boundary for everything beneath its layout, mutations included.
1019
- // Run only gates before the action; ordinary layout data loaders run after a native mutation.
1020
- layoutModules = await runLayoutGates(route, actionContext)
1021
- result = await mod.action(actionContext)
1022
- } catch (err) {
1023
- // A THROWN signal is the same control flow as a returned one (`throw redirect()` in a gate
1024
- // or an action) - route it through the same conversion as the returned branch below, so
1025
- // throw and return are interchangeable on the mutation path too.
1026
- if (isControlFlow(err)) return actionResponse(err, isDataRequest)
1027
- throw err
1028
- }
1029
- // An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
1030
- // Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
1031
- // responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
1032
- const isRevalidate =
1033
- result !== null && typeof result === "object" && "__nifraRevalidate" in result
1034
- const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
1035
- const revalidateHeader: Record<string, string> = isRevalidate
1036
- ? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
1037
- : {}
1038
- if (isControlFlow(actionResult)) return actionResponse(actionResult, isDataRequest)
1039
- // Client submit wants just the action's data (it revalidates the loader itself); a native
1040
- // form POST re-renders the full page (loader re-runs) with the action data.
1041
- if (isDataRequest) {
1042
- // An action may `defer()` slow parts of its result - stream them (critical data first, then
1043
- // each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
1044
- const { forClient, deferred } = prepareDeferred(actionResult)
1045
- if (deferred.length === 0)
1046
- return Response.json(actionResult ?? null, { headers: revalidateHeader })
1047
- return new Response(ndjsonStream(forClient, deferred), {
1048
- headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
478
+ parts.push({
479
+ evidence: await provider.call(api),
480
+ ...(apiStrip ? { pathPrefix: mountPathPrefix(apiPrefix) } : {}),
1049
481
  })
1050
482
  }
1051
- // Derived once and shared: the loader's `ctx.search` and the render's `RenderProps.search` are the
1052
- // SAME effective (layout chain + page) value, and the client mount rebuilds it identically. The
1053
- // layout modules are already loaded (the action ran through their gates above).
1054
- const search = searchChainOf(layoutModules, mod, c.req)
1055
- const data = mod.loader
1056
- ? await mod.loader({
1057
- params: c.params,
1058
- request: c.req,
1059
- req: c.req,
1060
- api,
1061
- env: c.env,
1062
- draft,
1063
- search,
1064
- })
1065
- : null
1066
- const { chain, head } = resolveChainAndHead(layoutModules, mod, {
1067
- data,
1068
- params: c.params,
1069
- origin: originOf(c.req),
1070
- })
1071
- const hydrateRoute = mod.hydrate !== false
1072
- // A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too -
1073
- // `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
1074
- return renderPageResult({
1075
- adapter,
1076
- chain,
1077
- data,
1078
- actionData: actionResult,
1079
- head,
1080
- clientEntry,
1081
- routeId: route.id,
1082
- params: c.params,
1083
- path: pathOf(c.req),
1084
- search,
1085
- hydrate: hydrateRoute,
1086
- ...preloadOf(route.id),
1087
- ...stylesOf(route.id),
1088
- prerenderedPaths: options.prerenderedPaths ?? [],
1089
- ...titleOption,
1090
- })
1091
- })
483
+ return composeProjectEvidence(parts)
484
+ } finally {
485
+ evidenceComposing = false
486
+ }
1092
487
  }
1093
-
1094
- // Register llms.txt & llms-full.txt
1095
- const llmsOptions = { includeLocalGuidelines: options.publishLocalGuidelines === true }
1096
- app.register("GET", "/llms.txt", undefined, async () => {
1097
- const text = await generateLlmsTxt(false, manifest.routes, api, llmsOptions)
1098
- return new Response(text, {
1099
- headers: { "content-type": "text/plain; charset=utf-8" },
1100
- })
488
+ Object.defineProperty(app, NIFRA_BACKEND_EVIDENCE, {
489
+ configurable: false,
490
+ enumerable: false,
491
+ value: evidenceProvider,
492
+ writable: false,
1101
493
  })
1102
494
 
1103
- app.register("GET", "/llms-full.txt", undefined, async () => {
1104
- const text = await generateLlmsTxt(true, manifest.routes, api, llmsOptions)
1105
- return new Response(text, {
1106
- headers: { "content-type": "text/plain; charset=utf-8" },
1107
- })
1108
- })
495
+ return app
496
+ }
1109
497
 
1110
- // Wildcard catch-all: unmatched paths render `_404` (404), or a plain text 404 if absent.
1111
- app.register("GET", "/*", undefined, (c: RouteContext) => renderNotFound(pathOf(c.req)))
498
+ /** Resolve one composed, token-only evidence snapshot for a web app and its mounted surfaces. */
499
+ export async function webProjectEvidence(
500
+ source: unknown,
501
+ ): Promise<import("@nifrajs/core/evidence").ProjectEvidenceSnapshot> {
502
+ const provider = evidenceProviderOf(source)
503
+ if (provider === undefined) {
504
+ throw new TypeError("webProjectEvidence(): expected an app created by createWebApp")
505
+ }
506
+ return provider.call(source)
507
+ }
1112
508
 
1113
- return app
509
+ // Up to this many characters of JSON, the prerendered set rides inline in each page's handover.
510
+ const INLINE_PRERENDERED_CHARS = 4096
511
+
512
+ /** The prerendered set as a served, content-versioned list, when it is too large to inline. */
513
+ function servedPrerenderedList(
514
+ paths: readonly string[] | undefined,
515
+ ): { readonly body: string; readonly version: string; readonly url: string } | undefined {
516
+ if (paths === undefined || paths.length === 0) return undefined
517
+ const body = JSON.stringify(paths)
518
+ if (body.length <= INLINE_PRERENDERED_CHARS) return undefined
519
+ // FNV-1a: a version that changes with the list is all the URL needs; a stale cached list only
520
+ // costs a fallback to the dynamic data request.
521
+ let hash = 0x811c9dc5
522
+ for (let i = 0; i < body.length; i++) hash = Math.imul(hash ^ body.charCodeAt(i), 0x01000193)
523
+ const version = (hash >>> 0).toString(16).padStart(8, "0")
524
+ return { body, version, url: `${PRERENDERED_LIST_PATH}?v=${version}` }
1114
525
  }