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);
+ }
+ }
+ }
+ }
+ });
+});