@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/README.md +8 -0
- package/dist/check.d.ts +82 -3
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +326 -11
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +178 -17
- package/dist/cli.js.map +1 -1
- package/dist/init-agents.d.ts +58 -0
- package/dist/init-agents.d.ts.map +1 -0
- package/dist/init-agents.js +126 -0
- package/dist/init-agents.js.map +1 -0
- package/dist/introspect.d.ts +77 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +138 -9
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +99 -8
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +49 -21
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +12 -3
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js +21 -4
- package/dist/openapi-tool.js.map +1 -1
- package/dist/port.d.ts +144 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +498 -0
- package/dist/port.js.map +1 -0
- package/docs/examples.json +91 -133
- package/docs/llms-full.txt +1090 -35
- package/package.json +6 -5
- package/src/check.ts +398 -14
- package/src/cli.ts +176 -21
- package/src/init-agents.ts +176 -0
- package/src/introspect.ts +208 -8
- package/src/mcp-render.ts +106 -11
- package/src/mcp.ts +56 -20
- package/src/openapi-tool.ts +24 -4
- package/src/port.ts +663 -0
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
|
-
/**
|
|
207
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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")
|
|
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 (
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
): Promise<{
|
|
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(
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
167
|
-
if (signal?.aborted) return
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
266
|
+
return `${label} cancelled${reason}.`
|
|
251
267
|
}
|
|
252
|
-
return spawnChild(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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? }.
|
|
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",
|
package/src/openapi-tool.ts
CHANGED
|
@@ -41,11 +41,31 @@ function toYaml(value: unknown, depth = 0): string {
|
|
|
41
41
|
.join("\n")
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
|
|
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
|
|
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
|
}
|