From 1f1c3b83ef5986b9d08813feba05c39e29374a8f Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:58:56 -0400 Subject: [PATCH 1/2] fix(cloudflare): handle aborted response body in miniflare worker proxy Client disconnects mid-response were emitting an unhandled stream 'error' that killed the alchemy dev process. Swallow the abort on the body Readable so the proxy tears down the response instead. Co-authored-by: Cursor --- alchemy/src/cloudflare/miniflare/miniflare-worker-proxy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/alchemy/src/cloudflare/miniflare/miniflare-worker-proxy.ts b/alchemy/src/cloudflare/miniflare/miniflare-worker-proxy.ts index cb2ee2a665..48069c512a 100644 --- a/alchemy/src/cloudflare/miniflare/miniflare-worker-proxy.ts +++ b/alchemy/src/cloudflare/miniflare/miniflare-worker-proxy.ts @@ -249,7 +249,9 @@ const writeMiniflareResponseToNode = ( }); if (res.body) { - Readable.fromWeb(res.body).pipe(out, { end: true }); + const body = Readable.fromWeb(res.body); + body.on("error", () => out.destroy()); + body.pipe(out, { end: true }); } else { out.end(); } From 82b4d3242e8c5e317d829c2571b5c48d105f6e90 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:02:28 -0400 Subject: [PATCH 2/2] test(cloudflare): add regression for mid-response abort in miniflare proxy Use a slow streaming worker and destroy connections on first body byte so the race reproduces reliably under Node, including on fast machines where early AbortController aborts often miss the pipe window. Co-authored-by: Cursor --- .../cloudflare/miniflare-worker-proxy.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 alchemy/test/cloudflare/miniflare-worker-proxy.test.ts diff --git a/alchemy/test/cloudflare/miniflare-worker-proxy.test.ts b/alchemy/test/cloudflare/miniflare-worker-proxy.test.ts new file mode 100644 index 0000000000..be5f40aad3 --- /dev/null +++ b/alchemy/test/cloudflare/miniflare-worker-proxy.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import * as miniflare from "miniflare"; +import http from "node:http"; +import { createMiniflareWorkerProxy } from "../../src/cloudflare/miniflare/miniflare-worker-proxy.ts"; + +/** + * Regression for https://github.com/alchemy-run/alchemy/issues/938 + * + * Client disconnect mid-response aborts Miniflare dispatch; the response body + * Readable then emits 'error'. Without a listener, Node treats that as an + * unhandled 'error' and kills the process (taking Vite + the whole `alchemy + * dev` session with it). + * + * Note: Bun swallows these stream errors, so this must run under Node (Vitest). + */ +describe("MiniflareWorkerProxy", () => { + it("survives client disconnects mid-response without unhandled stream errors", async () => { + const workerName = "abort-body-worker"; + const mf = new miniflare.Miniflare({ + workers: [ + { + name: workerName, + modules: true, + script: ` + export default { + async fetch() { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + for (let i = 0; i < 200; i++) { + controller.enqueue(encoder.encode("x".repeat(1024))); + await new Promise((r) => setTimeout(r, 20)); + } + controller.close(); + }, + }); + return new Response(stream, { + headers: { "Content-Type": "text/plain" }, + }); + }, + }; + `, + compatibilityDate: "2024-11-18", + }, + ], + }); + + await mf.ready; + + const proxy = await createMiniflareWorkerProxy({ + port: 0, + getWorkerName: () => workerName, + getMiniflare: async () => mf, + mode: "local", + }); + + const uncaught: Error[] = []; + const onUncaught = (error: Error) => { + uncaught.push(error); + }; + // Collect instead of letting Node terminate the worker thread. + process.on("uncaughtException", onUncaught); + + try { + const abortMidFlight = () => + new Promise((resolve) => { + const req = http.request(proxy.url, { method: "GET" }, (res) => { + res.on("data", () => { + // Destroy as soon as body bytes arrive so the proxy is mid-pipe. + req.destroy(); + }); + res.on("error", () => resolve()); + res.on("close", () => resolve()); + }); + req.on("error", () => resolve()); + req.setTimeout(50, () => req.destroy()); + req.end(); + }); + + // Concurrent aborts; slow stream + destroy-on-first-byte makes this + // reliable even on fast machines (unlike aborting before headers). + for (let round = 0; round < 20; round++) { + await Promise.all(Array.from({ length: 8 }, () => abortMidFlight())); + } + + expect(uncaught).toEqual([]); + } finally { + process.off("uncaughtException", onUncaught); + await proxy.close(); + await mf.dispose(); + } + }); +});