@nifrajs/cli 0.1.0-alpha.1

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/check.d.ts +100 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +690 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +208 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/docs-search.d.ts +30 -0
  12. package/dist/docs-search.d.ts.map +1 -0
  13. package/dist/docs-search.js +94 -0
  14. package/dist/docs-search.js.map +1 -0
  15. package/dist/doctor.d.ts +52 -0
  16. package/dist/doctor.d.ts.map +1 -0
  17. package/dist/doctor.js +291 -0
  18. package/dist/doctor.js.map +1 -0
  19. package/dist/examples.d.ts +30 -0
  20. package/dist/examples.d.ts.map +1 -0
  21. package/dist/examples.js +70 -0
  22. package/dist/examples.js.map +1 -0
  23. package/dist/introspect.d.ts +64 -0
  24. package/dist/introspect.d.ts.map +1 -0
  25. package/dist/introspect.js +243 -0
  26. package/dist/introspect.js.map +1 -0
  27. package/dist/load.d.ts +51 -0
  28. package/dist/load.d.ts.map +1 -0
  29. package/dist/load.js +70 -0
  30. package/dist/load.js.map +1 -0
  31. package/dist/mcp-docs-tools.d.ts +13 -0
  32. package/dist/mcp-docs-tools.d.ts.map +1 -0
  33. package/dist/mcp-docs-tools.js +63 -0
  34. package/dist/mcp-docs-tools.js.map +1 -0
  35. package/dist/mcp-http.d.ts +43 -0
  36. package/dist/mcp-http.d.ts.map +1 -0
  37. package/dist/mcp-http.js +136 -0
  38. package/dist/mcp-http.js.map +1 -0
  39. package/dist/mcp-protocol.d.ts +97 -0
  40. package/dist/mcp-protocol.d.ts.map +1 -0
  41. package/dist/mcp-protocol.js +248 -0
  42. package/dist/mcp-protocol.js.map +1 -0
  43. package/dist/mcp-render.d.ts +17 -0
  44. package/dist/mcp-render.d.ts.map +1 -0
  45. package/dist/mcp-render.js +73 -0
  46. package/dist/mcp-render.js.map +1 -0
  47. package/dist/mcp-run.d.ts +24 -0
  48. package/dist/mcp-run.d.ts.map +1 -0
  49. package/dist/mcp-run.js +142 -0
  50. package/dist/mcp-run.js.map +1 -0
  51. package/dist/mcp-ws.d.ts +26 -0
  52. package/dist/mcp-ws.d.ts.map +1 -0
  53. package/dist/mcp-ws.js +258 -0
  54. package/dist/mcp-ws.js.map +1 -0
  55. package/dist/mcp.d.ts +42 -0
  56. package/dist/mcp.d.ts.map +1 -0
  57. package/dist/mcp.js +712 -0
  58. package/dist/mcp.js.map +1 -0
  59. package/dist/openapi-tool.d.ts +6 -0
  60. package/dist/openapi-tool.d.ts.map +1 -0
  61. package/dist/openapi-tool.js +48 -0
  62. package/dist/openapi-tool.js.map +1 -0
  63. package/dist/scaffold.d.ts +35 -0
  64. package/dist/scaffold.d.ts.map +1 -0
  65. package/dist/scaffold.js +140 -0
  66. package/dist/scaffold.js.map +1 -0
  67. package/dist/search-terms.d.ts +17 -0
  68. package/dist/search-terms.d.ts.map +1 -0
  69. package/dist/search-terms.js +82 -0
  70. package/dist/search-terms.js.map +1 -0
  71. package/dist/test-tool.d.ts +29 -0
  72. package/dist/test-tool.d.ts.map +1 -0
  73. package/dist/test-tool.js +150 -0
  74. package/dist/test-tool.js.map +1 -0
  75. package/docs/examples.json +314 -0
  76. package/docs/llms-full.txt +4325 -0
  77. package/package.json +58 -0
  78. package/src/check.ts +790 -0
  79. package/src/cli.ts +237 -0
  80. package/src/docs-search.ts +111 -0
  81. package/src/doctor.ts +349 -0
  82. package/src/examples.ts +89 -0
  83. package/src/introspect.ts +284 -0
  84. package/src/load.ts +129 -0
  85. package/src/mcp-docs-tools.ts +72 -0
  86. package/src/mcp-http.ts +155 -0
  87. package/src/mcp-protocol.ts +326 -0
  88. package/src/mcp-render.ts +78 -0
  89. package/src/mcp-run.ts +153 -0
  90. package/src/mcp-ws.ts +328 -0
  91. package/src/mcp.ts +863 -0
  92. package/src/openapi-tool.ts +51 -0
  93. package/src/scaffold.ts +164 -0
  94. package/src/search-terms.ts +89 -0
  95. package/src/test-tool.ts +181 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Introspect a loaded nifra project into a Markdown brief — the API routes (`backend.routes()`), the
