diff --git a/docs/1.guide/5.options.md b/docs/1.guide/5.options.md index c57ec74c..1a599794 100644 --- a/docs/1.guide/5.options.md +++ b/docs/1.guide/5.options.md @@ -98,13 +98,13 @@ Client certificates (mutual TLS) are available through the [`mtls()` plugin](/gu :read-more{to="/guide/tls" title="TLS & mutual TLS"} -### `onError` +### `error` -Runtime agnostic error handler. +Runtime agnostic error handler. It runs whenever the `fetch` handler (or any middleware) throws or rejects, and returns the `Response` to send instead. > [!NOTE] > -> This handler will take over the built-in error handlers of Deno and Bun. +> This handler will also take over the built-in error handlers of Deno and Bun. **Example:** @@ -113,7 +113,7 @@ import { serve } from "srvx"; serve({ fetch: () => new Response("๐Ÿ‘‹ Hello there!"), - onError(error) { + error(error) { return new Response(`
${error}\n${error.stack}
`, { headers: { "Content-Type": "text/html" }, }); @@ -125,7 +125,7 @@ serve({ Maximum allowed size **in bytes** for the request body. Defaults to `undefined` (no limit). -As the body is read, its accumulated length is tracked and, once it exceeds the limit, reading is aborted and rejects with a `413`-style error. The error carries `statusCode: 413`, `status: 413` and `code: "ERR_BODY_TOO_LARGE"`, so a handler (or [`onError`](#onerror)) can map it to an HTTP `413 Payload Too Large` response. +As the body is read, its accumulated length is tracked and, once it exceeds the limit, reading is aborted and rejects with a `413`-style error. The error carries `statusCode: 413`, `status: 413` and `code: "ERR_BODY_TOO_LARGE"`, so a handler (or [`error`](#error)) can map it to an HTTP `413 Payload Too Large` response. The limit covers both buffered reads (`request.text()` / `request.json()`) and the streamed body (`request.body`, and therefore `request.arrayBuffer()` / `.blob()` / `.bytes()` / `.formData()`). @@ -182,6 +182,29 @@ serve({ > [!NOTE] > Applies to the Node, AWS Lambda, Bun and Deno adapters. +### `gracefulShutdown` + +Graceful shutdown on `SIGINT` and `SIGTERM` signals (supported for the Node.js, Deno and Bun runtimes). + +When enabled, the server stops accepting new connections and waits for in-flight requests to finish before exiting. Pressing `Ctrl+C` a second time forces an immediate close. + +- `true` (default): enable graceful shutdown. It is **disabled by default** when the `CI` or `TEST` environment variable is set. +- `false`: disable graceful shutdown. +- `{ gracefulTimeout?: number }`: enable and customize the timeout. + +The `gracefulTimeout` is the number of **seconds** to wait for in-flight requests before forcing a close. It defaults to `5` seconds and can also be set via the `SERVER_SHUTDOWN_TIMEOUT` environment variable. Set it to `0` to force close immediately once shutdown starts. + +**Example:** + +```js +import { serve } from "srvx"; + +serve({ + gracefulShutdown: { gracefulTimeout: 10 }, // seconds + fetch: () => new Response("๐Ÿ‘‹ Hello there!"), +}); +``` + ## Runtime Specific Options ### Node.js diff --git a/src/_middleware.ts b/src/_middleware.ts index 386ba60e..83f876db 100644 --- a/src/_middleware.ts +++ b/src/_middleware.ts @@ -3,9 +3,18 @@ import type { Server, ServerRequest, ServerHandler, ServerMiddleware } from "./t export function wrapFetch(server: Server): ServerHandler { const fetchHandler = server.options.fetch; const middleware = server.options.middleware || []; + // Initialize `request.context` once here (shared by every adapter) so the + // documented `request.context.user = ...` API works without each adapter + // having to set it. `??=` keeps any context an upstream layer already set. return middleware.length === 0 - ? fetchHandler - : (request) => callMiddleware(request, fetchHandler, middleware, 0); + ? (request) => { + request.context ??= {}; + return fetchHandler(request); + } + : (request) => { + request.context ??= {}; + return callMiddleware(request, fetchHandler, middleware, 0); + }; } function callMiddleware( diff --git a/src/_plugins.ts b/src/_plugins.ts index 5206d7a1..75c9894e 100644 --- a/src/_plugins.ts +++ b/src/_plugins.ts @@ -24,7 +24,7 @@ export const gracefulShutdownPlugin: ServerPlugin = (server) => { return; } const gracefulTimeout = - config === true || !config?.gracefulTimeout + config === true || config?.gracefulTimeout == null ? Number.parseInt(process.env.SERVER_SHUTDOWN_TIMEOUT || "") || 5 : config.gracefulTimeout; diff --git a/src/_utils.ts b/src/_utils.ts index c3f8397e..1d7eedf2 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -134,6 +134,30 @@ interface NodeResponseLike { _response?: Response; } +/** + * Surface a listen error (e.g. `EADDRINUSE`) that would otherwise be lost. + * + * Under the v1 contract `serve()` never throws and `ready()` rejects on a listen + * failure. But when the server is auto-started (non-`manual`) and the caller + * never awaits `ready()`, a failed listen would silently leave a live process + * with a dead server. To avoid that, this schedules the error as an unhandled + * promise rejection (Node's default handler then reports/aborts) unless + * `isObserved()` reports that `ready()` already claimed it. + * + * The check is deferred to a macrotask so a synchronous `await server.ready()` + * right after `serve()` marks the error observed first (no spurious report). + */ +export function reportUnhandledListenError(error: Error, isObserved: () => boolean): void { + const timer = globalThis.setTimeout?.(() => { + if (!isObserved()) { + // No one awaited ready(): re-surface as an unhandled rejection so the + // failure is visible instead of leaving a dead server behind. + Promise.reject(error); + } + }, 0); + (timer as { unref?: () => void })?.unref?.(); +} + export function createWaitUntil() { const promises = new Set | PromiseLike>(); return { diff --git a/src/adapters/_node/adapter.ts b/src/adapters/_node/adapter.ts index 1f9f90a5..7a358024 100644 --- a/src/adapters/_node/adapter.ts +++ b/src/adapters/_node/adapter.ts @@ -47,8 +47,6 @@ export function toNodeHandler( /** * Converts a Node.js HTTP handler into a Fetch API handler. - * - * @experimental Behavior might be unstable and won't work in Bun and Deno currently (tracker: https://github.com/h3js/srvx/issues/132) */ export function toFetchHandler(handler: NodeHttpHandler & AdapterMeta): FetchHandler & AdapterMeta { if (handler.__fetchHandler) { diff --git a/src/adapters/_node/response.ts b/src/adapters/_node/response.ts index 50a7388a..1cb43a41 100644 --- a/src/adapters/_node/response.ts +++ b/src/adapters/_node/response.ts @@ -30,6 +30,11 @@ export const NodeResponse: { const STATUS_CODES = globalThis.process?.getBuiltinModule?.("node:http")?.STATUS_CODES || {}; + // Statuses that must have a null body (RFC 9110 ยง8.6 / Fetch spec null body + // status). Native `Response` throws for a non-null body with these; mirror + // that instead of silently emitting an illegal body + content-length. + const NULL_BODY_STATUS = new Set([101, 204, 205, 304]); + class NodeResponse implements Partial { #body?: BodyInit | null; #init?: ResponseInit; @@ -37,6 +42,10 @@ export const NodeResponse: { #response?: globalThis.Response; constructor(body?: BodyInit | null, init?: ResponseInit) { + const status = init?.status; + if (body != null && status != null && NULL_BODY_STATUS.has(status)) { + throw new TypeError(`Response constructor: Invalid response status code ${status}`); + } this.#body = body; this.#init = init; } diff --git a/src/adapters/_node/web/fetch.ts b/src/adapters/_node/web/fetch.ts index 5fff6c58..00b80535 100644 --- a/src/adapters/_node/web/fetch.ts +++ b/src/adapters/_node/web/fetch.ts @@ -17,8 +17,6 @@ import { WebServerResponse } from "./response.ts"; * Otherwise, new Node.js IncomingMessage and ServerResponse objects are created and linked to a custom Duplex stream that bridges the Fetch API streams with Node.js streams. * * The handler is invoked with these objects, and the response is constructed from the ServerResponse once it is finished. - * - * @experimental Behavior might be unstable. */ export async function fetchNodeHandler( handler: NodeHttpHandler, diff --git a/src/adapters/aws-lambda.ts b/src/adapters/aws-lambda.ts index 033fbdbd..31151c84 100644 --- a/src/adapters/aws-lambda.ts +++ b/src/adapters/aws-lambda.ts @@ -89,7 +89,9 @@ class AWSLambdaServer implements Server { handleLambdaEvent(fetchHandler, event, context, this.options.trustProxy); } - serve() {} + serve(): Promise> { + return Promise.resolve().then(() => this); + } ready(): Promise> { return Promise.resolve().then(() => this); diff --git a/src/adapters/bun.ts b/src/adapters/bun.ts index 6c0c6f92..87abf5f6 100644 --- a/src/adapters/bun.ts +++ b/src/adapters/bun.ts @@ -7,9 +7,10 @@ import { resolveTLSOptions, createWaitUntil, toNativeResponse, + reportUnhandledListenError, } from "../_utils.ts"; import { wrapFetch } from "../_middleware.ts"; -import { gracefulShutdownPlugin } from "../_plugins.ts"; +import { errorPlugin, gracefulShutdownPlugin } from "../_plugins.ts"; import { trustProxyPlugin } from "../_trust-proxy.ts"; export { FastURL } from "../_url.ts"; @@ -30,6 +31,8 @@ class BunServer implements Server { readonly waitUntil?: Server["waitUntil"]; #wait: ReturnType | undefined; + #listenError?: Error; + #readyObserved = false; constructor(options: ServerOptions) { this.options = { ...options, middleware: [...(options.middleware || [])] }; @@ -38,6 +41,7 @@ class BunServer implements Server { trustProxyPlugin(this); gracefulShutdownPlugin(this); + errorPlugin(this); const fetchHandler = wrapFetch(this); @@ -98,13 +102,24 @@ class BunServer implements Server { }; if (!options.manual) { - this.serve(); + // `serve()` never throws; a listen error surfaces via `ready()`. If the + // caller never awaits `ready()`, re-surface it (see node adapter). + this.serve().catch((error) => { + reportUnhandledListenError(error, () => this.#readyObserved); + }); } } serve(): Promise { if (!this.bun!.server) { - this.bun!.server = Bun.serve(this.serveOptions!); + try { + // Bun.serve throws EADDRINUSE (and other listen errors) synchronously. + // Capture it so serve() never throws and ready() rejects instead. + this.bun!.server = Bun.serve(this.serveOptions!); + } catch (error) { + this.#listenError = error as Error; + return Promise.reject(error); + } } printListening(this.options, this.url); return Promise.resolve(this); @@ -125,6 +140,10 @@ class BunServer implements Server { } ready(): Promise { + this.#readyObserved = true; + if (this.#listenError) { + return Promise.reject(this.#listenError); + } return Promise.resolve(this); } diff --git a/src/adapters/bunny.ts b/src/adapters/bunny.ts index f3fb8a86..b55761d9 100644 --- a/src/adapters/bunny.ts +++ b/src/adapters/bunny.ts @@ -65,17 +65,18 @@ class BunnyServer implements Server { } } - serve() { + serve(): Promise { // Check if running in Bunny runtime if (typeof Bunny !== "undefined" && Bunny.v1?.serve) { // Prevent multiple calls to serve, mostly for Bunny - if (this._started) return; + if (this._started) return Promise.resolve(this); this._started = true; Bunny.v1.serve(this.fetch); } else { throw new Error("[srvx] Bunny runtime not detected."); } + return Promise.resolve(this); } ready(): Promise { diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index 41b92fbd..c855b49f 100644 --- a/src/adapters/cloudflare.ts +++ b/src/adapters/cloudflare.ts @@ -53,11 +53,12 @@ class CloudflareServer implements Server { } } - serve() { + serve(): Promise> { addEventListener("fetch", (event) => { // @ts-expect-error event.respondWith(this.fetch(event.request, {}, event)); }); + return Promise.resolve().then(() => this); } ready(): Promise> { diff --git a/src/adapters/deno.ts b/src/adapters/deno.ts index afd764ee..2f499f2b 100644 --- a/src/adapters/deno.ts +++ b/src/adapters/deno.ts @@ -6,9 +6,10 @@ import { resolvePortAndHost, resolveTLSOptions, toNativeResponse, + reportUnhandledListenError, } from "../_utils.ts"; import { wrapFetch } from "../_middleware.ts"; -import { gracefulShutdownPlugin } from "../_plugins.ts"; +import { errorPlugin, gracefulShutdownPlugin } from "../_plugins.ts"; import { trustProxyPlugin } from "../_trust-proxy.ts"; import { limitRequestBody } from "../_body-limit.ts"; @@ -34,6 +35,8 @@ class DenoServer implements Server { #listeningPromise?: Promise; #listeningInfo?: { hostname: string; port: number }; + #listenError?: Error; + #readyObserved = false; #wait: ReturnType | undefined; @@ -44,6 +47,7 @@ class DenoServer implements Server { trustProxyPlugin(this); gracefulShutdownPlugin(this); + errorPlugin(this); const fetchHandler = wrapFetch(this); @@ -99,7 +103,11 @@ class DenoServer implements Server { }; if (!options.manual) { - this.serve(); + // `serve()` never throws; a listen error surfaces via `ready()`. If the + // caller never awaits `ready()`, re-surface it (see node adapter). + this.serve().catch((error) => { + reportUnhandledListenError(error, () => this.#readyObserved); + }); } } @@ -109,20 +117,27 @@ class DenoServer implements Server { } const onListenPromise = Promise.withResolvers(); this.#listeningPromise = onListenPromise.promise; - this.deno!.server = Deno.serve( - { - ...this.serveOptions, - onListen: (info) => { - this.#listeningInfo = info; - if (this.options.deno?.onListen) { - this.options.deno.onListen(info); - } - printListening(this.options, this.url); - onListenPromise.resolve(); + try { + // Deno.serve throws EADDRINUSE (and other listen errors) synchronously. + // Capture it so serve() never throws and ready() rejects instead. + this.deno!.server = Deno.serve( + { + ...this.serveOptions, + onListen: (info) => { + this.#listeningInfo = info; + if (this.options.deno?.onListen) { + this.options.deno.onListen(info); + } + printListening(this.options, this.url); + onListenPromise.resolve(); + }, }, - }, - this.fetch, - ); + this.fetch, + ); + } catch (error) { + this.#listenError = error as Error; + onListenPromise.reject(error); + } return Promise.resolve(this.#listeningPromise).then(() => this); } @@ -137,6 +152,10 @@ class DenoServer implements Server { } ready(): Promise { + this.#readyObserved = true; + if (this.#listenError) { + return Promise.reject(this.#listenError); + } return Promise.resolve(this.#listeningPromise).then(() => this); } diff --git a/src/adapters/generic.ts b/src/adapters/generic.ts index 126f3f9c..ac69f558 100644 --- a/src/adapters/generic.ts +++ b/src/adapters/generic.ts @@ -37,7 +37,9 @@ class GenericServer implements Server { }; } - serve(): void {} + serve(): Promise { + return Promise.resolve(this); + } ready(): Promise { return Promise.resolve(this); diff --git a/src/adapters/node.ts b/src/adapters/node.ts index 40797163..58b6d84d 100644 --- a/src/adapters/node.ts +++ b/src/adapters/node.ts @@ -6,6 +6,7 @@ import { printListening, resolvePortAndHost, createWaitUntil, + reportUnhandledListenError, } from "../_utils.ts"; import { wrapFetch } from "../_middleware.ts"; import { errorPlugin, gracefulShutdownPlugin } from "../_plugins.ts"; @@ -53,6 +54,7 @@ class NodeServer implements Server { #listeningPromise?: Promise; #listenError?: Error; + #readyObserved = false; #wait?: ReturnType; @@ -138,7 +140,12 @@ class NodeServer implements Server { this.node.server = server; if (!options.manual) { - this.serve().catch(() => {}); + // `serve()` never throws; a listen error surfaces via `ready()`. If the + // caller never awaits `ready()`, re-surface it so a dead server isn't left + // running silently (see reportUnhandledListenError). + this.serve().catch((error) => { + reportUnhandledListenError(error, () => this.#readyObserved); + }); } } @@ -187,6 +194,7 @@ class NodeServer implements Server { } ready(): Promise { + this.#readyObserved = true; if (this.#listenError) { return Promise.reject(this.#listenError); } diff --git a/src/adapters/service-worker.ts b/src/adapters/service-worker.ts index a1890708..ca69e41d 100644 --- a/src/adapters/service-worker.ts +++ b/src/adapters/service-worker.ts @@ -51,7 +51,7 @@ class ServiceWorkerServer implements Server { } } - serve() { + serve(): Promise> { if (isBrowserWindow) { if (!navigator.serviceWorker) { throw new Error("Service worker is not supported in the current window."); @@ -103,6 +103,7 @@ class ServiceWorkerServer implements Server { self.clients?.claim?.(); }); } + return Promise.resolve(this.#listeningPromise).then(() => this); } ready(): Promise> { diff --git a/src/types.ts b/src/types.ts index c671c5e0..6da30728 100644 --- a/src/types.ts +++ b/src/types.ts @@ -123,7 +123,7 @@ export interface ServerOptions { * * @default true (disabled in test and ci environments) */ - gracefulShutdown?: boolean | { gracefulTimeout?: number; forceTimeout?: number }; + gracefulShutdown?: boolean | { gracefulTimeout?: number }; /** * Maximum allowed size (in bytes) for the request body. @@ -277,7 +277,7 @@ export interface Server { * Start listening for incoming requests. * When `manual` option is enabled, this method needs to be called explicitly to begin accepting connections. */ - serve(): void | Promise>; + serve(): Promise>; /** * Returns a promise that resolves when the server is ready. @@ -347,12 +347,6 @@ export interface ServerRuntimeContext { }; serviceWorker?: { event: FetchEvent }; - - netlify?: { context: any }; - - stormkit?: { event: any; context: any }; - - vercel?: { context: { waitUntil?: (promise: Promise) => void } }; } export interface ServerRequestContext { diff --git a/test/v1-api-freeze.test.ts b/test/v1-api-freeze.test.ts new file mode 100644 index 00000000..7ab89174 --- /dev/null +++ b/test/v1-api-freeze.test.ts @@ -0,0 +1,152 @@ +import { describe, test, expect, afterEach } from "vitest"; +import { serve } from "../src/adapters/node.ts"; +import { NodeResponse } from "../src/adapters/_node/response.ts"; +import { gracefulShutdownPlugin } from "../src/_plugins.ts"; +import type { Server } from "../src/types.ts"; + +// Behavioral coverage for the v1 "API freeze" batch. Uses the node adapter so +// it runs in the default (node-only) CI job. + +describe("v1 api freeze", () => { + const cleanup: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanup.length) { + await cleanup.pop()!(); + } + }); + + test("`error` option runs (errorPlugin wired)", async () => { + const server = serve({ + port: 0, + hostname: "localhost", + fetch: () => { + throw new Error("boom"); + }, + error: (error) => new Response(`handled: ${(error as Error).message}`, { status: 500 }), + }); + cleanup.push(() => server.close(true)); + await server.ready(); + + const res = await fetch(server.url!); + expect(res.status).toBe(500); + expect(await res.text()).toBe("handled: boom"); + }); + + test("`request.context` is lazily initialized and mutable", async () => { + const server = serve({ + port: 0, + hostname: "localhost", + fetch: (req) => { + // Must not throw: `context` is initialized in shared code. + req.context!.user = "alice"; + return Response.json({ user: req.context!.user }); + }, + }); + cleanup.push(() => server.close(true)); + await server.ready(); + + const res = await fetch(server.url!); + expect(await res.json()).toEqual({ user: "alice" }); + }); + + test("`serve()` returns a Promise that resolves to the server", async () => { + const server = serve({ + port: 0, + hostname: "localhost", + manual: true, + fetch: () => new Response("ok"), + }); + cleanup.push(() => server.close(true)); + + const ret = server.serve(); + expect(ret).toBeInstanceOf(Promise); + expect(await ret).toBe(server); + }); + + test("`ready()` rejects on EADDRINUSE while `serve()` does not throw", async () => { + const first = serve({ + port: 0, + hostname: "localhost", + fetch: () => new Response("ok"), + }); + cleanup.push(() => first.close(true)); + await first.ready(); + const port = Number(new URL(first.url!).port); + + let second!: Server; + // The top-level serve() must never throw synchronously. + expect(() => { + second = serve({ + port, + hostname: "localhost", + fetch: () => new Response("ok"), + }); + }).not.toThrow(); + cleanup.push(() => second.close(true).catch(() => {})); + + // The error surfaces via ready(). + await expect(second.ready()).rejects.toThrow(/EADDRINUSE|address already in use/i); + }); + + describe("NodeResponse null-body status", () => { + test("throws for a non-null body with a null-body status", () => { + for (const status of [101, 204, 205, 304]) { + expect(() => new NodeResponse("x", { status })).toThrow(TypeError); + } + }); + + test("allows a null body with a null-body status", () => { + for (const status of [101, 204, 205, 304]) { + expect(() => new NodeResponse(null, { status })).not.toThrow(); + expect(() => new NodeResponse(undefined, { status })).not.toThrow(); + } + }); + + test("allows a body with a normal status", () => { + expect(() => new NodeResponse("x", { status: 200 })).not.toThrow(); + }); + }); + + test("gracefulShutdown honors `gracefulTimeout: 0` (not treated as falsy default)", async () => { + const sigListeners = { + SIGINT: process.listeners("SIGINT"), + SIGTERM: process.listeners("SIGTERM"), + }; + + const closeCalls: Array = []; + const fakeServer = { + options: { + gracefulShutdown: { gracefulTimeout: 0 }, + silent: true, + middleware: [], + }, + close: (closeAll?: boolean) => { + closeCalls.push(closeAll); + return Promise.resolve(); + }, + } as unknown as Server; + + try { + gracefulShutdownPlugin(fakeServer); + // Synchronously invokes the registered handler without signaling the OS. + process.emit("SIGTERM"); + // Wait past the plugin's 100ms delayed SIGINT (force-close) registration + // so the cleanup below can remove that listener too. + await new Promise((r) => setTimeout(r, 150)); + + // With timeout 0 the graceful countdown is skipped and it force-closes + // (`close(true)`) immediately. The old `|| 5` bug would fall back to the + // 5s default and never force-close a fast-resolving server. + expect(closeCalls).toContain(true); + } finally { + // Remove only the listeners this plugin added. + for (const sig of ["SIGINT", "SIGTERM"] as const) { + for (const l of process.listeners(sig)) { + if (!sigListeners[sig].includes(l)) { + process.removeListener(sig, l); + } + } + } + } + }); +});