@nifrajs/cli 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
package/src/introspect.ts CHANGED
@@ -18,7 +18,7 @@ interface RouteDesc {
18
18
  }
19
19
 
20
20
  /** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
21
- function backendRoutes(backend: unknown): RouteDesc[] {
21
+ export function backendRoutes(backend: unknown): RouteDesc[] {
22
22
  if (backend && typeof (backend as { routes?: unknown }).routes === "function") {
23
23
  try {
24
24
  return (backend as { routes(): RouteDesc[] }).routes()
@@ -167,6 +167,20 @@ export function apiRoutesSection(routes: readonly RouteDesc[]): string {
167
167
  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")}`
168
168
  }
169
169
 
170
+ /** Compact API-routes INDEX for the no-arg `nifra_context` call — `METHOD path` per route, WITHOUT the
171
+ * body/query/response shapes or the per-route `call` line. Bounds the first-call payload on a big backend:
172
+ * an agent gets the full route list (so it knows what's mounted) plus a pointer to fetch any route's full
173
+ * contract via the `path`/`kind` slice, instead of every schema up front. */
174
+ export function apiRoutesIndexSection(routes: readonly RouteDesc[]): string {
175
+ if (routes.length === 0) {
176
+ return "## API routes (backend.ts)\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
177
+ }
178
+ const lines = [...routes]
179
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
180
+ .map((r) => `- \`${r.method} ${r.path}\``)
181
+ 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")}`
182
+ }
183
+
170
184
  /** Markdown section listing the file-routed pages (URL pattern → source file). */
171
185
  export function pageRoutesSection(manifest: Manifest | undefined): string {
172
186
  if (!manifest || manifest.routes.length === 0) {
@@ -203,8 +217,14 @@ export interface ContextFilter {
203
217
  readonly kind?: "api" | "pages"
204
218
  }
205
219
 
206
- /** Build the project brief (API routes + page routes + conventions) as Markdown — optionally
207
- * narrowed by {@link ContextFilter} for cheap follow-up calls. */
220
+ /**
221
+ * Build the project brief as Markdown. A no-arg call returns a tight **INDEX** — the route list (API
222
+ * routes as `METHOD path`, page routes as `pattern → file`) + conventions + a pointer to fetch full
223
+ * contracts via the slice — so learning a big project is cheap, not a full-surface schema dump. Passing a
224
+ * {@link ContextFilter} (`path` prefix and/or `kind`) returns the narrowed, FULL detail (per-route
225
+ * body/query/response shapes + the typed-client `call` form), and omits the conventions block (the agent
226
+ * already has it from the index call).
227
+ */
208
228
  export function describeProject(app: LoadedApp, filter?: ContextFilter): string {
209
229
  let manifest: Manifest | undefined
210
230
  try {
@@ -221,19 +241,24 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
221
241
  pages = { ...manifest, routes: manifest.routes.filter((r) => r.pattern.startsWith(prefix)) }
222
242
  }
223
243
  }
224
- const narrowed = filter?.kind !== undefined || (prefix !== undefined && prefix !== "")
244
+ // No `path` and no `kind` → the first-call index (compact route list, no per-route schemas). Any filter
245
+ // → the narrowed full-detail slice (schemas + call form), unchanged.
246
+ const isIndex = filter?.kind === undefined && (prefix === undefined || prefix === "")
225
247
  const sections: string[] = [`# nifra project: ${app.cwd.slice(app.cwd.lastIndexOf("/") + 1)}`]
226
- if (!narrowed) {
248
+ if (isIndex) {
227
249
  sections.push(
228
- "A full-stack nifra app — Web-standard, one app on Bun, Node, Deno, and the edge.",
250
+ "A full-stack nifra app — Web-standard, one app on Bun, Node, Deno, and the edge. This is the route INDEX; fetch a route's full body/query/response contract on demand (see below).",
229
251
  // Point agents at the verified surface, not recalled APIs — nifra is young, so training data is the
230
252
  // wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
231
253
  "> **Build against THIS surface + the verified tools — do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
232
254
  )
233
255
  }
234
- if (filter?.kind !== "pages") sections.push(apiRoutesSection(routes))
256
+ if (filter?.kind !== "pages") {
257
+ // Index → compact `METHOD path` list; a slice → the full schema + call-form detail.
258
+ sections.push(isIndex ? apiRoutesIndexSection(routes) : apiRoutesSection(routes))
259
+ }
235
260
  if (filter?.kind !== "api") sections.push(pageRoutesSection(pages))
236
- if (!narrowed) sections.push(CONVENTIONS)
261
+ if (isIndex) sections.push(CONVENTIONS)
237
262
  return sections.join("\n\n")
238
263
  }
239
264
 
@@ -282,3 +307,178 @@ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
282
307
  }
283
308
  })
