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) <noreply@anthropic.com>
This commit is contained in:
parent
8f6a2e0f78
commit
8ebd55723f
1 changed files with 52 additions and 27 deletions
|
|
@ -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<void> => {
|
||||
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<void>((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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue