@nifrajs/cli 4.0.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/README.md +10 -2
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +9 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/assure.d.ts +12 -2
  14. package/dist/assure.d.ts.map +1 -1
  15. package/dist/assure.js +59 -8
  16. package/dist/assure.js.map +1 -1
  17. package/dist/capabilities-tool.d.ts.map +1 -1
  18. package/dist/capabilities-tool.js +36 -9
  19. package/dist/capabilities-tool.js.map +1 -1
  20. package/dist/check-diagnostics.d.ts +22 -2
  21. package/dist/check-diagnostics.d.ts.map +1 -1
  22. package/dist/check-diagnostics.js +54 -7
  23. package/dist/check-diagnostics.js.map +1 -1
  24. package/dist/check-scan.d.ts +83 -24
  25. package/dist/check-scan.d.ts.map +1 -1
  26. package/dist/check-scan.js +1516 -318
  27. package/dist/check-scan.js.map +1 -1
  28. package/dist/check.d.ts +2 -2
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +203 -70
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +9 -0
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +215 -50
  35. package/dist/cli.js.map +1 -1
  36. package/dist/client-module.d.ts +20 -0
  37. package/dist/client-module.d.ts.map +1 -0
  38. package/dist/client-module.js +73 -0
  39. package/dist/client-module.js.map +1 -0
  40. package/dist/command-catalog.d.ts +24 -5
  41. package/dist/command-catalog.d.ts.map +1 -1
  42. package/dist/command-catalog.js +194 -45
  43. package/dist/command-catalog.js.map +1 -1
  44. package/dist/component-check.d.ts +13 -0
  45. package/dist/component-check.d.ts.map +1 -0
  46. package/dist/component-check.js +59 -0
  47. package/dist/component-check.js.map +1 -0
  48. package/dist/contract-proof.d.ts +1 -1
  49. package/dist/contract-proof.d.ts.map +1 -1
  50. package/dist/contract-proof.js +2 -24
  51. package/dist/contract-proof.js.map +1 -1
  52. package/dist/contracts.d.ts +19 -0
  53. package/dist/contracts.d.ts.map +1 -1
  54. package/dist/contracts.js +50 -3
  55. package/dist/contracts.js.map +1 -1
  56. package/dist/db-tool.d.ts.map +1 -1
  57. package/dist/db-tool.js +4 -3
  58. package/dist/db-tool.js.map +1 -1
  59. package/dist/dev-bun-config.d.ts +5 -1
  60. package/dist/dev-bun-config.d.ts.map +1 -1
  61. package/dist/dev-bun-config.js +44 -11
  62. package/dist/dev-bun-config.js.map +1 -1
  63. package/dist/dev-feed-tool.d.ts +18 -1
  64. package/dist/dev-feed-tool.d.ts.map +1 -1
  65. package/dist/dev-feed-tool.js +93 -0
  66. package/dist/dev-feed-tool.js.map +1 -1
  67. package/dist/diagnostics.d.ts +3 -0
  68. package/dist/diagnostics.d.ts.map +1 -1
  69. package/dist/diagnostics.js.map +1 -1
  70. package/dist/diff-tool.d.ts +9 -8
  71. package/dist/diff-tool.d.ts.map +1 -1
  72. package/dist/diff-tool.js +28 -24
  73. package/dist/diff-tool.js.map +1 -1
  74. package/dist/doctor.d.ts +13 -0
  75. package/dist/doctor.d.ts.map +1 -1
  76. package/dist/doctor.js +23 -15
  77. package/dist/doctor.js.map +1 -1
  78. package/dist/env-file.d.ts +6 -0
  79. package/dist/env-file.d.ts.map +1 -1
  80. package/dist/env-file.js +6 -0
  81. package/dist/env-file.js.map +1 -1
  82. package/dist/fix-recipes.d.ts +2 -0
  83. package/dist/fix-recipes.d.ts.map +1 -1
  84. package/dist/fix-recipes.js +102 -2
  85. package/dist/fix-recipes.js.map +1 -1
  86. package/dist/hand-built-manifest.d.ts +27 -0
  87. package/dist/hand-built-manifest.d.ts.map +1 -0
  88. package/dist/hand-built-manifest.js +124 -0
  89. package/dist/hand-built-manifest.js.map +1 -0
  90. package/dist/hooks-report.d.ts +23 -0
  91. package/dist/hooks-report.d.ts.map +1 -0
  92. package/dist/hooks-report.js +104 -0
  93. package/dist/hooks-report.js.map +1 -0
  94. package/dist/init-agents.d.ts +18 -2
  95. package/dist/init-agents.d.ts.map +1 -1
  96. package/dist/init-agents.js +20 -13
  97. package/dist/init-agents.js.map +1 -1
  98. package/dist/internal/config-paths.d.ts +16 -0
  99. package/dist/internal/config-paths.d.ts.map +1 -0
  100. package/dist/internal/config-paths.js +124 -0
  101. package/dist/internal/config-paths.js.map +1 -0
  102. package/dist/internal/installed-package.d.ts +4 -0
  103. package/dist/internal/installed-package.d.ts.map +1 -0
  104. package/dist/internal/installed-package.js +17 -0
  105. package/dist/internal/installed-package.js.map +1 -0
  106. package/dist/internal/nifra-project.d.ts +41 -0
  107. package/dist/internal/nifra-project.d.ts.map +1 -0
  108. package/dist/internal/nifra-project.js +183 -0
  109. package/dist/internal/nifra-project.js.map +1 -0
  110. package/dist/internal/project-specifiers.d.ts +29 -0
  111. package/dist/internal/project-specifiers.d.ts.map +1 -0
  112. package/dist/internal/project-specifiers.js +435 -0
  113. package/dist/internal/project-specifiers.js.map +1 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -1
  115. package/dist/internal/script-kind.js +2 -1
  116. package/dist/internal/script-kind.js.map +1 -1
  117. package/dist/internal/source-facts.d.ts +8 -1
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +22 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +1 -1
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +10 -6
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +15 -6
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +87 -25
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/levels-tool.d.ts.map +1 -1
  130. package/dist/levels-tool.js +98 -23
  131. package/dist/levels-tool.js.map +1 -1
  132. package/dist/load.d.ts +33 -10
  133. package/dist/load.d.ts.map +1 -1
  134. package/dist/load.js +60 -10
  135. package/dist/load.js.map +1 -1
  136. package/dist/mcp-context.d.ts +5 -5
  137. package/dist/mcp-context.d.ts.map +1 -1
  138. package/dist/mcp-context.js +8 -23
  139. package/dist/mcp-context.js.map +1 -1
  140. package/dist/mcp-delegate.d.ts +2 -2
  141. package/dist/mcp-delegate.d.ts.map +1 -1
  142. package/dist/mcp-delegate.js +19 -7
  143. package/dist/mcp-delegate.js.map +1 -1
  144. package/dist/mcp-exec.d.ts +13 -4
  145. package/dist/mcp-exec.d.ts.map +1 -1
  146. package/dist/mcp-exec.js +56 -14
  147. package/dist/mcp-exec.js.map +1 -1
  148. package/dist/mcp-http.js +1 -1
  149. package/dist/mcp-io.d.ts +30 -0
  150. package/dist/mcp-io.d.ts.map +1 -1
  151. package/dist/mcp-io.js +63 -0
  152. package/dist/mcp-io.js.map +1 -1
  153. package/dist/mcp-isolate.d.ts.map +1 -1
  154. package/dist/mcp-isolate.js +3 -2
  155. package/dist/mcp-isolate.js.map +1 -1
  156. package/dist/mcp-path.d.ts +2 -1
  157. package/dist/mcp-path.d.ts.map +1 -1
  158. package/dist/mcp-path.js +9 -24
  159. package/dist/mcp-path.js.map +1 -1
  160. package/dist/mcp-project-child.js +1 -1
  161. package/dist/mcp-project-child.js.map +1 -1
  162. package/dist/mcp-root.d.ts.map +1 -1
  163. package/dist/mcp-root.js +50 -10
  164. package/dist/mcp-root.js.map +1 -1
  165. package/dist/mcp-ws.d.ts.map +1 -1
  166. package/dist/mcp-ws.js +3 -1
  167. package/dist/mcp-ws.js.map +1 -1
  168. package/dist/mcp.d.ts.map +1 -1
  169. package/dist/mcp.js +48 -19
  170. package/dist/mcp.js.map +1 -1
  171. package/dist/migrate-layout.d.ts +47 -1
  172. package/dist/migrate-layout.d.ts.map +1 -1
  173. package/dist/migrate-layout.js +1972 -170
  174. package/dist/migrate-layout.js.map +1 -1
  175. package/dist/output-schemas.d.ts +128 -0
  176. package/dist/output-schemas.d.ts.map +1 -0
  177. package/dist/output-schemas.js +975 -0
  178. package/dist/output-schemas.js.map +1 -0
  179. package/dist/port.d.ts.map +1 -1
  180. package/dist/port.js +3 -2
  181. package/dist/port.js.map +1 -1
  182. package/dist/project-facts.d.ts +15 -4
  183. package/dist/project-facts.d.ts.map +1 -1
  184. package/dist/project-facts.js +1 -0
  185. package/dist/project-facts.js.map +1 -1
  186. package/dist/project-path.d.ts +6 -0
  187. package/dist/project-path.d.ts.map +1 -1
  188. package/dist/project-path.js +48 -14
  189. package/dist/project-path.js.map +1 -1
  190. package/dist/recipes/5.0.0.d.ts +11 -0
  191. package/dist/recipes/5.0.0.d.ts.map +1 -0
  192. package/dist/recipes/5.0.0.js +34 -0
  193. package/dist/recipes/5.0.0.js.map +1 -0
  194. package/dist/recipes/index.d.ts +7 -0
  195. package/dist/recipes/index.d.ts.map +1 -1
  196. package/dist/recipes/index.js +2 -0
  197. package/dist/recipes/index.js.map +1 -1
  198. package/dist/release-verification.d.ts +5 -0
  199. package/dist/release-verification.d.ts.map +1 -1
  200. package/dist/release-verification.js +14 -0
  201. package/dist/release-verification.js.map +1 -1
  202. package/dist/replay.d.ts.map +1 -1
  203. package/dist/replay.js +12 -3
  204. package/dist/replay.js.map +1 -1
  205. package/dist/review.d.ts +3 -1
  206. package/dist/review.d.ts.map +1 -1
  207. package/dist/review.js +43 -7
  208. package/dist/review.js.map +1 -1
  209. package/dist/rules/codes.d.ts +12 -0
  210. package/dist/rules/codes.d.ts.map +1 -1
  211. package/dist/rules/codes.js +12 -0
  212. package/dist/rules/codes.js.map +1 -1
  213. package/dist/rules/comment-markers.d.ts +16 -7
  214. package/dist/rules/comment-markers.d.ts.map +1 -1
  215. package/dist/rules/comment-markers.js +58 -19
  216. package/dist/rules/comment-markers.js.map +1 -1
  217. package/dist/rules/contract-visibility.d.ts +3 -0
  218. package/dist/rules/contract-visibility.d.ts.map +1 -0
  219. package/dist/rules/contract-visibility.js +337 -0
  220. package/dist/rules/contract-visibility.js.map +1 -0
  221. package/dist/rules/data-guard.d.ts +13 -0
  222. package/dist/rules/data-guard.d.ts.map +1 -1
  223. package/dist/rules/data-guard.js +356 -37
  224. package/dist/rules/data-guard.js.map +1 -1
  225. package/dist/rules/layout.d.ts +4 -0
  226. package/dist/rules/layout.d.ts.map +1 -0
  227. package/dist/rules/layout.js +19 -0
  228. package/dist/rules/layout.js.map +1 -0
  229. package/dist/rules/legacy.d.ts.map +1 -1
  230. package/dist/rules/legacy.js +178 -44
  231. package/dist/rules/legacy.js.map +1 -1
  232. package/dist/rules/pages.d.ts +50 -0
  233. package/dist/rules/pages.d.ts.map +1 -1
  234. package/dist/rules/pages.js +436 -4
  235. package/dist/rules/pages.js.map +1 -1
  236. package/dist/rules/routes.d.ts +6 -0
  237. package/dist/rules/routes.d.ts.map +1 -1
  238. package/dist/rules/routes.js +111 -16
  239. package/dist/rules/routes.js.map +1 -1
  240. package/dist/rules/secrets.js +2 -2
  241. package/dist/rules/secrets.js.map +1 -1
  242. package/dist/rules/security.d.ts +13 -1
  243. package/dist/rules/security.d.ts.map +1 -1
  244. package/dist/rules/security.js +313 -69
  245. package/dist/rules/security.js.map +1 -1
  246. package/dist/rules/zones.d.ts +13 -0
  247. package/dist/rules/zones.d.ts.map +1 -1
  248. package/dist/rules/zones.js +218 -44
  249. package/dist/rules/zones.js.map +1 -1
  250. package/dist/scaffold.d.ts.map +1 -1
  251. package/dist/scaffold.js +6 -4
  252. package/dist/scaffold.js.map +1 -1
  253. package/dist/sdk.d.ts.map +1 -1
  254. package/dist/sdk.js +14 -5
  255. package/dist/sdk.js.map +1 -1
  256. package/dist/stylex-migrate.d.ts.map +1 -1
  257. package/dist/stylex-migrate.js +290 -24
  258. package/dist/stylex-migrate.js.map +1 -1
  259. package/dist/test-tool.d.ts.map +1 -1
  260. package/dist/test-tool.js +6 -3
  261. package/dist/test-tool.js.map +1 -1
  262. package/dist/typecheck-coverage.d.ts +20 -0
  263. package/dist/typecheck-coverage.d.ts.map +1 -0
  264. package/dist/typecheck-coverage.js +129 -0
  265. package/dist/typecheck-coverage.js.map +1 -0
  266. package/dist/upgrade.d.ts +84 -0
  267. package/dist/upgrade.d.ts.map +1 -1
  268. package/dist/upgrade.js +498 -78
  269. package/dist/upgrade.js.map +1 -1
  270. package/dist/verification-plan.d.ts.map +1 -1
  271. package/dist/verification-plan.js +16 -2
  272. package/dist/verification-plan.js.map +1 -1
  273. package/dist/version-drift.d.ts +22 -0
  274. package/dist/version-drift.d.ts.map +1 -0
  275. package/dist/version-drift.js +94 -0
  276. package/dist/version-drift.js.map +1 -0
  277. package/dist/web-app-options.d.ts +1 -1
  278. package/dist/web-app-options.d.ts.map +1 -1
  279. package/dist/web-app-options.js +1 -1
  280. package/dist/web-app-options.js.map +1 -1
  281. package/dist/work-graph.d.ts.map +1 -1
  282. package/dist/work-graph.js +20 -8
  283. package/dist/work-graph.js.map +1 -1
  284. package/docs/examples.json +55 -7
  285. package/docs/llms-full.txt +2043 -222
  286. package/docs/types.json +1573 -246
  287. package/package.json +11 -12
  288. package/src/agent-files.ts +203 -0
  289. package/src/app-files.ts +34 -4
  290. package/src/assure-hydration.ts +18 -2
  291. package/src/assure.ts +88 -10
  292. package/src/capabilities-tool.ts +47 -9
  293. package/src/check-diagnostics.ts +75 -9
  294. package/src/check-scan.ts +1816 -377
  295. package/src/check.ts +258 -81
  296. package/src/cli.ts +242 -37
  297. package/src/client-module.ts +97 -0
  298. package/src/command-catalog.ts +237 -52
  299. package/src/component-check.ts +79 -0
  300. package/src/contract-proof.ts +3 -25
  301. package/src/contracts.ts +60 -3
  302. package/src/db-tool.ts +5 -2
  303. package/src/dev-bun-config.ts +51 -10
  304. package/src/dev-feed-tool.ts +112 -1
  305. package/src/diagnostics.ts +3 -0
  306. package/src/diff-tool.ts +33 -26
  307. package/src/doctor.ts +43 -21
  308. package/src/env-file.ts +7 -0
  309. package/src/fix-recipes.ts +116 -2
  310. package/src/hand-built-manifest.ts +144 -0
  311. package/src/hooks-report.ts +135 -0
  312. package/src/init-agents.ts +21 -14
  313. package/src/internal/config-paths.ts +154 -0
  314. package/src/internal/installed-package.ts +15 -0
  315. package/src/internal/nifra-project.ts +191 -0
  316. package/src/internal/project-specifiers.ts +477 -0
  317. package/src/internal/script-kind.ts +2 -1
  318. package/src/internal/source-facts.ts +48 -5
  319. package/src/internal/typescript-import.ts +15 -6
  320. package/src/introspect.ts +104 -28
  321. package/src/levels-tool.ts +124 -29
  322. package/src/load.ts +107 -17
  323. package/src/mcp-context.ts +12 -25
  324. package/src/mcp-delegate.ts +22 -10
  325. package/src/mcp-exec.ts +68 -18
  326. package/src/mcp-http.ts +1 -1
  327. package/src/mcp-io.ts +74 -0
  328. package/src/mcp-isolate.ts +9 -2
  329. package/src/mcp-path.ts +8 -22
  330. package/src/mcp-project-child.ts +1 -1
  331. package/src/mcp-root.ts +58 -8
  332. package/src/mcp-ws.ts +4 -2
  333. package/src/mcp.ts +56 -22
  334. package/src/migrate-layout.ts +2257 -176
  335. package/src/output-schemas.ts +1240 -0
  336. package/src/port.ts +3 -2
  337. package/src/project-facts.ts +17 -3
  338. package/src/project-path.ts +44 -13
  339. package/src/recipes/5.0.0.ts +35 -0
  340. package/src/recipes/index.ts +10 -0
  341. package/src/release-verification.ts +20 -0
  342. package/src/replay.ts +14 -3
  343. package/src/review.ts +44 -10
  344. package/src/rules/codes.ts +12 -0
  345. package/src/rules/comment-markers.ts +57 -21
  346. package/src/rules/contract-visibility.ts +383 -0
  347. package/src/rules/data-guard.ts +391 -42
  348. package/src/rules/layout.ts +22 -0
  349. package/src/rules/legacy.ts +206 -52
  350. package/src/rules/pages.ts +481 -4
  351. package/src/rules/routes.ts +124 -16
  352. package/src/rules/secrets.ts +2 -2
  353. package/src/rules/security.ts +391 -72
  354. package/src/rules/zones.ts +232 -42
  355. package/src/scaffold.ts +6 -4
  356. package/src/sdk.ts +14 -5
  357. package/src/stylex-migrate.ts +251 -24
  358. package/src/test-tool.ts +12 -3
  359. package/src/typecheck-coverage.ts +134 -0
  360. package/src/upgrade.ts +576 -72
  361. package/src/verification-plan.ts +33 -2
  362. package/src/version-drift.ts +118 -0
  363. package/src/web-app-options.ts +2 -2
  364. package/src/work-graph.ts +17 -12
