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) {
|
if (response.body) {
|
||||||
res.flushHeaders?.();
|
res.flushHeaders?.();
|
||||||
const reader = response.body.getReader();
|
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 {
|
try {
|
||||||
const { done, value } = await reader.read();
|
while (true) {
|
||||||
if (done) {
|
const { done, value } = await reader.read();
|
||||||
res.end();
|
if (done || clientGone || res.destroyed) break;
|
||||||
} else {
|
// Await each write so a slow client applies backpressure
|
||||||
res.write(Buffer.from(value), (err) => {
|
// instead of the proxy buffering the entire stream.
|
||||||
if (err) {
|
await new Promise<void>((resolve, reject) => {
|
||||||
console.error("[Hono Middleware] Write error:", err);
|
res.write(Buffer.from(value), (err) =>
|
||||||
reader.cancel().catch(() => {});
|
err ? reject(err) : resolve(),
|
||||||
res.end();
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
pump().catch((err) => {
|
|
||||||
console.error("[Hono Middleware] Pump error:", err);
|
|
||||||
reader.cancel().catch(() => {});
|
|
||||||
res.end();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Hono Middleware] Read error:", err);
|
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||||
reader.cancel().catch(() => {});
|
if (!clientGone && !res.destroyed && !isDisconnect(code)) {
|
||||||
res.end();
|
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
|
void pump();
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue