diff --git a/README.md b/README.md index 0adbc30..5e1dd44 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 94f65a2..084719c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@askrjs/node", - "version": "0.0.11", + "version": "0.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/node", - "version": "0.0.11", + "version": "0.0.12", "license": "Apache-2.0", "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" }, @@ -27,12 +27,12 @@ } }, "node_modules/@askrjs/auth": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@askrjs/auth/-/auth-0.0.8.tgz", - "integrity": "sha512-0Lg2fvD21LIi1C8ZelC8tUZr6XBNW2DQil9HRuUqedc4TRNJqsqizH3nXB+sTyYLjbIj0lqZmdI2LBzmPsYMHA==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@askrjs/auth/-/auth-0.0.9.tgz", + "integrity": "sha512-Akv4FCoakSa2WSowIMoxnaUrvnDBF84vmwgbThkWIgTUCgj+L846TGZ/7fR7657MCJAEveLjmqbTDq0WTfbKrg==", "license": "Apache-2.0", "dependencies": { - "@xmldom/xmldom": "0.8.13", + "@xmldom/xmldom": "0.9.10", "xml-crypto": "6.1.2", "xml-encryption": "5.0.0" }, @@ -40,29 +40,39 @@ "node": ">=24.0.0" } }, + "node_modules/@askrjs/auth/node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "deprecated": "this version has critical issues, please update to the latest version", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/@askrjs/schema": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@askrjs/schema/-/schema-0.0.5.tgz", - "integrity": "sha512-E0dUDc/ceGPZBi+ShyZuutQ1o8gvYJ8H6/d8yAqPsnRCFRJfglYYTXivw8ySWDRlJrPOL01hZmuiuYQdE9NHkg==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@askrjs/schema/-/schema-0.0.11.tgz", + "integrity": "sha512-itxXXHHKQ/AXesfIzSh+4/1kBKGhsvJO/20TiHvWf6avuNhw2AcPlduWTF/AQia7ED/Tl+cyiVhXTA7VPGNEfA==", "license": "Apache-2.0", "engines": { "node": ">=24.0.0" } }, "node_modules/@askrjs/server": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@askrjs/server/-/server-0.0.11.tgz", - "integrity": "sha512-yD2lUGPeImgoBEGcAo9YMFXYyAg8P3wbOyLZTyn2xB+JkaIngTQLnb09Lzk+EMSKbYl5Z67h0GF/7d9Sz71oug==", + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@askrjs/server/-/server-0.0.18.tgz", + "integrity": "sha512-PMy1dO9TfFx2tqYMMC5e0DBUhR/kTJCStmJSc1U20A1k/pEe/YMRCfqB7KcC8kJJfVa1vjx+j4ZkkIo7k0Xhlg==", "license": "Apache-2.0", "dependencies": { - "@askrjs/auth": ">=0.0.1 <0.1.0", - "@askrjs/schema": ">=0.0.1 <0.1.0" + "@askrjs/auth": ">=0.0.9 <0.1.0", + "@askrjs/schema": ">=0.0.6 <0.1.0" }, "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.0.64 <0.1.0" + "@askrjs/askr": ">=0.0.89 <0.1.0" }, "peerDependenciesMeta": { "@askrjs/askr": { diff --git a/package.json b/package.json index 46729e6..381e4ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/node", - "version": "0.0.11", + "version": "0.0.12", "description": "Node http adapter for @askrjs/server", "keywords": [ "askr", @@ -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" }, diff --git a/src/contracts.ts b/src/contracts.ts index f040088..93fd256 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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; diff --git a/src/listen.ts b/src/listen.ts index ba386c3..7ed7afc 100644 --- a/src/listen.ts +++ b/src/listen.ts @@ -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. */ @@ -30,7 +30,10 @@ export function listen(app: ServerApp, options: ListenOptions = {}): Promise> = { @@ -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; diff --git a/src/server-options.ts b/src/server-options.ts index a4ffeff..2d7d0c4 100644 --- a/src/server-options.ts +++ b/src/server-options.ts @@ -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 { @@ -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"); diff --git a/tests/node.test.ts b/tests/node.test.ts index c0192d6..3c6aca9 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -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"; @@ -26,7 +27,80 @@ async function withServer( } } +function closeListeningServer(server: Awaited>): Promise { + server.closeAllConnections(); + return new Promise((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 | undefined; + const close = () => (closing ??= closeListeningServer(server)); + cleanup.addEventListener("abort", () => void close(), { once: true }); + try { + await new Promise((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 | undefined; + const close = () => (closing ??= closeListeningServer(server)); + const abort = () => { + socket.destroy(); + void close(); + }; + cleanup.addEventListener("abort", abort, { once: true }); + try { + await new Promise((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([