284
309
  }
310
+
311
+ // ===================================================================================================
312
+ // `nifra routes [--json]` — the focused, uniform view of every route the app serves, with methods.
313
+ //
314
+ // `nifra context` lists page routes + API routes for an agent's prompt (Markdown, with schemas).
315
+ // `nifra routes` answers a different question: "what does this app actually serve, and with which
316
+ // methods?" — so an agent sees instantly that `POST /api/explain` is (or isn't) mounted, instead of
317
+ // discovering it via a 405. It marks which backend routes are AUTO-MOUNTED under the page router's
318
+ // `apiPrefix` (default `/api`), and `--json` makes it machine-consumable. The table-building +
319
+ // rendering are pure (no fs / no module loads), so they're unit-tested; the cwd gathering is the thin
320
+ // async wrapper `describeRoutes`.
321
+ // ===================================================================================================
322
+
323
+ /** The kind of route: a file-routed `page` (SSR/SSG) or a backend `api` (HTTP handler). */
324
+ export type RouteKind = "page" | "api"
325
+
326
+ /** One row of the unified route table. */
327
+ export interface RouteTableEntry {
328
+ readonly kind: RouteKind
329
+ /** The URL pattern (page: `/blog/:slug`; api: `/api/users`). */
330
+ readonly path: string
331
+ /** Methods this route serves, sorted (page: `GET`, plus `POST` when it has an `action`). */
332
+ readonly methods: readonly string[]
333
+ /** Source file (page only — the route module path relative to `routes/`). */
334
+ readonly file?: string
335
+ /** True when this API route is auto-mounted under the page router's `apiPrefix` (so it's reachable
336
+ * through the same web app, no hand-wired `if (pathname.startsWith("/api/"))`). Pages are never
337
+ * auto-mounted (they ARE the page router); only API routes carry this flag. */
338
+ readonly autoMounted?: boolean
339
+ }
340
+
341
+ /** A page route + whether its module exports an `action` (→ it also serves POST). The caller resolves
342
+ * `hasAction` (a module load); kept out of the pure builder so the builder needs no fs. */
343
+ export interface PageRouteInput {
344
+ readonly pattern: string
345
+ readonly file: string
346
+ readonly hasAction: boolean
347
+ }
348
+
349
+ export interface RouteTableInput {
350
+ readonly pages: readonly PageRouteInput[]
351
+ readonly api: readonly RouteDesc[]
352
+ /** The page router's `apiPrefix` (default `/api`); an API route at/under it is auto-mounted. `""`
353
+ * disables the auto-mount, so no API route is marked auto-mounted. */
354
+ readonly apiPrefix?: string
355
+ }
356
+
357
+ /** True when `path` is at or under `prefix` as a path segment boundary (`/api` matches `/api` and
358
+ * `/api/x`, but not `/apiary`). An empty prefix matches nothing (auto-mount disabled). */
359
+ function isUnderPrefix(path: string, prefix: string): boolean {
360
+ if (prefix === "") return false
361
+ return path === prefix || path.startsWith(`${prefix}/`)
362
+ }
363
+
364
+ /**
365
+ * Build the unified, sorted route table from page routes + backend API routes. Pure — the caller
366
+ * supplies page `hasAction` (resolved via a module load) and the API descriptors (`backend.routes()`).
367
+ * Page routes serve `GET` (+ `POST` when they export an `action`); API routes carry their declared
368
+ * method, and are flagged `autoMounted` when at/under `apiPrefix`. Sorted by path, then kind, then the
369
+ * joined methods, for stable output.
370
+ */
371
+ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
372
+ const apiPrefix = input.apiPrefix ?? "/api"
373
+ const rows: RouteTableEntry[] = []
374
+ for (const page of input.pages) {
375
+ const methods = page.hasAction ? ["GET", "POST"] : ["GET"]
376
+ rows.push({ kind: "page", path: page.pattern, methods, file: page.file })
377
+ }
378
+ // Collapse API routes that share a path into one row with all its methods (a REST resource).
379
+ const byPath = new Map<string, { methods: Set<string>; autoMounted: boolean }>()
380
+ for (const r of input.api) {
381
+ const existing = byPath.get(r.path)
382
+ if (existing === undefined) {
383
+ byPath.set(r.path, {
384
+ methods: new Set([r.method.toUpperCase()]),
385
+ autoMounted: isUnderPrefix(r.path, apiPrefix),
386
+ })
387
+ } else existing.methods.add(r.method.toUpperCase())
388
+ }
389
+ for (const [path, info] of byPath) {
390
+ rows.push({
391
+ kind: "api",
392
+ path,
393
+ methods: [...info.methods].sort(),
394
+ autoMounted: info.autoMounted,
395
+ })
396
+ }
397
+ return rows.sort(
398
+ (a, b) =>
399
+ a.path.localeCompare(b.path) ||
400
+ a.kind.localeCompare(b.kind) ||
401
+ a.methods.join(",").localeCompare(b.methods.join(",")),
402
+ )
403
+ }
404
+
405
+ /** Render the route table as a terse aligned text table (the `nifra routes` default output). Pure. */
406
+ export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
407
+ if (rows.length === 0)
408
+ return "No routes found (no `routes/` pages and no `backend.ts` API routes)."
409
+ const display = rows.map((r) => ({
410
+ methods: r.methods.join(", "),
411
+ kind: r.kind,
412
+ path: r.kind === "api" && r.autoMounted ? `${r.path} (auto-mounted)` : r.path,
413
+ }))
414
+ const methodW = Math.max("METHOD".length, ...display.map((d) => d.methods.length))
415
+ const kindW = Math.max("KIND".length, ...display.map((d) => d.kind.length))
416
+ const padEnd = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length))
417
+ const line = (methods: string, kind: string, path: string): string =>
418
+ `${padEnd(methods, methodW)} ${padEnd(kind, kindW)} ${path}`
419
+ return [
420
+ line("METHOD", "KIND", "PATH"),
421
+ ...display.map((d) => line(d.methods, d.kind, d.path)),
422
+ ].join("\n")
423
+ }
424
+
425
+ /** One JSON row for `nifra routes --json` (the agent-facing, stable shape). */
426
+ export interface RouteJsonRow {
427
+ readonly kind: RouteKind
428
+ readonly path: string
429
+ readonly methods: readonly string[]
430
+ readonly file?: string
431
+ readonly autoMounted?: boolean
432
+ }
433
+
434
+ /** The route table as a JSON document for `--json` (agents). Stable keys; `file`/`autoMounted` present
435
+ * only where meaningful (file on pages, autoMounted on API routes). */
436
+ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
437
+ readonly routes: readonly RouteJsonRow[]
438
+ } {
439
+ return {
440
+ routes: rows.map((r) => ({
441
+ kind: r.kind,
442
+ path: r.path,
443
+ methods: r.methods,
444
+ ...(r.file !== undefined ? { file: r.file } : {}),
445
+ ...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
446
+ })),
447
+ }
448
+ }
449
+
450
+ /** A route module that may export an `action` (→ the page serves POST). */
451
+ interface MaybeActionModule {
452
+ readonly action?: unknown
453
+ }
454
+
455
+ /**
456
+ * Gather + render the unified route table for `nifra routes`. Discovers page routes (`discoverRoutes`),
457
+ * loads each module to detect an `action` export (→ POST), reads the backend's API routes, then renders
458
+ * text (default) or JSON (`--json`). The page-route load mirrors what SSR would do; a load failure
459
+ * degrades to GET-only for that route (never throws the whole command).
460
+ */
461
+ export async function describeRoutes(
462
+ app: LoadedApp,
463
+ opts: { readonly json?: boolean } = {},
464
+ ): Promise<string> {
465
+ let manifest: Manifest | undefined
466
+ try {
467
+ manifest = discoverRoutes(app.routesDir)
468
+ } catch {
469
+ // No (or unreadable) routes/ — fall through with no pages; the table still shows API routes.
470
+ }
471
+ const pages: PageRouteInput[] = []
472
+ for (const route of manifest?.routes ?? []) {
473
+ let hasAction = false
474
+ try {
475
+ const mod = (await route.load()) as MaybeActionModule
476
+ hasAction = typeof mod.action === "function"
477
+ } catch {
478
+ // A route that can't be loaded at introspection time (e.g. a server-only import) → GET-only.
479
+ }
480
+ pages.push({ pattern: route.pattern, file: route.file, hasAction })
481
+ }
482
+ const rows = buildRouteTable({ pages, api: backendRoutes(app.backend) })
483
+ return opts.json ? JSON.stringify(routeTableToJson(rows), null, 2) : renderRouteTable(rows)
484
+ }
package/src/mcp-render.ts CHANGED
@@ -19,12 +19,10 @@ import { loadApp, resolvePlugins } from "./load.ts"
19
19
  const errString = (err: unknown): string =>
