@nifrajs/web 3.5.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 (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 +111 -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/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 +485 -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 +386 -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 +72 -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 +187 -36
  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 +111 -18
  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 +623 -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 +670 -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 +110 -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 +207 -36
  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
package/src/manifest.ts CHANGED
@@ -4,8 +4,37 @@
4
4
  * the filesystem and feeds `buildManifest`; everything here is pure logic, so it stays
5
5
  * portable (no fs, no DOM) and fully unit-testable. Edge deploys pre-build the manifest.
6
6
  */
7
- import type { StandardSchemaV1 } from "@nifrajs/core/server"
7
+ import { routePatternOverlap } from "@nifrajs/core"
8
+ import { paramConstraint } from "@nifrajs/core/pattern"
9
+ import type { CookieOptions, ResponseResult, StandardSchemaV1 } from "@nifrajs/core/server"
8
10
  import type { BoundaryDescriptor, BoundaryRegistration } from "./boundary.ts"
11
+ import { guardChannel, outputGuard } from "./internal/output-guard.ts"
12
+ import type { RevalidateTags } from "./isr.ts"
13
+
14
+ /**
15
+ * Response controls a loader or action reaches as `ctx.set` - the page counterpart of a route
16
+ * handler's `c.set`. Write before the loader or action returns; a write from a deferred promise that
17
+ * settles later throws.
18
+ */
19
+ export interface LoaderResponseControls {
20
+ /**
21
+ * Headers for the rendered document (`cache-control`, `x-robots-tag`, `link`, ...). A layout's
22
+ * headers are applied first, then the page's, then the action's, so the most specific writer wins a
23
+ * name. They are not applied to a redirect, a status page, an error page, or a navigation data
24
+ * response. `content-type`, `set-cookie`, `location`, transport headers, and the `x-nifra-` prefix
25
+ * are refused.
26
+ */
27
+ readonly headers: Record<string, string>
28
+ /**
29
+ * Queue a `Set-Cookie`, with the same secure defaults as `c.set.cookie`
30
+ * (`HttpOnly; Secure; SameSite=Lax; Path=/`). The cookie rides every outcome - the document, a
31
+ * navigation data response, a redirect, a status or error page - and makes the response
32
+ * `cache-control: private, no-store`.
33
+ */
34
+ cookie(name: string, value: string, options?: CookieOptions): void
35
+ /** Queue a cookie deletion. Match the `path`/`domain` the cookie was set with. */
36
+ deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void
37
+ }
9
38
 
10
39
  /** Context passed to a route `loader`. The `api` + `env` are injected by `createWebApp` and typed
11
40
  * per-route via `@nifrajs/client`'s `LoaderArgs<Api, Env>` (here they are opaque to the agnostic core). */
@@ -24,11 +53,66 @@ export interface LoaderContext {
24
53
  * declares one - failing closed to the schema's defaults - else the raw parsed query. Typed per-route
25
54
  * via `@nifrajs/client`'s `LoaderArgs<Api, Env, Search>`. */
26
55
  readonly search: Record<string, unknown>
56
+ /** Response headers and cookies for this page request. */
57
+ readonly set: LoaderResponseControls
27
58
  }
28
59
 
29
60
  /** A route's optional data loader: params/request in, data out. */
30
61
  export type Loader = (ctx: LoaderContext) => unknown | Promise<unknown>
31
62
 
63
+ /**
64
+ * The `middleware` export of a `_layout.backend.ts` file. It runs on the server before the layouts, loaders and
65
+ * action of every route in its directory and below - on a document request, a client navigation and a
66
+ * form post alike - and before a nested `_404` there. Middleware higher in the tree runs first.
67
+ *
68
+ * - Return nothing to let the request through.
69
+ * - Return or throw a `redirect()`, a status such as `notFound()`, or a `Response` to answer with it:
70
+ * nothing below the middleware runs.
71
+ * - `ctx.set` adds headers and cookies as a loader's does. A layout's or the page's header of the same
72
+ * name wins.
73
+ * - `ctx.params` holds the params of the directory's own URL prefix, as a layout's loader sees them.
74
+ *
75
+ * It does not run for a prerendered page, an ISR cache hit, a mounted API or a static file. For
76
+ * middleware on every request, export `use` from `backend/framework.ts`. Pass a typed context to type
77
+ * `ctx.api` and `ctx.env`: `RouteMiddleware<LoaderArgs<typeof app, Env>>`.
78
+ */
79
+ export type RouteMiddleware<Ctx = LoaderContext> = (
80
+ ctx: Ctx,
81
+ ) => MiddlewareOutcome | Promise<MiddlewareOutcome>
82
+
83
+ /** What a {@link RouteMiddleware} answers with: nothing, to let the request through, or a response. */
84
+ export type MiddlewareOutcome = undefined | Response | ResponseResult
85
+
86
+ /** One client navigation, as a layout's {@link ShouldRevalidate} sees it. */
87
+ export interface ShouldRevalidateArgs {
88
+ /** The URL the client is leaving. */
89
+ readonly currentUrl: URL
90
+ /** The URL being loaded. */
91
+ readonly nextUrl: URL
92
+ /** Every route param of the page being left - not only the ones the layout owns. */
93
+ readonly currentParams: Readonly<Record<string, string>>
94
+ /** Every route param of the page being loaded. */
95
+ readonly nextParams: Readonly<Record<string, string>>
96
+ /** The answer without the hook: `true` when a param the layout owns or the query changed. */
97
+ readonly defaultShouldRevalidate: boolean
98
+ }
99
+
100
+ /**
101
+ * A layout's say over whether its loader runs again on a client navigation. Export it from a
102
+ * `_layout.backend.ts` next to the loader: `export const shouldRevalidate: ShouldRevalidate = (args) => ...`.
103
+ *
104
+ * - Asked on the server, on a client navigation that keeps this layout on screen, when the browser
105
+ * already holds its data. A document request, the revalidation after an action, and
106
+ * `router.invalidate()` always run the loader.
107
+ * - Return `false` to keep the data the browser has; any other value runs the loader. Return
108
+ * `args.defaultShouldRevalidate` to keep the default for the cases you do not handle.
109
+ * - Must be synchronous: a returned promise is not `false`, so the loader runs.
110
+ * - Never asked for a `gate` layout, which runs on every request.
111
+ * - A page's loader runs on every navigation and a page's `shouldRevalidate` is not read. Query keys
112
+ * a page's loader never uses belong in its `searchClientKeys`.
113
+ */
114
+ export type ShouldRevalidate = (args: ShouldRevalidateArgs) => boolean
115
+
32
116
  /**
33
117
  * A route's optional mutation, run on POST. Shares the loader context (params/request/api);
34
118
  * read the form/JSON body off `request`. Returns either a control-flow value (a `redirect()`, a
@@ -72,8 +156,9 @@ export type ClientAction = (
72
156
  export interface ClientRouteHooks {
73
157
  readonly clientLoader?: ClientLoader
74
158
  readonly clientAction?: ClientAction
75
- /** Neutral boundary descriptors needed for soft-navigation interception; no server loader crosses. */
76
- readonly boundaries?: readonly BoundaryDescriptor[]
159
+ /** Neutral boundary descriptors needed for soft-navigation interception. A boundary's loader lives in
160
+ * the route's backend half, so the browser never learns whether one exists. */
161
+ readonly boundaries?: ReadonlyArray<Omit<BoundaryDescriptor, "hasLoad">>
77
162
  }
