@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,326 @@
1
+ /**
2
+ * The pure MCP (Model Context Protocol) JSON-RPC dispatch — no I/O, no `Bun.*`, no side effects, so it
3
+ * unit-tests cleanly. {@link runMcpServer} in `./mcp.ts` wires this to stdin/stdout; the tools are
4
+ * injected, so the protocol logic is exercised without spawning or reading streams.
5
+ */
6
+
7
+ export const PROTOCOL_VERSION = "2024-11-05"
8
+
9
+ type RequestId = string | number
10
+ type JsonRpcId = RequestId | null
11
+ type ProgressToken = string | number
12
+
13
+ export interface McpToolContext {
14
+ readonly signal: AbortSignal
15
+ readonly requestId: JsonRpcId
16
+ readonly progressToken?: ProgressToken
17
+ readonly reportProgress: (progress: number, total?: number) => void
18
+ }
19
+
20
+ /** A tool the agent can call. `handler` returns the text shown to the agent. */
21
+ export interface McpTool {
22
+ readonly name: string
23
+ readonly description: string
24
+ readonly inputSchema: Record<string, unknown>
25
+ readonly handler: (args: Record<string, unknown>, context: McpToolContext) => Promise<string>
26
+ }
27
+
28
+ export interface McpResource {
29
+ readonly uri: string
30
+ readonly name: string
31
+ readonly description?: string
32
+ readonly mimeType?: string
33
+ readonly read: () => Promise<{ readonly text: string; readonly mimeType?: string }>
34
+ }
35
+
36
+ export interface McpPromptMessage {
37
+ readonly role: "user" | "assistant"
38
+ readonly content: { readonly type: "text"; readonly text: string }
39
+ }
40
+
41
+ export interface McpPrompt {
42
+ readonly name: string
43
+ readonly description: string
44
+ readonly arguments?: readonly {
45
+ readonly name: string
46
+ readonly description?: string
47
+ readonly required?: boolean
48
+ }[]
49
+ readonly handler: (args: Record<string, unknown>) => Promise<readonly McpPromptMessage[]>
50
+ }
51
+
52
+ export interface McpServerFeatures {
53
+ readonly resources?: readonly McpResource[]
54
+ readonly prompts?: readonly McpPrompt[]
55
+ }
56
+
57
+ export interface JsonRpcRequest {
58
+ readonly jsonrpc?: string
59
+ readonly id?: JsonRpcId
60
+ readonly method?: string
61
+ readonly params?: Record<string, unknown>
62
+ }
63
+
64
+ export interface JsonRpcNotification {
65
+ readonly jsonrpc: "2.0"
66
+ readonly method: string
67
+ readonly params?: Record<string, unknown>
68
+ }
69
+
70
+ export type JsonRpcResponse =
71
+ | { jsonrpc: "2.0"; id: JsonRpcId; result: unknown }
72
+ | { jsonrpc: "2.0"; id: JsonRpcId; error: { code: number; message: string } }
73
+
74
+ export const rpcResult = (id: JsonRpcId, value: unknown): JsonRpcResponse => ({
75
+ jsonrpc: "2.0",
76
+ id,
77
+ result: value,
78
+ })
79
+ export const rpcError = (id: JsonRpcId, code: number, message: string): JsonRpcResponse => ({
80
+ jsonrpc: "2.0",
81
+ id,
82
+ error: { code, message },
83
+ })
84
+
85
+ export interface McpProtocolState {
86
+ readonly activeRequests: Map<string, AbortController>
87
+ }
88
+
89
+ export interface McpProtocolOptions {
90
+ readonly state?: McpProtocolState
91
+ readonly signal?: AbortSignal
92
+ readonly sendNotification?: (notification: JsonRpcNotification) => void
93
+ }
94
+
95
+ export function createMcpProtocolState(): McpProtocolState {
96
+ return { activeRequests: new Map() }
97
+ }
98
+
99
+ function requestKey(id: unknown): string | undefined {
100
+ if (typeof id === "string") return `s:${id}`
101
+ return typeof id === "number" && Number.isFinite(id) ? `n:${id}` : undefined
102
+ }
103
+
104
+ function progressTokenOf(params: Record<string, unknown> | undefined): ProgressToken | undefined {
105
+ const meta = params?._meta
106
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return undefined
107
+ const token = (meta as Record<string, unknown>).progressToken
108
+ return typeof token === "string" || (typeof token === "number" && Number.isFinite(token))
109
+ ? token
110
+ : undefined
111
+ }
112
+
113
+ function cancellationReason(params: Record<string, unknown> | undefined): string | undefined {
114
+ const reason = params?.reason
115
+ return typeof reason === "string" && reason.length > 0 ? reason : undefined
116
+ }
117
+
118
+ function linkAbortSignal(parent: AbortSignal | undefined, child: AbortController): () => void {
119
+ if (parent === undefined) return () => {}
120
+ if (parent.aborted) {
121
+ child.abort(parent.reason)
122
+ return () => {}
123
+ }
124
+ const abort = (): void => child.abort(parent.reason)
125
+ parent.addEventListener("abort", abort, { once: true })
126
+ return () => parent.removeEventListener("abort", abort)
127
+ }
128
+
129
+ function abortMessage(signal: AbortSignal): string {
130
+ const reason = signal.reason
131
+ if (typeof reason === "string" && reason.length > 0) return `Cancelled: ${reason}`
132
+ return "Cancelled"
133
+ }
134
+
135
+ function compactDescription(description: string): string {
136
+ const text = description.replace(/\s+/g, " ").trim()
137
+ const sentenceEnd = text.search(/[.!?](?:\s|$)/)
138
+ const sentence = sentenceEnd === -1 ? text : text.slice(0, sentenceEnd + 1)
139
+ if (sentence.length <= 120) return sentence
140
+ return `${sentence.slice(0, 117).trimEnd()}...`
141
+ }
142
+
143
+ /**
144
+ * Dispatch one JSON-RPC message against the given tools. Returns the response, or `null` for a
145
+ * notification (no reply). Tool errors are reported in-band (`isError`) so the agent can react to them.
146
+ */
147
+ export async function handleRpc(
148
+ message: JsonRpcRequest,
149
+ tools: readonly McpTool[],
150
+ serverInfo: { name: string; version: string },
151
+ features: McpServerFeatures = {},
152
+ options: McpProtocolOptions = {},
153
+ ): Promise<JsonRpcResponse | null> {
154
+ const { id, method, params } = message
155
+ const isNotification = id === undefined
156
+ const rid = id ?? null
157
+ const resources = features.resources ?? []
158
+ const prompts = features.prompts ?? []
159
+ const state = options.state ?? createMcpProtocolState()
160
+
161
+ switch (method) {
162
+ case "initialize":
163
+ return rpcResult(rid, {
164
+ protocolVersion: PROTOCOL_VERSION,
165
+ capabilities: {
166
+ tools: {},
167
+ ...(resources.length > 0 ? { resources: {} } : {}),
168
+ ...(prompts.length > 0 ? { prompts: {} } : {}),
169
+ },
170
+ serverInfo,
171
+ })
172
+ case "notifications/initialized":
173
+ return null
174
+ case "notifications/cancelled": {
175
+ const key = requestKey(params?.requestId)
176
+ if (key !== undefined) state.activeRequests.get(key)?.abort(cancellationReason(params))
177
+ return null
178
+ }
179
+ case "ping":
180
+ return rpcResult(rid, {})
181
+ case "tools/list":
182
+ return rpcResult(
183
+ rid,
184
+ params?.compact === true
185
+ ? {
186
+ tools: tools.map((t) => ({
187
+ name: t.name,
188
+ description: compactDescription(t.description),
189
+ })),
190
+ }
191
+ : {
192
+ tools: tools.map((t) => ({
193
+ name: t.name,
194
+ description: t.description,
195
+ inputSchema: t.inputSchema,
196
+ })),
197
+ },
198
+ )
199
+ case "tools/describe": {
200
+ const name = params?.name
201
+ const tool = tools.find((t) => t.name === name)
202
+ if (!tool) return rpcError(rid, -32602, `unknown tool: ${String(name)}`)
203
+ return rpcResult(rid, {
204
+ tool: {
205
+ name: tool.name,
206
+ description: tool.description,
207
+ inputSchema: tool.inputSchema,
208
+ },
209
+ })
210
+ }
211
+ case "tools/call": {
212
+ const name = params?.name
213
+ const tool = tools.find((t) => t.name === name)
214
+ if (!tool) return rpcError(rid, -32602, `unknown tool: ${String(name)}`)
215
+ const args = (params?.arguments as Record<string, unknown>) ?? {}
216
+ const controller = new AbortController()
217
+ const key = requestKey(id)
218
+ const cleanupAbort = linkAbortSignal(options.signal, controller)
219
+ if (key !== undefined) state.activeRequests.set(key, controller)
220
+ const progressToken = progressTokenOf(params)
221
+ let lastProgress = Number.NEGATIVE_INFINITY
222
+ const reportProgress = (progress: number, total?: number): void => {
223
+ if (
224
+ progressToken === undefined ||
225
+ !Number.isFinite(progress) ||
226
+ progress <= lastProgress ||
227
+ (total !== undefined && !Number.isFinite(total))
228
+ ) {
229
+ return
230
+ }
231
+ lastProgress = progress
232
+ options.sendNotification?.({
233
+ jsonrpc: "2.0",
234
+ method: "notifications/progress",
235
+ params: {
236
+ progressToken,
237
+ progress,
238
+ ...(total !== undefined ? { total } : {}),
239
+ },
240
+ })
241
+ }
242
+ try {
243
+ reportProgress(0, 1)
244
+ if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError")
245
+ const text = await tool.handler(args, {
246
+ signal: controller.signal,
247
+ requestId: rid,
248
+ ...(progressToken !== undefined ? { progressToken } : {}),
249
+ reportProgress,
250
+ })
251
+ if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError")
252
+ reportProgress(1, 1)
253
+ return rpcResult(rid, { content: [{ type: "text", text }] })
254
+ } catch (err) {
255
+ if (controller.signal.aborted) {
256
+ return rpcResult(rid, {
257
+ content: [{ type: "text", text: abortMessage(controller.signal) }],
258
+ isError: true,
259
+ })
260
+ }
261
+ const msg = err instanceof Error ? err.message : String(err)
262
+ return rpcResult(rid, { content: [{ type: "text", text: `Error: ${msg}` }], isError: true })
263
+ } finally {
264
+ cleanupAbort()
265
+ if (key !== undefined && state.activeRequests.get(key) === controller) {
266
+ state.activeRequests.delete(key)
267
+ }
268
+ }
269
+ }
270
+ case "resources/list":
271
+ return rpcResult(rid, {
272
+ resources: resources.map((r) => ({
273
+ uri: r.uri,
274
+ name: r.name,
275
+ ...(r.description !== undefined ? { description: r.description } : {}),
276
+ ...(r.mimeType !== undefined ? { mimeType: r.mimeType } : {}),
277
+ })),
278
+ })
279
+ case "resources/read": {
280
+ const uri = params?.uri
281
+ const resource = resources.find((r) => r.uri === uri)
282
+ if (!resource) return rpcError(rid, -32602, `unknown resource: ${String(uri)}`)
283
+ try {
284
+ const content = await resource.read()
285
+ return rpcResult(rid, {
286
+ contents: [
287
+ {
288
+ uri: resource.uri,
289
+ mimeType: content.mimeType ?? resource.mimeType ?? "text/plain",
290
+ text: content.text,
291
+ },
292
+ ],
293
+ })
294
+ } catch (err) {
295
+ const msg = err instanceof Error ? err.message : String(err)
296
+ return rpcError(rid, -32000, msg)
297
+ }
298
+ }
299
+ case "prompts/list":
300
+ return rpcResult(rid, {
301
+ prompts: prompts.map((p) => ({
302
+ name: p.name,
303
+ description: p.description,
304
+ ...(p.arguments !== undefined ? { arguments: p.arguments } : {}),
305
+ })),
306
+ })
307
+ case "prompts/get": {
308
+ const name = params?.name
309
+ const prompt = prompts.find((p) => p.name === name)
310
+ if (!prompt) return rpcError(rid, -32602, `unknown prompt: ${String(name)}`)
311
+ const args = (params?.arguments as Record<string, unknown>) ?? {}
312
+ try {
313
+ return rpcResult(rid, {
314
+ description: prompt.description,
315
+ messages: await prompt.handler(args),
316
+ })
317
+ } catch (err) {
318
+ const msg = err instanceof Error ? err.message : String(err)
319
+ return rpcError(rid, -32000, msg)
320
+ }
321
+ }
322
+ default:
323
+ if (isNotification) return null
324
+ return rpcError(rid, -32601, `method not found: ${String(method)}`)
325
+ }
326
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The `nifra_render` engine + its child-process entry — the page half of `nifra_run`. Where `nifra_run`
3
+ * runs requests through the BACKEND, this SSRs page routes through the WEB app (`createWebApp`), so an
4
+ * agent can verify a page renders and its loader ran after an edit. Spawned fresh per call (`bun
5
+ * mcp-render.ts <cwd>`) so the project's CURRENT route/loader code is loaded.
6
+ *
7
+ * No build required: a placeholder `clientEntry` is injected (the SSR HTML renders regardless — the
8
+ * client bundle only matters for hydration in the browser, not for "did this page render server-side").
9
+ * {@link renderPages} never throws — every failure (no config, a loader error, a render throw) becomes
10
+ * `{ error }` so the agent gets the actionable message.
11
+ */
12
+
13
+ import { inProcessClient } from "@nifrajs/client"
14
+ import { type AppLike, runApp } from "@nifrajs/runner"
15
+ import { createWebApp, type RenderAdapter } from "@nifrajs/web"
16
+ import { discoverRoutes } from "@nifrajs/web/fs"
17
+ import { loadApp, resolvePlugins } from "./load.ts"
18
+
19
+ const errString = (err: unknown): string =>
20
+ err instanceof Error ? `${err.name}: ${err.message}` : String(err)
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
+
28
+ let app: Awaited<ReturnType<typeof loadApp>>
29
+ try {
30
+ app = await loadApp(cwd, "dist")
31
+ } catch (err) {
32
+ return { error: errString(err) }
33
+ }
34
+
35
+ // Register the framework's SSR Bun plugins so `.vue`/`.svelte`/Solid route files compile on import
36
+ // (React/Preact JSX is Bun-native → none). Mirrors `nifra start`.
37
+ try {
38
+ const { plugin } = await import("bun")
39
+ for (const p of await resolvePlugins(app.framework.serverPlugins)) {
40
+ plugin(p as Parameters<typeof plugin>[0])
41
+ }
42
+ } catch (err) {
43
+ return { error: errString(err) }
44
+ }
45
+
46
+ let webApp: AppLike
47
+ try {
48
+ webApp = createWebApp({
49
+ // The adapter is validated by loadApp; cast at the seam (same as the CLI's `asAdapter`).
50
+ adapter: app.framework.adapter as RenderAdapter,
51
+ manifest: discoverRoutes(app.routesDir),
52
+ clientEntry: "/_nifra-render-only.js", // placeholder; SSR HTML does not need the built client
53
+ ...(app.backend !== undefined ? { api: inProcessClient(app.backend as never) } : {}),
54
+ }) as unknown as AppLike
55
+ } catch (err) {
56
+ return { error: errString(err) }
57
+ }
58
+
59
+ try {
60
+ return { results: await runApp(webApp, requests as Parameters<typeof runApp>[1]) }
61
+ } catch (err) {
62
+ return { error: errString(err) }
63
+ }
64
+ }
65
+
66
+ // Child-process entry: read `{ requests }` from stdin, SSR, print JSON. Guarded so importing this
67
+ // module in a test does not execute it.
68
+ if (import.meta.main) {
69
+ const cwd = process.argv[2] ?? process.cwd()
70
+ let output: unknown
71
+ try {
72
+ const { requests } = JSON.parse(await Bun.stdin.text()) as { requests: unknown }
73
+ output = await renderPages(cwd, requests)
74
+ } catch {
75
+ output = { error: "invalid input: expected JSON { requests: [...] }" }
76
+ }
77
+ process.stdout.write(JSON.stringify(output, null, 2))
78
+ }
package/src/mcp-run.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The `nifra_run` engine + its child-process entry. The MCP server spawns this file fresh on every
3
+ * `nifra_run` call (`bun mcp-run.ts <cwd>`) so the project's CURRENT backend code is loaded — picking
4
+ * up the agent's latest edits. {@link runBackend} is the pure-ish core (resolve entry → import → run),
5
+ * exported so it's unit-testable in-process; the entry below wires it to stdin/stdout.
6
+ */
7
+
8
+ import { existsSync } from "node:fs"
9
+ import { resolve } from "node:path"
10
+ import { pathToFileURL } from "node:url"
11
+ import { type AppLike, runApp } from "@nifrajs/runner"
12
+
13
+ const ENTRY_CANDIDATES = ["backend.ts", "app.ts", "src/backend.ts", "src/app.ts"]
14
+
15
+ const errString = (err: unknown): string =>
16
+ err instanceof Error ? `${err.name}: ${err.message}` : String(err)
17
+
18
+ export async function loadBackend(
19
+ cwd: string,
20
+ entry?: string,
21
+ ): Promise<{ app: AppLike } | { error: string }> {
22
+ const candidates = entry && entry.length > 0 ? [entry] : ENTRY_CANDIDATES
23
+ const found = candidates.map((c) => resolve(cwd, c)).find((p) => existsSync(p))
24
+ if (!found) {
25
+ return { error: `no backend entry found in ${cwd} (looked for ${candidates.join(", ")})` }
26
+ }
27
+
28
+ let mod: Record<string, unknown>
29
+ try {
30
+ mod = (await import(pathToFileURL(found).href)) as Record<string, unknown>
31
+ } catch (err) {
32
+ return { error: errString(err) }
33
+ }
34
+ const app = (mod.app ?? mod.backend ?? mod.default) as AppLike | undefined
35
+ if (!app || typeof app.fetch !== "function") {
36
+ return {
37
+ error: `${found} does not export a nifra app (expected \`app\`, \`backend\`, or default).`,
38
+ }
39
+ }
40
+ return { app }
41
+ }
42
+
43
+ /**
44
+ * Resolve the backend entry under `cwd`, import it, and run `requests` through its exported app. Never
45
+ * throws — every failure (missing/invalid entry, import/compile/runtime error) becomes `{ error }`, so
46
+ * the caller (and the agent) always gets actionable output. An import error here IS the failure the
47
+ * agent needs to see and fix.
48
+ */
49
+ export async function runBackend(
50
+ cwd: string,
51
+ requests: unknown,
52
+ entry?: string,
53
+ ): Promise<{ results: unknown } | { error: string }> {
54
+ if (!Array.isArray(requests)) return { error: "expected { requests: [...] }" }
55
+
56
+ const loaded = await loadBackend(cwd, entry)
57
+ if ("error" in loaded) return loaded
58
+
59
+ try {
60
+ return { results: await runApp(loaded.app, requests as Parameters<typeof runApp>[1]) }
61
+ } catch (err) {
62
+ return { error: errString(err) }
63
+ }
64
+ }
65
+
66
+ interface WorkerMessage {
67
+ readonly id?: unknown
68
+ readonly input?: { readonly requests?: unknown; readonly entry?: string }
69
+ }
70
+
71
+ function redirectConsoleToStderr(): void {
72
+ const write = (level: string, args: unknown[]): void => {
73
+ Bun.stderr.write(`[${level}] ${args.map((arg) => String(arg)).join(" ")}\n`)
74
+ }
75
+ console.debug = (...args: unknown[]) => write("debug", args)
76
+ console.info = (...args: unknown[]) => write("info", args)
77
+ console.log = (...args: unknown[]) => write("log", args)
78
+ console.warn = (...args: unknown[]) => write("warn", args)
79
+ console.error = (...args: unknown[]) => write("error", args)
80
+ }
81
+
82
+ async function runWorker(cwd: string): Promise<void> {
83
+ redirectConsoleToStderr()
84
+ const apps = new Map<string, Promise<{ app: AppLike } | { error: string }>>()
85
+ const decoder = new TextDecoder()
86
+ let buffer = ""
87
+ const send = (id: unknown, output: unknown): void => {
88
+ process.stdout.write(`${JSON.stringify({ id, output })}\n`)
89
+ }
90
+ for await (const chunk of Bun.stdin.stream()) {
91
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true })
92
+ let nl = buffer.indexOf("\n")
93
+ while (nl !== -1) {
94
+ const line = buffer.slice(0, nl).trim()
95
+ buffer = buffer.slice(nl + 1)
96
+ nl = buffer.indexOf("\n")
97
+ if (line === "") continue
98
+
99
+ let message: WorkerMessage
100
+ try {
101
+ message = JSON.parse(line) as WorkerMessage
102
+ } catch {
103
+ send(null, { error: "invalid worker input: expected JSON line" })
104
+ continue
105
+ }
106
+ const requests = message.input?.requests
107
+ const entry = message.input?.entry
108
+ if (!Array.isArray(requests)) {
109
+ send(message.id, { error: "expected { requests: [...] }" })
110
+ continue
111
+ }
112
+ const key = entry ?? ""
113
+ let loaded = apps.get(key)
114
+ if (loaded === undefined) {
115
+ loaded = loadBackend(cwd, entry)
116
+ apps.set(key, loaded)
117
+ }
118
+ const app = await loaded
119
+ if ("error" in app) {
120
+ send(message.id, app)
121
+ continue
122
+ }
123
+ try {
124
+ send(message.id, {
125
+ results: await runApp(app.app, requests as Parameters<typeof runApp>[1]),
126
+ })
127
+ } catch (err) {
128
+ send(message.id, { error: errString(err) })
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // Child-process entry: read `{ requests, entry? }` from stdin, run, print JSON to stdout. Guarded so
135
+ // this only executes when run directly (not when imported by a test).
136
+ if (import.meta.main) {
137
+ const cwd = process.argv[2] ?? process.cwd()
138
+ if (process.argv.includes("--worker")) {
139
+ await runWorker(cwd)
140
+ process.exit(0)
141
+ }
142
+ let output: unknown
143
+ try {
144
+ const { requests, entry } = JSON.parse(await Bun.stdin.text()) as {
145
+ requests: unknown
146
+ entry?: string
147
+ }
148
+ output = await runBackend(cwd, requests, entry)
149
+ } catch {
150
+ output = { error: "invalid input: expected JSON { requests: [...] }" }
151
+ }
152
+ process.stdout.write(JSON.stringify(output, null, 2))
153
+ }