@nifrajs/web 3.5.0 → 4.0.2

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