Skip to content
Closed
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
33 changes: 28 additions & 5 deletions docs/1.guide/5.options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -113,7 +113,7 @@ import { serve } from "srvx";

serve({
fetch: () => new Response("👋 Hello there!"),
onError(error) {
error(error) {
return new Response(`<pre>${error}\n${error.stack}</pre>`, {
headers: { "Content-Type": "text/html" },
});
Expand All @@ -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()`).

Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions src/_middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/_plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
24 changes: 24 additions & 0 deletions src/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Promise<any> | PromiseLike<any>>();
return {
Expand Down
2 changes: 0 additions & 2 deletions src/adapters/_node/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions src/adapters/_node/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,22 @@ 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<Response> {
#body?: BodyInit | null;
#init?: ResponseInit;
#headers?: Headers;
#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;
}
Expand Down
2 changes: 0 additions & 2 deletions src/adapters/_node/web/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/aws-lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ class AWSLambdaServer implements Server<AWSLambdaHandler> {
handleLambdaEvent(fetchHandler, event, context, this.options.trustProxy);
}

serve() {}
serve(): Promise<Server<AWSLambdaHandler>> {
return Promise.resolve().then(() => this);
}

ready(): Promise<Server<AWSLambdaHandler>> {
return Promise.resolve().then(() => this);
Expand Down
25 changes: 22 additions & 3 deletions src/adapters/bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,6 +31,8 @@ class BunServer implements Server<BunFetchHandler> {
readonly waitUntil?: Server["waitUntil"];

#wait: ReturnType<typeof createWaitUntil> | undefined;
#listenError?: Error;
#readyObserved = false;

constructor(options: ServerOptions) {
this.options = { ...options, middleware: [...(options.middleware || [])] };
Expand All @@ -38,6 +41,7 @@ class BunServer implements Server<BunFetchHandler> {

trustProxyPlugin(this);
gracefulShutdownPlugin(this);
errorPlugin(this);

const fetchHandler = wrapFetch(this);

Expand Down Expand Up @@ -98,13 +102,24 @@ class BunServer implements Server<BunFetchHandler> {
};

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<this> {
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);
Expand All @@ -125,6 +140,10 @@ class BunServer implements Server<BunFetchHandler> {
}

ready(): Promise<this> {
this.#readyObserved = true;
if (this.#listenError) {
return Promise.reject(this.#listenError);
}
return Promise.resolve(this);
}

Expand Down
5 changes: 3 additions & 2 deletions src/adapters/bunny.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,18 @@ class BunnyServer implements Server {
}
}

serve() {
serve(): Promise<Server> {
// 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<Server> {
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ class CloudflareServer implements Server<CloudflareFetchHandler> {
}
}

serve() {
serve(): Promise<Server<CF.ExportedHandlerFetchHandler>> {
addEventListener("fetch", (event) => {
// @ts-expect-error
event.respondWith(this.fetch(event.request, {}, event));
});
return Promise.resolve().then(() => this);
}

ready(): Promise<Server<CF.ExportedHandlerFetchHandler>> {
Expand Down
49 changes: 34 additions & 15 deletions src/adapters/deno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -34,6 +35,8 @@ class DenoServer implements Server<DenoFetchHandler> {

#listeningPromise?: Promise<void>;
#listeningInfo?: { hostname: string; port: number };
#listenError?: Error;
#readyObserved = false;

#wait: ReturnType<typeof createWaitUntil> | undefined;

Expand All @@ -44,6 +47,7 @@ class DenoServer implements Server<DenoFetchHandler> {

trustProxyPlugin(this);
gracefulShutdownPlugin(this);
errorPlugin(this);

const fetchHandler = wrapFetch(this);

Expand Down Expand Up @@ -99,7 +103,11 @@ class DenoServer implements Server<DenoFetchHandler> {
};

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

Expand All @@ -109,20 +117,27 @@ class DenoServer implements Server<DenoFetchHandler> {
}
const onListenPromise = Promise.withResolvers<void>();
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);
}

Expand All @@ -137,6 +152,10 @@ class DenoServer implements Server<DenoFetchHandler> {
}

ready(): Promise<Server> {
this.#readyObserved = true;
if (this.#listenError) {
return Promise.reject(this.#listenError);
}
return Promise.resolve(this.#listeningPromise).then(() => this);
}

Expand Down
4 changes: 3 additions & 1 deletion src/adapters/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ class GenericServer implements Server {
};
}

serve(): void {}
serve(): Promise<Server> {
return Promise.resolve(this);
}

ready(): Promise<Server> {
return Promise.resolve(this);
Expand Down
Loading
Loading