1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeHttp from "node:http";
3import * as NodeNet from "node:net";
4import { afterEach, describe, expect, it } from "vite-plus/test";
5
6import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
7
8const servers: NodeHttp.Server[] = [];
9
10function listen(server: NodeHttp.Server): Promise<number> {
11 servers.push(server);
12 return new Promise((resolve) => {
13 server.listen(0, "127.0.0.1", () => {
14 resolve((server.address() as NodeNet.AddressInfo).port);
15 });
16 });
17}
18
19function fetchStatus(port: number, path: string): Promise<number> {
20 return new Promise((resolve, reject) => {
21 const request = NodeHttp.get({ host: "127.0.0.1", port, path }, (response) => {
22 response.resume();
23 resolve(response.statusCode ?? 0);
24 });
25 request.on("error", reject);
26 request.setTimeout(5_000, () => reject(new Error("request timed out")));
27 });
28}
29
30afterEach(() => {
31 for (const server of servers.splice(0)) {
32 server.close();
33 }
34});
35
36describe("guardHttpResponseWriteErrors", () => {
37 it("contains an upgrade socket write failure instead of crashing the process", async () => {
38 const writeErrors: unknown[] = [];
39 const failureObserved = Promise.withResolvers<void>();
40 const server = guardHttpResponseWriteErrors(NodeHttp.createServer(), (error) => {
41 writeErrors.push(error);
42 failureObserved.resolve();
43 });
44
45 server.on("upgrade", (_request, socket) => {
46 // Simulate the client vanishing while the auth rejection response is
47 // written to the upgrade socket: the write failure surfaces as an
48 // "error" event on a socket Node's http server no longer listens to.
49 socket.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
50 });
51
52 const port = await listen(server);
53
54 const client = NodeNet.connect(port, "127.0.0.1", () => {
55 client.write(
56 [
57 "GET /rpc HTTP/1.1",
58 "Host: 127.0.0.1",
59 "Connection: Upgrade",
60 "Upgrade: websocket",
61 "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
62 "Sec-WebSocket-Version: 13",
63 "",
64 "",
65 ].join("\r\n"),
66 );
67 });
68 client.on("error", () => {});
69
70 await failureObserved.promise;
71 client.destroy();
72
73 expect(writeErrors).toHaveLength(1);
74 expect(writeErrors[0]).toBeInstanceOf(Error);
75 expect((writeErrors[0] as NodeJS.ErrnoException).code).toBe("EPIPE");
76
77 // The process survived the failed write and the server keeps serving.
78 server.on("request", (_request, response) => {
79 response.writeHead(200, { "content-type": "text/plain" });
80 response.end("ok");
81 });
82 await expect(fetchStatus(port, "/")).resolves.toBe(200);
83 });
84
85 it("arms every response with an error listener without disturbing normal traffic", async () => {
86 const writeErrors: unknown[] = [];
87 let responseErrorListeners = -1;
88 const server = guardHttpResponseWriteErrors(NodeHttp.createServer(), (error) => {
89 writeErrors.push(error);
90 });
91
92 server.on("request", (_request, response) => {
93 responseErrorListeners = response.listenerCount("error");
94 response.writeHead(200, { "content-type": "text/plain" });
95 response.end("ok");
96 });
97
98 const port = await listen(server);
99
100 await expect(fetchStatus(port, "/")).resolves.toBe(200);
101 expect(responseErrorListeners).toBeGreaterThan(0);
102 expect(writeErrors).toEqual([]);
103 });
104});