From 8ebd55723fe40ad4ccec6f12e1f040e159894970 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 2 Jun 2026 19:08:48 -0400 Subject: [PATCH] fix(dev): handle client disconnect in the dev proxy stream pump (#1418) (#1419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hono dev-proxy middleware pumped streaming /api responses to the Node ServerResponse without handling client disconnect. When the browser dropped a stream mid-flight (SSE close, navigation, HMR reload) `res` was destroyed and the next write failed with ERR_STREAM_DESTROYED, which the pump logged as an error. It also had no `error` listener (a failed write could surface as an uncaught exception) and no `close` listener (it kept reading upstream after the client left). Make the pump disconnect-aware: - Listen for res `close`/`error`; either cancels the upstream reader (resolving the pending read so the loop exits) and flips a `clientGone` flag. - Treat ERR_STREAM_DESTROYED / EPIPE / ECONNRESET as a normal end — not logged. - Only log genuinely unexpected stream errors. - Await each write so a slow client applies backpressure instead of the proxy buffering the whole stream. Dev-only plugin (excluded from the coverage gate — it needs a real Vite server to exercise). Verified via npm run validate. Closes #1418 Co-authored-by: Claude Opus 4.8 (1M context) --- clients/web/server/vite-hono-plugin.ts | 79 +++++++++++++++++--------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 4fa411cc..be57a063 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -175,39 +175,64 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { if (response.body) { res.flushHeaders?.(); const reader = response.body.getReader(); - const pump = async () => { + + // The browser can drop a streaming response mid-flight — an SSE + // connection closing, a navigation, or an HMR reload. Once it does, + // `res` is destroyed and further writes fail with + // ERR_STREAM_DESTROYED / EPIPE / ECONNRESET. Treat those as a + // normal end: cancel the upstream reader and stop quietly rather + // than logging a spurious error. (Cancelling resolves the awaiting + // `reader.read()`, so the pump loop exits on its own.) + const isDisconnect = (code?: string): boolean => + code === "ERR_STREAM_DESTROYED" || + code === "EPIPE" || + code === "ECONNRESET"; + + let clientGone = false; + const onClose = (): void => { + clientGone = true; + reader.cancel().catch(() => {}); + }; + // Without an `error` listener a failed write becomes an uncaught + // exception; absorb benign disconnects and log anything else. + const onError = (err: Error): void => { + clientGone = true; + reader.cancel().catch(() => {}); + if (!isDisconnect((err as NodeJS.ErrnoException).code)) { + console.error("[Hono Middleware] Response error:", err); + } + }; + res.on("close", onClose); + res.on("error", onError); + + const pump = async (): Promise => { try { - const { done, value } = await reader.read(); - if (done) { - res.end(); - } else { - res.write(Buffer.from(value), (err) => { - if (err) { - console.error("[Hono Middleware] Write error:", err); - reader.cancel().catch(() => {}); - res.end(); - } - }); - pump().catch((err) => { - console.error("[Hono Middleware] Pump error:", err); - reader.cancel().catch(() => {}); - res.end(); + while (true) { + const { done, value } = await reader.read(); + if (done || clientGone || res.destroyed) break; + // Await each write so a slow client applies backpressure + // instead of the proxy buffering the entire stream. + await new Promise((resolve, reject) => { + res.write(Buffer.from(value), (err) => + err ? reject(err) : resolve(), + ); }); } } catch (err) { - console.error("[Hono Middleware] Read error:", err); - reader.cancel().catch(() => {}); - res.end(); + const code = (err as NodeJS.ErrnoException | null)?.code; + if (!clientGone && !res.destroyed && !isDisconnect(code)) { + console.error("[Hono Middleware] Stream error:", err); + } + } finally { + res.off("close", onClose); + res.off("error", onError); + await reader.cancel().catch(() => {}); + if (!res.writableEnded && !res.destroyed) { + res.end(); + } } }; - // The recursive pump() awaits internally, but a sync throw before - // the first `await reader.read()` (e.g. a broken reader) would - // surface as an unhandled rejection without this kickoff catch. - pump().catch((err) => { - console.error("[Hono Middleware] Initial pump error:", err); - reader.cancel().catch(() => {}); - res.end(); - }); + void pump(); } else { res.end(); }