Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/adapters/_node/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const NodeRequest: {
runtime: ServerRequest["runtime"];

#req: NodeServerRequest;
#res: NodeServerResponse | undefined;
#url?: URL;
#bodyStream?: ReadableStream | null;
#request?: globalThis.Request;
Expand All @@ -64,6 +65,7 @@ export const NodeRequest: {

constructor(ctx: NodeRequestContext) {
this.#req = ctx.req;
this.#res = ctx.res;
this.runtime = {
name: "node",
node: ctx,
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions test/_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
114 changes: 114 additions & 0 deletions test/node-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
abortFired = resolve;
});

let requestReceived: () => void;
const requestReceivedPromise = new Promise<void>((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
});
});
Loading