78
163
 
79
164
  /**
@@ -189,6 +274,8 @@ export interface Meta {
189
274
  export interface MetaArgs<Data = unknown> {
190
275
  readonly data: Data
191
276
  readonly params: Record<string, string>
277
+ /** The resolved document CSP nonce, when this render uses per-request nonce protection. */
278
+ readonly nonce?: string
192
279
  /**
193
280
  * The site origin - scheme + host (+ port), e.g. `"https://news.example.com"`, **with no trailing
194
281
  * slash**. The single piece of server/env knowledge `meta()` otherwise can't see: it runs in BOTH
@@ -232,12 +319,24 @@ export interface RouteModule {
232
319
  readonly default: unknown
233
320
  readonly loader?: Loader
234
321
  readonly action?: Action
322
+ /**
323
+ * Backend half only: the schema of what {@link loader} sends to the browser. Required whenever the
324
+ * loader returns data. Keys it does not declare are dropped before the page renders; a declared
325
+ * field of the wrong shape fails the request with a 500 naming the field's path.
326
+ */
327
+ readonly loaderOutput?: StandardSchemaV1
328
+ /** Backend half only: the schema of what {@link action} sends back, held to the same rule. */
329
+ readonly actionOutput?: StandardSchemaV1
235
330
  /** Optional client-only loader. It runs after hydration and on client navigations. */
236
331
  readonly clientLoader?: ClientLoader
237
332
  /** Optional client-only submit wrapper. The server action remains mandatory for mutations. */
238
333
  readonly clientAction?: ClientAction
239
- /** Named async boundaries owned by this route. The adapter renders their neutral state seam. */
334
+ /** Named async boundaries owned by this route. The adapter renders their neutral state seam. A
335
+ * boundary's `load` comes from {@link boundaryLoaders} in the route's backend half; the frontend
336
+ * file declares only how it renders. */
240
337
  readonly boundaries?: readonly BoundaryRegistration[]
338
+ /** Backend half only: each boundary's server loader, keyed by boundary name. */
339
+ readonly boundaryLoaders?: Readonly<Record<string, BoundaryLoaderEntry>>
241
340
  /** A Standard Schema validating this route's URL search params. When present, `ctx.search` is parsed +
242
341
  * validated against it (failing closed to the schema's defaults on invalid input); type it into the
243
342
  * loader with `LoaderArgs<Api, Env, typeof searchSchema>`. */
