diff --git a/src/adapters/_node/request.ts b/src/adapters/_node/request.ts index 19a60d60..b51b9469 100644 --- a/src/adapters/_node/request.ts +++ b/src/adapters/_node/request.ts @@ -24,6 +24,10 @@ export const NodeRequest: { class Request implements Partial { runtime: ServerRequest["runtime"]; + // Declared so the post-construction `request.waitUntil = ...` assignment + // in the adapters doesn't add a property (hidden-class transition) per + // request. + waitUntil?: ServerRequest["waitUntil"]; #req: NodeServerRequest; #url?: URL; @@ -134,6 +138,13 @@ export const NodeRequest: { return this.#bodyStream; } + // Buffer the raw request body once; consumers add their own single + // continuation (`.toString()` / `JSON.parse`) so no extra promise or + // microtask hop is introduced vs. inlining the read. + #readBuffered() { + return readBody(this.#req, this.#maxRequestBodySize); + } + text() { if (this.#request) { return this.#request.text(); @@ -141,14 +152,19 @@ export const NodeRequest: { if (this.#bodyStream !== undefined) { return this.#bodyStream ? new Response(this.#bodyStream).text() : Promise.resolve(""); } - return readBody(this.#req, this.#maxRequestBodySize).then((buf) => buf.toString()); + return this.#readBuffered().then((buf) => buf.toString()); } json() { if (this.#request) { return this.#request.json(); } - return this.text().then((text) => JSON.parse(text)); + if (this.#bodyStream !== undefined) { + return this.text().then((text) => JSON.parse(text)); + } + // Parse in a single continuation (readBody -> parse) instead of going + // through text() — one less promise + microtask hop per body read. + return this.#readBuffered().then((buf) => JSON.parse(buf.toString())); } get _request(): globalThis.Request { diff --git a/src/adapters/_node/send.ts b/src/adapters/_node/send.ts index 15f6b388..24b5601d 100644 --- a/src/adapters/_node/send.ts +++ b/src/adapters/_node/send.ts @@ -4,13 +4,66 @@ import type NodeHttp from "node:http"; import type { NodeServerResponse } from "../../types.ts"; import type { NodeResponse } from "./response.ts"; -export async function sendNodeResponse( +/** + * Sends a web `Response` to a Node.js `ServerResponse`. + * + * The returned promise resolves once the response has been fully sent + * (kept for `toNodeHandler` consumers that await completion). + */ +export function sendNodeResponse( nodeRes: NodeServerResponse, webRes: Response | NodeResponse, ): Promise { + try { + return _sendNodeResponse(nodeRes, webRes, false) || Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } +} + +/** + * Fire-and-forget variant for the internal `serve()` path: node:http ignores + * the request listener's return value, so tracking `end()` completion with a + * per-response Promise (and the microtask hops to settle it) is pure overhead + * there. Streaming bodies still return their tracking promise (it drives + * their own cleanup). + * + * A synchronous throw during serialization (e.g. an invalid header value in + * `writeHead`) must not escape the request listener — that would surface as an + * `uncaughtException` and take the process down. Guard it here and fail the + * single response instead. + * + * @internal + */ +export function sendNodeResponseDetached( + nodeRes: NodeServerResponse, + webRes: Response | NodeResponse, +): Promise | void { + try { + return _sendNodeResponse(nodeRes, webRes, true); + } catch (error) { + handleSendError(nodeRes, error); + } +} + +function handleSendError(nodeRes: NodeServerResponse, _error: unknown): void { + if (nodeRes.headersSent) { + // Response already committed — the only recovery is to tear down the socket. + nodeRes.destroy(); + } else { + nodeRes.statusCode = 500; + nodeRes.end(); + } +} + +function _sendNodeResponse( + nodeRes: NodeServerResponse, + webRes: Response | NodeResponse, + detached: boolean, +): Promise | void { if (!webRes) { nodeRes.statusCode = 500; - return endNodeResponse(nodeRes); + return endNodeResponse(nodeRes, detached); } // Fast path for NodeResponse @@ -31,7 +84,7 @@ export async function sendNodeResponse( } else { writeHead(nodeRes, res.status, res.statusText, res.headers); } - return endNodeResponse(nodeRes); + return endNodeResponse(nodeRes, detached); } const rawHeaders: string[] = []; @@ -40,7 +93,7 @@ export async function sendNodeResponse( } writeHead(nodeRes, webRes.status, webRes.statusText, rawHeaders); - return webRes.body ? streamBody(webRes.body, nodeRes) : endNodeResponse(nodeRes); + return webRes.body ? streamBody(webRes.body, nodeRes) : endNodeResponse(nodeRes, detached); } function writeHead( @@ -66,7 +119,11 @@ function writeHead( } } -function endNodeResponse(nodeRes: NodeServerResponse) { +function endNodeResponse(nodeRes: NodeServerResponse, detached?: boolean): Promise | void { + if (detached) { + nodeRes.end(); + return; + } return new Promise((resolve) => nodeRes.end(resolve)); } @@ -106,7 +163,7 @@ function pipeBody( stream.off("readable", onReadable); stream.destroy(); writeHead(nodeRes, 500, "Internal Server Error", []); - endNodeResponse(nodeRes).then(resolve); + (endNodeResponse(nodeRes) as Promise).then(resolve); } function onReadable() { stream.off("error", onEarlyError); diff --git a/src/adapters/node.ts b/src/adapters/node.ts index f3172e63..55004a94 100644 --- a/src/adapters/node.ts +++ b/src/adapters/node.ts @@ -1,4 +1,4 @@ -import { sendNodeResponse } from "./_node/send.ts"; +import { sendNodeResponseDetached } from "./_node/send.ts"; import { NodeRequest } from "./_node/request.ts"; import { fmtURL, @@ -82,9 +82,11 @@ class NodeServer implements Server { }); request.waitUntil = this.#wait?.waitUntil; const res = fetchHandler(request); + // node:http ignores the listener's return value — use the detached + // variant to skip the per-response end-tracking Promise. return res instanceof Promise - ? res.then((resolvedRes) => sendNodeResponse(nodeRes, resolvedRes)) - : sendNodeResponse(nodeRes, res); + ? res.then((resolvedRes) => sendNodeResponseDetached(nodeRes, resolvedRes)) + : sendNodeResponseDetached(nodeRes, res); }; this.node = { handler, server: undefined };