3
+ * page routes (`discoverRoutes`), and a conventions summary — for piping into an AI coding agent's
4
+ * prompt. Used by `nifra context` (stdout) and the `nifra mcp` server (a tool result), so an agent sees
5
+ * *this* project's actual surface, not just generic docs.
6
+ */
7
+
8
+ import type { Manifest } from "@nifrajs/web"
9
+ import { discoverRoutes } from "@nifrajs/web/fs"
10
+ import type { LoadedApp } from "./load.ts"
11
+
12
+ /** Structural view of a `@nifrajs/core` route descriptor — declared locally so the CLI needs no type
13
+ * dependency on core. `app.routes()` returns these. */
14
+ interface RouteDesc {
15
+ readonly method: string
16
+ readonly path: string
17
+ readonly schema?: unknown
18
+ }
19
+
20
+ /** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
21
+ function backendRoutes(backend: unknown): RouteDesc[] {
22
+ if (backend && typeof (backend as { routes?: unknown }).routes === "function") {
23
+ try {
24
+ return (backend as { routes(): RouteDesc[] }).routes()
25
+ } catch {
26
+ return []
27
+ }
28
+ }
29
+ return []
30
+ }
31
+
32
+ /** The JSON Schema a `t`/Standard Schema carries (nifra's `t` is TypeBox-backed and exposes `.jsonSchema`),
33
+ * or `undefined` for a validator that doesn't expose one. This is the exact field-level contract. */
34
+ function jsonSchemaOf(schema: unknown): unknown {
35
+ if (schema && typeof schema === "object" && "jsonSchema" in schema) {
36
+ const js = (schema as { jsonSchema?: unknown }).jsonSchema
37
+ if (js !== undefined && js !== null) return js
38
+ }
39
+ return undefined
40
+ }
41
+
42
+ interface JsonSchemaNode {
43
+ readonly type?: unknown
44
+ readonly properties?: Record<string, unknown>
45
+ readonly required?: unknown
46
+ readonly items?: unknown
47
+ readonly additionalProperties?: unknown
48
+ readonly anyOf?: unknown
49
+ readonly oneOf?: unknown
50
+ readonly enum?: unknown
51
+ readonly const?: unknown
52
+ }
53
+
54
+ /**
55
+ * Render a JSON Schema as a compact TypeScript-like type (`{ id: string, name?: string }`) — the
56
+ * shape agents already think in, at a fraction of the raw JSON Schema's tokens (~70-80% smaller).
57
+ * Anything the renderer doesn't model falls back to the raw JSON for that subtree, so the output
58
+ * is always faithful, just not always minimal.
59
+ */
60
+ export function tsTypeOf(schema: unknown, depth = 0): string {
61
+ if (typeof schema !== "object" || schema === null || depth > 6) return JSON.stringify(schema)
62
+ const node = schema as JsonSchemaNode
63
+ const union = (node.anyOf ?? node.oneOf) as unknown[] | undefined
64
+ if (Array.isArray(union)) return union.map((u) => tsTypeOf(u, depth + 1)).join(" | ")
65
+ if (Array.isArray(node.enum)) return node.enum.map((v) => JSON.stringify(v)).join(" | ")
66
+ if (node.const !== undefined) return JSON.stringify(node.const)
67
+ switch (node.type) {
68
+ case "string":
69
+ return "string"
70
+ case "number":
71
+ case "integer":
72
+ return "number"
73
+ case "boolean":
74
+ return "boolean"
75
+ case "null":
76
+ return "null"
77
+ case "array": {
78
+ const item = tsTypeOf(node.items, depth + 1)
79
+ // Parenthesize unions so `(A | B)[]` reads as the array it is.
80
+ return item.includes(" | ") ? `(${item})[]` : `${item}[]`
81
+ }
82
+ case "object": {
83
+ const props = node.properties
84
+ if (props === undefined) {
85
+ return node.additionalProperties === undefined || node.additionalProperties === false
86
+ ? "{}"
87
+ : `Record<string, ${node.additionalProperties === true ? "unknown" : tsTypeOf(node.additionalProperties, depth + 1)}>`
88
+ }
89
+ const required = new Set(Array.isArray(node.required) ? (node.required as string[]) : [])
90
+ const fields = Object.entries(props).map(
91
+ ([key, value]) => `${key}${required.has(key) ? "" : "?"}: ${tsTypeOf(value, depth + 1)}`,
92
+ )
93
+ return `{ ${fields.join(", ")} }`
94
+ }
95
+ default:
96
+ return JSON.stringify(schema)
97
+ }
98
+ }
99
+
100
+ /** One route's request/response field shapes, indented under its line. Reads the declared schemas off the
101
+ * descriptor so an agent gets the exact input + output contract — not just "this route is validated". */
102
+ function schemaLines(schema: unknown): string[] {
103
+ const s = schema as { body?: unknown; query?: unknown; response?: unknown } | undefined
104
+ if (!s) return []
105
+ const out: string[] = []
106
+ for (const key of ["query", "body", "response"] as const) {
107
+ const declared = s[key]
108
+ if (!declared) continue
109
+ const js = jsonSchemaOf(declared)
110
+ out.push(
111
+ js
112
+ ? ` - ${key}: \`${tsTypeOf(js)}\``
113
+ : ` - ${key}: _(validated; shape not introspectable from this validator)_`,
114
+ )
115
+ }
116
+ return out
117
+ }
118
+
119
+ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
120
+
121
+ /**
122
+ * The typed-client call form for a route — the exact `client<typeof app>` proxy chain an agent should
123
+ * write, derived from the same convention `@nifrajs/client` implements (so it never has to read the
124
+ * client tests to learn it): a static segment is a property (`.users`), a path param/wildcard is a call
125
+ * that appends the value (`({ id })`), the root path is `.index`, and the HTTP verb is the terminal call.
126
+ * Body verbs (POST/PUT/PATCH) take the body first then call-options; other verbs take call-options first —
127
+ * so the `{ query }` argument lands in the right slot for each.
128
+ */
129
+ export function clientCall(method: string, path: string, schema: unknown): string {
130
+ const s = schema as { body?: unknown; query?: unknown } | undefined
131
+ const verb = method.toLowerCase()
132
+ const segs = path.split("/").filter((seg) => seg !== "")
133
+ let chain = "api"
134
+ if (segs.length === 0) chain += ".index"
135
+ else
136
+ for (const seg of segs) {
137
+ if (seg.startsWith(":") || seg.startsWith("*")) {
138
+ const name = seg.replace(/^[:*]/, "") || "value"
139
+ chain += `({ ${name} })`
140
+ } else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
141
+ }
142
+ const isBodyVerb = verb === "post" || verb === "put" || verb === "patch"
143
+ let call: string
144
+ if (isBodyVerb) {
145
+ // body is the 1st arg; if a route has a query but no body, the body slot must still be filled.
146
+ const bodyArg = s?.body ? "body" : s?.query ? "undefined" : ""
147
+ const opts = s?.query ? (bodyArg ? ", { query }" : "{ query }") : ""
148
+ call = `.${verb}(${bodyArg}${opts})`
149
+ } else {
150
+ call = `.${verb}(${s?.query ? "{ query }" : ""})`
151
+ }
152
+ return `await ${chain}${call}`
153
+ }
154
+
155
+ /** Markdown section listing the backend's API routes with their request + response field shapes. */
156
+ export function apiRoutesSection(routes: readonly RouteDesc[]): string {
157
+ if (routes.length === 0) {
158
+ return "## API routes\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
159
+ }
160
+ const lines = [...routes]
161
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
162
+ .flatMap((r) => [
163
+ `- \`${r.method} ${r.path}\``,
164
+ ...schemaLines(r.schema),
165
+ ` - call: \`${clientCall(r.method, r.path, r.schema)}\` → \`{ ok, status, data, error }\``,
166
+ ])
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
+ }
169
+
170
+ /** Markdown section listing the file-routed pages (URL pattern → source file). */
171
+ export function pageRoutesSection(manifest: Manifest | undefined): string {
172
+ if (!manifest || manifest.routes.length === 0) {
173
+ return "## Page routes\n\nNo file routes found under `routes/`."
174
+ }
175
+ const lines = [...manifest.routes]
176
+ .sort((a, b) => a.pattern.localeCompare(b.pattern))
177
+ .map((r) => `- \`${r.pattern}\` → \`${r.file}\``)
178
+ return `## Page routes (routes/)\n\n${lines.join("\n")}`
179
+ }
180
+
181
+ const CONVENTIONS = `## Conventions (summary)
182
+
183
+ - **Backend:** \`server()\` from \`@nifrajs/core\`; returning a value sends JSON. Validate untrusted input with
184
+ a route schema (\`{ body, query, params }\` using \`t\` from \`@nifrajs/schema\` or any Standard Schema) and read
185
+ the typed \`c.body\` / \`c.query\` / \`c.params\` — never hand-parse.
186
+ - **Response contract (no drift):** declare \`{ response: t.object({...}) }\` on a route to lock its output
187
+ shape — the handler is type-checked against it and the client sees exactly that shape. The contract above
188
+ is the single source of truth for both sides.
189
+ - **Client — ALWAYS use this for API calls:** \`client<typeof app>(url)\` derives request inputs AND
190
+ \`res.data\` from the backend's route types, so the compiler catches any frontend/backend drift. Never
191
+ hand-roll \`fetch\` + ad-hoc response types for an internal API — that's exactly how screens drift. It
192
+ never throws: branch on \`res.ok ? res.data : res.error\`.
193
+ - **Pages:** file-routed under \`routes/\`; \`loader\`/\`action\` are server-only but the module is also bundled
194
+ for the browser — **never top-level-import server-only code** (DB, secrets, \`process.env\`) into a route
195
+ file; reach it via \`ctx.api\` / \`ctx.env\`.
196
+ - \`app.fetch(Request)\` is the universal entry. Full reference: this repo's \`AGENTS.md\`, or \`llms-full.txt\`.`
197
+
198
+ /** Optional narrowing for {@link describeProject} — a path prefix and/or one section. A filtered
199
+ * brief omits the conventions block (the agent already has it from its first full call), so
200
+ * follow-up calls cost a fraction of the tokens. */
201
+ export interface ContextFilter {
202
+ readonly path?: string
203
+ readonly kind?: "api" | "pages"
204
+ }
205
+
206
+ /** Build the project brief (API routes + page routes + conventions) as Markdown — optionally
207
+ * narrowed by {@link ContextFilter} for cheap follow-up calls. */
208
+ export function describeProject(app: LoadedApp, filter?: ContextFilter): string {
209
+ let manifest: Manifest | undefined
210
+ try {
211
+ manifest = discoverRoutes(app.routesDir)
212
+ } catch {
213
+ // No (or unreadable) routes/ — the page-routes section reports that.
214
+ }
215
+ const prefix = filter?.path
216
+ let routes = backendRoutes(app.backend)
217
+ let pages = manifest
218
+ if (prefix !== undefined && prefix !== "") {
219
+ routes = routes.filter((r) => r.path.startsWith(prefix))
220
+ if (manifest !== undefined) {
221
+ pages = { ...manifest, routes: manifest.routes.filter((r) => r.pattern.startsWith(prefix)) }
222
+ }
223
+ }
224
+ const narrowed = filter?.kind !== undefined || (prefix !== undefined && prefix !== "")
225
+ const sections: string[] = [`# nifra project: ${app.cwd.slice(app.cwd.lastIndexOf("/") + 1)}`]
226
+ if (!narrowed) {
227
+ sections.push(
228
+ "A full-stack nifra app — Web-standard, one app on Bun, Node, Deno, and the edge.",
229
+ // Point agents at the verified surface, not recalled APIs — nifra is young, so training data is the
230
+ // wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
231
+ "> **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
+ )
233
+ }
234
+ if (filter?.kind !== "pages") sections.push(apiRoutesSection(routes))
235
+ if (filter?.kind !== "api") sections.push(pageRoutesSection(pages))
236
+ if (!narrowed) sections.push(CONVENTIONS)
237
+ return sections.join("\n\n")
238
+ }
239
+
240
+ /** One API route as structured JSON: method, path, the exact typed-client call form, and compact
241
+ * TS-shaped request/response contracts (present only when the route declares the schema). */
242
+ export interface RouteJson {
243
+ readonly method: string
244
+ readonly path: string
245
+ readonly call: string
246
+ readonly body?: string
247
+ readonly query?: string
248
+ readonly response?: string
249
+ }
250
+
251
+ /**
252
+ * The backend's API routes as structured JSON — what the `nifra_routes` MCP tool returns, so an agent
253
+ * consumes the contract programmatically (`list_routes` / `get_route_schema`) instead of parsing the
254
+ * Markdown brief. Optionally filtered to routes whose path starts with `pathPrefix`. Reuses the exact
255
+ * same `call` form and TS-shaped contracts the Markdown brief shows.
256
+ */
257
+ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
258
+ const shape = (v: unknown): string | undefined => {
259
+ if (!v) return undefined
260
+ const js = jsonSchemaOf(v)
261
+ return js ? tsTypeOf(js) : "(validated; shape not introspectable from this validator)"
262
+ }
263
+ let routes = backendRoutes(app.backend)
264
+ if (pathPrefix !== undefined && pathPrefix !== "") {
265
+ routes = routes.filter((r) => r.path.startsWith(pathPrefix))
266
+ }
267
+ return [...routes]
268
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
269
+ .map((r) => {
270
+ const s = r.schema as { body?: unknown; query?: unknown; response?: unknown } | undefined
271
+ const body = shape(s?.body)
272
+ const query = shape(s?.query)
273
+ const response = shape(s?.response)
274
+ // Spread only the shapes that exist, so an unschematized route has no body/query/response key.
275
+ return {
276
+ method: r.method,
277
+ path: r.path,
278
+ call: clientCall(r.method, r.path, r.schema),
279
+ ...(body !== undefined && { body }),
280
+ ...(query !== undefined && { query }),
281
+ ...(response !== undefined && { response }),
282
+ }
283
+ })
284
+ }
package/src/load.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Zero-config app discovery. The CLI reads a few conventions from the project root:
3
+ * - `routes/` — the file-based routes (per `@nifrajs/web/fs`).
4
+ * - `nifra.config.ts` — the CLI's framework wiring (see {@link NifraFramework}); `framework.ts` is
5
+ * used as a fallback. One of the two is required.
6
+ * - `backend.ts` — exports `backend` (a `@nifrajs/core` server, the contract). Optional.
7
+ *
8
+ * Why two filenames: a multi-target app's edge entry (`_worker.ts`) imports `framework.ts` for the
9
+ * render adapter, so `framework.ts` must stay edge-bundlable — it can't reference Vite plugins or the
10
+ * SFC compiler (Bun would pull them into the `target:"browser"` worker and fail on `child_process`).
11
+ * `nifra.config.ts` holds that build/dev tooling instead: it's imported ONLY by this CLI (which runs on
12
+ * Bun), so its Vite plugin + SFC-compiler imports (`@vitejs/plugin-vue`, `@nifrajs/web-vue/plugin`) never
13
+ * reach the edge bundle. A simple single-target app with no edge build can put it all in `framework.ts`.
14
+ */
15
+ import { existsSync } from "node:fs"
16
+ import { resolve } from "node:path"
17
+
18
+ /**
19
+ * A plugin list — either an array, or a **thunk** that returns one (optionally async). Both forms are
20
+ * accepted; {@link resolvePlugins} normalizes them. The thunk is just a convenience for deferring a
21
+ * plugin's construction — it does NOT make a plugin edge-safe (Bun.build follows a dynamic `import()`
22
+ * even from an unused export, so a Vite/compiler import in an edge-bundled file fails regardless). Edge
23
+ * safety comes from WHERE the config lives: keep these fields in `nifra.config.ts` (CLI-only), not in
24
+ * the edge-imported `framework.ts`. See the module header.
25
+ */
26
+ export type PluginsField =
27
+ | readonly unknown[]
28
+ | (() => readonly unknown[] | Promise<readonly unknown[]>)
29
+
30
+ /**
31
+ * The CLI's framework wiring — exported from `nifra.config.ts` (or `framework.ts` as a fallback).
32
+ * `create-nifra` generates it. Only `adapter` + `clientModule` are required; the plugin/condition
33
+ * fields are framework-specific extras (Vue/Svelte/Solid need them; React/Preact don't).
34
+ */
35
+ export interface NifraFramework {
36
+ /** The render adapter, e.g. `reactAdapter` from `@nifrajs/web-react`. */
37
+ readonly adapter: unknown
38
+ /** The client runtime module providing `mountRouter`, e.g. `"@nifrajs/web-react/client"`. */
39
+ readonly clientModule: string
40
+ /** Vite plugins for `nifra dev`'s HMR, e.g. `[react()]` / `() => import("@vitejs/plugin-vue")…`. */
41
+ readonly vitePlugins?: PluginsField
42
+ /** Bun build plugins that compile routes for the CLIENT bundle (`nifra build`), e.g.
43
+ * `[vueBunPlugin("dom")]`. React/Preact JSX is Bun-native → none. */
44
+ readonly clientPlugins?: PluginsField
45
+ /** Bun runtime plugins that compile routes for SSR — `nifra dev` (Bun-native route import) and
46
+ * `nifra start` register them via `Bun.plugin`, e.g. `[vueBunPlugin("ssr")]`. React/Preact → none. */
47
+ readonly serverPlugins?: PluginsField
48
+ /** Extra Bun.build / Vite resolve conditions, e.g. Solid's `["solid"]`. */
49
+ readonly conditions?: readonly string[]
50
+ /** Compile-time `define` replacements, e.g. Vue's `__VUE_*` flags. */
51
+ readonly define?: Readonly<Record<string, string>>
52
+ }
53
+
54
+ /** Normalize a {@link PluginsField} (array | thunk | undefined) to a fresh array, awaiting a thunk. */
55
+ export async function resolvePlugins(field: PluginsField | undefined): Promise<unknown[]> {
56
+ const value = typeof field === "function" ? await field() : field
57
+ return value ? [...value] : []
58
+ }
59
+
60
+ export interface LoadedApp {
61
+ readonly cwd: string
62
+ readonly routesDir: string
63
+ /** Build output dir (also where `nifra start` reads `manifest.json` + serves `/assets/*`). */
64
+ readonly outDir: string
65
+ readonly framework: NifraFramework
66
+ /** The `backend` export from `backend.ts`, or `undefined` if there's no `backend.ts`. */
67
+ readonly backend: unknown
68
+ }
69
+
70
+ export interface LoadAppOptions {
71
+ /** Optional dynamic-import query used by long-lived MCP processes to bust Bun's module cache. */
72
+ readonly importQuery?: string
73
+ }
74
+
75
+ const isAdapter = (v: unknown): boolean => typeof v === "object" && v !== null
76
+
77
+ const importWithQuery = (path: string, query: string | undefined): Promise<unknown> =>
78
+ import(query === undefined || query === "" ? path : `${path}?${query}`)
79
+
80
+ /** Discover + validate the app conventions rooted at `cwd`. Prefers `nifra.config.ts`, falls back to
81
+ * `framework.ts`. Throws a clear, actionable error if neither exists or the config is malformed. */
82
+ export async function loadApp(
83
+ cwd: string,
84
+ outDirName = "dist",
85
+ options: LoadAppOptions = {},
86
+ ): Promise<LoadedApp> {
87
+ // nifra.config.ts is the CLI's config (it may import Vite plugins / the SFC compiler, which a
88
+ // multi-target app keeps OUT of the edge-imported framework.ts). framework.ts is the fallback for a
89
+ // simple single-target app that has no edge build.
90
+ const configFile = existsSync(resolve(cwd, "nifra.config.ts"))
91
+ ? "nifra.config.ts"
92
+ : "framework.ts"
93
+ const configPath = resolve(cwd, configFile)
94
+ if (!existsSync(configPath)) {
95
+ throw new Error(
96
+ `[nifra] no nifra.config.ts or framework.ts found in ${cwd}.\n` +
97
+ " nifra is zero-config but needs one of them exporting at least:\n" +
98
+ " export const adapter = reactAdapter // from @nifrajs/web-react\n" +
99
+ ' export const clientModule = "@nifrajs/web-react/client"\n' +
100
+ " (create-nifra scaffolds this for you.)",
101
+ )
102
+ }
103
+ if (!existsSync(resolve(cwd, "routes"))) {
104
+ throw new Error(
105
+ `[nifra] no routes/ directory in ${cwd} — nifra apps are file-routed under routes/.`,
106
+ )
107
+ }
108
+ const fw = (await importWithQuery(configPath, options.importQuery)) as Partial<NifraFramework>
109
+ if (!isAdapter(fw.adapter) || typeof fw.clientModule !== "string") {
110
+ throw new Error(
111
+ `[nifra] ${configFile} must export \`adapter\` (a render adapter object) and \`clientModule\` (a string).`,
112
+ )
113
+ }
114
+
115
+ let backend: unknown
116
+ const backendPath = resolve(cwd, "backend.ts")
117
+ if (existsSync(backendPath)) {
118
+ backend = ((await importWithQuery(backendPath, options.importQuery)) as { backend?: unknown })
119
+ .backend
120
+ }
121
+
122
+ return {
123
+ cwd,
124
+ routesDir: resolve(cwd, "routes"),
125
+ outDir: resolve(cwd, outDirName),
126
+ framework: fw as NifraFramework,
127
+ backend,
128
+ }
129
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The two project-INDEPENDENT MCP tools — `nifra_docs` + `nifra_example` — as a single factory shared
3
+ * by every transport: the project stdio server ({@link projectTools}), the CLI HTTP server
4
+ * (`nifra docs-mcp`), and the site's Cloudflare-Pages worker (`/mcp`). The corpus source is injected as
5
+ * loaders, so the SAME tool definitions work whether the corpus comes from disk (`Bun.file`, on the
6
+ * CLI) or a cached same-origin fetch (on the edge, no filesystem). One source → the tools can never be
7
+ * described differently across surfaces.
8
+ */
9
+
10
+ import { renderDocsResult } from "./docs-search.ts"
11
+ import { type Example, renderExamplesResult } from "./examples.ts"
12
+ import type { McpTool } from "./mcp-protocol.ts"
13
+
14
+ const clamp = (n: number | undefined, lo: number, hi: number, dflt: number): number =>
15
+ Math.min(Math.max(typeof n === "number" ? n : dflt, lo), hi)
16
+
17
+ /** Build `nifra_docs` + `nifra_example` over injected corpus loaders. */
18
+ export function docsTools(
19
+ loadDocs: () => Promise<string | undefined>,
20
+ loadExamples: () => Promise<Example[] | undefined>,
21
+ ): McpTool[] {
22
+ return [
23
+ {
24
+ name: "nifra_docs",
25
+ description:
26
+ 'Search nifra\'s framework documentation and get back ONLY the matching sections — auth, uploads, ISR, WebSockets, loaders, deployment, anything. Call with no query for the cheap section index; pass query (e.g. "isr revalidate") for the top matching sections. Use this instead of reading llms-full.txt (~150 KB) whole.',
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ query: {
31
+ type: "string",
32
+ description: "Keywords to match against doc sections (omit for the index).",
33
+ },
34
+ limit: { type: "number", description: "Max sections to return (default 3, max 5)." },
35
+ },
36
+ additionalProperties: false,
37
+ },
38
+ handler: async (args) => {
39
+ const corpus = await loadDocs()
40
+ if (corpus === undefined) {
41
+ return "docs corpus missing — run `bun run gen:llms` in the nifra repo (published builds ship it)."
42
+ }
43
+ const { query, limit } = args as { query?: string; limit?: number }
44
+ return renderDocsResult(corpus, query, clamp(limit, 1, 5, 3))
45
+ },
46
+ },
47
+ {
48
+ name: "nifra_example",
49
+ description:
50
+ 'Get a VERIFIED, copy-pasteable nifra code example for a task — auth route, file upload, ISR page, loader/action, typed client, SSE, deployment, etc. Every snippet is typechecked against the installed nifra version, so it compiles as-is. PREFER THIS over writing nifra code from memory (training data drifts). Call with no query for the grouped index; pass query (e.g. "protected route", "upload", "isr revalidate") for matching snippets.',
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ query: {
55
+ type: "string",
56
+ description: "What you want an example of (omit for the index).",
57
+ },
58
+ limit: { type: "number", description: "Max examples to return (default 3, max 5)." },
59
+ },
60
+ additionalProperties: false,
61
+ },
62
+ handler: async (args) => {
63
+ const corpus = await loadExamples()
64
+ if (corpus === undefined) {
65
+ return "examples corpus missing — run `bun run gen:llms` in the nifra repo (published builds ship it)."
66
+ }
67
+ const { query, limit } = args as { query?: string; limit?: number }
68
+ return renderExamplesResult(corpus, query, clamp(limit, 1, 5, 3))
69
+ },
70
+ },
71
+ ]
72
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The PUBLIC nifra docs MCP — Streamable-HTTP transport exposing the project-INDEPENDENT tools
3
+ * (`nifra_docs` + `nifra_example`) so any remote AI agent can learn nifra without a local checkout.
4
+ * Both tools read the bundled corpus (llms-full.txt / examples.json shipped in this package) and ignore
5
+ * `cwd`, so they reuse the exact definitions from {@link projectTools} — one source, no drift.
6
+ *
7
+ * {@link handleMcpHttp} is a plain Web `fetch` handler, so it self-hosts ANYWHERE Bun runs — the
8
+ * simplest path is `nifra docs-mcp` on a VPS behind a reverse proxy (Caddy/nginx + TLS), where the
9
+ * bundled corpus reads straight from disk (`Bun.file`). `export default { port, fetch }` also lets
10
+ * `bun run mcp-http.ts` serve it directly, and the same handler runs on Cloudflare/Vercel edge — only
11
+ * there must you inline the two corpus files as string imports (a Worker has no filesystem). The
12
+ * dispatch is the shared, transport-agnostic {@link handleRpc}.
13
+ */
14
+
15
+ import { loadDocsCorpus } from "./docs-search.ts"
16
+ import { loadExamplesCorpus } from "./examples.ts"
17
+ import { docsTools } from "./mcp-docs-tools.ts"
18
+ import { handleRpc, type JsonRpcRequest, type McpTool, rpcError } from "./mcp-protocol.ts"
19
+
20
+ export type { Example } from "./examples.ts"
21
+ // Re-exported so a self-host (e.g. a Cloudflare-Pages `/mcp` worker route) gets the corpus-injectable
22
+ // tool factory + the transport core from one entry: `import { respondMcpHttp, docsTools } from "@nifrajs/cli/mcp"`.
23
+ export { docsTools } from "./mcp-docs-tools.ts"
24
+
25
+ const VERSION = "0.1.0-alpha.1"
26
+ const SERVER_INFO = { name: "nifra-docs", version: VERSION }
27
+
28
+ const CORS: Record<string, string> = {
29
+ "access-control-allow-origin": "*",
30
+ "access-control-allow-methods": "POST, GET, OPTIONS",
31
+ "access-control-allow-headers": "content-type",
32
+ }
33
+
34
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000
35
+ const TEXT_DECODER = new TextDecoder()
36
+
37
+ export interface McpHttpOptions {
38
+ /** Maximum JSON-RPC request body size in bytes. Default 1 MB. */
39
+ readonly maxBodyBytes?: number
40
+ }
41
+
42
+ function parseContentLength(value: string): number | undefined {
43
+ if (value.length === 0) return undefined
44
+ let length = 0
45
+ for (let i = 0; i < value.length; i++) {
46
+ const digit = value.charCodeAt(i) - 48
47
+ if (digit < 0 || digit > 9) return undefined
48
+ length = length * 10 + digit
49
+ if (length > Number.MAX_SAFE_INTEGER) return Number.POSITIVE_INFINITY
50
+ }
51
+ return length
52
+ }
53
+
54
+ async function readJsonBounded(
55
+ request: Request,
56
+ maxBytes: number,
57
+ ): Promise<{ ok: true; value: unknown } | { ok: false; status: 400 | 413 }> {
58
+ const declared = request.headers.get("content-length")
59
+ if (declared !== null) {
60
+ const length = parseContentLength(declared)
61
+ if (length === undefined) return { ok: false, status: 400 }
62
+ if (length > maxBytes) return { ok: false, status: 413 }
63
+ }
64
+
65
+ const body = request.body
66
+ if (body === null) return { ok: false, status: 400 }
67
+
68
+ const reader = body.getReader()
69
+ const chunks: Uint8Array[] = []
70
+ let total = 0
71
+ for (;;) {
72
+ const { done, value } = await reader.read()
73
+ if (done) break
74
+ total += value.byteLength
75
+ if (total > maxBytes) {
76
+ await reader.cancel()
77
+ return { ok: false, status: 413 }
78
+ }
79
+ chunks.push(value)
80
+ }
81
+
82
+ const bytes = new Uint8Array(total)
83
+ let offset = 0
84
+ for (const chunk of chunks) {
85
+ bytes.set(chunk, offset)
86
+ offset += chunk.byteLength
87
+ }
88
+ try {
89
+ return { ok: true, value: JSON.parse(TEXT_DECODER.decode(bytes)) as unknown }
90
+ } catch {
91
+ return { ok: false, status: 400 }
92
+ }
93
+ }
94
+
95
+ /** The two project-independent tools, reading the package's bundled corpus from disk (CLI use). */
96
+ export function publicDocsTools(): McpTool[] {
97
+ return docsTools(loadDocsCorpus, loadExamplesCorpus)
98
+ }
99
+
100
+ /**
101
+ * The transport core, shared by every HTTP host (CLI `docs-mcp`, the site's edge worker): handle one
102
+ * MCP request against the given `tools`. POST a JSON-RPC body → JSON-RPC response; GET is a health page;
103
+ * OPTIONS is the CORS preflight. Never throws — a bad body becomes a JSON-RPC parse error. Pass the
104
+ * tools so each host supplies its own corpus source (disk vs cached fetch) behind identical defs.
105
+ */
106
+ export async function respondMcpHttp(
107
+ request: Request,
108
+ tools: McpTool[],
109
+ options: McpHttpOptions = {},
110
+ ): Promise<Response> {
111
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
112
+ if (request.method === "GET") {
113
+ return new Response(
114
+ "nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example.",
115
+ { headers: { "content-type": "text/plain; charset=utf-8", ...CORS } },
116
+ )
117
+ }
118
+ if (request.method !== "POST") {
119
+ return new Response("method not allowed", {
120
+ status: 405,
121
+ headers: { allow: "POST, GET", ...CORS },
122
+ })
123
+ }
124
+ const parsed = await readJsonBounded(request, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES)
125
+ if (!parsed.ok) {
126
+ if (parsed.status === 413) {
127
+ return Response.json(rpcError(null, -32000, "payload too large"), {
128
+ status: 413,
129
+ headers: CORS,
130
+ })
131
+ }
132
+ return Response.json(rpcError(null, -32700, "parse error"), { status: 400, headers: CORS })
133
+ }
134
+ const message = parsed.value as JsonRpcRequest
135
+ const response = await handleRpc(message, tools, SERVER_INFO, {}, { signal: request.signal })
136
+ // A notification (no id) yields null — acknowledge with 204, no body.
137
+ if (response === null) return new Response(null, { status: 204, headers: CORS })
138
+ return Response.json(response, { headers: CORS })
139
+ }
140
+
141
+ /** The CLI HTTP handler: serves the disk-backed corpus tools. (`nifra docs-mcp` / `bun run` this file.) */
142
+ export function handleMcpHttp(request: Request): Promise<Response> {
143
+ return respondMcpHttp(request, publicDocsTools())
144
+ }
145
+
146
+ /**
147
+ * Worker/edge + local entry. `export default { fetch }` is the universal server shape: Cloudflare /
148
+ * Vercel edge / Deno deploy use `fetch` (and ignore `port`); `bun run mcp-http.ts` auto-serves it on
149
+ * `port` (PORT env, default 8787) — Bun serves a module's default-exported server, so NO manual
150
+ * `Bun.serve` here (that would double-bind the port).
151
+ */
152
+ export default {
153
+ port: typeof Bun !== "undefined" ? Number(Bun.env.PORT ?? 8787) : 8787,
154
+ fetch: handleMcpHttp,
155
+ }