@@ -256,6 +355,30 @@ export interface RouteModule {
256
355
  * island-hydrated pages where interactivity is mounted by smaller, explicit client entries.
257
356
  */
258
357
  readonly hydrate?: boolean
358
+ /**
359
+ * `false` renders this route's component in the browser only. The server still matches the route,
360
+ * runs its gates and loaders, renders the layouts around it and embeds the data - it skips the
361
+ * component alone, putting {@link HydrateFallback} (or nothing) in the page slot. The browser
362
+ * hydrates against that, then renders the component. For a component that cannot render on a
363
+ * server: one that reads `window` or a browser-only API while it renders.
364
+ *
365
+ * The module itself is still imported on the server, for its loader and options - so an import
366
+ * that needs a browser at load time belongs inside the component (a dynamic `import()`), not at the
367
+ * top of the file. Cannot be combined with `hydrate = false`: nothing would ever render the page.
368
+ */
369
+ readonly ssr?: boolean
370
+ /**
371
+ * What the server renders in the page slot of an `ssr = false` route, and what the browser hydrates
372
+ * before the component takes over - a skeleton, a spinner. Receives the same props as the
373
+ * component, loader `data` included. Omitted ⇒ the slot is empty until the component renders.
374
+ */
375
+ readonly HydrateFallback?: unknown
376
+ /**
377
+ * Any value to report through `useMatches` - a breadcrumb label, a render function, a flag a layout
378
+ * reads about the page inside it. Layouts, pages, and status pages may export one. It is read from
379
+ * the module on the server and in the browser, so it must be the same value on both.
380
+ */
381
+ readonly handle?: unknown
259
382
  /**
260
383
  * Opt a **static** route (no `:param`/`*`) into build-time prerendering (SSG): `prerenderRoutes`
261
384
  * (from `@nifrajs/web/build`) renders it to a static `index.html` at build. The loader runs at build
@@ -276,8 +399,12 @@ export interface RouteModule {
276
399
  * list the client parses to refetch - so the two channels never alias.)
277
400
  */
278
401
  readonly revalidate?: number
279
- /** Optional bounded tags used by ISR on-demand invalidation (`?tag=...`). */
280
- readonly revalidateTags?: readonly string[]
402
+ /**
403
+ * Bounded tags ISR on-demand invalidation (`?tag=...`) and a CDN purge reach this page by. A list, or
404
+ * a function of the route's params and URL (`({ params }) => [\`product:${params.id}\`]`) for tags
405
+ * per page. Tags travel to the CDN and reach anyone reading the origin: never put personal data in one.
406
+ */
407
+ readonly revalidateTags?: RevalidateTags
281
408
  /**
282
409
  * No-framework island bundles (`@nifrajs/web/islands`) to load on this route, as `<script
283
410
  * type="module">` in the document tail. Loaded **regardless of `hydrate`** - pair with
@@ -287,6 +414,13 @@ export interface RouteModule {
287
414
  readonly islandScripts?: readonly string[]
288
415
  }
289
416
 
417
+ /** A boundary's server loader, declared in the route's backend half under `boundaryLoaders`. */
418
+ export interface BoundaryLoaderEntry {
419
+ readonly load: NonNullable<BoundaryRegistration["load"]>
420
+ /** The boundary data's output schema. Required whenever `load` returns data. */
421
+ readonly output?: StandardSchemaV1
422
+ }
423
+
290
424
  /** A layout (or `_404`/`_error`) entry: its source file (for client codegen) + a lazy loader.
291
425
  *
292
426
  * A `_layout.tsx` may export `meta` (static {@link Meta} or a function of the loader data + params,
@@ -295,7 +429,11 @@ export interface RouteModule {
295
429
  * (`meta`/`link`) concatenate outermost→innermost→page; scalars (`title`) are nearest-wins (the page
296
430
  * overrides an inner layout, which overrides an outer one). See `mergeHeads` in `@nifrajs/web`. */
297
431
  export interface LayoutEntry {
432
+ /** The frontend half: the file the browser bundle imports. */
298
433
  readonly file: string
434
+ /** The backend half (`x.backend.ts`), when the file has one. Server-only: never in a client bundle. */
435
+ readonly backend?: string
436
+ /** Both halves, merged and checked. */
299
437
  readonly load: () => Promise<RouteModule>
300
438
  }
301
439
 
@@ -314,14 +452,65 @@ export interface RouteEntry {
314
452
  * Optional only so hand-built test manifests may omit it.
315
453
  */
316
454
  readonly layoutParams?: ReadonlyArray<readonly string[]>
455
+ /** `_middleware` ids in this route's ancestor chain (outermost → innermost), run before its
456
+ * layouts. Absent ⇒ none. */
457
+ readonly middlewareIds?: readonly string[]
458
+ /** Param names each middleware in {@link middlewareIds} sees, aligned by index: the params of its
459
+ * directory's URL prefix, as {@link layoutParams} holds them for a layout. */
460
+ readonly middlewareParams?: ReadonlyArray<readonly string[]>
317
461
  /** `_error` boundary ids in this route's ancestor chain (outermost → innermost). The last is the
318
462
  * **nearest** boundary, rendered when the route's loader throws. Always set by `buildManifest`
319
463
  * (optional only so hand-built test manifests may omit it); absent/empty ⇒ no boundary (error 500s). */
320
464
  readonly errorIds?: readonly string[]
465
+ /** Nested `_404` page ids in this route's ancestor chain (outermost → innermost); the root `_404`
466
+ * is not listed. The last is the **nearest**, rendered when the route's loader answers
467
+ * `notFound()`. Absent ⇒ the root `_404`. */
468
+ readonly notFoundIds?: readonly string[]
469
+ /** `_loading` page ids in this route's ancestor chain (outermost → innermost). A client navigation
470
+ * to the route shows the innermost one whose layouts are already on screen. Absent ⇒ none. */
471
+ readonly loadingIds?: readonly string[]
472
+ /** The frontend half: the file the browser bundle imports. */
321
473
  readonly file: string
474
+ /** The backend half (`x.backend.ts`), when the route has one. Server-only. */
475
+ readonly backend?: string
476
+ /** Both halves, merged and checked. */
322
477
  readonly load: () => Promise<RouteModule>
323
478
  }
324
479
 
480
+ /** One URL pattern a nested `_404` answers when no route matches it. */
481
+ export interface NotFoundScope {
482
+ /** The directory's URL prefix (`/admin`) or everything beneath it (`/admin/*`). */
483
+ readonly pattern: string
484
+ /** Param names each layout in {@link NotFoundEntry.layoutIds} owns on this pattern, by index. */
485
+ readonly layoutParams: ReadonlyArray<readonly string[]>
486
+ /** Param names each middleware in {@link NotFoundEntry.middlewareIds} sees on this pattern, by
487
+ * index. Absent when the page has no middleware. */
488
+ readonly middlewareParams?: ReadonlyArray<readonly string[]>
489
+ }
490
+
491
+ /** A `_404` page below the routes root. */
492
+ export interface NotFoundEntry extends LayoutEntry {
493
+ /** Layouts at or above the page's directory (outermost → innermost) - the ones it renders inside. */
494
+ readonly layoutIds: readonly string[]
495
+ /** `_error` boundaries at or above the page's directory (outermost → innermost). */
496
+ readonly errorIds: readonly string[]
497
+ /** `_middleware` ids at or above the page's directory (outermost → innermost). Absent ⇒ none. */
498
+ readonly middlewareIds?: readonly string[]
499
+ /**
500
+ * The URL patterns this page answers when no route matches. Empty when the directory is a
501
+ * catch-all, or when a `_404` in a directory above it answers the same URLs - the page is then
502
+ * reached only by `notFound()` from a route beneath it.
503
+ */
504
+ readonly scopes: readonly NotFoundScope[]
505
+ }
506
+
507
+ /** A `_loading` page: what the page slot shows while a client navigation loads. */
508
+ export interface LoadingEntry extends LayoutEntry {
509
+ /** Layouts at or above the page's directory (outermost → innermost). The page shows only while
510
+ * every one of them is on screen, so it never renders outside a layout above it. */
511
+ readonly layoutIds: readonly string[]
512
+ }
513
+
325
514
  /** The full route manifest. */
326
515
  export interface Manifest {
327
516
  readonly routes: readonly RouteEntry[]
@@ -329,12 +518,33 @@ export interface Manifest {
329
518
  /** Per-segment `_error` boundary components, keyed by id (`_error`, `a/_error`, …). Always set by
330
519
  * `buildManifest` (optional only so hand-built test manifests may omit it). */
331
520
  readonly errors?: Readonly<Record<string, LayoutEntry>>
521
+ /** The `_404` at the routes root: unmatched URLs and `notFound()` wherever no nested one applies. */
332
522
  readonly notFound?: LayoutEntry
523
+ /**
524
+ * `_404` pages below the routes root, keyed by id (`admin/_404`, …). Each answers `notFound()` from
525
+ * the routes beneath its directory and the unmatched URLs under that directory's URL prefix, and
526
+ * renders inside the layouts at or above it. Absent when the app has none.
527
+ */
528
+ readonly notFounds?: Readonly<Record<string, NotFoundEntry>>
333
529
  /** Per-status terminal pages from `_<status>.tsx` at the routes root (`_410`, `_451`, …), keyed by
334
530
  * the status as a string. Rendered by a loader's `gone()` / `statusPage(n)`; a status with no page
335
531
  * falls back to `_404`, and then to plain text. `_404` itself stays on {@link notFound} - it is
336
532
  * reached by unmatched paths too, which these are not. */
337
533
  readonly statusPages?: Readonly<Record<string, LayoutEntry>>
534
+ /**
535
+ * `_loading` pages, keyed by id (`_loading`, `admin/_loading`, …). A client navigation that is
536
+ * still loading after a short delay shows the target route's nearest one in the page slot, inside
537
+ * the layouts the two pages share. Browser-only: the server never renders one. Absent when the app
538
+ * has none.
539
+ */
540
+ readonly loadings?: Readonly<Record<string, LoadingEntry>>
541
+ /**
542
+ * Middleware candidates, keyed by id (`_middleware`, `admin/_middleware`, …): one per directory
543
+ * with a `_layout.backend.ts`, whose `middleware` export each loads as `default` (`undefined` when
544
+ * that file exports none). Server-only: the client build never imports one. Absent when no
545
+ * directory has a layout backend half.
546
+ */
547
+ readonly middlewares?: Readonly<Record<string, LayoutEntry>>
338
548
  }
339
549
 
340
550
  // `.svelte` and `.vue` routes are supported too: their `default` export is the component and
@@ -354,13 +564,211 @@ const GROUP = /^\(.+\)$/
354
564
  // thing. Restricted to 3 digits so `_401k` or `_4` is treated as an ordinary underscore-prefixed
355
565
  // file (ignored) rather than silently becoming a status page.
356
566
  const STATUS_PAGE = /^_[1-5][0-9][0-9]$/
567
+ // The retired `_middleware.ts`. Discovery still collects it, so that it fails loudly rather than
568
+ // silently stopping guarding anything.
569
+ const MIDDLEWARE_FILE = /^_middleware\.(?:ts|js)$/
570
+ // A route's backend half: `x.backend.ts` beside `x.tsx`. Script extensions only - JSX renders, and
571
+ // rendering belongs to the frontend half.
572
+ const ROUTE_BACKEND = /\.backend\.(?:[cm]?[jt]s)$/
573
+ // A zone or retired suffix on a route file (`x.backend.tsx`) would otherwise become a URL segment.
574
+ const SUFFIXED_ROUTE = /\.(?:backend|frontend|shared|fn|server)$/
357
575
 
358
576
  const stripExt = (file: string): string => file.replace(ROUTE_EXT, "")
359
577
  const baseName = (file: string): string => file.slice(file.lastIndexOf("/") + 1)
578
+
579
+ /** Whether a routes-relative path is a retired `_middleware` module. Discovery collects it only so
580
+ * that {@link buildManifest} can refuse it with the migration. */
581
+ export const isMiddlewareFile = (file: string): boolean => MIDDLEWARE_FILE.test(baseName(file))
582
+
583
+ /** Whether a routes-relative path is a route's backend half (`x.backend.ts`). */
584
+ export const isRouteBackendFile = (file: string): boolean => ROUTE_BACKEND.test(baseName(file))
585
+
586
+ /** The backend half a frontend route file pairs with: `blog/[slug].tsx` -> `blog/[slug].backend.ts`. */
587
+ export const backendFileFor = (file: string): string => `${stripExt(file)}.backend.ts`
588
+
589
+ /** Exports the browser runs or reads. They live in a route's frontend file, which ships whole. */
590
+ export const FRONTEND_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
591
+ "default",
592
+ "meta",
593
+ "handle",
594
+ "HydrateFallback",
595
+ "searchSchema",
596
+ "searchClientKeys",
597
+ "clientLoader",
598
+ "clientAction",
599
+ "boundaries",
600
+ "ssr",
601
+ ])
602
+
603
+ /** Exports only the server or the build reads. They live in the route's `x.backend.ts`. */
604
+ export const BACKEND_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
605
+ "loader",
606
+ "action",
607
+ "loaderOutput",
608
+ "actionOutput",
609
+ "boundaryLoaders",
610
+ "getStaticPaths",
611
+ "prerender",
612
+ "revalidate",
613
+ "revalidateTags",
614
+ "hydrate",
615
+ "islandScripts",
616
+ "middleware",
617
+ "gate",
618
+ "shouldRevalidate",
619
+ ])
620
+
621
+ const routeError = (message: string): Error => new Error(`[nifra/web] ${message}`)
622
+
623
+ /** The frontend export names a backend-only name was found under, for the move-it message. */
624
+ function assertFrontendHalf(file: string, front: Readonly<Record<string, unknown>>): void {
625
+ for (const name of Object.keys(front)) {
626
+ if (BACKEND_ROUTE_EXPORTS.has(name)) {
627
+ throw routeError(
628
+ `"${file}" exports "${name}", which runs on the server only. Move it to "${backendFileFor(file)}": a route's frontend file ships to the browser whole (\`nifra migrate layout\` splits every route of an app)`,
629
+ )
630
+ }
631
+ }
632
+ const boundaries = front.boundaries
633
+ if (Array.isArray(boundaries)) {
634
+ for (const boundary of boundaries as ReadonlyArray<{ name?: unknown; load?: unknown }>) {
635
+ if (boundary?.load !== undefined) {
636
+ throw routeError(
637
+ `boundary "${String(boundary.name)}" in "${file}" declares its load function in the frontend file. Move it to boundaryLoaders["${String(boundary.name)}"].load in "${backendFileFor(file)}"`,
638
+ )
639
+ }
640
+ }
641
+ }
642
+ }
643
+
644
+ function assertBackendHalf(
645
+ file: string,
646
+ backend: string,
647
+ back: Readonly<Record<string, unknown>>,
648
+ allowMiddleware: boolean,
649
+ ): void {
650
+ for (const name of Object.keys(back)) {
651
+ if (name === "middleware" && !allowMiddleware) {
652
+ throw routeError(
653
+ `"${backend}" exports "middleware", which only a directory's _layout.backend.ts may export`,
654
+ )
655
+ }
656
+ if (BACKEND_ROUTE_EXPORTS.has(name)) continue
657
+ throw routeError(
658
+ FRONTEND_ROUTE_EXPORTS.has(name)
659
+ ? `"${backend}" exports "${name}", which the browser needs. Move it to "${file}"`
660
+ : `"${backend}" exports "${name}", which nifra does not read from a backend half. It may export only: ${[...BACKEND_ROUTE_EXPORTS].join(", ")}`,
661
+ )
662
+ }
663
+ }
664
+
665
+ // One merged module per (frontend namespace, backend namespace) pair. A runtime caches each module,
666
+ // so a page served many times merges once; a dev re-import yields new namespaces and a fresh merge.
667
+ const mergedHalves = new WeakMap<
668
+ object,
669
+ { readonly back: object | undefined; readonly module: RouteModule }
670
+ >()
671
+
672
+ /**
673
+ * Merge a route's two halves into the one {@link RouteModule} the server runs, refusing any export on
674
+ * the wrong side. The checks run on every server, dev and production alike, so a misplaced `loader`
675
+ * fails the first request to its route even before a client build has looked at it.
676
+ */
677
+ export function mergeRouteHalves(
678
+ file: string,
679
+ front: RouteModule,
680
+ backend: string | undefined,
681
+ back: Readonly<Record<string, unknown>> | undefined,
682
+ ): RouteModule {
683
+ const cached = mergedHalves.get(front)
684
+ if (cached !== undefined && cached.back === back) return cached.module
685
+ assertFrontendHalf(file, front as unknown as Readonly<Record<string, unknown>>)
686
+ if (back === undefined || backend === undefined) {
687
+ mergedHalves.set(front, { back, module: front })
688
+ return front
689
+ }
690
+ assertBackendHalf(file, backend, back, stripExt(baseName(file)) === "_layout")
691
+ const {
692
+ middleware: _middleware,
693
+ boundaryLoaders,
694
+ loader,
695
+ action,
696
+ loaderOutput,
697
+ actionOutput,
698
+ ...server
699
+ } = back
700
+ const guarded: { loader?: unknown; action?: unknown } = {}
701
+ for (const [name, fn, schema, schemaName] of [
702
+ ["loader", loader, loaderOutput, "loaderOutput"],
703
+ ["action", action, actionOutput, "actionOutput"],
704
+ ] as const) {
705
+ if (fn === undefined) {
706
+ if (schema !== undefined) {
707
+ throw routeError(`"${backend}" exports ${schemaName} but no ${name} for it to describe`)
708
+ }
709
+ continue
710
+ }
711
+ if (typeof fn !== "function")
712
+ throw routeError(`"${backend}" exports a ${name} that is not a function`)
713
+ guarded[name] = guardChannel(fn as (...args: unknown[]) => unknown, {
714
+ label: `the ${name} of "${file}"`,
715
+ guard: schema === undefined ? undefined : outputGuard(schema, `"${backend}"`, schemaName),
716
+ missing: `export const ${schemaName} = t.object({ ... }) from "${backend}"`,
717
+ revalidate: name === "action",
718
+ })
719
+ }
720
+ let boundaries = front.boundaries
721
+ if (boundaryLoaders !== undefined) {
722
+ if (typeof boundaryLoaders !== "object" || boundaryLoaders === null) {
723
+ throw routeError(`"${backend}" exports boundaryLoaders that is not an object of loaders`)
724
+ }
725
+ const loaders = boundaryLoaders as Readonly<
726
+ Record<string, { readonly load?: unknown; readonly output?: unknown }>
727
+ >
728
+ const declared = new Set((front.boundaries ?? []).map((boundary) => boundary.name))
729
+ for (const [name, entry] of Object.entries(loaders)) {
730
+ if (!declared.has(name)) {
731
+ throw routeError(
732
+ `"${backend}" has boundaryLoaders["${name}"], but "${file}" declares no boundary named "${name}"`,
733
+ )
734
+ }
735
+ if (typeof entry?.load !== "function") {
736
+ throw routeError(`boundaryLoaders["${name}"].load in "${backend}" must be a function`)
737
+ }
738
+ }
739
+ boundaries = front.boundaries?.map((boundary) => {
740
+ const entry = loaders[boundary.name]
741
+ if (entry === undefined) return boundary
742
+ const key = `boundaryLoaders["${boundary.name}"]`
743
+ const load = guardChannel(entry.load as (...args: unknown[]) => unknown, {
744
+ label: `${key} of "${backend}"`,
745
+ guard:
746
+ entry.output === undefined
747
+ ? undefined
748
+ : outputGuard(entry.output, `"${backend}"`, `${key}.output`),
749
+ missing: `${key}.output in "${backend}"`,
750
+ })
751
+ return { ...boundary, load } as BoundaryRegistration
752
+ })
753
+ }
754
+ const module = {
755
+ ...front,
756
+ ...server,
757
+ ...guarded,
758
+ ...(boundaries === undefined ? {} : { boundaries }),
759
+ } as RouteModule
760
+ mergedHalves.set(front, { back, module })
761
+ return module
762
+ }
360
763
  const dirOf = (file: string): string =>
