Skip to content

Commit e36e23c

Browse files
lxsmnsycclaude
andauthored
fix(1.x): abort event.request.signal when the client disconnects (#2340)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f4d42c6 commit e36e23c

3 files changed

Lines changed: 111 additions & 1 deletion

File tree

‎.changeset/request-abort-signal.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
Make `event.request.signal` abort when the client disconnects on Node. The request was built without a signal tied to the connection, so it never aborted and API routes kept running after the client went away. On web runtimes the platform signal still applies.

‎packages/start/src/server/fetchEvent.spec.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EventEmitter } from "node:events";
12
import { H3Event } from "vinxi/http";
23
import { beforeEach, describe, expect, it, vi } from "vitest";
34
import { cloneEvent, createFetchEvent, getFetchEvent, mergeResponseHeaders } from "./fetchEvent";
@@ -319,3 +320,75 @@ describe("fetchEvent", () => {
319320
});
320321
});
321322
});
323+
324+
describe("request abort signal", () => {
325+
const createNodeEvent = (request: Request) => {
326+
const res = Object.assign(new EventEmitter(), { writableEnded: false });
327+
const req = { errored: null as Error | null };
328+
const event = {
329+
node: { req, res },
330+
context: {},
331+
web: { request }
332+
} as unknown as H3Event;
333+
mockedVinxiHttp.getWebRequest.mockReturnValue(request);
334+
return { event, req, res };
335+
};
336+
337+
beforeEach(() => {
338+
vi.clearAllMocks();
339+
});
340+
341+
it("aborts when the response closes before it was sent", () => {
342+
const { event, res } = createNodeEvent(new Request("http://localhost/api"));
343+
const { request } = createFetchEvent(event);
344+
expect(request.signal.aborted).toBe(false);
345+
346+
res.emit("close");
347+
348+
expect(request.signal.aborted).toBe(true);
349+
});
350+
351+
it("does not abort when the response closes after it was sent", () => {
352+
const { event, res } = createNodeEvent(new Request("http://localhost/api"));
353+
const { request } = createFetchEvent(event);
354+
355+
res.writableEnded = true;
356+
res.emit("close");
357+
358+
expect(request.signal.aborted).toBe(false);
359+
});
360+
361+
it("aborts with the request error when the request failed", () => {
362+
const { event, req, res } = createNodeEvent(new Request("http://localhost/api"));
363+
const { request } = createFetchEvent(event);
364+
const error = new Error("socket hang up");
365+
366+
req.errored = error;
367+
res.emit("close");
368+
369+
expect(request.signal.aborted).toBe(true);
370+
expect(request.signal.reason).toBe(error);
371+
});
372+
373+
it("keeps the abort signal of the platform request", () => {
374+
const controller = new AbortController();
375+
const { event } = createNodeEvent(
376+
new Request("http://localhost/api", { signal: controller.signal })
377+
);
378+
const { request } = createFetchEvent(event);
379+
380+
controller.abort();
381+
382+
expect(request.signal.aborted).toBe(true);
383+
});
384+
385+
it("gives later readers the same request, with its body", async () => {
386+
const { event } = createNodeEvent(
387+
new Request("http://localhost/api", { method: "POST", body: "hello" })
388+
);
389+
const { request } = createFetchEvent(event);
390+
391+
expect(event.web!.request).toBe(request);
392+
expect(await request.text()).toBe("hello");
393+
});
394+
});

‎packages/start/src/server/fetchEvent.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,41 @@ import type { FetchEvent, ResponseStub } from "./types";
1515

1616
const fetchEventContext = "solidFetchEvent";
1717

18+
/**
19+
* On Node, the web request vinxi builds has a signal that never aborts, so a
20+
* handler cannot tell when the client goes away. This wires the signal to the
21+
* response closing before it was fully sent. On web runtimes the platform
22+
* request already has a working signal, and it is kept.
23+
*/
24+
function createWebRequest(event: H3Event): Request {
25+
const request = getWebRequest(event);
26+
const res = event.node?.res;
27+
if (!res || typeof res.once !== "function") {
28+
return request;
29+
}
30+
const controller = new AbortController();
31+
res.once("close", () => {
32+
const error = event.node.req.errored;
33+
if (error) {
34+
controller.abort(error);
35+
} else if (!res.writableEnded) {
36+
controller.abort();
37+
}
38+
});
39+
const wrapped = new Request(request, {
40+
signal: AbortSignal.any([request.signal, controller.signal])
41+
});
42+
// The wrapped request takes over the body of the original one, so later
43+
// calls to getWebRequest must get the wrapped request too.
44+
if (event.web) {
45+
event.web.request = wrapped;
46+
}
47+
return wrapped;
48+
}
49+
1850
export function createFetchEvent(event: H3Event): FetchEvent {
1951
return {
20-
request: getWebRequest(event),
52+
request: createWebRequest(event),
2153
response: createResponseStub(event),
2254
clientAddress: getRequestIP(event),
2355
locals: {},

0 commit comments

Comments
 (0)