20
20
  err instanceof Error ? `${err.name}: ${err.message}` : String(err)
21
21
 
22
- export async function renderPages(
23
- cwd: string,
24
- requests: unknown,
25
- ): Promise<{ results: unknown } | { error: string }> {
26
- if (!Array.isArray(requests)) return { error: "expected { requests: [...] }" }
27
-
22
+ /** Load the project, register its SSR plugins, and build the SSR `createWebApp` once — the cost the warm
23
+ * worker amortizes across calls. Never throws (every failure → `{ error }`). Factored out of
24
+ * {@link renderPages} so the cold path (fresh process per call) and the warm worker share one builder. */
25
+ async function buildWebApp(cwd: string): Promise<{ app: AppLike } | { error: string }> {
28
26
  let app: Awaited<ReturnType<typeof loadApp>>
29
27
  try {
30
28
  app = await loadApp(cwd, "dist")
@@ -43,30 +41,127 @@ export async function renderPages(
43
41
  return { error: errString(err) }
44
42
  }
45
43
 
46
- let webApp: AppLike
47
44
  try {
48
- webApp = createWebApp({
45
+ const webApp = createWebApp({
49
46
  // The adapter is validated by loadApp; cast at the seam (same as the CLI's `asAdapter`).
50
47
  adapter: app.framework.adapter as RenderAdapter,
51
48
  manifest: discoverRoutes(app.routesDir),
52
49
  clientEntry: "/_nifra-render-only.js", // placeholder; SSR HTML does not need the built client
53
50
  ...(app.backend !== undefined ? { api: inProcessClient(app.backend as never) } : {}),
54
51
  }) as unknown as AppLike
52
+ return { app: webApp }
55
53
  } catch (err) {
56
54
  return { error: errString(err) }
57
55
  }
56
+ }
57
+
58
+ export async function renderPages(
59
+ cwd: string,
60
+ requests: unknown,
61
+ ): Promise<{ results: unknown } | { error: string }> {
62
+ if (!Array.isArray(requests)) return { error: "expected { requests: [...] }" }
63
+
64
+ const built = await buildWebApp(cwd)
65
+ if ("error" in built) return built
58
66
 
59
67
  try {
60
- return { results: await runApp(webApp, requests as Parameters<typeof runApp>[1]) }
68
+ return { results: await runApp(built.app, requests as Parameters<typeof runApp>[1]) }
61
69
  } catch (err) {
62
70
  return { error: errString(err) }
63
71
  }
64
72
  }
65
73
 
66
- // Child-process entry: read `{ requests }` from stdin, SSR, print JSON. Guarded so importing this
67
- // module in a test does not execute it.
74
+ interface RenderWorkerMessage {
75
+ readonly id?: unknown
76
+ readonly input?: { readonly requests?: unknown }
77
+ }
78
+
79
+ /** Send console output to stderr so it never corrupts the newline-delimited JSON-RPC on stdout (mirrors
80
+ * `mcp-run`'s worker). A loader that logs would otherwise inject noise into a response line. */
81
+ function redirectConsoleToStderr(): void {
82
+ const write = (level: string, args: unknown[]): void => {
83
+ Bun.stderr.write(`[${level}] ${args.map((arg) => String(arg)).join(" ")}\n`)
84
+ }
85
+ console.debug = (...args: unknown[]) => write("debug", args)
86
+ console.info = (...args: unknown[]) => write("info", args)
87
+ console.log = (...args: unknown[]) => write("log", args)
88
+ console.warn = (...args: unknown[]) => write("warn", args)
89
+ console.error = (...args: unknown[]) => write("error", args)
90
+ }
91
+
92
+ /**
93
+ * Warm-worker loop (`--worker`): build the web app ONCE, then SSR each `{ id, input: { requests } }` line
94
+ * against that hot app and reply `{ id, output }`. The parent (`mcp.ts`'s warm-render handler) spawns this,
95
+ * fingerprints the source tree, and restarts the worker when a file changes — so warm reuse never serves a
96
+ * stale render. Mirrors `mcp-run.ts`'s worker (single backend entry → no per-call entry key needed here).
97
+ */
98
+ async function runWorker(cwd: string): Promise<void> {
99
+ redirectConsoleToStderr()
100
+ // Build once; the same `{ error }` is returned for every request if the build failed, so an agent sees
101
+ // the actionable message and the parent restarts the worker on the next file change.
102
+ const built = await buildWebApp(cwd)
103
+ const decoder = new TextDecoder()
104
+ let buffer = ""
105
+ const send = (id: unknown, output: unknown): void => {
106
+ process.stdout.write(`${JSON.stringify({ id, output })}\n`)
107
+ }
108
+ for await (const chunk of Bun.stdin.stream()) {
109
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true })
110
+ let nl = buffer.indexOf("\n")
111
+ while (nl !== -1) {
112
+ const line = buffer.slice(0, nl).trim()
113
+ buffer = buffer.slice(nl + 1)
114
+ nl = buffer.indexOf("\n")
115
+ if (line === "") continue
116
+
117
+ let message: RenderWorkerMessage
118
+ try {
119
+ message = JSON.parse(line) as RenderWorkerMessage
120
+ } catch {
121
+ send(null, { error: "invalid worker input: expected JSON line" })
122
+ continue
123
+ }
124
+ const requests = message.input?.requests
125
+ if (!Array.isArray(requests)) {
126
+ send(message.id, { error: "expected { requests: [...] }" })
127
+ continue
128
+ }
129
+ if ("error" in built) {
130
+ send(message.id, built)
131
+ continue
132
+ }
133
+ try {
134
+ send(message.id, {
135
+ results: await runApp(built.app, requests as Parameters<typeof runApp>[1]),
136
+ })
137
+ } catch (err) {
138
+ send(message.id, { error: errString(err) })
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ // Child-process entry: read `{ requests }` from stdin, SSR, print JSON. `--worker` runs the persistent
145
+ // warm loop instead. Guarded so importing this module in a test does not execute it.
68
146
  if (import.meta.main) {
69
147
  const cwd = process.argv[2] ?? process.cwd()
148
+ // The parent (mcp.ts) spawns `bun mcp-render.ts <cwd>` WITHOUT setting the subprocess `cwd` option, so
149
+ // this process inherits the MCP server's working dir, not the app's. SSR resolves `react-dom/server`
150
+ // from `process.cwd()` to share ONE React core with the app's route components (the dual-React fix in
151
+ // @nifrajs/web-react) — so we must chdir to the app root before any render. Best-effort: a bad path
152
+ // surfaces later as a load error rather than crashing the child here.
153
+ if (cwd !== process.cwd()) {
154
+ try {
155
+ process.chdir(cwd)
156
+ } catch {
157
+ // Leave cwd as-is; loadApp(cwd) below uses the explicit `cwd` arg and will report an actionable
158
+ // error if the directory is unusable. The adapter then falls back to its bundled react-dom.
159
+ }
160
+ }
161
+ if (process.argv.includes("--worker")) {
162
+ await runWorker(cwd)
163
+ process.exit(0)
164
+ }
70
165
  let output: unknown
71
166
  try {
72
167
  const { requests } = JSON.parse(await Bun.stdin.text()) as { requests: unknown }
package/src/mcp.ts CHANGED
@@ -119,7 +119,12 @@ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
119
119
  readonly stderr: ReadableStream<Uint8Array>
120
120
  }
121
121
 
122
- class WarmRunWorker {
122
+ /** A persistent `mcp-run`/`mcp-render` `--worker` subprocess: the backend/web app is loaded ONCE and
123
+ * reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
124
+ * powers both `nifra_run warm` and `nifra_render warm` — `child` selects which engine, `label` shapes the
125
+ * cancellation message. The owning handler ({@link createWarmHandler}) fingerprints the source tree and
126
+ * replaces the worker when a file changes, so warm reuse never serves a stale result. */
127
+ class WarmWorker {
123
128
  private readonly proc: PipeSubprocess
124
129
  private readonly pending = new Map<
125
130
  number,
@@ -135,10 +140,12 @@ class WarmRunWorker {
135
140
  private closed = false
136
141
 
137
142
  constructor(
143
+ child: "mcp-run" | "mcp-render",
138
144
  cwd: string,
139
145
  readonly fingerprint: string,
146
+ private readonly label: string,
140
147
  ) {
141
- this.proc = Bun.spawn(["bun", childPath("mcp-run"), cwd, "--worker"], {
148
+ this.proc = Bun.spawn(["bun", childPath(child), cwd, "--worker"], {
142
149
  stdin: "pipe",
143
150
  stdout: "pipe",
144
151
  stderr: "pipe",
@@ -148,7 +155,7 @@ class WarmRunWorker {
148
155
  void this.proc.exited.then((code) => {
149
156
  this.closed = true
150
157
  const err = new Error(
151
- `warm run worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
158
+ `warm ${this.label} worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
152
159
  )
153
160
  for (const pending of this.pending.values()) {
154
161
  pending.cleanup()
@@ -163,15 +170,15 @@ class WarmRunWorker {
163
170
  }
164
171
 
165
172
  async request(input: unknown, signal?: AbortSignal): Promise<string> {
166
- if (this.closed) throw new Error("warm run worker is closed")
167
- if (signal?.aborted) return "run cancelled before it started."
173
+ if (this.closed) throw new Error(`warm ${this.label} worker is closed`)
174
+ if (signal?.aborted) return `${this.label} cancelled before it started.`
168
175
  const id = ++this.nextId
169
176
  return new Promise((resolve, reject) => {
170
177
  const abort = (): void => {
171
178
  this.pending.delete(id)
172
179
  this.stop()
173
180
  const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
174
- resolve(`run cancelled${reason}.`)
181
+ resolve(`${this.label} cancelled${reason}.`)
175
182
  }
176
183
  const cleanup = (): void => signal?.removeEventListener("abort", abort)
177
184
  signal?.addEventListener("abort", abort, { once: true })
@@ -218,7 +225,10 @@ class WarmRunWorker {
218
225
  try {
219
226
  message = JSON.parse(line) as { id?: unknown; output?: unknown }
220
227
  } catch {
221
- this.stderrBuffer = boundedAppend(this.stderrBuffer, `invalid warm worker line: ${line}\n`)
228
+ this.stderrBuffer = boundedAppend(
229
+ this.stderrBuffer,
230
+ `invalid warm ${this.label} worker line: ${line}\n`,
231
+ )
222
232
  return
223
233
  }
224
234
  if (typeof message.id !== "number") return
@@ -230,15 +240,21 @@ class WarmRunWorker {
230
240
  }
231
241
  }
232
242
 
233
- function createWarmRunHandler(
243
+ /** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
244
+ * subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
245
+ * `nifra_render` (`child: "mcp-render"`, `label: "render"`) — the only differences are the engine and the
246
+ * message label, so there's a single source for the reuse + auto-restart-on-file-change logic. */
247
+ function createWarmHandler(
248
+ child: "mcp-run" | "mcp-render",
234
249
  cwd: string,
250
+ label: string,
235
251
  ): (input: unknown, signal?: AbortSignal) => Promise<string> {
236
- let worker: WarmRunWorker | undefined
252
+ let worker: WarmWorker | undefined
237
253
  return async (input, signal) => {
238
254
  const fingerprint = await warmRunFingerprint(cwd)
239
255
  if (worker === undefined || worker.fingerprint !== fingerprint) {
240
256
  worker?.stop()
241
- worker = new WarmRunWorker(cwd, fingerprint)
257
+ worker = new WarmWorker(child, cwd, fingerprint, label)
242
258
  }
243
259
  try {
244
260
  return await worker.request(input, signal)
@@ -247,9 +263,9 @@ function createWarmRunHandler(
247
263
  worker = undefined
248
264
  if (signal?.aborted) {
249
265
  const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
250
- return `run cancelled${reason}.`
266
+ return `${label} cancelled${reason}.`
251
267
  }
252
- return spawnChild("mcp-run", cwd, input, "run", signal)
268
+ return spawnChild(child, cwd, input, label, signal)
253
269
  }
254
270
  }
255
271
  }
@@ -273,17 +289,25 @@ async function runHandler(
273
289
  return spawnChild("mcp-run", cwd, input, "run", context.signal)
274
290
  }
275
291
 
276
- /** The `nifra_render` handler: SSR page routes through the project's CURRENT web app, return the HTML. */
292
+ /** The `nifra_render` handler: SSR page routes through the project's CURRENT web app, return the HTML.
293
+ * By default a fresh subprocess loads the current code each call; `warm:true` reuses a hot worker (like
294
+ * `nifra_run`) that auto-restarts when a source file changes. */
277
295
  async function renderHandler(
278
296
  cwd: string,
279
297
  args: Record<string, unknown>,
280
298
  context: McpToolContext,
299
+ warmRender: (input: unknown, signal?: AbortSignal) => Promise<string>,
281
300
  ): Promise<string> {
282
301
  const requests = Array.isArray(args.requests) ? args.requests : []
283
302
  if (requests.length === 0) {
284
303
  return 'No requests provided. Pass { "requests": [{ "path": "/..." }] }.'
285
304
  }
286
- return spawnChild("mcp-render", cwd, { requests }, "render", context.signal)
305
+ const input = { requests }
306
+ if ((args as { warm?: boolean }).warm === true) {
307
+ context.reportProgress(0.2, 1)
308
+ return warmRender(input, context.signal)
309
+ }
310
+ return spawnChild("mcp-render", cwd, input, "render", context.signal)
287
311
  }
288
312
 
289
313
  /** The `nifra_ws` handler: verify WebSocket routes through a fresh app subprocess. */
@@ -386,7 +410,8 @@ async function openApiHandler(
386
410
  loadAppCached: (outDirName?: string) => Promise<LoadedApp>,
387
411
  ): Promise<string> {
388
412
  const { renderOpenApi } = await import("./openapi-tool.ts")
389
- return renderOpenApi(await loadAppCached(), openApiFormat(args))
413
+ const pathPrefix = typeof args.path === "string" ? args.path : undefined
414
+ return renderOpenApi(await loadAppCached(), openApiFormat(args), pathPrefix)
390
415
  }
391
416
 
392
417
  async function readProjectFile(
@@ -540,12 +565,13 @@ export function projectTools(
540
565
  cwd: string,
541
566
  loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
542
567
  ): McpTool[] {
543
- const warmRun = createWarmRunHandler(cwd)
568
+ const warmRun = createWarmHandler("mcp-run", cwd, "run")
569
+ const warmRender = createWarmHandler("mcp-render", cwd, "render")
544
570
  return [
545
571
  {
546
572
  name: "nifra_context",
547
573
  description:
548
- "Get this nifra project's surface: API routes (backend.ts) with compact TypeScript-shaped contracts, page routes (routes/), and the framework conventions. Call it once unfiltered to learn the project; afterwards pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) for a cheap, narrow slice instead of the full dump.",
574
+ "Get this nifra project's surface. Call it once UNFILTERED for a tight INDEX: the route list (API routes as `METHOD path`, page routes as `pattern → file`) + framework conventions + a pointer — cheap even on a big app, no per-route schema dump. Then pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) to fetch the FULL contract for that slice (body/query/response TS shapes + the exact typed-client call form).",
549
575
  inputSchema: {
550
576
  type: "object",
551
577
  properties: {
@@ -589,7 +615,7 @@ export function projectTools(
589
615
  {
590
616
  name: "nifra_openapi",
591
617
  description:
592
- 'Return this project\'s backend OpenAPI 3.1 document generated from backend.ts route schemas via @nifrajs/schema. Use `format:"json"` for machine edits (default) or `format:"yaml"` for humans. Frontend-only apps return a valid empty paths object.',
618
+ 'Return this project\'s backend OpenAPI 3.1 document generated from backend.ts route schemas via @nifrajs/schema. Use `format:"json"` for machine edits (default) or `format:"yaml"` for humans. Pass `path` (a route prefix like /api/orders, mirroring nifra_routes) to narrow a large backend to operations under that prefix instead of the whole document. Frontend-only apps return a valid empty paths object.',
593
619
  inputSchema: {
594
620
  type: "object",
595
621
  properties: {
@@ -598,6 +624,11 @@ export function projectTools(
598
624
  enum: ["json", "yaml"],
599
625
  description: "Output format (default: json).",
600
626
  },
627
+ path: {
628
+ type: "string",
629
+ description:
630
+ "Only operations whose path starts with this prefix (omit for the whole document).",
631
+ },
601
632
  },
602
633
  additionalProperties: false,
603
634
  },
@@ -641,7 +672,7 @@ export function projectTools(
641
672
  {
642
673
  name: "nifra_render",
643
674
  description:
644
- "SSR a page route (routes/) through this project's CURRENT web app and return { status, headers, body: the rendered HTML }. The page half of nifra_run: use it to verify a page renders and its loader ran after an edit. No build needed (a placeholder client entry is used; the SSR HTML renders regardless). Each request: { path, headers? }. Re-loaded in a fresh process each call.",
675
+ "SSR a page route (routes/) through this project's CURRENT web app and return { status, headers, body: the rendered HTML }. The page half of nifra_run: use it to verify a page renders and its loader ran after an edit. No build needed (a placeholder client entry is used; the SSR HTML renders regardless). Each request: { path, headers? }. By default re-loaded in a fresh process each call; pass warm:true to reuse a hot worker while source files are unchanged (it restarts automatically when files change), mirroring nifra_run.",
645
676
  inputSchema: {
646
677
  type: "object",
647
678
  properties: {
@@ -656,11 +687,16 @@ export function projectTools(
656
687
  required: ["path"],
657
688
  },
658
689
  },
690
+ warm: {
691
+ type: "boolean",
692
+ description:
693
+ "Reuse a hot web-app worker while source files are unchanged. Default false for maximum isolation.",
694
+ },
659
695
  },
660
696
  required: ["requests"],
661
697
  additionalProperties: false,
662
698
  },
663
- handler: (args, context) => renderHandler(cwd, args, context),
699
+ handler: (args, context) => renderHandler(cwd, args, context, warmRender),
664
700
  },
665
701
  {
666
702
  name: "nifra_ws",
@@ -41,11 +41,31 @@ function toYaml(value: unknown, depth = 0): string {
41
41
  .join("\n")
42
42
  }
43
43
 
44
- /** Render a project's backend as OpenAPI 3.1. Frontend-only apps get a valid empty document, which is
45
- * easier for agents and API tooling to handle than a tool error. */
46
- export function renderOpenApi(app: LoadedApp, format: OpenApiFormat): string {
44
+ /**
45
+ * Render a project's backend as OpenAPI 3.1. Frontend-only apps get a valid empty document, which is
46
+ * easier for agents and API tooling to handle than a tool error.
47
+ *
48
+ * `pathPrefix` narrows the document to operations whose (OpenAPI-templated) path starts with the prefix —
49
+ * mirroring `nifra_routes`'s `path` filter so a large backend needn't return the whole document. The keys
50
+ * are templated (`/users/{id}`), so a prefix up to the first param (`/api/orders`) matches `/api/orders`
51
+ * and `/api/orders/{id}` alike. `components.schemas` is left intact: it's keyed by schema name (not path),
52
+ * the trimmed document still references those names, and recomputing the exact reachable subset isn't
53
+ * worth the risk of emitting a `$ref` with no target.
54
+ */
55
+ export function renderOpenApi(app: LoadedApp, format: OpenApiFormat, pathPrefix?: string): string {
47
56
  const title = `${basename(app.cwd)} API`
48
57
  const input = hasRoutesMethod(app.backend) ? app.backend : {}
49
- const document = toOpenAPI(input, { title, version: "1.0.0" })
58
+ const generated = toOpenAPI(input, { title, version: "1.0.0" })
59
+ // Narrow to operations under the prefix, rebuilding the doc (its `paths` is readonly). `components`
60
+ // stays — see the doc comment for why the schema set isn't recomputed.
61
+ const document =
62
+ pathPrefix !== undefined && pathPrefix !== ""
63
+ ? {
64
+ ...generated,
65
+ paths: Object.fromEntries(
66
+ Object.entries(generated.paths).filter(([path]) => path.startsWith(pathPrefix)),
67
+ ),
68
+ }
69
+ : generated
50
70
  return format === "yaml" ? toYaml(document) : JSON.stringify(document, null, 2)
51
71
  }