diff --git a/src/core/dev/inspector/invocations.test.ts b/src/core/dev/inspector/invocations.test.ts new file mode 100644 index 000000000..26d4f72e5 --- /dev/null +++ b/src/core/dev/inspector/invocations.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { type HttpRequestHandler, startHttpServer } from "../../../io/httpServer"; +import { parseAgentEvent } from "./invocations"; +import { ServerFarm, fakeSupervisor, post, runningAgent } from "./testkit"; +import type { InspectorDeps } from "./types"; + +const farm = new ServerFarm(); +afterEach(() => farm.close()); + +function sseAgent(frames: string[]): HttpRequestHandler { + return () => ({ + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: frames.map((frame) => `data: ${frame}\n\n`).join(""), + }); +} + +async function inspectorFor(handler: HttpRequestHandler, protocol = "HTTP") { + const agent = await farm.serve(handler); + const supervisor = fakeSupervisor({ agents: [runningAgent("orders", agent.port, protocol)] }); + return farm.inspector({ supervisor } satisfies InspectorDeps); +} + +describe("POST /invocations routing", () => { + test("returns 409 when no agent is running", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + const response = await post(url, "/invocations", { prompt: "hi" }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ success: false }); + }); + + test("directs MCP agents to the dedicated proxy instead of mis-proxying them", async () => { + const { url } = await inspectorFor(sseAgent([]), "MCP"); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + success: false, + error: "MCP agents are invoked through POST /api/mcp, not /invocations.", + }); + }); +}); + +describe("upstream connection failures", () => { + async function deadPort(): Promise { + const handle = await startHttpServer(() => ({ status: 200 })); + await handle.close(); + return handle.port; + } + + test.each([ + { protocol: "HTTP", errorPrefix: "Agent server error" }, + { protocol: "A2A", errorPrefix: "A2A agent error" }, + ])("returns 502 when a $protocol agent is unreachable", async ({ protocol, errorPrefix }) => { + const supervisor = fakeSupervisor({ + agents: [runningAgent("orders", await deadPort(), protocol)], + }); + const { url } = await farm.inspector({ supervisor }); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.status).toBe(502); + const body = (await response.json()) as { success: boolean; error: string }; + expect(body.success).toBe(false); + expect(body.error).toContain(errorPrefix); + }); +}); + +describe("parseAgentEvent drops non-renderable frames", () => { + test.each([ + { name: "a JSON primitive that is not text", data: JSON.stringify(42) }, + { name: "a blank error field", data: JSON.stringify({ error: "" }) }, + { name: "a blank text field", data: JSON.stringify({ text: "" }) }, + { name: "an empty non-JSON token", data: "" }, + ])("returns null for $name", ({ data }) => { + expect(parseAgentEvent(data)).toBeNull(); + }); +}); + +describe("HTTP agent SSE normalization", () => { + test.each([ + { name: "a bedrock text event", frame: JSON.stringify({ text: "hello" }), expected: "hello" }, + { name: "a bare JSON string token", frame: JSON.stringify("world"), expected: "world" }, + { + name: "a ConverseStream content delta", + frame: JSON.stringify({ event: { contentBlockDelta: { delta: { text: "delta" } } } }), + expected: "delta", + }, + { name: "a non-JSON plain-text token", frame: "raw-token", expected: "raw-token" }, + ])("normalizes $name to a data frame", async ({ frame, expected }) => { + const { url } = await inspectorFor(sseAgent([frame])); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(await response.text()).toBe(`data: ${JSON.stringify(expected)}\n\n`); + }); + + test("re-frames an agent error event as an error payload", async () => { + const { url } = await inspectorFor(sseAgent([JSON.stringify({ error: "boom" })])); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(await response.text()).toBe(`data: ${JSON.stringify({ error: "boom" })}\n\n`); + }); + + test("passes a non-SSE response body through untouched", async () => { + const { url } = await inspectorFor(() => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ answer: 42 }), + })); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.json()).toEqual({ answer: 42 }); + }); + + test("echoes one session id and forwards it to the agent", async () => { + let received: string | undefined; + const { url } = await inspectorFor((request) => { + received = request.headers["x-amzn-bedrock-agentcore-runtime-session-id"] as string; + return { status: 200, headers: { "Content-Type": "text/event-stream" }, body: "" }; + }); + const response = await post(url, "/invocations", { + agentName: "orders", + prompt: "hi", + sessionId: "session-42", + }); + expect(response.headers.get("x-session-id")).toBe("session-42"); + expect(received).toBe("session-42"); + }); +}); + +describe("A2A agent invocation", () => { + test("translates the prompt to message/stream and reduces events to text frames", async () => { + let rpcMethod: string | undefined; + const { url } = await inspectorFor((request) => { + rpcMethod = (JSON.parse(request.body.toString()) as { method: string }).method; + const status = JSON.stringify({ + result: { + kind: "status-update", + status: { message: { parts: [{ kind: "text", text: "streamed" }] } }, + }, + }); + return { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: `data: ${status}\n\n`, + }; + }, "A2A"); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(rpcMethod).toBe("message/stream"); + expect(await response.text()).toBe(`data: ${JSON.stringify("streamed")}\n\n`); + }); + + test("skips artifact text already streamed by a preceding status-update", async () => { + const status = JSON.stringify({ + result: { + kind: "status-update", + status: { message: { parts: [{ kind: "text", text: "answer" }] } }, + }, + }); + const artifact = JSON.stringify({ + result: { kind: "artifact-update", artifact: { parts: [{ kind: "text", text: "answer" }] } }, + }); + const { url } = await inspectorFor(sseAgent([status, artifact]), "A2A"); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(await response.text()).toBe(`data: ${JSON.stringify("answer")}\n\n`); + }); + + test("falls back to extracting text from a non-streaming JSON-RPC result", async () => { + const { url } = await inspectorFor( + () => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + result: { artifacts: [{ parts: [{ kind: "text", text: "final" }] }] }, + }), + }), + "A2A", + ); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(await response.text()).toBe(`data: ${JSON.stringify("final")}\n\n`); + }); + + test.each([ + { + name: "an artifact-update with no preceding status-update", + frame: JSON.stringify({ + result: { kind: "artifact-update", artifact: { parts: [{ kind: "text", text: "art" }] } }, + }), + expected: "art", + }, + { name: "a frame that is not JSON", frame: "not-json", expected: "not-json" }, + { + name: "a task event carrying a status message", + frame: JSON.stringify({ + result: { + kind: "task", + status: { message: { parts: [{ kind: "text", text: "task-text" }] } }, + }, + }), + expected: "task-text", + }, + ])("streams text extracted from $name", async ({ frame, expected }) => { + const { url } = await inspectorFor(sseAgent([frame]), "A2A"); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(await response.text()).toBe(`data: ${JSON.stringify(expected)}\n\n`); + }); + + test("drops a task frame that carries no renderable parts", async () => { + const { url } = await inspectorFor( + sseAgent([JSON.stringify({ result: { kind: "task" } })]), + "A2A", + ); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(await response.text()).toBe(""); + }); + + test("passes a non-JSON A2A response through as plain text", async () => { + const { url } = await inspectorFor( + () => ({ status: 200, headers: { "Content-Type": "text/plain" }, body: "not json" }), + "A2A", + ); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.headers.get("content-type")).toContain("text/plain"); + expect(await response.text()).toBe("not json"); + }); + + test("requires a prompt", async () => { + const { url } = await inspectorFor(sseAgent([]), "A2A"); + const response = await post(url, "/invocations", { agentName: "orders" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: "prompt is required" }); + }); +}); + +describe("AGUI agent invocation", () => { + test("sends a RunAgentInput body and passes the response through", async () => { + let body: Record | undefined; + const { url } = await inspectorFor((request) => { + body = JSON.parse(request.body.toString()) as Record; + return { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: "data: passthrough\n\n", + }; + }, "AGUI"); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(body).toMatchObject({ messages: [{ role: "user", content: "hi" }] }); + expect(await response.text()).toBe("data: passthrough\n\n"); + }); + + test("requires a prompt", async () => { + const { url } = await inspectorFor(sseAgent([]), "AGUI"); + const response = await post(url, "/invocations", { agentName: "orders" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: "prompt is required" }); + }); +}); diff --git a/src/core/dev/inspector/invocations.ts b/src/core/dev/inspector/invocations.ts new file mode 100644 index 000000000..b3056dcbe --- /dev/null +++ b/src/core/dev/inspector/invocations.ts @@ -0,0 +1,267 @@ +// Every upstream fetch carries the client abort signal, so a browser disconnect tears down the agent request. +import { randomUUID } from "node:crypto"; +import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; +import { + apiError, + asString, + errorMessage, + iterateBody, + parseJsonBody, + sse, + sseData, + sseEvent, +} from "./respond"; +import type { InspectorDeps } from "./types"; + +export async function handleInvocations( + deps: InspectorDeps, + request: HttpRequest, +): Promise { + const parsed = parseJsonBody(request.body); + const agentName = asString(parsed?.agentName); + // Request header, agent body, and echoed x-session-id must agree, so one session id is computed once. + const sessionId = asString(parsed?.sessionId) ?? randomUUID(); + const userId = asString(parsed?.userId); + const signal = request.signal; + + let running = agentName ? deps.supervisor.running(agentName) : undefined; + if (!running) { + const first = deps.supervisor.snapshot().find((agent) => agent.phase === "running"); + if (first) running = deps.supervisor.running(first.name); + } + if (!running) return apiError(409, "No agent is running. Call POST /api/start first."); + + if (running.protocol === "MCP") { + return apiError(400, "MCP agents are invoked through POST /api/mcp, not /invocations."); + } + if (running.protocol === "A2A") { + return invokeA2aAgent(running.port, parsed, sessionId, signal); + } + if (running.protocol === "AGUI") { + return invokeAguiAgent(running.port, parsed, sessionId, userId, signal); + } + return forwardInvocation(running.port, request.body, sessionId, userId, signal, { + accept: "text/event-stream, */*", + normalizeSse: true, + }); +} + +async function forwardInvocation( + port: number, + body: Buffer | string, + sessionId: string, + userId: string | undefined, + signal: AbortSignal, + options: { accept: string; normalizeSse: boolean }, +): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: options.accept, + "x-amzn-bedrock-agentcore-runtime-session-id": sessionId, + }; + if (userId) headers["x-amzn-bedrock-agentcore-runtime-user-id"] = userId; + + let agentResponse: Response; + try { + agentResponse = await fetch(`http://127.0.0.1:${port}/invocations`, { + method: "POST", + headers, + body, + signal, + }); + } catch (error) { + return apiError(502, `Agent server error: ${errorMessage(error)}`); + } + + const contentType = agentResponse.headers.get("content-type") ?? "text/plain"; + const stream = iterateBody(agentResponse.body); + return { + status: agentResponse.status, + headers: { "Content-Type": contentType, "x-session-id": sessionId }, + body: + options.normalizeSse && contentType.includes("text/event-stream") + ? transformAgentSse(stream) + : stream, + }; +} + +async function* transformAgentSse( + stream: AsyncIterable, +): AsyncGenerator { + for await (const data of sseData(stream)) { + const payload = parseAgentEvent(data); + if (payload !== null) yield sseEvent(payload); + } +} + +// Handles bedrock {text}, {error}, ConverseStream contentBlockDelta, bare JSON string, and non-JSON tokens. +export function parseAgentEvent(data: string): string | { error: string } | null { + try { + const parsed: unknown = JSON.parse(data); + if (typeof parsed === "string") return parsed || null; + if (parsed && typeof parsed === "object") { + if ("error" in parsed) { + const error = String((parsed as { error: unknown }).error); + return error ? { error } : null; + } + if ("text" in parsed) return String((parsed as { text: unknown }).text) || null; + const event = (parsed as { event?: { contentBlockDelta?: { delta?: { text?: string } } } }) + .event; + return event?.contentBlockDelta?.delta?.text || null; + } + } catch { + return data || null; + } + return null; +} + +// A2A agents speak JSON-RPC at their root path, so {prompt} becomes a message/stream call reduced to text frames. +async function invokeA2aAgent( + port: number, + body: Record | undefined, + sessionId: string, + signal: AbortSignal, +): Promise { + const prompt = asString(body?.prompt); + if (!prompt) return apiError(400, "prompt is required"); + + const a2aBody = { + jsonrpc: "2.0", + id: randomUUID(), + method: "message/stream", + params: { + message: { + messageId: randomUUID(), + role: "user", + parts: [{ kind: "text", text: prompt }], + contextId: sessionId, + }, + }, + }; + + let agentResponse: Response; + try { + agentResponse = await fetch(`http://127.0.0.1:${port}/`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, + body: JSON.stringify(a2aBody), + signal, + }); + } catch (error) { + return apiError(502, `A2A agent error: ${errorMessage(error)}`); + } + if (!agentResponse.ok) return apiError(502, `A2A agent returned ${agentResponse.status}`); + + const contentType = agentResponse.headers.get("content-type") ?? ""; + if (contentType.includes("text/event-stream") && agentResponse.body) { + return sse(transformA2aSse(iterateBody(agentResponse.body)), sessionId); + } + + const responseText = await agentResponse.text(); + try { + const parsed = JSON.parse(responseText) as Record; + const result = parsed.result as Record | undefined; + const text = result + ? (extractTaskText(result) ?? JSON.stringify(result, null, 2)) + : responseText; + return { + status: 200, + headers: { "Content-Type": "text/event-stream", "x-session-id": sessionId }, + body: Buffer.from(sseEvent(text)), + }; + } catch { + return { status: 200, headers: { "Content-Type": "text/plain" }, body: responseText }; + } +} + +async function* transformA2aSse( + stream: AsyncIterable, +): AsyncGenerator { + let streamedFromStatus = false; + for await (const data of sseData(stream)) { + try { + const event = JSON.parse(data) as Record; + const { text, kind } = extractSseEventText(event, streamedFromStatus); + if (text) { + if (kind === "status-update") streamedFromStatus = true; + yield sseEvent(text); + } + } catch { + yield sseEvent(data); + } + } +} + +// When streamedFromStatus is set, artifact-update text is skipped because status-update already streamed it. +function extractSseEventText( + event: Record, + streamedFromStatus: boolean, +): { text: string | null; kind: string | undefined } { + const target = (event.result as Record) ?? event; + const kind = target.kind as string | undefined; + + if (kind === "artifact-update") { + if (streamedFromStatus) return { text: null, kind }; + const artifact = target.artifact as { parts?: A2aPart[] } | undefined; + return { text: extractPartsText(artifact?.parts), kind }; + } + + if (kind === "status-update") { + const status = target.status as { message?: { parts?: A2aPart[] } } | undefined; + return { text: status?.message?.parts ? extractPartsText(status.message.parts) : null, kind }; + } + + return { text: extractTaskText(target), kind }; +} + +function extractTaskText(result: Record): string | null { + const artifacts = result.artifacts as { parts?: A2aPart[] }[] | undefined; + if (artifacts) { + const text = artifacts + .map((artifact) => extractPartsText(artifact.parts)) + .filter((part): part is string => part !== null) + .join("\n"); + if (text) return text; + } + + const status = result.status as { message?: { parts?: A2aPart[] } } | undefined; + if (status?.message?.parts) return extractPartsText(status.message.parts); + return null; +} + +type A2aPart = { kind?: string; type?: string; text?: string }; + +function extractPartsText(parts: A2aPart[] | undefined): string | null { + const text = (parts ?? []) + .filter((part) => (part.kind === "text" || part.type === "text") && part.text) + .map((part) => part.text ?? "") + .join(""); + return text || null; +} + +// AGUI agents expect a RunAgentInput body, and the typed AG-UI SSE response passes through untouched. +async function invokeAguiAgent( + port: number, + body: Record | undefined, + sessionId: string, + userId: string | undefined, + signal: AbortSignal, +): Promise { + const prompt = asString(body?.prompt); + if (!prompt) return apiError(400, "prompt is required"); + + const aguiBody = JSON.stringify({ + threadId: sessionId, + runId: randomUUID(), + messages: [{ id: randomUUID(), role: "user", content: prompt }], + tools: [], + context: [], + state: {}, + forwardedProps: {}, + }); + + return forwardInvocation(port, aguiBody, sessionId, userId, signal, { + accept: "text/event-stream", + normalizeSse: false, + }); +} diff --git a/src/core/dev/inspector/proxies.test.ts b/src/core/dev/inspector/proxies.test.ts new file mode 100644 index 000000000..fcb718dc4 --- /dev/null +++ b/src/core/dev/inspector/proxies.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { HttpRequestHandler } from "../../../io/httpServer"; +import { ServerFarm, fakeSupervisor, get, post, runningAgent } from "./testkit"; + +const farm = new ServerFarm(); +afterEach(() => farm.close()); + +async function inspectorFor(handler: HttpRequestHandler) { + const agent = await farm.serve(handler); + const supervisor = fakeSupervisor({ agents: [runningAgent("orders", agent.port, "MCP")] }); + return farm.inspector({ supervisor }); +} + +describe("POST /api/mcp", () => { + test("forwards the JSON-RPC body and returns the parsed result with the session id", async () => { + const { url } = await inspectorFor((request) => { + expect(request.url).toBe("/mcp"); + return { + status: 200, + headers: { "Content-Type": "application/json", "mcp-session-id": "mcp-1" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, result: { tools: [] } }), + }; + }); + const response = await post(url, "/api/mcp", { + agentName: "orders", + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + result: { jsonrpc: "2.0", id: 1, result: { tools: [] } }, + sessionId: "mcp-1", + }); + }); + + test.each([ + { name: "invalid JSON", body: "not json", expected: "Invalid JSON" }, + { name: "a missing agentName", body: { body: {} }, expected: "agentName is required" }, + { name: "a missing body", body: { agentName: "orders" }, expected: "body is required" }, + ])("rejects $name with 400", async ({ body, expected }) => { + const { url } = await inspectorFor(() => ({ status: 200, body: "{}" })); + const response = await fetch(`${url}/api/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Agentcore-Local": "1" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: expected }); + }); + + test("rejects an agent that is not running with 400", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + const response = await post(url, "/api/mcp", { agentName: "ghost", body: {} }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + success: false, + error: 'Agent "ghost" is not running', + }); + }); + + test("returns 502 when the agent responds with an error status", async () => { + const { url } = await inspectorFor(() => ({ status: 500, body: "down" })); + const response = await post(url, "/api/mcp", { agentName: "orders", body: {} }); + expect(response.status).toBe(502); + expect(await response.json()).toMatchObject({ success: false }); + }); + + test("returns 502 when the response exceeds the size limit", async () => { + const { url } = await inspectorFor(() => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: "x".repeat(10 * 1024 * 1024 + 1), + })); + const response = await post(url, "/api/mcp", { agentName: "orders", body: {} }); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + success: false, + error: "MCP response exceeded the size limit", + }); + }); +}); + +describe("GET /api/a2a/agent-card", () => { + test("returns the running agent's card", async () => { + const { url } = await inspectorFor((request) => { + expect(request.url).toBe("/.well-known/agent.json"); + return { + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "orders", version: "1.0" }), + }; + }); + const response = await get(url, "/api/a2a/agent-card?agentName=orders"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + card: { name: "orders", version: "1.0" }, + }); + }); + + test("requires the agentName query parameter", async () => { + const { url } = await inspectorFor(() => ({ status: 200, body: "{}" })); + const response = await get(url, "/api/a2a/agent-card"); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + success: false, + error: "agentName query parameter is required", + }); + }); + + test("returns 502 when the card is not available", async () => { + const { url } = await inspectorFor(() => ({ status: 404, body: "missing" })); + const response = await get(url, "/api/a2a/agent-card?agentName=orders"); + expect(response.status).toBe(502); + expect(await response.json()).toMatchObject({ success: false }); + }); +}); diff --git a/src/core/dev/inspector/proxies.ts b/src/core/dev/inspector/proxies.ts new file mode 100644 index 000000000..fa47c2ea7 --- /dev/null +++ b/src/core/dev/inspector/proxies.ts @@ -0,0 +1,90 @@ +import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; +import { apiError, asString, errorMessage, iterateBody, json, parseJsonBody } from "./respond"; +import type { InspectorDeps } from "./types"; + +/** Cap the buffered MCP response so a runaway agent cannot exhaust memory. */ +const MAX_MCP_RESPONSE_BYTES = 10 * 1024 * 1024; + +export async function handleMcpProxy( + deps: InspectorDeps, + request: HttpRequest, +): Promise { + const parsed = parseJsonBody(request.body); + if (!parsed) return apiError(400, "Invalid JSON"); + const agentName = asString(parsed.agentName); + if (!agentName) return apiError(400, "agentName is required"); + const body = parsed.body; + if (!body || typeof body !== "object") return apiError(400, "body is required"); + const sessionId = asString(parsed.sessionId); + + const running = deps.supervisor.running(agentName); + if (!running) return apiError(400, `Agent "${agentName}" is not running`); + + let mcpResponse: Response; + try { + mcpResponse = await fetch(`http://127.0.0.1:${running.port}/mcp`, { + method: "POST", + // Accept JSON only because this proxy buffers the full response and never streams. + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...(sessionId !== undefined && { "mcp-session-id": sessionId }), + }, + body: JSON.stringify(body), + signal: request.signal, + }); + } catch (error) { + return apiError(502, `Failed to connect to MCP agent: ${errorMessage(error)}`); + } + if (!mcpResponse.ok) return apiError(502, `MCP server returned status ${mcpResponse.status}`); + + const responseText = await readCapped(mcpResponse, MAX_MCP_RESPONSE_BYTES); + if (responseText === undefined) return apiError(502, "MCP response exceeded the size limit"); + + const responseSessionId = mcpResponse.headers.get("mcp-session-id") ?? undefined; + let result: unknown; + try { + result = JSON.parse(responseText); + } catch { + result = responseText; + } + return json(200, { success: true, result, sessionId: responseSessionId }); +} + +export async function handleA2aAgentCard( + deps: InspectorDeps, + url: URL, + signal: AbortSignal, +): Promise { + const agentName = url.searchParams.get("agentName") ?? undefined; + if (!agentName) return apiError(400, "agentName query parameter is required"); + + const running = deps.supervisor.running(agentName); + if (!running) return apiError(400, `Agent "${agentName}" is not running`); + + try { + const cardResponse = await fetch(`http://127.0.0.1:${running.port}/.well-known/agent.json`, { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + if (!cardResponse.ok) { + return apiError(502, `Agent card not available (${cardResponse.status})`); + } + const card: unknown = await cardResponse.json(); + return json(200, { success: true, card }); + } catch (error) { + return apiError(502, `Failed to fetch agent card: ${errorMessage(error)}`); + } +} + +async function readCapped(response: Response, maxBytes: number): Promise { + const chunks: Uint8Array[] = []; + let size = 0; + for await (const value of iterateBody(response.body)) { + size += value.length; + if (size > maxBytes) return undefined; + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/core/dev/inspector/resources.test.ts b/src/core/dev/inspector/resources.test.ts new file mode 100644 index 000000000..ff3c01cf7 --- /dev/null +++ b/src/core/dev/inspector/resources.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; +import type { Project } from "../../../handlers/project/types"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { handleResources } from "./resources"; +import { fakeSupervisor } from "./testkit"; +import type { InspectorDeps } from "./types"; + +function deps(overrides: Partial = {}): InspectorDeps { + return { supervisor: fakeSupervisor(), ...overrides }; +} + +function project(): Project { + return { + name: "Demo", + rootPath: "/workspace/demo", + spec: ProjectSpecSchema.parse({ + name: "Demo", + version: 1, + managedBy: "CDK", + runtimes: [ + { + name: "orders", + build: "Container", + entrypoint: "index.ts", + codeLocation: "src", + envVars: [{ name: "STAGE", value: "dev" }], + }, + ], + harnesses: [{ name: "support", path: "harness/support" }], + memories: [ + { + name: "chat", + eventExpiryDuration: 30, + strategies: [{ type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }], + }, + ], + credentials: [{ authorizerType: "ApiKeyCredentialProvider", name: "stripe-key" }], + evaluators: [ + { + name: "quality", + level: "SESSION", + description: "Checks quality", + config: { + llmAsAJudge: { + model: "anthropic.claude-v2", + instructions: "Judge the answer", + ratingScale: { numerical: [{ value: 1, label: "bad", definition: "Bad answer" }] }, + }, + }, + }, + ], + onlineEvalConfigs: [ + { name: "prod_eval", agent: "orders", samplingRate: 10, evaluators: ["quality"] }, + ], + agentCoreGateways: [ + { + name: "gw", + targets: [ + { + name: "orders-target", + targetType: "lambda", + toolDefinitions: [ + { + name: "lookup", + description: "Look up an order", + inputSchema: { type: "object" }, + }, + ], + compute: { + host: "Lambda", + implementation: { language: "Python", path: "tools", handler: "handler.main" }, + pythonVersion: "PYTHON_3_12", + }, + }, + ], + }, + ], + mcpRuntimeTools: [ + { + name: "search-tool", + toolDefinition: { + name: "search", + description: "Search the catalog", + inputSchema: { type: "object" }, + }, + compute: { + host: "AgentCoreRuntime", + implementation: { language: "Python", path: "tools", handler: "handler.main" }, + }, + bindings: [{ runtimeName: "orders", envVarName: "SEARCH_URL" }], + }, + ], + unassignedTargets: [ + { + name: "catalog", + targetType: "smithyModel", + schemaSource: { inline: { path: "schema.smithy" } }, + }, + ], + policyEngines: [ + { + name: "guardrails", + description: "Access policies", + policies: [ + { + name: "allow_read", + description: "Allow reads", + statement: "permit(principal, action, resource);", + }, + ], + }, + ], + }), + }; +} + +describe("GET /api/resources", () => { + test("flattens the project spec into the resource graph", () => { + const response = handleResources(deps({ project: project() })); + expect(response.status).toBe(200); + expect(JSON.parse(response.body as string)).toEqual({ + success: true, + project: "Demo", + agents: [ + { + name: "orders", + build: "Container", + entrypoint: "index.ts", + codeLocation: "src", + runtimeVersion: "", + networkMode: "PUBLIC", + protocol: "HTTP", + envVars: ["STAGE"], + }, + ], + harnesses: [{ name: "support", model: "", tools: [] }], + memories: [ + { + name: "chat", + strategies: [{ type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }], + expiryDays: 30, + }, + ], + credentials: [{ name: "stripe-key", type: "ApiKeyCredentialProvider" }], + gateways: [{ name: "gw", targets: [{ name: "lookup", targetType: "lambda" }] }], + mcpRuntimeTools: [ + { name: "search-tool", bindings: [{ runtimeName: "orders", envVarName: "SEARCH_URL" }] }, + ], + evaluators: [ + { + name: "quality", + level: "SESSION", + description: "Checks quality", + configType: "llm-as-a-judge", + }, + ], + onlineEvalConfigs: [ + { name: "prod_eval", agent: "orders", evaluators: ["quality"], samplingRate: 10 }, + ], + policyEngines: [ + { + name: "guardrails", + description: "Access policies", + policies: [{ name: "allow_read", description: "Allow reads" }], + }, + ], + unassignedTargets: [{ name: "catalog", targetType: "smithyModel" }], + deploymentTargets: [], + }); + }); + + test("returns 404 when there is no project", () => { + const response = handleResources(deps()); + expect(response.status).toBe(404); + expect(JSON.parse(response.body as string)).toEqual({ + success: false, + error: "No agentcore project found", + }); + }); +}); diff --git a/src/core/dev/inspector/resources.ts b/src/core/dev/inspector/resources.ts new file mode 100644 index 000000000..e25c81845 --- /dev/null +++ b/src/core/dev/inspector/resources.ts @@ -0,0 +1,81 @@ +import type { HttpResponse } from "../../../io/httpServer"; +import { apiError, json } from "./respond"; +import type { InspectorDeps } from "./types"; + +export function handleResources(deps: InspectorDeps): HttpResponse { + const project = deps.project; + if (!project) return apiError(404, "No agentcore project found"); + + const spec = project.spec; + // The literal is the wire contract. The SPA depends on these exact field names. + const resources = { + success: true, + project: project.name, + agents: spec.runtimes.map((runtime) => ({ + name: runtime.name, + build: runtime.build, + entrypoint: runtime.entrypoint, + codeLocation: runtime.codeLocation, + runtimeVersion: runtime.runtimeVersion ?? "", + networkMode: runtime.networkMode ?? "PUBLIC", + protocol: runtime.protocol ?? "HTTP", + envVars: runtime.envVars?.map((envVar) => envVar.name) ?? [], + })), + // Project schema has no per-harness model or tool spec yet, so neutral defaults. + harnesses: spec.harnesses.map((harness) => ({ name: harness.name, model: "", tools: [] })), + memories: spec.memories.map((memory) => ({ + name: memory.name, + strategies: memory.strategies.map((strategy) => ({ + type: strategy.type, + namespaceTemplates: strategy.namespaceTemplates ?? strategy.namespaces ?? [], + })), + expiryDays: memory.eventExpiryDuration, + })), + credentials: spec.credentials.map((credential) => ({ + name: credential.name, + type: credential.authorizerType, + })), + gateways: spec.agentCoreGateways.map((gateway) => ({ + name: gateway.name, + targets: gateway.targets.map((target) => ({ + name: target.toolDefinitions?.[0]?.name ?? target.name, + targetType: target.targetType, + })), + })), + mcpRuntimeTools: (spec.mcpRuntimeTools ?? []).map((tool) => ({ + name: tool.name, + bindings: tool.bindings ?? [], + })), + evaluators: spec.evaluators.map((evaluator) => ({ + name: evaluator.name, + level: evaluator.level, + description: evaluator.description, + configType: evaluator.config.codeBased ? "code-based" : "llm-as-a-judge", + })), + onlineEvalConfigs: spec.onlineEvalConfigs.map((config) => ({ + name: config.name, + agent: config.agent, + evaluators: config.evaluators, + insights: config.insights, + samplingRate: config.samplingRate, + description: config.description, + logGroupNames: config.logGroupNames, + serviceNames: config.serviceNames, + })), + policyEngines: spec.policyEngines.map((engine) => ({ + name: engine.name, + description: engine.description, + policies: engine.policies.map((policy) => ({ + name: policy.name, + description: policy.description, + })), + })), + unassignedTargets: (spec.unassignedTargets ?? []).map((target) => ({ + name: target.name, + targetType: target.targetType, + })), + // Project schema has no aws-targets or deployed-state equivalent yet, so neutral defaults. + deploymentTargets: [], + }; + return json(200, resources); +} diff --git a/src/core/dev/inspector/respond.ts b/src/core/dev/inspector/respond.ts index 6c426b255..ba675901e 100644 --- a/src/core/dev/inspector/respond.ts +++ b/src/core/dev/inspector/respond.ts @@ -1,6 +1,7 @@ -/** Small response and parsing helpers shared by the Inspector route modules. */ import type { HttpResponse } from "../../../io/httpServer"; +const encoder = new TextEncoder(); + export function json(status: number, body: unknown): HttpResponse { return { status, @@ -13,21 +14,14 @@ export function apiError(status: number, error: string): HttpResponse { return json(status, { success: false, error }); } -/** Parse a JSON request body, or undefined when it is not valid JSON. */ export function parseJsonBody(body: Buffer): Record | undefined { try { const parsed: unknown = JSON.parse(body.toString()); if (parsed && typeof parsed === "object") return parsed as Record; - } catch { - // fall through - } + } catch {} return undefined; } -/** - * Parse an optional epoch-milliseconds query parameter. Returns the value (or - * undefined when absent) or an error response matching the reference wording. - */ export function parseTimeParam( url: URL, name: string, @@ -51,3 +45,66 @@ export function asString(value: unknown): string | undefined { export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +export function sse(body: AsyncIterable, sessionId?: string): HttpResponse { + return { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...(sessionId !== undefined && { "x-session-id": sessionId }), + }, + body, + }; +} + +export function sseEvent(payload: unknown): Uint8Array { + return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`); +} + +export async function* iterateBody( + stream: ReadableStream | null, +): AsyncGenerator { + if (!stream) return; + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } +} + +export async function* lines(stream: AsyncIterable): AsyncGenerator { + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }); + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + yield* parts; + } + buffer += decoder.decode(); + if (buffer) yield buffer; +} + +/** SSE framing: strip one optional space after `data:`, join multiple `data:` lines with newlines, end the event on a blank line, ignore non-data lines. */ +export async function* sseData(stream: AsyncIterable): AsyncGenerator { + let data: string[] = []; + for await (const raw of lines(stream)) { + const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + if (line === "") { + if (data.length > 0) { + yield data.join("\n"); + data = []; + } + } else if (line.startsWith("data:")) { + data.push(line.slice(5).replace(/^ /, "")); + } + } + if (data.length > 0) yield data.join("\n"); +} diff --git a/src/core/dev/inspector/server.ts b/src/core/dev/inspector/server.ts index 22b92bd9b..28470b6d8 100644 --- a/src/core/dev/inspector/server.ts +++ b/src/core/dev/inspector/server.ts @@ -1,30 +1,27 @@ /** - * The Agent Inspector request handler: a pure request → response function the - * dev command composes with io/startHttpServer. It ports the reference - * WebUIServer's security model (DNS-rebinding Host check, server-side origin - * allowlist, X-Agentcore-Local on POSTs, CORS preflight) and the routes the SPA - * calls this layer: status, on-demand start, trace reads, and the static SPA. - * Agent-proxy routes (invocations, MCP, A2A, resources) register in a later PR. + * The Agent Inspector request handler: a pure request to response function + * wrapping the security model (loopback Host check, origin allowlist, + * X-Agentcore-Local on POSTs) around the SPA, proxy, and trace routes. */ import { ResourceNotFoundError } from "../../../errors"; import type { HttpRequest, HttpRequestHandler, HttpResponse } from "../../../io/httpServer"; +import { handleInvocations } from "./invocations"; +import { handleMcpProxy, handleA2aAgentCard } from "./proxies"; +import { handleResources } from "./resources"; import { apiError, asString, errorMessage, json, parseJsonBody, parseTimeParam } from "./respond"; import type { InspectorDeps } from "./types"; -/** Origins the Vite dev server uses for the frontend HMR workflow. */ const DEV_SERVER_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173"]; -/** Loopback Host headers the DNS-rebinding guard accepts, port already stripped. */ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); -/** Newest traces returned per list poll — bounds a payload that carries full spans per row. */ +/** Bounds a payload that carries full spans per row. */ const TRACE_LIST_LIMIT = 200; -/** CSP for served HTML, blocking inline-script injection from agent responses. */ +/** Blocks inline-script injection from agent responses in served HTML. */ const CSP_HEADER = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:"; -/** CORS headers that never vary; only Access-Control-Allow-Origin is per-request. */ const STATIC_CORS_HEADERS = { "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, X-Agentcore-Local, Mcp-Session-Id", @@ -34,15 +31,12 @@ const STATIC_CORS_HEADERS = { export function createInspectorHandler(deps: InspectorDeps): HttpRequestHandler { return async (request) => { - // DNS rebinding protection — a custom domain resolving to 127.0.0.1 would - // bypass origin checks, so only loopback Host headers are accepted. + // A custom domain resolving to 127.0.0.1 would bypass origin checks, so only loopback Host headers are accepted. const host = request.headers.host ?? ""; const hostname = host.replace(/:\d+$/, ""); if (!LOOPBACK_HOSTS.has(hostname)) return forbidden("Forbidden"); - // Server-side origin validation — CORS headers alone only stop the browser - // from reading responses; the request's side effects (starting agents, - // invoking with AWS credentials) must be blocked before any handler runs. + // CORS only stops the browser reading responses, so side effects (starting agents, invoking with AWS credentials) must be blocked here before any handler runs. const origin = asString(request.headers.origin); const allowedOrigins = [`http://${host}`, ...DEV_SERVER_ORIGINS]; if (origin && !allowedOrigins.includes(origin)) return forbidden("Forbidden"); @@ -50,8 +44,7 @@ export function createInspectorHandler(deps: InspectorDeps): HttpRequestHandler if (request.method === "OPTIONS") return { status: 204, headers: cors }; - // Require a custom header on all POSTs: it forces a CORS preflight (which - // the origin check blocks cross-origin), closing the simple-form-POST gap. + // The custom header forces a CORS preflight that the origin check blocks cross-origin, closing the simple-form-POST gap. if (request.method === "POST" && !request.headers["x-agentcore-local"]) { return withHeaders(forbidden("Forbidden: missing X-Agentcore-Local header"), cors); } @@ -70,6 +63,12 @@ async function route(deps: InspectorDeps, request: HttpRequest): Promise ({ name, buildType, protocol })), @@ -96,7 +94,6 @@ function handleStatus(deps: InspectorDeps): HttpResponse { return json(200, status); } -/** POST /api/start — start an agent on demand; concurrent starts share one attempt. */ async function handleStart(deps: InspectorDeps, request: HttpRequest): Promise { const agentName = asString(parseJsonBody(request.body)?.agentName); if (!agentName) return apiError(400, "agentName is required"); @@ -110,7 +107,6 @@ async function handleStart(deps: InspectorDeps, request: HttpRequest): Promise { if (!deps.traces) return apiError(404, "Traces are not available"); @@ -132,7 +128,6 @@ async function handleListTraces(deps: InspectorDeps, url: URL): Promise { if (!deps.traces) return apiError(404, "Traces are not available"); @@ -148,7 +143,6 @@ async function handleGetTrace(deps: InspectorDeps, url: URL): Promise { - // A present origin already passed the allowlist check above, so echo it back; - // otherwise fall back to the primary allowed origin. + // A present origin already passed the allowlist check above, so it is safe to echo back. return { "Access-Control-Allow-Origin": origin || allowedOrigins[0]!, ...STATIC_CORS_HEADERS }; } diff --git a/src/io/httpServer.test.ts b/src/io/httpServer.test.ts index 8325451e6..b9660b0f8 100644 --- a/src/io/httpServer.test.ts +++ b/src/io/httpServer.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { type HttpServerHandle, startHttpServer } from "./httpServer"; +import type { ServerResponse } from "node:http"; +import { type HttpServerHandle, startHttpServer, stream } from "./httpServer"; let handle: HttpServerHandle | undefined; @@ -71,6 +72,37 @@ describe("startHttpServer", () => { expect((await fetch(`http://127.0.0.1:${handle.port}/`)).status).toBe(200); }); + test("streams an async-iterable body chunk by chunk", async () => { + handle = await startHttpServer(() => ({ + status: 200, + headers: { "Content-Type": "text/plain" }, + body: (async function* () { + yield new TextEncoder().encode("one\n"); + yield new TextEncoder().encode("two\n"); + })(), + })); + + const response = await fetch(`http://127.0.0.1:${handle.port}/`); + expect(response.status).toBe(200); + expect(await response.text()).toBe("one\ntwo\n"); + }); + + test("a handler stream that throws after starting ends the response without crashing", async () => { + handle = await startHttpServer(() => ({ + status: 200, + headers: { "Content-Type": "text/plain" }, + body: (async function* () { + yield new TextEncoder().encode("partial"); + throw new Error("mid-stream boom"); + })(), + })); + + const response = await fetch(`http://127.0.0.1:${handle.port}/`); + expect(response.status).toBe(200); + expect(await response.text()).toBe("partial"); + expect((await fetch(`http://127.0.0.1:${handle.port}/`)).status).toBe(200); + }); + test("aborting the signal closes the server", async () => { const controller = new AbortController(); const server = await startHttpServer(() => ({ status: 200 }), { signal: controller.signal }); @@ -91,3 +123,77 @@ describe("startHttpServer", () => { expect(startHttpServer(() => ({ status: 200 }), { port: handle.port })).rejects.toThrow(); }); }); + +describe("stream", () => { + function fakeResponse() { + const written: string[] = []; + let ended = false; + const response = { + write: (chunk: Uint8Array) => { + written.push(new TextDecoder().decode(chunk)); + return true; + }, + end: () => { + ended = true; + }, + } as unknown as ServerResponse; + return { response, written, ended: () => ended }; + } + + test("pumps every chunk then ends the response", async () => { + const { response, written, ended } = fakeResponse(); + async function* body() { + yield new TextEncoder().encode("a"); + yield new TextEncoder().encode("b"); + } + await stream(body(), response, new AbortController().signal); + expect(written).toEqual(["a", "b"]); + expect(ended()).toBe(true); + }); + + test("waits for a drain event when the socket applies backpressure", async () => { + const written: string[] = []; + let drainListener: (() => void) | undefined; + let ended = false; + const response = { + write: (chunk: Uint8Array) => { + written.push(new TextDecoder().decode(chunk)); + // First write reports a full buffer, so the pump must await drain before continuing. + return written.length > 1; + }, + once: (event: string, listener: () => void) => { + if (event === "drain") drainListener = listener; + }, + off: () => {}, + end: () => { + ended = true; + }, + } as unknown as ServerResponse; + + async function* body() { + yield new TextEncoder().encode("a"); + yield new TextEncoder().encode("b"); + } + const pumped = stream(body(), response, new AbortController().signal); + await Bun.sleep(0); + expect(drainListener).toBeDefined(); + drainListener?.(); + await pumped; + + expect(written).toEqual(["a", "b"]); + expect(ended).toBe(true); + }); + + test("stops writing and skips end() once the signal aborts mid-stream", async () => { + const controller = new AbortController(); + const { response, written, ended } = fakeResponse(); + async function* body() { + yield new TextEncoder().encode("a"); + controller.abort(); + yield new TextEncoder().encode("b"); + } + await stream(body(), response, controller.signal); + expect(written).toEqual(["a"]); + expect(ended()).toBe(false); + }); +}); diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts index 142ae6fd0..c32c5846b 100644 --- a/src/io/httpServer.ts +++ b/src/io/httpServer.ts @@ -1,5 +1,4 @@ -// Uses node:http rather than Bun.serve because the npm bundle targets Node, -// where Bun APIs are absent (same constraint as exec.ts). +// Uses node:http rather than Bun.serve because the npm bundle targets Node, where Bun APIs are absent. import { type IncomingHttpHeaders, type IncomingMessage, @@ -16,36 +15,29 @@ export interface HttpRequest { url: string; headers: IncomingHttpHeaders; body: Buffer; + signal: AbortSignal; } export interface HttpResponse { status: number; headers?: Record; - body?: string | Buffer; + body?: string | Buffer | AsyncIterable; } export type HttpRequestHandler = (request: HttpRequest) => HttpResponse | Promise; export interface HttpServerHandle { - /** The port the server is listening on. */ port: number; - /** Stops accepting connections and closes active ones. Idempotent. */ close(): Promise; } -/** - * Starts an HTTP server for local dev tooling. Binds `host` (default 127.0.0.1) - * on the given port (0 lets the OS assign one). Handler errors become plain 500s; - * oversized bodies become 413s. Aborting the signal closes the server. A wider - * bind such as 0.0.0.0 is only for reaching the server from a container. - */ +// Binds 127.0.0.1 by default. A wider bind like 0.0.0.0 is only for reaching the server from a container. export async function startHttpServer( handler: HttpRequestHandler, options: { port?: number; host?: string; signal?: AbortSignal } = {}, ): Promise { const server = createServer((request, response) => { - // A dropped connection mid-response can reject here; swallow it so a client - // that disconnects can never take down the whole dev command. + // Swallow rejections so a client that disconnects mid-response cannot take down the dev command. void respond(handler, request, response).catch(() => {}); }); @@ -68,12 +60,15 @@ async function respond( request: IncomingMessage, response: ServerResponse, ): Promise { + // Under Bun's node:http a mid-stream disconnect is not surfaced, so this teardown is a no-op there and leaks a local dev fetch until it finishes. + const controller = new AbortController(); + response.on("close", () => controller.abort()); + let body: Buffer; try { body = await readBody(request); } catch (error) { - // Answer before closing: destroying the socket first surfaces as a connection - // reset, which many clients treat as transient and silently retry. + // Answer before closing. Destroying the socket first surfaces as a connection reset, which many clients silently retry. const status = error instanceof BodyTooLargeError ? 413 : 400; response.writeHead(status, { Connection: "close" }).end(() => request.destroy()); return; @@ -85,12 +80,16 @@ async function respond( url: request.url ?? "/", headers: request.headers, body, + signal: controller.signal, }); response.writeHead(result.status, result.headers); - response.end(result.body); + if (isAsyncIterable(result.body)) { + await stream(result.body, response, controller.signal); + } else { + response.end(result.body); + } } catch { - // Once any byte is written, writeHead throws, so only send the 500 when the - // response has not started; otherwise just close what is already open. + // Once any byte is written, writeHead throws, so only send the 500 before the response has started. if (response.headersSent) { response.end(); return; @@ -100,6 +99,35 @@ async function respond( } } +function isAsyncIterable(body: HttpResponse["body"]): body is AsyncIterable { + return typeof body === "object" && body !== null && Symbol.asyncIterator in body; +} + +export async function stream( + body: AsyncIterable, + response: ServerResponse, + signal: AbortSignal, +): Promise { + for await (const chunk of body) { + if (signal.aborted) break; + if (!response.write(chunk)) await drain(response, signal); + } + if (!signal.aborted) response.end(); +} + +function drain(response: ServerResponse, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + response.off("drain", done); + signal.removeEventListener("abort", done); + resolve(); + }; + response.once("drain", done); + signal.addEventListener("abort", done, { once: true }); + }); +} + class BodyTooLargeError extends Error {} function readBody(request: IncomingMessage): Promise {