diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 5e56ef200c..e9fd35ce38 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -241,6 +241,14 @@ function canonicalize(value: unknown): string { return `{${entries.join(",")}}`; } +/** + * Null-body statuses the `Response` constructor can represent (the Fetch + * spec also defines 101, but the constructor rejects it by status range). + * The constructor throws "Invalid response status code" when one of these + * carries a body, so the http.fetch shim rebuilds them with `null` instead. + */ +const NULL_BODY_STATUSES: ReadonlySet = new Set([204, 205, 304]); + export function isWorkerEntrypoint(entry: string, moduleUrl: string): boolean { const thisFile = realpathOrResolvedPath(fileURLToPath(moduleUrl)); const entryPath = realpathOrResolvedPath(entry); @@ -609,8 +617,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost init: Object.keys(serializedInit).length > 0 ? serializedInit : undefined, }); - // Reconstruct a Response-like object from the serialized result - return new Response(result.body, { + // Reconstruct a Response-like object from the serialized result. + // The Fetch spec forbids a body on null-body statuses, so + // `new Response("")` would throw for a successful 204 No Content + // and the plugin would report a landed write as failed. Pass + // `null` for those statuses, matching global fetch. + return new Response(NULL_BODY_STATUSES.has(result.status) ? null : result.body ?? null, { status: result.status, statusText: result.statusText, headers: result.headers, diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index 6652995631..00e2726830 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -1098,3 +1098,157 @@ describe("worker duplex channel dispatch", () => { } }); }); + +describe("worker http.fetch response rebuild", () => { + // The host serializes the upstream reply as + // `{ status, statusText, headers, body }`. The worker rebuilds a `Response` + // from those fields. The Fetch spec forbids a body on null-body + // statuses (204, 205, 304), so a plain `new Response(body)` throws for a + // successful 204 No Content and the plugin reports a landed write as failed. + // The shim must drop the serialized body for those statuses, matching what + // global `fetch` returns for the same upstream reply. + const hostReplies = new Map; body: string }>([ + ["https://example.test/write-204", { status: 204, statusText: "No Content", headers: {}, body: "" }], + ["https://example.test/write-205", { status: 205, statusText: "Reset Content", headers: {}, body: "" }], + ["https://example.test/cache-304", { status: 304, statusText: "Not Modified", headers: {}, body: "" }], + ["https://example.test/read-200", { status: 200, statusText: "OK", headers: { "content-type": "application/json" }, body: "{\"ok\":true}" }], + ]); + + function makeWorker() { + const hostToWorker = new PassThrough(); + const workerToHost = new PassThrough(); + const hostReadline = createInterface({ input: workerToHost }); + const pending = new Map void>(); + let nextRequestId = 1; + + const plugin = definePlugin({ + async setup(ctx) { + ctx.actions.register("http-probe", async (params) => { + const response = await ctx.http.fetch(String(params.url), { + method: "PUT", + body: JSON.stringify({ probe: true }), + }); + return { + status: response.status, + ok: response.ok, + statusText: response.statusText, + contentType: response.headers.get("content-type"), + bodyText: await response.text(), + }; + }); + }, + }); + + const worker = startWorkerRpcHost({ + plugin, + stdin: hostToWorker, + stdout: workerToHost, + }); + + function callWorker(method: string, params: unknown) { + const id = `host-${nextRequestId++}`; + const result = new Promise((resolve, reject) => { + pending.set(id, (response) => { + if ("error" in response && response.error) { + reject(new Error(response.error.message)); + return; + } + resolve((response as { result?: unknown }).result); + }); + }); + hostToWorker.write(serializeMessage(createRequest(method, params, id))); + return result; + } + + hostReadline.on("line", (line) => { + const message = parseMessage(line); + if (isJsonRpcResponse(message)) { + pending.get(String(message.id))?.(message); + pending.delete(String(message.id)); + return; + } + if (!isJsonRpcRequest(message)) return; + if (message.method !== "http.fetch") return; + const url = String((message.params as { url?: unknown }).url ?? ""); + const reply = hostReplies.get(url); + if (!reply) { + hostToWorker.write(serializeMessage(createErrorResponse( + message.id, + PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + `no canned reply for ${url}`, + ))); + return; + } + hostToWorker.write(serializeMessage(createSuccessResponse(message.id, reply))); + }); + + return { worker, hostReadline, hostToWorker, workerToHost, callWorker }; + } + + async function probeUrl(url: string) { + const { worker, hostReadline, hostToWorker, workerToHost, callWorker } = makeWorker(); + try { + await callWorker("initialize", { + manifest: { + id: "paperclip.http-fetch-null-body", + apiVersion: 1, + version: "1.0.0", + displayName: "HTTP fetch null-body test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["http.outbound"], + entrypoints: {}, + }, + config: {}, + databaseNamespace: null, + }); + return await callWorker("performAction", { key: "http-probe", params: { url } }); + } finally { + worker.stop(); + hostReadline.close(); + hostToWorker.destroy(); + workerToHost.destroy(); + } + } + + it("rebuilds a 204 No Content without throwing on the null-body rule", async () => { + await expect(probeUrl("https://example.test/write-204")).resolves.toEqual({ + status: 204, + ok: true, + statusText: "No Content", + contentType: null, + bodyText: "", + }); + }); + + it("rebuilds a 205 Reset Content without throwing on the null-body rule", async () => { + await expect(probeUrl("https://example.test/write-205")).resolves.toEqual({ + status: 205, + ok: true, + statusText: "Reset Content", + contentType: null, + bodyText: "", + }); + }); + + it("rebuilds a 304 Not Modified without throwing on the null-body rule", async () => { + await expect(probeUrl("https://example.test/cache-304")).resolves.toEqual({ + status: 304, + ok: false, + statusText: "Not Modified", + contentType: null, + bodyText: "", + }); + }); + + it("keeps the serialized body for ordinary 200 replies", async () => { + await expect(probeUrl("https://example.test/read-200")).resolves.toEqual({ + status: 200, + ok: true, + statusText: "OK", + contentType: "application/json", + bodyText: "{\"ok\":true}", + }); + }); +});