package/src/load.ts CHANGED
@@ -40,6 +40,12 @@ export type PluginsField =
40
40
  * don't).
41
41
  */
42
42
  export interface NifraFramework {
43
+ /** Optional coordinated Vite 8 component graph. Tooling stays in nifra.config.ts. */
44
+ readonly componentPipeline?: Omit<
45
+ import("@nifrajs/web/server-components/build").ComponentPipelineOptions,
46
+ "root" | "outDir" | "port" | "plugins"
47
+ >
48
+ readonly serverComponents?: CreateWebAppOptions["serverComponents"]
43
49
  /** The render adapter, e.g. `reactAdapter` from `@nifrajs/web-react`. */
44
50
  readonly adapter: unknown
45
51
  /** The client runtime module providing `mountRouter`, e.g. `"@nifrajs/web-react/client"`. */
@@ -72,8 +78,11 @@ export interface NifraFramework {
72
78
  * targets use the socket peer either way. */
73
79
  readonly clientIp?: "platform"
74
80
  /** `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 }
81
+ * (`nifra dev --no-indicator` does it for one); browser errors still reach `nifra errors`.
82
+ * `allowedHosts` lists host names the Bun dev server answers besides loopback ones; it refuses
83
+ * every other `Host` so a DNS-rebound page cannot read it. The Vite pipeline uses Vite's own
84
+ * `server.allowedHosts`. */
85
+ readonly dev?: { readonly indicator?: boolean; readonly allowedHosts?: readonly string[] }
77
86
  /** Static files directory. Defaults to `<app>/public`; `false` disables it. */
78
87
  readonly publicDir?: string | false
79
88
  /** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
@@ -85,9 +94,9 @@ export interface NifraFramework {
85
94
  readonly cssCodeSplit?: boolean
86
95
  /** Framework-owned SSR stylesheet activation. Deferred mode is intended for aggregate CSS. */
87
96
  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.
97
+ /** Path `backend/app.ts`'s backend is served at over HTTP, such as `"/api"`; unset or `""`, it is
98
+ * not mounted. A page file under it can never render, so the app refuses to start and `nifra build`
99
+ * refuses to build.
91
100
  *
92
101
  * The fields from here down reach the generated server entry, so like `use` they MUST be exported
93
102
  * from `backend/framework.ts` (re-export them from `nifra.config.ts` when both exist). */
@@ -106,7 +115,14 @@ export interface NifraFramework {
106
115
 
107
116
  /** The {@link NifraFramework} fields forwarded to `createWebApp` and imported by the generated server
108
117
  * 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
118
+ export const FRAMEWORK_WEB_OPTIONS = [
119
+ "apiPrefix",
120
+ "apiStrip",
121
+ "mounts",
122
+ "csp",
123
+ "nonce",
124
+ "serverComponents",
125
+ ] as const
110
126
  export type FrameworkWebOption = (typeof FRAMEWORK_WEB_OPTIONS)[number]
111
127
 
112
128
  /** Refuse a forwarded field of the wrong shape at load, naming the field, rather than letting it fail
@@ -115,6 +131,24 @@ function assertFrameworkWebOptions(fw: Partial<NifraFramework>, configFile: stri
115
131
  const wrong = (field: string, expected: string): never => {
116
132
  throw new Error(`[nifra] ${configFile}: \`${field}\` must be ${expected}.`)
117
133
  }
134
+ if (fw.componentPipeline !== undefined) {
135
+ const pipeline = fw.componentPipeline
136
+ if (
137
+ typeof pipeline !== "object" ||
138
+ pipeline === null ||
139
+ !["react", "vue", "svelte", "solid", "preact"].includes(pipeline.adapter) ||
140
+ typeof pipeline.entries !== "object" ||
141
+ pipeline.entries === null ||
142
+ typeof pipeline.entries.client !== "string" ||
143
+ typeof pipeline.entries.ssr !== "string" ||
144
+ (pipeline.adapter === "react" && typeof pipeline.entries.rsc !== "string") ||
145
+ (pipeline.adapter !== "react" && pipeline.entries.rsc !== undefined)
146
+ )
147
+ wrong(
148
+ "componentPipeline",
149
+ "an adapter plus existing client/ssr entries and a React-only rsc entry",
150
+ )
151
+ }
118
152
  if (fw.apiPrefix !== undefined && typeof fw.apiPrefix !== "string") {
119
153
  wrong("apiPrefix", 'a path string such as "/api", or "" to turn the backend mount off')
120
154
  }
@@ -143,6 +177,13 @@ function assertFrameworkWebOptions(fw: Partial<NifraFramework>, configFile: stri
143
177
  const indicator: unknown = Reflect.get(fw.dev, "indicator")
144
178
  if (indicator !== undefined && typeof indicator !== "boolean")
145
179
  wrong("dev.indicator", "a boolean")
180
+ const allowedHosts: unknown = Reflect.get(fw.dev, "allowedHosts")
181
+ if (
182
+ allowedHosts !== undefined &&
183
+ (!Array.isArray(allowedHosts) ||
184
+ allowedHosts.some((host) => typeof host !== "string" || host.trim() === ""))
185
+ )
186
+ wrong("dev.allowedHosts", 'an array of host names such as ["app.test"]')
146
187
  }
147
188
  }
148
189
 
@@ -198,6 +239,23 @@ export interface LoadedApp {
198
239
  readonly backend: unknown
199
240
  }
200
241
 
242
+ /** An API-only app as the backend readers see it: `backend/app.ts` and no web config. */
243
+ export interface BackendOnlyApp {
244
+ readonly cwd: string
245
+ readonly backend: unknown
246
+ readonly framework?: undefined
247
+ }
248
+
249
+ /** What the backend readers (routes, context, hooks, OpenAPI, the app's MCP surface) load. */
250
+ export type IntrospectedApp = LoadedApp | BackendOnlyApp
251
+
252
+ /** How a command reaches the app. The MCP server hands out one whose calls both refuse. */
253
+ export interface AppLoader {
254
+ (outDirName?: string): Promise<LoadedApp>
255
+ /** The whole app with a web config, else `backend/app.ts` alone. */
256
+ readonly backend: () => Promise<IntrospectedApp>
257
+ }
258
+
201
259
  export interface LoadAppOptions {
202
260
  /** Optional dynamic-import query used by long-lived MCP processes to bust Bun's module cache. */
203
261
  readonly importQuery?: string
@@ -258,22 +316,24 @@ const isAdapter = (v: unknown): boolean => typeof v === "object" && v !== null
258
316
  const importWithQuery = (path: string, query: string | undefined): Promise<unknown> =>
259
317
  import(query === undefined || query === "" ? path : `${path}?${query}`)
260
318
 
319
+ /** Whether `cwd` has a web config file, the one thing that makes it more than its backend. */
320
+ export const hasAppConfig = (cwd: string): boolean =>
321
+ existsSync(resolve(cwd, CONFIG_FILE)) || existsSync(resolve(cwd, FRAMEWORK_FILE))
322
+
261
323
  /**
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.
324
+ * What reads only the backend: the whole app when it has a web config, else `backend/app.ts`
325
+ * alone, so an API-only app needs no adapter for its routes, context, hooks or OpenAPI document.
264
326
  */
265
327
  export async function loadBackendApp(
266
328
  cwd: string,
267
329
  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
- }
330
+ ): Promise<IntrospectedApp> {
331
+ if (hasAppConfig(cwd)) return loadApp(cwd, "dist", options)
272
332
  assertCurrentLayout(cwd)
273
333
  const backendPath = resolve(cwd, BACKEND_APP_FILE)
274
334
  if (!existsSync(backendPath)) {
275
335
  throw new Error(
276
- `[nifra] no ${BACKEND_APP_FILE} in ${cwd}: the OpenAPI document describes its routes`,
336
+ `[nifra] no ${CONFIG_FILE}, ${FRAMEWORK_FILE} or ${BACKEND_APP_FILE} in ${cwd}: a web app needs its config, an API-only app its ${BACKEND_APP_FILE}`,
277
337
  )
278
338
  }
279
339
  const backend = (
@@ -282,9 +342,31 @@ export async function loadBackendApp(
282
342
  return { cwd, backend }
283
343
  }
284
344
 
345
+ /** The fields the generated server entry imports from backend/framework.ts. */
346
+ const SERVER_OPTIONS = [...FRAMEWORK_WEB_OPTIONS, "use"] as const
347
+
348
+ /**
349
+ * `config` with each server option it leaves unset taken from backend/framework.ts, so `nifra dev` and
350
+ * the server entry `nifra build` generates read one value without nifra.config.ts re-exporting it.
351
+ */
352
+ async function withServerOptions(
353
+ config: Partial<NifraFramework>,
354
+ frameworkPath: string,
355
+ importQuery: string | undefined,
356
+ ): Promise<Partial<NifraFramework>> {
357
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a config module, read like nifra.config.ts; every web option taken from it is validated below.
358
+ const framework = (await importWithQuery(frameworkPath, importQuery)) as Partial<NifraFramework>
359
+ const taken: Record<string, unknown> = {}
360
+ for (const name of SERVER_OPTIONS) {
361
+ if (config[name] === undefined && framework[name] !== undefined) taken[name] = framework[name]
362
+ }
363
+ assertFrameworkWebOptions(taken, FRAMEWORK_FILE)
364
+ return { ...config, ...taken }
365
+ }
366
+
285
367
  /** 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
368
+ * to `backend/framework.ts`; with both, a server option nifra.config.ts leaves unset comes from
369
+ * backend/framework.ts. Throws a clear, actionable error if neither exists or the config is
288
370
  * malformed. */
289
371
  export async function loadApp(
290
372
  cwd: string,
@@ -303,7 +385,11 @@ export async function loadApp(
303
385
  " nifra is zero-config but needs one of them exporting at least:\n" +
304
386
  " export const adapter = reactAdapter // from @nifrajs/web-react\n" +
305
387
  ' export const clientModule = "@nifrajs/web-react/client"\n' +
306
- " (create-nifra scaffolds this for you.)",
388
+ " (create-nifra scaffolds this for you.)" +
389
+ (existsSync(resolve(cwd, BACKEND_APP_FILE))
390
+ ? `\n With ${BACKEND_APP_FILE} and no web config this is an API-only app: \`nifra routes\`,\n` +
391
+ " `nifra context`, `nifra openapi` and `nifra mcp` read its backend; pages need the config."
392
+ : ""),
307
393
  )
308
394
  }
309
395
  if (!existsSync(resolve(cwd, "routes"))) {
@@ -311,7 +397,11 @@ export async function loadApp(
311
397
  `[nifra] no routes/ directory in ${cwd} - nifra apps are file-routed under routes/.`,
312
398
  )
313
399
  }
314
- const fw = (await importWithQuery(configPath, options.importQuery)) as Partial<NifraFramework>
400
+ const loaded = (await importWithQuery(configPath, options.importQuery)) as Partial<NifraFramework>
401
+ const fw =
402
+ configFile === CONFIG_FILE && existsSync(resolve(cwd, FRAMEWORK_FILE))
403
+ ? await withServerOptions(loaded, resolve(cwd, FRAMEWORK_FILE), options.importQuery)
404
+ : loaded
315
405
  if (!isAdapter(fw.adapter) || typeof fw.clientModule !== "string") {
316
406
  throw new Error(
317
407
  `[nifra] ${configFile} must export \`adapter\` (a render adapter object) and \`clientModule\` (a string).`,
@@ -8,10 +8,11 @@
8
8
  import { realpathSync } from "node:fs"
9
9
  import { readFile } from "node:fs/promises"
10
10
  import { basename, isAbsolute, relative, resolve } from "node:path"
11
- import type { LoadedApp } from "./load.ts"
11
+ import type { AppLoader } from "./load.ts"
12
12
  import { createCachedAppLoader } from "./mcp-exec.ts"
13
13
  import { resolveMcpProjectPath } from "./mcp-path.ts"
14
14
  import type { McpPrompt, McpResource, McpServerFeatures, McpTool } from "./mcp-protocol.ts"
15
+ import { writeTargetOf } from "./project-path.ts"
15
16
 
16
17
  function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
17
18
  const format = args.format
@@ -20,11 +21,11 @@ function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
20
21
 
21
22
  export async function openApiHandler(
22
23
  args: Record<string, unknown>,
23
- loadAppCached: (outDirName?: string) => Promise<LoadedApp>,
24
+ loadAppCached: AppLoader,
24
25
  ): Promise<string> {
25
26
  const { renderOpenApiWithTypes } = await import("./openapi-tool.ts")
26
27
  const pathPrefix = typeof args.path === "string" ? args.path : undefined
27
- return renderOpenApiWithTypes(await loadAppCached(), openApiFormat(args), pathPrefix)
28
+ return renderOpenApiWithTypes(await loadAppCached.backend(), openApiFormat(args), pathPrefix)
28
29
  }
29
30
 
30
31
  async function readProjectFile(
@@ -57,7 +58,7 @@ function promptText(
57
58
 
58
59
  export function projectResources(
59
60
  cwd: string,
60
- loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
61
+ loadAppCached: AppLoader = createCachedAppLoader(cwd),
61
62
  ): McpResource[] {
62
63
  return [
63
64
  {
@@ -68,7 +69,7 @@ export function projectResources(
68
69
  mimeType: "application/json",
69
70
  read: async () => {
70
71
  const { routesToJson } = await import("./introspect.ts")
71
- return { text: JSON.stringify(routesToJson(await loadAppCached()), null, 2) }
72
+ return { text: JSON.stringify(routesToJson(await loadAppCached.backend()), null, 2) }
72
73
  },
73
74
  },
74
75
  {
@@ -165,7 +166,7 @@ export function projectPrompts(): McpPrompt[] {
165
166
 
166
167
  export function projectFeatures(
167
168
  cwd: string,
168
- loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
169
+ loadAppCached: AppLoader = createCachedAppLoader(cwd),
169
170
  ): McpServerFeatures {
170
171
  return { resources: projectResources(cwd, loadAppCached), prompts: projectPrompts() }
171
172
  }
@@ -186,24 +187,10 @@ export function resolveProjectDir(root: string, dir: string | undefined): string
186
187
  const lexical = relative(root, target)
187
188
  return lexical === "" || (!lexical.startsWith("..") && !isAbsolute(lexical)) ? target : null
188
189
  }
189
- let probe = target
190
- const missing: string[] = []
191
- while (true) {
192
- try {
193
- const canonicalProbe = realpathSync(probe)
194
- const rel = relative(canonicalRoot, canonicalProbe)
195
- if (rel.startsWith("..") || isAbsolute(rel)) return null
196
- return missing.length === 0 ? canonicalProbe : resolve(canonicalProbe, ...missing.reverse())
197
- } catch {
198
- const parent = resolve(probe, "..")
199
- if (parent === probe) return null
200
- // `basename`, not a slice past `parent`: when `parent` is the filesystem root its trailing
201
- // separator is part of it, so `parent.length + 1` eats the first character of the segment and
202
- // the path gets rebuilt wrong.
203
- missing.push(basename(probe))
204
- probe = parent
205
- }
206
- }
190
+ const canonical = writeTargetOf(target)
191
+ if (canonical === undefined) return null
192
+ const rel = relative(canonicalRoot, canonical)
193
+ return rel.startsWith("..") || isAbsolute(rel) ? null : canonical
207
194
  }
208
195
 
209
196
  /** Consistent error string for a `dir` that escapes the project root. */
@@ -218,7 +205,7 @@ export function dirError(dir: string | undefined): string {
218
205
  /** Build the project-scoped tools for `cwd`. */
219
206
  export interface CommandMcpToolOptions {
220
207
  readonly cwd: string
221
- readonly loadAppCached?: () => Promise<LoadedApp>
208
+ readonly loadAppCached?: AppLoader
222
209
  }
223
210
 
224
211
  /** Adapt one executable catalog spec to its MCP descriptor and project-scoped handler. */
@@ -6,7 +6,7 @@
6
6
  * answer still reads as authoritative. So the server settles the version before it serves:
7
7
  *
8
8
  * 1. The project installs its own `@nifrajs/cli` at a different version: hand the whole session to
9
- * that CLI (`node_modules/.bin/nifra mcp`, stdio inherited) and step aside.
9
+ * that CLI (its `bin` script, run by this Bun with `--no-env-file`, stdio inherited) and step aside.
10
10
  * 2. Handing off is impossible (no project CLI, a missing bin, or this process IS the hand-off and
11
11
  * the versions still disagree): serve, but the version-sensitive tools refuse with the fix
12
12
  * instead of answering for the wrong release.
@@ -31,27 +31,37 @@ export const VERSION_SENSITIVE_TOOLS: ReadonlySet<string> = new Set([
31
31
  "nifra_contracts",
32
32
  ])
33
33
 
34
- /** The project's own CLI: its version and, when the install linked one, its bin. */
34
+ /** The project's own CLI: its version and, when it is on disk, the script its `bin` names. */
35
35
  export interface ProjectCli {
36
36
  readonly version: string
37
37
  readonly bin?: string
38
38
  }
39
39
 
40
- const BIN_NAME = process.platform === "win32" ? "nifra.exe" : "nifra"
41
40
  const MAX_ANCESTOR_DIRS = 8
42
41
 
42
+ function binScript(bin: unknown): string | undefined {
43
+ if (typeof bin === "string") return bin
44
+ if (typeof bin !== "object" || bin === null) return undefined
45
+ const named: unknown = Reflect.get(bin, "nifra")
46
+ return typeof named === "string" ? named : undefined
47
+ }
48
+
43
49
  /** The nearest `@nifrajs/cli` install at or above `root` (a monorepo app hoists to the repo root). */
44
50
  export async function findProjectCli(root: string): Promise<ProjectCli | undefined> {
45
51
  let dir = root
46
52
  for (let depth = 0; depth < MAX_ANCESTOR_DIRS; depth++) {
47
- const modules = join(dir, "node_modules")
53
+ const pkg = join(dir, "node_modules", "@nifrajs", "cli")
48
54
  try {
49
- const meta = JSON.parse(
50
- await readFile(join(modules, "@nifrajs", "cli", "package.json"), "utf8"),
51
- ) as { version?: unknown }
55
+ const meta = JSON.parse(await readFile(join(pkg, "package.json"), "utf8")) as {
56
+ version?: unknown
57
+ bin?: unknown
58
+ }
52
59
  if (typeof meta.version === "string" && meta.version.length > 0) {
53
- const bin = join(modules, ".bin", BIN_NAME)
54
- return existsSync(bin) ? { version: meta.version, bin } : { version: meta.version }
60
+ const script = binScript(meta.bin)
61
+ const bin = script === undefined ? undefined : join(pkg, script)
62
+ return bin !== undefined && existsSync(bin)
63
+ ? { version: meta.version, bin }
64
+ : { version: meta.version }
55
65
  }
56
66
  } catch {
57
67
  // Not installed at this level - keep walking up.
@@ -79,7 +89,9 @@ export async function delegateToProjectCli(options: {
79
89
  const cli = await findProjectCli(options.root)
80
90
  if (cli === undefined || cli.version === options.version || cli.bin === undefined)
81
91
  return undefined
82
- return handOffSession([cli.bin, "mcp", ...options.args], {
92
+ // Not the `.bin` shim: its `bun` would load the project's `.env`, and the inherited
93
+ // NIFRA_MCP_WITHOUT_ENV_FILES then tells that CLI the values are already gone.
94
+ return handOffSession([process.execPath, "--no-env-file", cli.bin, "mcp", ...options.args], {
83
95
  cwd: options.cwd,
84
96
  env: { ...process.env, [MCP_DELEGATED_ENV]: "1" },
85
97
  announce: `[nifra] mcp: this CLI is ${options.version}; the project installs @nifrajs/cli ${cli.version} - serving this session from ${cli.bin}`,
package/src/mcp-exec.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { type Dirent, realpathSync } from "node:fs"
10
10
  import { readdir, stat } from "node:fs/promises"
11
- import { relative, resolve } from "node:path"
11
+ import { isAbsolute, relative, resolve } from "node:path"
12
12
  import { fileURLToPath } from "node:url"
13
13
  import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
14
14
  import {
@@ -24,7 +24,7 @@ import {
24
24
  import { collectContractProof } from "./contract-proof.ts"
25
25
  import { loadDocsCorpus } from "./docs-search.ts"
26
26
  import { loadExamplesCorpus } from "./examples.ts"
27
- import type { LoadAppOptions, LoadedApp } from "./load.ts"
27
+ import type { AppLoader, IntrospectedApp, LoadAppOptions, LoadedApp } from "./load.ts"
28
28
  import {
29
29
  type CommandMcpToolOptions,
30
30
  dirError,
@@ -36,7 +36,11 @@ import {
36
36
  CHILD_INPUT_MAX_BYTES,
37
37
  CHILD_OUTPUT_MAX_BYTES,
38
38
  CHILD_TIMEOUT_MS,
39
+ type EscalatingStop,
40
+ escalatingStop,
41
+ OWN_PROCESS_GROUP,
39
42
  readBoundedStream,
43
+ signalProcessGroup,
40
44
  timeoutMessage,
41
45
  validateLocalPort,
42
46
  } from "./mcp-io.ts"
@@ -46,20 +50,29 @@ import { loadTypesCorpus } from "./types-search.ts"
46
50
 
47
51
  const CODEFRAME_SOURCE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/i
48
52
 
53
+ type GatePaths = Pick<typeof import("node:path"), "isAbsolute" | "relative" | "resolve">
54
+
49
55
  /** The files a stack the caller supplies may show a codeframe from: project source, never a dotfile
50
- * or anything under a dot directory (`.env`, `.git/`, `.nifra/`). */
51
- function explainSourceGate(root: string): (file: string) => boolean {
56
+ * or anything under a dot directory (`.env`, `.git/`, `.nifra/`), and never a file outside the root -
57
+ * which on Windows includes one on another drive, where `relative` answers with an absolute path. */
58
+ export function explainSourceGate(
59
+ root: string,
60
+ paths: GatePaths = { isAbsolute, relative, resolve },
61
+ ): (file: string) => boolean {
52
62
  // The codeframe reads a native realpath, which expands a Windows short name (`RUNNER~1`); the root
53
63
  // has to be spelled the same way or every file looks like it sits outside it.
54
- let base = resolve(root)
64
+ let base = paths.resolve(root)
55
65
  try {
56
66
  base = realpathSync.native(base)
57
67
  } catch {}
58
- return (file) =>
59
- CODEFRAME_SOURCE.test(file) &&
60
- !relative(base, file)
61
- .split(/[\\/]/)
62
- .some((segment) => segment.startsWith("."))
68
+ return (file) => {
69
+ const inside = paths.relative(base, file)
70
+ return (
71
+ CODEFRAME_SOURCE.test(file) &&
72
+ !paths.isAbsolute(inside) &&
73
+ !inside.split(/[\\/]/).some((segment) => segment.startsWith("."))
74
+ )
75
+ }
63
76
  }
64
77
 
65
78
  /** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
@@ -74,7 +87,16 @@ export function toMcpTool(
74
87
  const entry = "run" in source ? undefined : source
75
88
  const { cwd } = options
76
89
  const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd)
77
- const pathFields = ["config", "out", "lockfile", "before", "after", "baseline", "file"] as const
90
+ const pathFields = [
91
+ "config",
92
+ "out",
93
+ "lockfile",
94
+ "before",
95
+ "after",
96
+ "baseline",
97
+ "file",
98
+ "since",
99
+ ] as const
78
100
  const catalogEntry = entry ?? toCommandCatalogEntry(spec)
79
101
  return {
80
102
  name: commandMcpName(catalogEntry.name),
@@ -143,7 +165,7 @@ export function toMcpTool(
143
165
 
144
166
  export function catalogProjectTools(
145
167
  cwd: string,
146
- loadAppCached: () => Promise<LoadedApp> = createCachedAppLoader(cwd),
168
+ loadAppCached: AppLoader = createCachedAppLoader(cwd),
147
169
  ): McpTool[] {
148
170
  return commandCatalog
149
171
  .filter((entry) => entry.transports.includes("mcp"))
@@ -156,11 +178,11 @@ export function catalogProjectTools(
156
178
 
157
179
  export function projectTools(
158
180
  cwd: string,
159
- loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
181
+ loadAppCached: AppLoader = createCachedAppLoader(cwd),
160
182
  ): McpTool[] {
161
183
  const warmRun = createWarmHandler("mcp-run", cwd, "run")
162
184
  const warmRender = createWarmHandler("mcp-render", cwd, "render")
163
- const catalogTools = catalogProjectTools(cwd, () => loadAppCached())
185
+ const catalogTools = catalogProjectTools(cwd, loadAppCached)
164
186
  const legacyTools: McpTool[] = [
165
187
  {
166
188
  name: "nifra_verify",
@@ -692,12 +714,14 @@ export async function spawnChild(
692
714
  stdin: "pipe",
693
715
  stdout: "pipe",
694
716
  stderr: "pipe",
717
+ detached: OWN_PROCESS_GROUP,
695
718
  })
719
+ const stopper = escalatingStop((name) => signalProcessGroup(proc, name))
696
720
  let killed = false
697
721
  const kill = (): void => {
698
722
  if (killed) return
699
723
  killed = true
700
- proc.kill()
724
+ stopper.stop()
701
725
  }
702
726
  const abort = (): void => kill()
703
727
  signal?.addEventListener("abort", abort, { once: true })
@@ -738,6 +762,7 @@ export async function spawnChild(
738
762
  return text || `${label} failed:\n${err.text.trim() || "(no output)"}`
739
763
  } finally {
740
764
  clearTimeout(timer)
765
+ stopper.dispose()
741
766
  signal?.removeEventListener("abort", abort)
742
767
  }
743
768
  }
@@ -791,6 +816,16 @@ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
791
816
  readonly stderr: ReadableStream<Uint8Array>
792
817
  }
793
818
 
819
+ const liveWarmWorkers = new Set<WarmWorker>()
820
+ let warmWorkersClosed = false
821
+
822
+ /** Stop every warm worker and start no more, once the MCP client has closed stdin. A live worker
823
+ * holds the process open, so the server would otherwise never exit. */
824
+ export function closeWarmWorkers(): void {
825
+ warmWorkersClosed = true
826
+ for (const worker of liveWarmWorkers) worker.stop()
827
+ }
828
+
794
829
  /** A persistent `mcp-run`/`mcp-render` `--worker` subprocess: the backend/web app is loaded ONCE and
795
830
  * reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
796
831
  * powers both `nifra_run warm` and `nifra_render warm` - `child` selects which engine, `label` shapes the
@@ -807,6 +842,7 @@ export class WarmWorker {
807
842
  readonly cleanup: () => void
808
843
  }
809
844
  >()
845
+ private readonly stopper: EscalatingStop
810
846
  private stdoutBuffer = ""
811
847
  private stdoutBufferBytes = 0
812
848
  private stderrBuffer = ""
@@ -827,10 +863,14 @@ export class WarmWorker {
827
863
  stdout: "pipe",
828
864
  stderr: "pipe",
829
865
  }) as PipeSubprocess
866
+ this.stopper = escalatingStop((name) => this.proc.kill(name))
867
+ liveWarmWorkers.add(this)
830
868
  void this.readStdout()
831
869
  void this.readStderr()
832
870
  void this.proc.exited.then((code) => {
833
871
  this.closed = true
872
+ this.stopper.dispose()
873
+ liveWarmWorkers.delete(this)
834
874
  const err = new Error(
835
875
  `warm ${this.label} worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
836
876
  )
@@ -843,7 +883,7 @@ export class WarmWorker {
843
883
  }
844
884
 
845
885
  stop(): void {
846
- if (!this.closed) this.proc.kill()
886
+ if (!this.closed) this.stopper.stop()
847
887
  }
848
888
 
849
889
  async request(input: unknown, signal?: AbortSignal): Promise<string> {
@@ -1025,8 +1065,10 @@ export function createWarmHandler(
1025
1065
  label: string,
1026
1066
  ): (input: unknown, signal?: AbortSignal) => Promise<string> {
1027
1067
  let worker: WarmWorker | undefined
1068
+ const shuttingDown = `${label} cancelled: the MCP server is shutting down.`
1028
1069
  return async (input, signal) => {
1029
1070
  const fingerprint = await warmRunFingerprint(cwd)
1071
+ if (warmWorkersClosed) return shuttingDown
1030
1072
  let current = worker
1031
1073
  if (current === undefined || current.fingerprint !== fingerprint) {
1032
1074
  current?.stop()
@@ -1044,6 +1086,7 @@ export function createWarmHandler(
1044
1086
  const reason = cancellationSuffix(signal)
1045
1087
  return `${label} cancelled${reason}.`
1046
1088
  }
1089
+ if (warmWorkersClosed) return shuttingDown
1047
1090
  return spawnChild(child, cwd, input, label, signal)
1048
1091
  }
1049
1092
  }
@@ -1146,7 +1189,7 @@ export async function appFingerprint(cwd: string): Promise<string> {
1146
1189
  export function createCachedAppLoader(
1147
1190
  cwd: string,
1148
1191
  options: CachedAppLoaderOptions = {},
1149
- ): (outDirName?: string) => Promise<LoadedApp> {
1192
+ ): AppLoader {
1150
1193
  const loadAppCached =
1151
1194
  options.loadApp ??
1152
1195
  (async (root: string, outDirName?: string, loadOptions?: LoadAppOptions) => {
@@ -1162,7 +1205,7 @@ export function createCachedAppLoader(
1162
1205
  }
1163
1206
  | undefined
1164
1207
 
1165
- return async (outDirName = "dist") => {
1208
+ const whole = async (outDirName = "dist"): Promise<LoadedApp> => {
1166
1209
  const nextFingerprint = await fingerprint(cwd)
1167
1210
  if (
1168
1211
  cached !== undefined &&
@@ -1177,4 +1220,11 @@ export function createCachedAppLoader(
1177
1220
  cached = { outDirName, fingerprint: nextFingerprint, app }
1178
1221
  return app
1179
1222
  }
1223
+ const backend = async (): Promise<IntrospectedApp> => {
1224
+ const { hasAppConfig, loadBackendApp } = await import("./load.ts")
1225
+ if (hasAppConfig(cwd)) return whole()
1226
+ // The fingerprint names backend/app.ts, so an edit loads a fresh module and no edit reuses it.
1227
+ return loadBackendApp(cwd, { importQuery: cacheToken(await fingerprint(cwd)) })
1228
+ }
1229
+ return Object.assign(whole, { backend })
1180
1230
  }
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "4.0.2"
32
+ const VERSION = "5.1.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>