@nifrajs/cli 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 (404) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
package/src/introspect.ts CHANGED
@@ -12,9 +12,12 @@ import {
12
12
  reflectedRoutesFromEvidence,
13
13
  snapshotProjectEvidence,
14
14
  } from "@nifrajs/core/evidence"
15
+ import { compileRoutePattern, type RoutePatternSegment } from "@nifrajs/core/pattern"
15
16
  import type { ReflectedRoute } from "@nifrajs/core/reflection"
16
17
  import type { Manifest } from "@nifrajs/web"
17
18
  import { discoverRoutes } from "@nifrajs/web/fs"
19
+ import { normalizeMountPath } from "@nifrajs/web/route-manifest"
20
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
18
21
  import type { LoadedApp } from "./load.ts"
19
22
  import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
20
23
 
@@ -102,6 +105,44 @@ function schemaLines(schema: ReflectedRoute["schema"]): string[] {
102
105
 
103
106
  const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
104
107
 
108
+ /** The verbs the typed client has a terminal call for. */
109
+ const TYPED_VERBS: ReadonlySet<string> = new Set([
110
+ "get",
111
+ "post",
112
+ "put",
113
+ "patch",
114
+ "delete",
115
+ "head",
116
+ "options",
117
+ ])
118
+
119
+ /**
120
+ * The call a non-static segment adds to the chain, or `undefined` for a static one. The segment is
121
+ * read by the router's own grammar, so the spelling matches what the typed client accepts:
122
+ * - a whole param or a wildcard is called by its name - `:id` and `:id{[0-9]+}` are `({ id })`;
123
+ * - a segment that is part literal, part parameter is called with the segment text -
124
+ * `:name.json` is ``(`${name}.json`)``.
125
+ */
126
+ function paramCall(seg: string): string | undefined {
127
+ let segment: RoutePatternSegment | undefined
128
+ try {
129
+ segment = compileRoutePattern(`/${seg}`).segments[0]
130
+ } catch {
131
+ return undefined
132
+ }
133
+ if (segment === undefined || segment.kind === "static") return undefined
134
+ if (segment.kind === "param") return `({ ${segment.name} })`
135
+ if (segment.kind === "wildcard") {
136
+ return segment.name === "*" ? '({ "*": rest })' : `({ ${segment.name} })`
137
+ }
138
+ const [only] = segment.parts
139
+ if (segment.parts.length === 1 && only?.t === "param") return `({ ${only.name} })`
140
+ const text = segment.parts
141
+ .map((part) => (part.t === "lit" ? part.v.replace(/[`\\]|\$\{/g, "\\$&") : `\${${part.name}}`))
142
+ .join("")
143
+ return `(\`${text}\`)`
144
+ }
145
+
105
146
  /**
106
147
  * The typed-client proxy chain for a path, without the terminal verb call. Shared by `clientCall`
107
148
  * and the `nifra routes` collision annotation so both teach exactly one spelling.
@@ -111,10 +152,9 @@ function clientChain(path: string): string {
111
152
  if (segs.length === 0) return "api.index"
112
153
  let chain = "api"
113
154
  for (const seg of segs) {
114
- if (seg.startsWith(":") || seg.startsWith("*")) {
115
- const name = seg.replace(/^[:*]/, "") || "value"
116
- chain += `({ ${name} })`
117
- } else if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
155
+ const call = paramCall(seg)
156
+ if (call !== undefined) chain += call
157
+ else if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
118
158
  else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
119
159
  }
120
160
  return chain
@@ -124,7 +164,8 @@ function clientChain(path: string): string {
124
164
  * The typed-client call form for a route - the exact `client<typeof app>` proxy chain an agent should
125
165
  * write, derived from the same convention `@nifrajs/client` implements (so it never has to read the
126
166
  * client tests to learn it): a static segment is a property (`.users`), a path param/wildcard is a call
127
- * that appends the value (`({ id })`), the root path is `.index`, and the HTTP verb is the terminal call.
167
+ * that appends the value (`({ id })`), a segment that is part literal, part parameter is a call with
168
+ * the segment text (``(`${name}.json`)``), the root path is `.index`, and the HTTP verb is the terminal call.
128
169
  * Body verbs (POST/PUT/PATCH) take the body first then call-options; other verbs take call-options first -
129
170
  * so the `{ query }` argument lands in the right slot for each.
130
171
  *
@@ -136,6 +177,9 @@ function clientChain(path: string): string {
136
177
  export function clientCall(method: string, path: string, schema: unknown): string {
137
178
  const s = schema as { body?: unknown; query?: unknown } | undefined
138
179
  const verb = method.toLowerCase()
180
+ // The typed client has a call for the standard methods only; any other method goes out through
181
+ // `fetch`, against the route's own path.
182
+ if (!TYPED_VERBS.has(verb)) return `await fetch(url, { method: ${JSON.stringify(method)} })`
139
183
  const chain = clientChain(path)
140
184
  const isBodyVerb = verb === "post" || verb === "put" || verb === "patch"
141
185
  let call: string
@@ -153,16 +197,18 @@ export function clientCall(method: string, path: string, schema: unknown): strin
153
197
  /** Markdown section listing the backend's API routes with their request + response field shapes. */
154
198
  export function apiRoutesSection(routes: readonly ReflectedRoute[]): string {
155
199
  if (routes.length === 0) {
156
- return "## API routes\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
200
+ return "## API routes\n\nNo `backend/app.ts` server routes found (this app may be frontend-only)."
157
201
  }
158
202
  const lines = [...routes]
159
- .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
203
+ .sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
160
204
  .flatMap((r) => [
161
205
  `- \`${r.method} ${r.path}\``,
162
206
  ...schemaLines(r.schema),
163
- ` - call: \`${clientCall(r.method, r.path, r.schema)}\` → \`{ ok, status, data, error }\``,
207
+ TYPED_VERBS.has(r.method.toLowerCase())
208
+ ? ` - call: \`${clientCall(r.method, r.path, r.schema)}\` → \`{ ok, status, data, error }\``
209
+ : ` - call: \`${clientCall(r.method, r.path, r.schema)}\` (no typed-client call for this method; \`url\` is the path above)`,
164
210
  ])
165
- return `## API routes (backend.ts)\n\nEach route's \`body\`/\`query\`/\`response\` shape is its contract - the typed client derives request inputs and \`res.data\` from these, so a screen built on \`client<typeof app>\` stays in sync automatically. The \`call\` line is the exact \`client<typeof app>\` form: static path segments are properties, a path param is a call (\`({ id })\`), the verb is the terminal call (body first for POST/PUT/PATCH), and every call returns the never-throwing \`{ ok, status, data, error }\` Result.\n\n${lines.join("\n")}`
211
+ return `## API routes (backend/app.ts)\n\nEach route's \`body\`/\`query\`/\`response\` shape is its contract - the typed client derives request inputs and \`res.data\` from these, so a screen built on \`client<typeof app>\` stays in sync automatically. The \`call\` line is the exact \`client<typeof app>\` form: static path segments are properties, a path param is a call (\`({ id })\`), the verb is the terminal call (body first for POST/PUT/PATCH), and every call returns the never-throwing \`{ ok, status, data, error }\` Result.\n\n${lines.join("\n")}`
166
212
  }
167
213
 
168
214
  /** Compact API-routes INDEX for the no-arg `nifra_context` call - `METHOD path` per route, WITHOUT the
@@ -171,12 +217,12 @@ export function apiRoutesSection(routes: readonly ReflectedRoute[]): string {
171
217
  * contract via the `path`/`kind` slice, instead of every schema up front. */
172
218
  export function apiRoutesIndexSection(routes: readonly ReflectedRoute[]): string {
173
219
  if (routes.length === 0) {
174
- return "## API routes (backend.ts)\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
220
+ return "## API routes (backend/app.ts)\n\nNo `backend/app.ts` server routes found (this app may be frontend-only)."
175
221
  }
176
222
  const lines = [...routes]
177
- .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
223
+ .sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
178
224
  .map((r) => `- \`${r.method} ${r.path}\``)
179
- return `## API routes (backend.ts)\n\n${routes.length} route${routes.length === 1 ? "" : "s"}. Call \`nifra_context\` again with \`path\` (a route prefix) and/or \`kind: "api"\` for the body/query/response contracts + the exact \`client<typeof app>\` call form - or \`nifra_routes\` for the same as structured JSON.\n\n${lines.join("\n")}`
225
+ return `## API routes (backend/app.ts)\n\n${routes.length} route${routes.length === 1 ? "" : "s"}. Call \`nifra_context\` again with \`path\` (a route prefix) and/or \`kind: "api"\` for the body/query/response contracts + the exact \`client<typeof app>\` call form - or \`nifra_routes\` for the same as structured JSON.\n\n${lines.join("\n")}`
180
226
  }
181
227
 
182
228
  /** Markdown section listing the file-routed pages (URL pattern → source file). */
@@ -185,7 +231,7 @@ export function pageRoutesSection(manifest: Manifest | undefined): string {
185
231
  return "## Page routes\n\nNo file routes found under `routes/`."
186
232
  }
187
233
  const lines = [...manifest.routes]
188
- .sort((a, b) => a.pattern.localeCompare(b.pattern))
234
+ .sort((a, b) => codeUnitOrder(a.pattern, b.pattern))
189
235
  .map((r) => `- \`${r.pattern}\` → \`${r.file}\``)
190
236
  return `## Page routes (routes/)\n\n${lines.join("\n")}`
191
237
  }
@@ -207,9 +253,9 @@ const CONVENTIONS = `## Conventions (summary)
207
253
  \`res.data\` from the backend's route types, so the compiler catches any frontend/backend drift. Never
208
254
  hand-roll \`fetch\` + ad-hoc response types for an internal API - that's exactly how screens drift. It
209
255
  never throws: branch on \`res.ok ? res.data : res.error\`.
210
- - **Pages:** file-routed under \`routes/\`; \`loader\`/\`action\` are server-only but the module is also bundled
211
- for the browser - **never top-level-import server-only code** (DB, secrets, \`process.env\`) into a route
212
- file; reach it via \`ctx.api\` / \`ctx.env\`.
256
+ - **Pages:** file-routed under \`routes/\`. A route is two files: \`x.tsx\` (browser) and \`x.backend.ts\`
257
+ (\`loader\`, \`action\`, \`middleware\`; server only). Server code lives under \`backend/\`, code both sides
258
+ use under \`shared/\`, components under \`frontend/\`; the browser build refuses backend code outright.
213
259
  - \`app.fetch(Request)\` is the universal entry. Full reference: this repo's \`AGENTS.md\`, or \`llms-full.txt\`.`
214
260
 
215
261
  /** Optional narrowing for {@link describeProject} - a path prefix and/or one section. A filtered
@@ -303,7 +349,7 @@ export function routesToJsonFromEvidence(
303
349
  routes = routes.filter((r) => r.path.startsWith(pathPrefix))
304
350
  }
305
351
  return [...routes]
306
- .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
352
+ .sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
307
353
  .map((r) => {
308
354
  const s = r.schema
309
355
  const body = shape(s?.body)
@@ -373,12 +419,16 @@ export interface RouteTableInput {
373
419
  /** The page router's `apiPrefix` (default `/api`); an API route at/under it is auto-mounted. `""`
374
420
  * disables the auto-mount, so no API route is marked auto-mounted. */
375
421
  readonly apiPrefix?: string
422
+ /** The backend declares its routes without the prefix and is served at `apiPrefix + path`: every API
423
+ * route is auto-mounted, listed at the path a request uses. */
424
+ readonly apiStrip?: boolean
376
425
  }
377
426
 
378
427
  /** True when `path` is at or under `prefix` as a path segment boundary (`/api` matches `/api` and
379
428
  * `/api/x`, but not `/apiary`). An empty prefix matches nothing (auto-mount disabled). */
380
429
  function isUnderPrefix(path: string, prefix: string): boolean {
381
430
  if (prefix === "") return false
431
+ if (prefix === "/") return true
382
432
  return path === prefix || path.startsWith(`${prefix}/`)
383
433
  }
384
434
 
@@ -390,7 +440,14 @@ function isUnderPrefix(path: string, prefix: string): boolean {
390
440
  * joined methods, for stable output.
391
441
  */
392
442
  export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
393
- const apiPrefix = input.apiPrefix ?? "/api"
443
+ const configured = input.apiPrefix ?? "/api"
444
+ // Read the prefix as the server's mount table does (`/api/` and `/api/*` are `/api`).
445
+ const apiPrefix = configured === "" ? "" : (normalizeMountPath(configured) ?? configured)
446
+ const stripped = input.apiStrip === true && apiPrefix !== ""
447
+ const servedPath = (path: string): string => {
448
+ if (!stripped || apiPrefix === "/") return path
449
+ return path === "/" ? apiPrefix : `${apiPrefix}${path}`
450
+ }
394
451
  const rows: RouteTableEntry[] = []
395
452
  for (const page of input.pages) {
396
453
  const methods = page.hasAction ? ["GET", "POST"] : ["GET"]
@@ -399,11 +456,12 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
399
456
  // Collapse API routes that share a path into one row with all its methods (a REST resource).
400
457
  const byPath = new Map<string, { methods: Set<string>; autoMounted: boolean }>()
401
458
  for (const r of input.api) {
402
- const existing = byPath.get(r.path)
459
+ const path = servedPath(r.path)
460
+ const existing = byPath.get(path)
403
461
  if (existing === undefined) {
404
- byPath.set(r.path, {
462
+ byPath.set(path, {
405
463
  methods: new Set([r.method.toUpperCase()]),
406
- autoMounted: isUnderPrefix(r.path, apiPrefix),
464
+ autoMounted: stripped || isUnderPrefix(r.path, apiPrefix),
407
465
  })
408
466
  } else existing.methods.add(r.method.toUpperCase())
409
467
  }
@@ -417,9 +475,9 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
417
475
  }
418
476
  return rows.sort(
419
477
  (a, b) =>
420
- a.path.localeCompare(b.path) ||
421
- a.kind.localeCompare(b.kind) ||
422
- a.methods.join(",").localeCompare(b.methods.join(",")),
478
+ codeUnitOrder(a.path, b.path) ||
479
+ codeUnitOrder(a.kind, b.kind) ||
480
+ codeUnitOrder(a.methods.join(","), b.methods.join(",")),
423
481
  )
424
482
  }
425
483
 
@@ -447,7 +505,7 @@ export function clientSpellingFor(
447
505
  /** Render the route table as a terse aligned text table (the `nifra routes` default output). Pure. */
448
506
  export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
449
507
  if (rows.length === 0)
450
- return "No routes found (no `routes/` pages and no `backend.ts` API routes)."
508
+ return "No routes found (no `routes/` pages and no `backend/app.ts` API routes)."
451
509
  const display = rows.map((r) => ({
452
510
  methods: r.methods.join(", "),
453
511
  kind: r.kind,
@@ -543,7 +601,7 @@ function parentPath(path: string): string | undefined {
543
601
  /** Build a stable parent/child graph from the public route table. Pure and topology-neutral. */
544
602
  export function buildRouteGraph(rows: readonly RouteTableEntry[]): RouteGraph {
545
603
  const sorted = [...rows].sort(
546
- (a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind),
604
+ (a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.kind, b.kind),
547
605
  )
548
606
  const nodes: RouteGraphNode[] = [
549
607
  { id: "root", kind: "root", path: "/" },
@@ -643,7 +701,12 @@ async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
643
701
  }
644
702
  pages.push({ pattern: route.pattern, file: route.file, hasAction })
645
703
  }
646
- const rows = buildRouteTable({ pages, api: backendRoutes(app.backend) })
704
+ const rows = buildRouteTable({
705
+ pages,
706
+ api: backendRoutes(app.backend),
707
+ ...(app.framework.apiPrefix !== undefined ? { apiPrefix: app.framework.apiPrefix } : {}),
708
+ ...(app.framework.apiStrip !== undefined ? { apiStrip: app.framework.apiStrip } : {}),
709
+ })
647
710
  return rows
648
711
  }
649
712
 
package/src/learn.ts CHANGED
@@ -39,7 +39,7 @@ export const LEARN_PATH: readonly LearnStep[] = [
39
39
  id: "page-route",
40
40
  title: "Add a page route",
41
41
  goal: "A second page, server-rendered, reachable by URL.",
42
- do: "Ask nifra_scaffold for the path (e.g. /about) - it returns the exact `routes/` file and a contract-correct page stub. File-based: `routes/about.tsx` serves `/about`.",
42
+ do: "Ask nifra_scaffold for the path (e.g. /about) - it writes the route pair: the page `routes/about.tsx` (serves `/about`) and its backend half `routes/about.backend.ts`.",
43
43
  verify: "nifra_render /about returns the rendered HTML.",
44
44
  tools: ["nifra_scaffold", "nifra_render"],
45
45
  seeAlso: "page routes file-based routing layouts",
@@ -48,7 +48,7 @@ export const LEARN_PATH: readonly LearnStep[] = [
48
48
  id: "loader",
49
49
  title: "Load data for a page",
50
50
  goal: "A page whose data is fetched on the server before render.",
51
- do: "Export a `loader` from the route and read its result in the component. Get the exact shape from nifra_example ('loader').",
51
+ do: "Export a `loader` and its `loaderOutput` schema from the route's `.backend.ts` half and read the result as `data` (`Route.ComponentProps`) in the page. Get the exact shape from nifra_example ('loader').",
52
52
  verify: "nifra_render the page and confirm the loaded data is in the HTML.",
53
53
  tools: ["nifra_example", "nifra_render"],
54
54
  seeAlso: "loader server data page",
@@ -57,7 +57,7 @@ export const LEARN_PATH: readonly LearnStep[] = [
57
57
  id: "api-route",
58
58
  title: "Add a typed API route",
59
59
  goal: "A backend endpoint with a typed request/response contract.",
60
- do: "Add the route to `backend.ts` with an @nifrajs/schema body/query/response schema. nifra_context shows the resulting contract and the exact typed-client call form.",
60
+ do: "Add the route to `backend/app.ts` with an @nifrajs/schema body/query/response schema. nifra_context shows the resulting contract and the exact typed-client call form.",
61
61
  verify:
62
62
  "nifra_run { method, path, body } and check the status + parsed body; nifra_check confirms the contract typechecks.",
63
63
  tools: ["nifra_context", "nifra_run", "nifra_check"],
@@ -36,6 +36,17 @@ export interface VerificationLevelsResult {
36
36
  /** Highest level whose whole ladder holds; -1 when even L0 fails. */
37
37
  readonly achieved: number
38
38
  readonly levels: readonly VerificationLevelStatus[]
39
+ /** Response/error runtime enforcement is reported separately from the cumulative L0-L4 ladder. */
40
+ readonly responseContracts: ResponseContractStatus
41
+ }
42
+
43
+ export interface ResponseContractStatus {
44
+ readonly declared: number
45
+ readonly enforced: number
46
+ readonly warnOnly: number
47
+ readonly typedOnly: number
48
+ readonly status: "none" | "partial" | "enforced"
49
+ readonly reasons: readonly string[]
39
50
  }
40
51
 
41
52
  export interface CollectLevelsOptions {
@@ -82,7 +93,7 @@ export async function collectVerificationLevels(
82
93
  ] as const) {
83
94
  statuses.push({ level, name, ok: false, reasons: [reason] })
84
95
  }
85
- return finalize(statuses)
96
+ return finalize(statuses, [])
86
97
  }
87
98
  const config = verification.config
88
99
 
@@ -197,11 +208,12 @@ export async function collectVerificationLevels(
197
208
  reasons: l4Reasons,
198
209
  })
199
210
 
200
- return finalize(statuses)
211
+ return finalize(statuses, assurance.routes)
201
212
  }
202
213
 
203
214
  function finalize(
204
215
  statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
216
+ routes: readonly { readonly responseContract?: "enforce" | "warn" | "unchecked" }[],
205
217
  ): VerificationLevelsResult {
206
218
  // Cumulative: the achieved level is the last rung with every rung below it also holding.
207
219
  let achieved = -1
@@ -209,6 +221,31 @@ function finalize(
209
221
  if (!status.ok) break
210
222
  achieved = status.level
211
223
  }
224
+ const declared = routes.filter((route) => route.responseContract !== undefined)
225
+ const enforced = declared.filter((route) => route.responseContract === "enforce").length
226
+ const warnOnly = declared.filter((route) => route.responseContract === "warn").length
227
+ const typedOnly = declared.filter((route) => route.responseContract === "unchecked").length
228
+ const responseContracts: ResponseContractStatus = {
229
+ declared: declared.length,
230
+ enforced,
231
+ warnOnly,
232
+ typedOnly,
233
+ status:
234
+ declared.length === 0 ? "none" : typedOnly === 0 && warnOnly === 0 ? "enforced" : "partial",
235
+ reasons:
236
+ declared.length === 0
237
+ ? ["no response/error schemas declared"]
238
+ : [
239
+ ...(typedOnly > 0
240
+ ? [
241
+ `${typedOnly} declared route(s) are typed-only; install responseContract("enforce")`,
242
+ ]
243
+ : []),
244
+ ...(warnOnly > 0
245
+ ? [`${warnOnly} declared route(s) are warn-only and still send the original payload`]
246
+ : []),
247
+ ],
248
+ }
212
249
  return Object.freeze({
213
250
  achieved,
214
251
  levels: Object.freeze(
@@ -216,6 +253,10 @@ function finalize(
216
253
  Object.freeze({ ...status, reasons: Object.freeze([...status.reasons]) }),
217
254
  ),
218
255
  ),
256
+ responseContracts: Object.freeze({
257
+ ...responseContracts,
258
+ reasons: Object.freeze([...responseContracts.reasons]),
259
+ }),
219
260
  })
220
261
  }
221
262
 
@@ -233,6 +274,10 @@ export async function runLevels(
233
274
  console.log(`${mark} L${status.level} ${status.name}`)
234
275
  for (const reason of status.reasons) console.log(` - ${reason}`)
235
276
  }
277
+ console.log(
278
+ `[nifra] response contracts: ${result.responseContracts.status} (${result.responseContracts.enforced} enforced, ${result.responseContracts.warnOnly} warn-only, ${result.responseContracts.typedOnly} typed-only)`,
279
+ )
280
+ for (const reason of result.responseContracts.reasons) console.log(` - ${reason}`)
236
281
  console.log(
237
282
  result.achieved < 0
238
283
  ? "[nifra] verification level: none (L0 failing)"
package/src/load.ts CHANGED
@@ -1,38 +1,43 @@
1
1
  /**
2
2
  * Zero-config app discovery. The CLI reads a few conventions from the project root:
3
3
  * - `routes/` - the file-based routes (per `@nifrajs/web/fs`).
4
- * - `nifra.config.ts` - the CLI's framework wiring (see {@link NifraFramework}); `framework.ts` is
5
- * used as a fallback. One of the two is required.
6
- * - `backend.ts` - exports `backend` (a `@nifrajs/core` server, the contract). Optional.
4
+ * - `nifra.config.ts` - the CLI's framework wiring (see {@link NifraFramework});
5
+ * `backend/framework.ts` is used as a fallback. One of the two is required.
6
+ * - `backend/app.ts` - exports `backend` (a `@nifrajs/core` server, the contract). Optional.
7
7
  *
8
- * Why two filenames: a multi-target app's edge entry (`_worker.ts`) imports `framework.ts` for the
9
- * render adapter, so `framework.ts` must stay edge-bundlable - it can't reference Vite plugins or the
10
- * SFC compiler (Bun would pull them into the `target:"browser"` worker and fail on `child_process`).
11
- * `nifra.config.ts` holds that build/dev tooling instead: it's imported ONLY by this CLI (which runs on
12
- * Bun), so its Vite plugin + SFC-compiler imports (`@vitejs/plugin-vue`, `@nifrajs/web-vue/plugin`) never
13
- * reach the edge bundle. A simple single-target app with no edge build can put it all in `framework.ts`.
8
+ * Why two filenames: a multi-target app's edge entry (`_worker.ts`) imports `backend/framework.ts`
9
+ * for the render adapter, so `backend/framework.ts` must stay edge-bundlable - it can't reference
10
+ * Vite plugins or the SFC compiler (Bun would pull them into the `target:"browser"` worker and fail
11
+ * on `child_process`). `nifra.config.ts` holds that build/dev tooling instead: it's imported ONLY
12
+ * by this CLI (which runs on Bun), so its Vite plugin + SFC-compiler imports (`@vitejs/plugin-vue`,
13
+ * `@nifrajs/web-vue/plugin`) never reach the edge bundle. A simple single-target app with no edge
14
+ * build can put it all in `backend/framework.ts`.
14
15
  */
15
16
  import { existsSync } from "node:fs"
16
17
  import { isAbsolute, resolve } from "node:path"
17
- import type { CssLoadingMode } from "@nifrajs/web"
18
+ import type { CreateWebAppOptions, CssLoadingMode } from "@nifrajs/web"
19
+ import type { SecretExemption } from "@nifrajs/web/build"
20
+ import { assertCurrentLayout, BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
18
21
  import { checkPipelineSeparation } from "./pipeline-guard.ts"
19
22
 
20
23
  /**
21
- * A plugin list - either an array, or a **thunk** that returns one (optionally async). Both forms are
22
- * accepted; {@link resolvePlugins} normalizes them. The thunk is just a convenience for deferring a
23
- * plugin's construction - it does NOT make a plugin edge-safe (Bun.build follows a dynamic `import()`
24
- * even from an unused export, so a Vite/compiler import in an edge-bundled file fails regardless). Edge
25
- * safety comes from WHERE the config lives: keep these fields in `nifra.config.ts` (CLI-only), not in
26
- * the edge-imported `framework.ts`. See the module header.
24
+ * A plugin list - either an array, or a **thunk** that returns one (optionally async). Both forms
25
+ * are accepted; {@link resolvePlugins} normalizes them. The thunk is just a convenience for
26
+ * deferring a plugin's construction - it does NOT make a plugin edge-safe (Bun.build follows a
27
+ * dynamic `import()` even from an unused export, so a Vite/compiler import in an edge-bundled file
28
+ * fails regardless). Edge safety comes from WHERE the config lives: keep these fields in
29
+ * `nifra.config.ts` (CLI-only), not in the edge-imported `backend/framework.ts`. See the module
30
+ * header.
27
31
  */
28
32
  export type PluginsField =
29
33
  | readonly unknown[]
30
34
  | (() => readonly unknown[] | Promise<readonly unknown[]>)
31
35
 
32
36
  /**
33
- * The CLI's framework wiring - exported from `nifra.config.ts` (or `framework.ts` as a fallback).
34
- * `create-nifra` generates it. Only `adapter` + `clientModule` are required; the plugin/condition
35
- * fields are framework-specific extras (Vue/Svelte/Solid need them; React/Preact don't).
37
+ * The CLI's framework wiring - exported from `nifra.config.ts` (or `backend/framework.ts` as a
38
+ * fallback). `create-nifra` generates it. Only `adapter` + `clientModule` are required; the
39
+ * plugin/condition fields are framework-specific extras (Vue/Svelte/Solid need them; React/Preact
40
+ * don't).
36
41
  */
37
42
  export interface NifraFramework {
38
43
  /** The render adapter, e.g. `reactAdapter` from `@nifrajs/web-react`. */
@@ -42,7 +47,7 @@ export interface NifraFramework {
42
47
  /** App-level middleware applied to the web app before its page routes are declared -
43
48
  * e.g. `(app) => app.use(securityHeaders())`. Runs ahead of the `api`/`mounts` hooks.
44
49
  *
45
- * MUST be exported from `framework.ts`, not `nifra.config.ts`: `nifra build` emits a
50
+ * MUST be exported from `backend/framework.ts`, not `nifra.config.ts`: `nifra build` emits a
46
51
  * server entry that imports it, and that entry is edge-bundled. See the module header. */
47
52
  readonly use?: (app: never) => void
48
53
  /** Vite plugins for `nifra dev`'s HMR, e.g. `[react()]` / `() => import("@vitejs/plugin-vue")…`. */
@@ -57,14 +62,88 @@ export interface NifraFramework {
57
62
  readonly conditions?: readonly string[]
58
63
  /** Compile-time `define` replacements, e.g. Vue's `__VUE_*` flags. */
59
64
  readonly define?: Readonly<Record<string, string>>
65
+ /** The deploy target `nifra build` emits without `--target` (default `"bun"`); `nifra target <t>`
66
+ * rewrites it. */
67
+ readonly target?: string
68
+ /** `"platform"` makes a `cloudflare` / `vercel` build read `c.clientIp` from the header that
69
+ * platform's edge overwrites (`cf-connecting-ip` / `x-real-ip`). Left out, an edge build has no
70
+ * caller address, so a per-caller `rateLimit` refuses every request. Declare it only while that edge
71
+ * is the sole way in: served any other way the header is whatever the client sent. Self-hosting
72
+ * targets use the socket peer either way. */
73
+ readonly clientIp?: "platform"
74
+ /** `nifra dev` settings. `indicator: false` turns the in-page issues badge off for every run
75
+ * (`nifra dev --no-indicator` does it for one); browser errors still reach `nifra errors`. */
76
+ readonly dev?: { readonly indicator?: boolean }
60
77
  /** Static files directory. Defaults to `<app>/public`; `false` disables it. */
61
78
  readonly publicDir?: string | false
62
79
  /** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
63
80
  readonly publicEnvPrefix?: string
81
+ /** Reviewed false positives of `nifra build`'s secret scan, each `{ rule, file, reason }` (or
82
+ * `{ rule: "private-env-value", env, reason }`). There is no exemption by value. */
83
+ readonly secretExemptions?: readonly SecretExemption[]
64
84
  /** Vite production CSS output policy. `false` emits one aggregate stylesheet for the whole client. */
65
85
  readonly cssCodeSplit?: boolean
66
86
  /** Framework-owned SSR stylesheet activation. Deferred mode is intended for aggregate CSS. */
67
87
  readonly cssLoading?: CssLoadingMode
88
+ /** Path `backend/app.ts`'s backend is served at (default `"/api"`; `""` turns the mount off). A
89
+ * page file under it can never render, so the app refuses to start and `nifra build` refuses to
90
+ * build.
91
+ *
92
+ * The fields from here down reach the generated server entry, so like `use` they MUST be exported
93
+ * from `backend/framework.ts` (re-export them from `nifra.config.ts` when both exist). */
94
+ readonly apiPrefix?: string
95
+ /** Strip {@link apiPrefix} before the backend sees the path, for a backend that declares its routes
96
+ * without it. Default `false`. */
97
+ readonly apiStrip?: boolean
98
+ /** Apps mounted ahead of page routing - an auth handler, a webhook receiver, a backend served at an
99
+ * extra path. See `createWebApp`'s `mounts`. */
100
+ readonly mounts?: CreateWebAppOptions["mounts"]
101
+ /** A hash-based Content-Security-Policy from `createCspPolicy`. Exclusive with {@link nonce}. */
102
+ readonly csp?: CreateWebAppOptions["csp"]
103
+ /** A per-request CSP nonce resolver. Exclusive with {@link csp}. */
104
+ readonly nonce?: CreateWebAppOptions["nonce"]
105
+ }
106
+
107
+ /** The {@link NifraFramework} fields forwarded to `createWebApp` and imported by the generated server
108
+ * entry, in one list so every command that builds the app forwards the same set. */
109
+ export const FRAMEWORK_WEB_OPTIONS = ["apiPrefix", "apiStrip", "mounts", "csp", "nonce"] as const
110
+ export type FrameworkWebOption = (typeof FRAMEWORK_WEB_OPTIONS)[number]
111
+
112
+ /** Refuse a forwarded field of the wrong shape at load, naming the field, rather than letting it fail
113
+ * later inside `createWebApp` or the generated server entry. */
114
+ function assertFrameworkWebOptions(fw: Partial<NifraFramework>, configFile: string): void {
115
+ const wrong = (field: string, expected: string): never => {
116
+ throw new Error(`[nifra] ${configFile}: \`${field}\` must be ${expected}.`)
117
+ }
118
+ if (fw.apiPrefix !== undefined && typeof fw.apiPrefix !== "string") {
119
+ wrong("apiPrefix", 'a path string such as "/api", or "" to turn the backend mount off')
120
+ }
121
+ if (fw.apiStrip !== undefined && typeof fw.apiStrip !== "boolean") wrong("apiStrip", "a boolean")
122
+ if (fw.mounts !== undefined && !Array.isArray(fw.mounts)) {
123
+ wrong("mounts", "an array of { path, app } entries")
124
+ }
125
+ if (fw.csp !== undefined && (typeof fw.csp !== "object" || fw.csp === null)) {
126
+ wrong("csp", "the policy createCspPolicy() returns")
127
+ }
128
+ if (fw.nonce !== undefined && typeof fw.nonce !== "function") {
129
+ wrong("nonce", "a nonce resolver function, such as createNonceResolver() returns")
130
+ }
131
+ if (fw.secretExemptions !== undefined && !Array.isArray(fw.secretExemptions)) {
132
+ wrong("secretExemptions", "an array of { rule, file, reason } entries")
133
+ }
134
+ if (fw.target !== undefined && typeof fw.target !== "string") {
135
+ wrong("target", 'a deploy target name such as "bun"')
136
+ }
137
+ if (fw.clientIp !== undefined && fw.clientIp !== "platform") {
138
+ wrong("clientIp", '"platform" (trust the edge platform\'s client-address header), or left out')
139
+ }
140
+ if (fw.dev !== undefined) {
141
+ if (typeof fw.dev !== "object" || fw.dev === null)
142
+ wrong("dev", "an object such as { indicator: false }")
143
+ const indicator: unknown = Reflect.get(fw.dev, "indicator")
144
+ if (indicator !== undefined && typeof indicator !== "boolean")
145
+ wrong("dev.indicator", "a boolean")
146
+ }
68
147
  }
69
148
 
70
149
  /**
@@ -104,8 +183,9 @@ export interface ResolvedPlugins {
104
183
 
105
184
  export interface LoadedApp {
106
185
  readonly cwd: string
107
- /** Absolute path of the config this app was loaded from - `nifra.config.ts`, or `framework.ts` as the
108
- * fallback. `nifra dev`'s Bun pipeline re-imports it from a GENERATED module so the app's own
186
+ /** Absolute path of the config this app was loaded from - `nifra.config.ts`, or
187
+ * `backend/framework.ts` as the fallback. `nifra dev`'s Bun pipeline re-imports it from a
188
+ * GENERATED module so the app's own
109
189
  * `clientPlugins` reach Bun's dev-server bundler, which takes plugins only as module paths. */
110
190
  readonly configPath: string
111
191
  readonly routesDir: string
@@ -114,7 +194,7 @@ export interface LoadedApp {
114
194
  readonly framework: NifraFramework
115
195
  /** Plugin thunks resolved exactly once during app loading and reused by every command phase. */
116
196
  readonly resolvedPlugins: ResolvedPlugins
117
- /** The `backend` export from `backend.ts`, or `undefined` if there's no `backend.ts`. */
197
+ /** The `backend` export from `backend/app.ts`, or `undefined` if there's no `backend/app.ts`. */
118
198
  readonly backend: unknown
119
199
  }
120
200
 
@@ -126,7 +206,8 @@ export interface LoadAppOptions {
126
206
  /**
127
207
  * Root-level monorepo config - exported from `nifra.config.ts` at the workspace root.
128
208
  * Each key in `apps` is the short name used to namespace MCP tools (`nifra_<name>_context` etc.);
129
- * each value is a path relative to the root that contains its own `nifra.config.ts` / `framework.ts`.
209
+ * each value is a path relative to the root that contains its own `nifra.config.ts` /
210
+ * `backend/framework.ts`.
130
211
  *
131
212
  * Example:
132
213
  * ```ts
@@ -177,23 +258,48 @@ const isAdapter = (v: unknown): boolean => typeof v === "object" && v !== null
177
258
  const importWithQuery = (path: string, query: string | undefined): Promise<unknown> =>
178
259
  import(query === undefined || query === "" ? path : `${path}?${query}`)
179
260
 
180
- /** Discover + validate the app conventions rooted at `cwd`. Prefers `nifra.config.ts`, falls back to
181
- * `framework.ts`. Throws a clear, actionable error if neither exists or the config is malformed. */
261
+ /**
262
+ * What reads only the backend (`nifra openapi`): the whole app when it has a web config, else
263
+ * `backend/app.ts` alone, so an API-only app needs no adapter to describe its routes.
264
+ */
265
+ export async function loadBackendApp(
266
+ cwd: string,
267
+ options: LoadAppOptions = {},
268
+ ): Promise<Pick<LoadedApp, "cwd" | "backend">> {
269
+ if (existsSync(resolve(cwd, CONFIG_FILE)) || existsSync(resolve(cwd, FRAMEWORK_FILE))) {
270
+ return loadApp(cwd, "dist", options)
271
+ }
272
+ assertCurrentLayout(cwd)
273
+ const backendPath = resolve(cwd, BACKEND_APP_FILE)
274
+ if (!existsSync(backendPath)) {
275
+ throw new Error(
276
+ `[nifra] no ${BACKEND_APP_FILE} in ${cwd}: the OpenAPI document describes its routes`,
277
+ )
278
+ }
279
+ const backend = (
280
+ (await importWithQuery(backendPath, options.importQuery)) as { backend?: unknown }
281
+ ).backend
282
+ return { cwd, backend }
283
+ }
284
+
285
+ /** Discover + validate the app conventions rooted at `cwd`. Prefers `nifra.config.ts`, falls back
286
+ * to
287
+ * `backend/framework.ts`. Throws a clear, actionable error if neither exists or the config is
288
+ * malformed. */
182
289
  export async function loadApp(
183
290
  cwd: string,
184
291
  outDirName = "dist",
185
292
  options: LoadAppOptions = {},
186
293
  ): Promise<LoadedApp> {
187
294
  // nifra.config.ts is the CLI's config (it may import Vite plugins / the SFC compiler, which a
188
- // multi-target app keeps OUT of the edge-imported framework.ts). framework.ts is the fallback for a
189
- // simple single-target app that has no edge build.
190
- const configFile = existsSync(resolve(cwd, "nifra.config.ts"))
191
- ? "nifra.config.ts"
192
- : "framework.ts"
295
+ // multi-target app keeps OUT of the edge-imported backend/framework.ts). backend/framework.ts is
296
+ // the fallback for a simple single-target app that has no edge build.
297
+ assertCurrentLayout(cwd)
298
+ const configFile = existsSync(resolve(cwd, CONFIG_FILE)) ? CONFIG_FILE : FRAMEWORK_FILE
193
299
  const configPath = resolve(cwd, configFile)
194
300
  if (!existsSync(configPath)) {
195
301
  throw new Error(
196
- `[nifra] no nifra.config.ts or framework.ts found in ${cwd}.\n` +
302
+ `[nifra] no ${CONFIG_FILE} or ${FRAMEWORK_FILE} found in ${cwd}.\n` +
197
303
  " nifra is zero-config but needs one of them exporting at least:\n" +
198
304
  " export const adapter = reactAdapter // from @nifrajs/web-react\n" +
199
305
  ' export const clientModule = "@nifrajs/web-react/client"\n' +
@@ -211,6 +317,7 @@ export async function loadApp(
211
317
  `[nifra] ${configFile} must export \`adapter\` (a render adapter object) and \`clientModule\` (a string).`,
212
318
  )
213
319
  }
320
+ assertFrameworkWebOptions(fw, configFile)
214
321
  // A `clientModule` given as a RELATIVE path (a local client entry, e.g. `./src/client.tsx`) is
215
322
  // resolved to absolute here. It is embedded verbatim as an import specifier in the generated client
216
323
  // entry, and `nifra dev` and `nifra build` write that entry into DIFFERENT directories - so a relative
@@ -251,7 +358,7 @@ export async function loadApp(
251
358
  assertPipelineSeparation(resolvedPlugins, configFile)
252
359
 
253
360
  let backend: unknown
254
- const backendPath = resolve(cwd, "backend.ts")
361
+ const backendPath = resolve(cwd, BACKEND_APP_FILE)
255
362
  if (existsSync(backendPath)) {
256
363
  backend = ((await importWithQuery(backendPath, options.importQuery)) as { backend?: unknown })
257
364
  .backend