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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ const server = await listen(app, {
server.close();
```

`listen()` and `serve()` apply timeout options during `http.Server` construction and configure
Node's incomplete-connection check interval no slower than the smallest finite request/header
timeout (capped at one second). This makes the authored values enforceable for stalled headers and
bodies instead of relying on Node's much slower default checking interval. Use explicit finite
`requestTimeout` and `headersTimeout` values in production; `0` retains Node's disable semantics.

Pass an `AbortSignal` to integrate shutdown with your process lifecycle.

Enable the built-in `ws` transport with `websocket: true`. It defaults to a
Expand Down
42 changes: 26 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@askrjs/node",
"version": "0.0.11",
"version": "0.0.12",
"description": "Node http adapter for @askrjs/server",
"keywords": [
"askr",
Expand Down Expand Up @@ -58,7 +58,7 @@
},
"dependencies": {
"@askrjs/auth": ">=0.0.8 <0.1.0",
"@askrjs/server": ">=0.0.11 <0.1.0",
"@askrjs/server": ">=0.0.18 <0.1.0",
"@types/ws": "^8.18.1",
"ws": "^8.21.3"
},
Expand Down
4 changes: 2 additions & 2 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export interface ListenOptions extends NodeHandlerOptions {
backlog?: number;
/** Aborting this signal stops the server. */
signal?: AbortSignal;
/** Node HTTP server `requestTimeout`, in milliseconds. */
/** Node HTTP server `requestTimeout`, enforced from server construction, in milliseconds. */
requestTimeout?: number;
/** Node HTTP server `headersTimeout`, in milliseconds. */
/** Node HTTP server `headersTimeout`, enforced from server construction, in milliseconds. */
headersTimeout?: number;
/** Node HTTP server `keepAliveTimeout`, in milliseconds. */
keepAliveTimeout?: number;
Expand Down
7 changes: 5 additions & 2 deletions src/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ServerApp } from "@askrjs/server";
import type { ListenOptions } from "./contracts.js";
import { handlerOptionsForHost, resolveBindHost } from "./bind.js";
import { createNodeHandler } from "./handler.js";
import { applyServerTimeouts } from "./server-options.js";
import { applyServerTimeouts, resolveServerOptions } from "./server-options.js";
import { installWebSockets } from "./websocket.js";

/** A Node HTTP server that is guaranteed to be listening for connections. */
Expand All @@ -30,7 +30,10 @@ export function listen(app: ServerApp, options: ListenOptions = {}): Promise<Lis
options.signal?.throwIfAborted();
const host = resolveBindHost(options);
const handlerOptions = handlerOptionsForHost(options, host);
const server = createServer(createNodeHandler(app, handlerOptions));
const server = createServer(
resolveServerOptions(options),
createNodeHandler(app, handlerOptions),
);
applyServerTimeouts(server, options);
const webSockets = options.websocket
? installWebSockets(
Expand Down
4 changes: 2 additions & 2 deletions src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { ServeOptions, ServedApplication } from "./contracts.js";
import { formatHostForUrl, handlerOptionsForHost, resolveBindHost } from "./bind.js";
import { createNodeHandler } from "./handler.js";
import { prepareNodeHandlerOptions, resolveNodeRequestUrl } from "./request.js";
import { applyServerTimeouts } from "./server-options.js";
import { applyServerTimeouts, resolveServerOptions } from "./server-options.js";
import { installWebSockets } from "./websocket.js";

const mimeTypes: Readonly<Record<string, string>> = {
Expand Down Expand Up @@ -97,7 +97,7 @@ export async function serve(
},
handlerOptions,
);
const server = createServer(async (request, response) => {
const server = createServer(resolveServerOptions(options), async (request, response) => {
if (!root) {
applicationHandler(request, response);
return;
Expand Down
22 changes: 21 additions & 1 deletion src/server-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Server } from "node:http";
import type { Server, ServerOptions } from "node:http";
import type { ListenOptions } from "./contracts.js";

function timeout(value: number | undefined, name: string): number | undefined {
Expand All @@ -8,6 +8,26 @@ function timeout(value: number | undefined, name: string): number | undefined {
return value;
}

export function resolveServerOptions(options: ListenOptions): ServerOptions {
const requestTimeout = timeout(options.requestTimeout, "requestTimeout");
const headersTimeout = timeout(options.headersTimeout, "headersTimeout");
const keepAliveTimeout = timeout(options.keepAliveTimeout, "keepAliveTimeout");
const enforced = [requestTimeout, headersTimeout].filter(
(value): value is number => value !== undefined && value > 0,
);
return {
requestTimeout:
requestTimeout !== undefined &&
headersTimeout !== undefined &&
headersTimeout > requestTimeout
? headersTimeout
: requestTimeout,
headersTimeout,
keepAliveTimeout,
connectionsCheckingInterval: enforced.length ? Math.min(1_000, ...enforced) : undefined,
};
}

export function applyServerTimeouts(server: Server, options: ListenOptions): void {
const requestTimeout = timeout(options.requestTimeout, "requestTimeout");
const headersTimeout = timeout(options.headersTimeout, "headersTimeout");
Expand Down
74 changes: 74 additions & 0 deletions tests/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRouter, createServerApp } from "@askrjs/server";
import { runAdapterConformance } from "@askrjs/server/testing";
import { describe, expect, it } from "vitest";
import WebSocket from "ws";
import { formatHostForUrl } from "../src/bind.js";
Expand All @@ -26,7 +27,80 @@ async function withServer(
}
}

function closeListeningServer(server: Awaited<ReturnType<typeof listen>>): Promise<void> {
server.closeAllConnections();
return new Promise<void>((resolve) => server.close(() => resolve()));
}

describe("Node adapter", () => {
it("should satisfy shared cancellation and timeout conformance over real sockets", async () => {
const report = await runAdapterConformance(
{
async abortStreamingResponse(response, cleanup) {
const server = await listen({ fetch: async () => response });
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP address");
let closing: Promise<void> | undefined;
const close = () => (closing ??= closeListeningServer(server));
cleanup.addEventListener("abort", () => void close(), { once: true });
try {
await new Promise<void>((resolve, reject) => {
const request = get(`http://127.0.0.1:${address.port}`, (incoming) => {
incoming.once("data", () => {
incoming.destroy();
resolve();
});
incoming.once("error", reject);
});
request.once("error", reject);
});
} finally {
await close();
}
},
async enforceRequestTimeout(app, cleanup) {
const server = await listen(app, { headersTimeout: 100, requestTimeout: 100 });
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP address");
const socket = createConnection({ host: "127.0.0.1", port: address.port });
let closing: Promise<void> | undefined;
const close = () => (closing ??= closeListeningServer(server));
const abort = () => {
socket.destroy();
void close();
};
cleanup.addEventListener("abort", abort, { once: true });
try {
await new Promise<void>((resolve, reject) => {
let connected = false;
socket.once("connect", () => {
connected = true;
socket.resume();
socket.write(
`POST / HTTP/1.1\r\nHost: 127.0.0.1:${address.port}\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhe`,
);
});
socket.once("close", () => resolve());
socket.once("error", (error) => {
if (!connected) reject(error);
});
});
} finally {
cleanup.removeEventListener("abort", abort);
socket.destroy();
await close();
}
},
},
{ deadlineMs: 2_000 },
);

expect(report).toEqual({
streamingResponseCancellation: "passed",
incompleteRequestTimeout: "passed",
});
});

it("should normalize client addresses without conflating distinct peers", () => {
expect(CLIENT_ADDRESS_HEADER).toBe("x-askr-client-address");
expect([
Expand Down