Open History to see the last commit
1// These guards run on the raw Node HTTP server before Effect handles requests.
2// @effect-diagnostics nodeBuiltinImport:off
3import type * as NodeHttp from "node:http";
4import type * as NodeStream from "node:stream";
5
6export function guardHttpResponseWriteErrors<T extends NodeHttp.Server>(
7 server: T,
8 onError?: (error: unknown) => void,
9): T {
10 server.on("request", (_request, response) => {
11 response.on("error", (error) => {
12 onError?.(error);
13 });
14 });
15 server.on("upgrade", (_request, socket) => {
16 socket.on("error", (error) => {
17 onError?.(error);
18 });
19 });
20 return server;
21}
22
23// cloudflared and Tailscale serve keep an idle connection to this server for 90
24// seconds. Node's 5 second default closes it first, so they can send a request
25// into a closed connection, and a slow client reconnects in the middle of a connect.
26const HTTP_KEEP_ALIVE_TIMEOUT_MS = 95_000;
27
28/** Install before `rejectRequestsUntilServing`, whose handoff starts at the next request listener. */
29export function keepIdleConnections<T extends NodeHttp.Server>(server: T): T {
30 server.keepAliveTimeout = HTTP_KEEP_ALIVE_TIMEOUT_MS;
31 // `close` drops only the connections idle at that moment. One that finishes
32 // its responses later would hold shutdown for the whole keep-alive time.
33 // `closeIdleConnections` cannot do this: it destroys a socket whose next
34 // pipelined response is ended but still has output to send.
35 const responsesInFlight = new WeakMap<object, number>();
36 server.on("request", (request, response) => {
37 const socket = request.socket;
38 responsesInFlight.set(socket, (responsesInFlight.get(socket) ?? 0) + 1);
39 response.once("close", () => {
40 const left = (responsesInFlight.get(socket) ?? 1) - 1;
41 responsesInFlight.set(socket, left);
42 // Pipelined requests share the socket, so the last response ends it.
43 if (left === 0 && !server.listening) socket.end();
44 });
45 });
46 return server;
47}
48
49/**
50 * NodeHttpServer listens before serve() attaches its handlers, so requests in
51 * between would hang. Install this after the write-error guards so only the
52 * serving handlers trigger the handoff.
53 */
54export function rejectRequestsUntilServing<T extends NodeHttp.Server>(server: T): T {
55 const rejectRequest = (_request: NodeHttp.IncomingMessage, response: NodeHttp.ServerResponse) => {
56 response.writeHead(503, { "retry-after": "1", connection: "close" }).end();
57 };
58 const rejectUpgrade = (_request: NodeHttp.IncomingMessage, socket: NodeStream.Duplex) => {
59 socket.end(
60 "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 1\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
61 );
62 };
63 server.on("request", rejectRequest);
64 server.on("upgrade", rejectUpgrade);
65 // Node emits newListener before adding the handler, making the handoff synchronous.
66 server.on("newListener", function onNewListener(event) {
67 if (event === "request") server.off("request", rejectRequest);
68 if (event === "upgrade") server.off("upgrade", rejectUpgrade);
69 if (
70 !server.listeners("request").includes(rejectRequest) &&
71 !server.listeners("upgrade").includes(rejectUpgrade)
72 ) {
73 server.off("newListener", onNewListener);
74 }
75 });
76 return server;
77}