361
764
  file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ""
362
765
  const layoutIdFor = (dir: string): string => (dir === "" ? "_layout" : `${dir}/_layout`)
363
766
  const errorIdFor = (dir: string): string => (dir === "" ? "_error" : `${dir}/_error`)
767
+ const notFoundIdFor = (dir: string): string => `${dir}/_404`
768
+ const loadingIdFor = (dir: string): string => (dir === "" ? "_loading" : `${dir}/_loading`)
769
+ const middlewareIdFor = (dir: string): string => (dir === "" ? "_middleware" : `${dir}/_middleware`)
770
+ const isDirAtOrAbove = (dir: string, other: string): boolean =>
771
+ dir === "" || dir === other || other.startsWith(`${dir}/`)
364
772
 
365
773
  /**
366
774
  * Derive **every** nifra router pattern a route file maps to (relative to the routes dir):
@@ -386,6 +794,9 @@ const FILE_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
386
794
  * `stripExt` already reduces `[inKey].txt.tsx` to `[inKey].txt` - `robots.txt.tsx` proves literal
387
795
  * dots survive into a segment, so this was only ever a parser gap.
388
796
  *
797
+ * A `{...}` group straight after a marker that the router would read as a param constraint
798
+ * (`[id]{a|b}`) is rejected too: see the check below.
799
+ *
389
800
  * `[[optional]]` and `[...catchAll]` are **rejected** inside a mixed segment rather than given a
390
801
  * meaning. There is no sensible absent form for `/[[locale]]-feed.xml` - `/-feed.xml` and dropping
391
802
  * the segment are both surprising - and a catch-all captures the remaining path verbatim, which
@@ -421,6 +832,13 @@ function mixedFileSegment(seg: string, file: string): string | undefined {
421
832
  }
422
833
  pattern += `${seg.slice(cursor, marker.index)}:${inner}`
423
834
  cursor = (marker.index ?? 0) + marker[0].length
835
+ // The router reads `:name{...}` as a constraint on the param. A file name is not the place for
836
+ // one: links, prerendered paths and hydration all rebuild a URL from the pattern by name.
837
+ if (paramConstraint(seg.slice(cursor)) !== undefined) {
838
+ throw new Error(
839
+ `[nifra/web] a param constraint cannot be written in a route file name: "${file}" (segment "${seg}"). Check the value in the route's loader instead, or rename the file so "[${inner}]" is not followed by a "{...}" group.`,
840
+ )
841
+ }
424
842
  }
425
843
  return pattern + seg.slice(cursor)
426
844
  }
@@ -550,6 +968,14 @@ function paramsInPrefix(pattern: string, count: number): string[] {
550
968
  return names
551
969
  }
552
970
 
971
+ /** The params each directory in `dirs` owns on one expanded pattern - see {@link paramsInPrefix}. */
972
+ const paramsOwnedBy = (
973
+ dirs: readonly string[],
974
+ pattern: string,
975
+ depths: readonly number[],
976
+ ): string[][] =>
977
+ dirs.map((dir) => paramsInPrefix(pattern, depths[dir === "" ? 0 : dir.split("/").length] ?? 0))
978
+
553
979
  const ancestorDirs = (file: string): string[] => {
554
980
  const dirs = [""]
555
981
  const dir = dirOf(file)
@@ -565,8 +991,8 @@ const ancestorDirs = (file: string): string[] => {
565
991
  /**
566
992
  * Build a manifest from route file paths (relative to the routes dir) + an `importer` that
567
993
  * turns a path into a lazy module loader. Pure - no fs. Throws at boot (the loud-and-early
568
- * RouteConfigError ethos) on duplicate patterns. `_layout`/`_404`/`_error` files are special; other
569
- * `_`-prefixed files are ignored (private/colocated, never routed).
994
+ * RouteConfigError ethos) on duplicate patterns. `_layout`/`_404`/`_error`/`_loading`/`_middleware`
995
+ * files are special; other `_`-prefixed files are ignored (private/colocated, never routed).
570
996
  */
571
997
  export function buildManifest(
572
998
  files: readonly string[],
@@ -577,26 +1003,105 @@ export function buildManifest(
577
1003
  const errorDirs = new Set<string>()
578
1004
  const errors: Record<string, LayoutEntry> = {}
579
1005
  let notFound: LayoutEntry | undefined
1006
+ const notFoundFiles = new Map<string, string>()
580
1007
  const statusPages: Record<string, LayoutEntry> = {}
1008
+ const loadingFiles = new Map<string, string>()
1009
+ const middlewareFiles = new Map<string, string>()
581
1010
  const routeFiles: string[] = []
582
1011
 
1012
+ // Pair each frontend file with its `x.backend.ts`. A backend half is never a route of its own.
1013
+ const backendByStem = new Map<string, string>()
1014
+ const frontendFiles: string[] = []
583
1015
  for (const file of files) {
1016
+ if (isMiddlewareFile(file)) {
1017
+ throw routeError(
1018
+ `"${file}" is retired: export \`middleware\` from "${dirOf(file) === "" ? "" : `${dirOf(file)}/`}_layout.backend.ts" instead (\`nifra migrate layout\` moves it)`,
1019
+ )
1020
+ }
1021
+ if (!isRouteBackendFile(file)) {
1022
+ frontendFiles.push(file)
1023
+ continue
1024
+ }
1025
+ const stem = file.replace(ROUTE_BACKEND, "")
1026
+ const other = backendByStem.get(stem)
1027
+ if (other !== undefined) {
1028
+ throw routeError(`two backend halves for one route: "${other}" and "${file}"; keep one`)
1029
+ }
1030
+ backendByStem.set(stem, file)
1031
+ }
1032
+ const pairedBackends = new Set<string>()
1033
+ const frontendStems = new Map<string, string>()
1034
+ const halves = (
1035
+ file: string,
1036
+ ): { readonly backend?: string; readonly load: () => Promise<RouteModule> } => {
1037
+ const backend = backendByStem.get(stripExt(file))
1038
+ const loadFront = importer(file)
1039
+ if (backend === undefined) {
1040
+ return {
1041
+ load: () =>
1042
+ loadFront().then((front) => mergeRouteHalves(file, front, undefined, undefined)),
1043
+ }
1044
+ }
1045
+ pairedBackends.add(backend)
1046
+ const loadBack = importer(backend)
1047
+ return {
1048
+ backend,
1049
+ load: () =>
1050
+ Promise.all([loadFront(), loadBack()]).then(([front, back]) =>
1051
+ mergeRouteHalves(
1052
+ file,
1053
+ front,
1054
+ backend,
1055
+ back as unknown as Readonly<Record<string, unknown>>,
1056
+ ),
1057
+ ),
1058
+ }
1059
+ }
1060
+
1061
+ // Every `_layout.backend.ts` is a middleware candidate for its directory, with or without a
1062
+ // frontend layout beside it; one exporting no `middleware` resolves to `undefined` and is skipped.
1063
+ for (const [stem, backend] of backendByStem) {
1064
+ if (baseName(stem) !== "_layout") continue
1065
+ middlewareFiles.set(dirOf(backend), backend)
1066
+ pairedBackends.add(backend)
1067
+ }
1068
+
1069
+ for (const file of frontendFiles) {
584
1070
  const stem = stripExt(baseName(file))
1071
+ const twin = frontendStems.get(stripExt(file))
1072
+ if (twin !== undefined) {
1073
+ throw routeError(`two frontend files for one route: "${twin}" and "${file}"; keep one`)
1074
+ }
1075
+ frontendStems.set(stripExt(file), file)
1076
+ if (SUFFIXED_ROUTE.test(stem)) {
1077
+ throw routeError(
1078
+ `"${file}" is a route file with a zone suffix. A route's backend half is a script file ("${stem}.ts"), and other zoned code belongs outside routes/`,
1079
+ )
1080
+ }
1081
+ if (stem === "_middleware") {
1082
+ throw routeError(
1083
+ `"${file}" is retired: export \`middleware\` from "${dirOf(file) === "" ? "" : `${dirOf(file)}/`}_layout.backend.ts" instead (\`nifra migrate layout\` moves it)`,
1084
+ )
1085
+ }
585
1086
  if (stem === "_layout") {
586
1087
  const dir = dirOf(file)
587
1088
  layoutDirs.add(dir)
588
- layouts[layoutIdFor(dir)] = { file, load: importer(file) }
1089
+ layouts[layoutIdFor(dir)] = { file, ...halves(file) }
589
1090
  } else if (stem === "_error") {
590
1091
  const dir = dirOf(file)
591
1092
  errorDirs.add(dir)
592
- errors[errorIdFor(dir)] = { file, load: importer(file) }
1093
+ errors[errorIdFor(dir)] = { file, ...halves(file) }
593
1094
  } else if (stem === "_404") {
594
- notFound = { file, load: importer(file) }
1095
+ const dir = dirOf(file)
1096
+ notFoundFiles.set(dir, file)
1097
+ if (dir === "") notFound = { file, ...halves(file) }
1098
+ } else if (stem === "_loading") {
1099
+ loadingFiles.set(dirOf(file), file)
595
1100
  } else if (STATUS_PAGE.test(stem) && dirOf(file) === "") {
596
1101
  // `_410.tsx`, `_451.tsx`, … at the routes root. Root-only: unlike `_error`, these are not
597
1102
  // resolved per segment - a terminal status is a property of the outcome, not of where in the
598
1103
  // tree the route lives, and a per-segment variant would need a precedence rule nobody asked for.
599
- statusPages[stem.slice(1)] = { file, load: importer(file) }
1104
+ statusPages[stem.slice(1)] = { file, ...halves(file) }
600
1105
  } else if (!stem.startsWith("_")) {
601
1106
  routeFiles.push(file)
602
1107
  }
@@ -609,8 +1114,15 @@ export function buildManifest(
609
1114
  const layoutDirsForFile = dirs.filter((dir) => layoutDirs.has(dir))
610
1115
  const layoutIds = layoutDirsForFile.map(layoutIdFor)
611
1116
  const errorIds = dirs.filter((dir) => errorDirs.has(dir)).map(errorIdFor)
1117
+ const notFoundIds = dirs
1118
+ .filter((dir) => dir !== "" && notFoundFiles.has(dir))
1119
+ .map(notFoundIdFor)
1120
+ const loadingIds = dirs.filter((dir) => loadingFiles.has(dir)).map(loadingIdFor)
1121
+ const middlewareDirsForFile = dirs.filter((dir) => middlewareFiles.has(dir))
1122
+ const middlewareIds = middlewareDirsForFile.map(middlewareIdFor)
612
1123
  const id = stripExt(file)
613
- const load = importer(file) // one lazy loader per file, shared by its (possibly expanded) patterns
1124
+ // One lazy loader per file, shared by its (possibly expanded) patterns.
1125
+ const { backend, load } = halves(file)
614
1126
  // An optional `[[x]]` segment expands a file into multiple patterns, all pointing at the same
615
1127
  // module (same id/load/layout chain). Distinct patterns ⇒ no match ambiguity (different lengths).
616
1128
  for (const { pattern, depths } of filePathToRoutes(file)) {
@@ -620,19 +1132,161 @@ export function buildManifest(
620
1132
  `[nifra/web] duplicate route: "${file}" and "${existing}" both map to "${pattern}"`,
621
1133
  )
622
1134
  }
1135
+ for (const previous of routes) {
1136
+ const witness = routePatternOverlap(previous.pattern, pattern)
1137
+ if (witness !== undefined) {
1138
+ throw new Error(
1139
+ `[nifra/web] overlapping routes: "${previous.file}" (${previous.pattern}) and "${file}" (${pattern}) both match "${witness}"; make the patterns disjoint or remove one route`,
1140
+ )
1141
+ }
1142
+ }
623
1143
  byPattern.set(pattern, file)
624
- const layoutParams = layoutDirsForFile.map((dir) =>
625
- paramsInPrefix(pattern, depths[dir === "" ? 0 : dir.split("/").length] ?? 0),
1144
+ routes.push({
1145
+ id,
1146
+ pattern,
1147
+ layoutIds,
1148
+ layoutParams: paramsOwnedBy(layoutDirsForFile, pattern, depths),
1149
+ ...(middlewareIds.length > 0
1150
+ ? {
1151
+ middlewareIds,
1152
+ middlewareParams: paramsOwnedBy(middlewareDirsForFile, pattern, depths),
1153
+ }
1154
+ : {}),
1155
+ errorIds,
1156
+ ...(notFoundIds.length > 0 ? { notFoundIds } : {}),
1157
+ ...(loadingIds.length > 0 ? { loadingIds } : {}),
1158
+ file,
1159
+ ...(backend === undefined ? {} : { backend }),
1160
+ load,
1161
+ })
1162
+ }
1163
+ }
1164
+
1165
+ // Nested `_404` pages. Each answers the unmatched URLs under its directory's URL prefix; when two
1166
+ // directories share a prefix (a route group adds no segment), the one that contains the others
1167
+ // answers, and with no such directory the choice would be arbitrary - so it is a boot error.
1168
+ const scopeOwners = new Map<string, string[]>()
1169
+ const scopesByDir = new Map<string, Array<NotFoundScope & { readonly shape: string }>>()
1170
+ for (const dir of notFoundFiles.keys()) {
1171
+ const layoutDirsForDir = ancestorDirs(`${dir}/_404`).filter((d) => layoutDirs.has(d))
1172
+ const middlewareDirsForDir = ancestorDirs(`${dir}/_404`).filter((d) => middlewareFiles.has(d))
1173
+ const scopes: Array<NotFoundScope & { readonly shape: string }> = []
1174
+ const forms =
1175
+ dir === "" ? [{ pattern: "/", depths: [0] }] : filePathToRoutes(`${dir}/index.tsx`)
1176
+ for (const { pattern: prefix, depths } of forms) {
1177
+ // A catch-all directory already matches everything beneath it: nothing there is unmatched.
1178
+ if (/(?:^|\/)\*[^/]*$/.test(prefix)) continue
1179
+ const layoutParams = paramsOwnedBy(layoutDirsForDir, prefix, depths)
1180
+ const middlewareParams =
1181
+ middlewareDirsForDir.length === 0
1182
+ ? undefined
1183
+ : paramsOwnedBy(middlewareDirsForDir, prefix, depths)
1184
+ for (const pattern of prefix === "/" ? ["/*"] : [prefix, `${prefix}/*`]) {
1185
+ // Two directories answer the same URLs when their patterns differ only in param names.
1186
+ const shape = pattern.replace(/([:*])[A-Za-z_][A-Za-z0-9_]*/g, "$1")
1187
+ if (scopes.some((scope) => scope.shape === shape)) continue
1188
+ scopes.push({
1189
+ pattern,
1190
+ layoutParams,
1191
+ ...(middlewareParams === undefined ? {} : { middlewareParams }),
1192
+ shape,
1193
+ })
1194
+ const owners = scopeOwners.get(shape)
1195
+ if (owners === undefined) scopeOwners.set(shape, [dir])
1196
+ else owners.push(dir)
1197
+ }
1198
+ }
1199
+ scopesByDir.set(dir, scopes)
1200
+ }
1201
+ const ownerOf = new Map<string, string>()
1202
+ for (const [shape, dirs] of scopeOwners) {
1203
+ const owner = dirs.find((dir) => dirs.every((other) => isDirAtOrAbove(dir, other)))
1204
+ if (owner === undefined) {
1205
+ const [first, second] = dirs as [string, string]
1206
+ throw new Error(
1207
+ `[nifra/web] ambiguous _404: "${notFoundFiles.get(first)}" and "${notFoundFiles.get(second)}" both answer unmatched URLs under "${shape.replace(/\/?\*$/, "") || "/"}"; keep one, or add a _404 in a directory that contains both`,
626
1208
  )
627
- routes.push({ id, pattern, layoutIds, layoutParams, errorIds, file, load })
628
1209
  }
1210
+ ownerOf.set(shape, owner)
1211
+ }
1212
+ const notFounds: Record<string, NotFoundEntry> = {}
1213
+ for (const [dir, file] of notFoundFiles) {
1214
+ if (dir === "") continue
1215
+ const dirs = ancestorDirs(file)
1216
+ const middlewareIds = dirs.filter((d) => middlewareFiles.has(d)).map(middlewareIdFor)
1217
+ notFounds[notFoundIdFor(dir)] = {
1218
+ file,
1219
+ ...halves(file),
1220
+ layoutIds: dirs.filter((d) => layoutDirs.has(d)).map(layoutIdFor),
1221
+ errorIds: dirs.filter((d) => errorDirs.has(d)).map(errorIdFor),
1222
+ ...(middlewareIds.length > 0 ? { middlewareIds } : {}),
1223
+ scopes: (scopesByDir.get(dir) ?? [])
1224
+ .filter((scope) => ownerOf.get(scope.shape) === dir)
1225
+ .map(({ pattern, layoutParams, middlewareParams }) => ({
1226
+ pattern,
1227
+ layoutParams,
1228
+ ...(middlewareParams === undefined ? {} : { middlewareParams }),
1229
+ })),
1230
+ }
1231
+ }
1232
+
1233
+ const loadings: Record<string, LoadingEntry> = {}
1234
+ for (const [dir, file] of loadingFiles) {
1235
+ const backend = backendByStem.get(stripExt(file))
1236
+ if (backend !== undefined) {
1237
+ throw routeError(
1238
+ `"${backend}" pairs with a _loading page, which renders in the browser only and has no backend half`,
1239
+ )
1240
+ }
1241
+ loadings[loadingIdFor(dir)] = {
1242
+ file,
1243
+ load: () =>
1244
+ importer(file)().then((front) => mergeRouteHalves(file, front, undefined, undefined)),
1245
+ layoutIds: ancestorDirs(file)
1246
+ .filter((d) => layoutDirs.has(d))
1247
+ .map(layoutIdFor),
1248
+ }
1249
+ }
1250
+
1251
+ const middlewares: Record<string, LayoutEntry> = {}
1252
+ for (const [dir, backend] of middlewareFiles) {
1253
+ const loadBack = importer(backend)
1254
+ const layout = layoutDirs.has(dir) ? layouts[layoutIdFor(dir)]?.file : undefined
1255
+ middlewares[middlewareIdFor(dir)] = {
1256
+ file: backend,
1257
+ load: () =>
1258
+ loadBack().then((module) => {
1259
+ const back = module as unknown as Readonly<Record<string, unknown>>
1260
+ // Without a frontend layout there is no layout data to load: middleware is all it may hold.
1261
+ if (layout === undefined) {
1262
+ for (const name of Object.keys(back)) {
1263
+ if (name !== "middleware") {
1264
+ throw routeError(
1265
+ `"${backend}" exports "${name}", but its directory has no _layout frontend file, so it may export only middleware`,
1266
+ )
1267
+ }
1268
+ }
1269
+ }
1270
+ return { default: back.middleware } as RouteModule
1271
+ }),
1272
+ }
1273
+ }
1274
+
1275
+ for (const backend of backendByStem.values()) {
1276
+ if (pairedBackends.has(backend)) continue
1277
+ throw routeError(
1278
+ `"${backend}" has no frontend half. A backend half pairs with the file that renders its page ("${backend.replace(ROUTE_BACKEND, "")}.tsx" or another route extension); an endpoint with no page is a route in backend/app.ts`,
1279
+ )
629
1280
  }
630
1281
 
631
1282
  const base: Manifest = {
632
1283
  routes,
633
1284
  layouts,
634
1285
  errors,
1286
+ ...(Object.keys(notFounds).length > 0 ? { notFounds } : {}),
635
1287
  ...(Object.keys(statusPages).length > 0 ? { statusPages } : {}),
1288
+ ...(Object.keys(loadings).length > 0 ? { loadings } : {}),
1289
+ ...(Object.keys(middlewares).length > 0 ? { middlewares } : {}),
636
1290
  }
637
1291
  return notFound === undefined ? base : { ...base, notFound }
638
1292
  }