diff --git a/src/adapters/_node/request.ts b/src/adapters/_node/request.ts index 654e7f17..cb83926f 100644 --- a/src/adapters/_node/request.ts +++ b/src/adapters/_node/request.ts @@ -56,6 +56,7 @@ export const NodeRequest: { runtime: ServerRequest["runtime"]; #req: NodeServerRequest; + #res: NodeServerResponse | undefined; #url?: URL; #bodyStream?: ReadableStream | null; #request?: globalThis.Request; @@ -64,6 +65,7 @@ export const NodeRequest: { constructor(ctx: NodeRequestContext) { this.#req = ctx.req; + this.#res = ctx.res; this.runtime = { name: "node", node: ctx, @@ -111,11 +113,33 @@ export const NodeRequest: { if (!this.#abortController) { this.#abortController = new AbortController(); const req = this.#req; + const res = this.#res; + const abortController = this.#abortController; + const abort = (err?: any) => { - this.#abortController?.abort?.(err); + abortController?.abort?.(err); }; + req.once("error", abort); - req.once("end", abort); + + if (res) { + // Primary path: detect client disconnect via response close + res.once("close", () => { + if (req.errored) { + abort(req.errored); + } else if (!res.writableEnded) { + abort(); + } + }); + } else { + // Fallback for request-only contexts (no response object) + req.once("close", () => { + if (!req.complete) { + // Request body wasn't fully received - client disconnected + abort(); + } + }); + } } return this.#abortController; } diff --git a/test/_tests.ts b/test/_tests.ts index 33293171..c9c35e2c 100644 --- a/test/_tests.ts +++ b/test/_tests.ts @@ -142,9 +142,9 @@ export function addTests(opts: { }); test("total aborts", async () => { - let expectedAbortCount = fetchCount; - if (opts.runtime === "bun") { - expectedAbortCount = 1; // Bun only aborts explicitly + let expectedAbortCount = 1; + if (opts.runtime === "deno") { + expectedAbortCount = fetchCount; // TODO: why? } const res = await fetch(url("/abort-log")); diff --git a/test/node-adapters.test.ts b/test/node-adapters.test.ts index 21732b79..ffa8aa6e 100644 --- a/test/node-adapters.test.ts +++ b/test/node-adapters.test.ts @@ -174,3 +174,117 @@ describe("adapters", () => { ); }); }); + +describe("request signal", () => { + test("should not fire abort signal on successful GET request", async () => { + let abortFired = false; + + const server = serve({ + port: 0, + fetch(request) { + request.signal.addEventListener("abort", () => { + abortFired = true; + }); + return new Response("ok"); + }, + }); + + await server.ready(); + + const res = await fetch(server.url!); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + + // Close the server and let all pending handlers flush + await server.close(); + + expect(abortFired).toBe(false); + }); + + test("should not fire abort signal on successful POST request", async () => { + let abortFired = false; + let receivedBody: string | undefined; + + const server = serve({ + port: 0, + async fetch(request) { + request.signal.addEventListener("abort", () => { + abortFired = true; + }); + receivedBody = await request.text(); + return new Response(`Received: ${receivedBody}`); + }, + }); + + await server.ready(); + + const res = await fetch(server.url!, { + method: "POST", + body: "test body", + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("Received: test body"); + expect(receivedBody).toBe("test body"); + + await server.close(); + + expect(abortFired).toBe(false); + }); + + test("should fire abort signal when client disconnects", async () => { + let abortFired: () => void; + const abortFiredPromise = new Promise((resolve) => { + abortFired = resolve; + }); + + let requestReceived: () => void; + const requestReceivedPromise = new Promise((resolve) => { + requestReceived = resolve; + }); + + const server = serve({ + port: 0, + fetch(request) { + request.signal.addEventListener("abort", () => { + abortFired(); + }); + requestReceived(); + + return new Response( + new ReadableStream({ + async pull(controller) { + if (request.signal.aborted) { + controller.close(); + return; + } + await new Promise((r) => setTimeout(r, 100)); + controller.enqueue(new TextEncoder().encode("data")); + }, + }), + ); + }, + }); + + await server.ready(); + + const controller = new AbortController(); + const fetchPromise = fetch(server.url!, { signal: controller.signal }); + + await requestReceivedPromise; + controller.abort(); + + // Wait for both client rejection and server-side abort + await fetchPromise.catch(() => {}); + await Promise.race([ + abortFiredPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Abort signal not fired within 1s")), + 1000, + ), + ), + ]); + + await server.close(true); // Force close all connections + }); +});