diff --git a/docs/1.guide/5.options.md b/docs/1.guide/5.options.md
index 41255d3..b14dba3 100644
--- a/docs/1.guide/5.options.md
+++ b/docs/1.guide/5.options.md
@@ -98,6 +98,29 @@ serve({
> - Use environment variables or secure secret management for production deployments
> - Consider using automatic certificate management (e.g., Let's Encrypt) for production
+### `onError`
+
+Runtime agnostic error handler.
+
+> [!NOTE]
+>
+> This handler will take over the built-in error handlers of Deno and Bun.
+
+**Example:**
+
+```js
+import { serve } from "srvx";
+
+serve({
+ fetch: () => new Response("👋 Hello there!"),
+ onError(error) {
+ return new Response(`
${error}\n${error.stack}
`, {
+ headers: { "Content-Type": "text/html" },
+ });
+ },
+});
+```
+
## Runtime specific options
### Node.js
diff --git a/playground/app.mjs b/playground/app.mjs
index 53abcd2..7117eaa 100644
--- a/playground/app.mjs
+++ b/playground/app.mjs
@@ -4,7 +4,7 @@ serve({
// tls: { cert: "server.crt", key: "server.key" },
fetch(_request) {
return new Response(
- `
+ /*html */ `
👋 Hello there
Learn more: srvx.h3.dev
`,
@@ -15,4 +15,12 @@ serve({
},
);
},
+ onError(error) {
+ return new Response(
+ /*html */ `${error.stack || error}
`,
+ {
+ headers: { "Content-Type": "text/html" },
+ },
+ );
+ },
});
diff --git a/src/_error.ts b/src/_error.ts
new file mode 100644
index 0000000..61ab90d
--- /dev/null
+++ b/src/_error.ts
@@ -0,0 +1,19 @@
+import type { ErrorHandler, ServerHandler } from "./types.ts";
+
+export function wrapFetchOnError(
+ fetchHandler: ServerHandler,
+ onError?: ErrorHandler,
+): ServerHandler {
+ if (!onError) return fetchHandler;
+ return (...params) => {
+ try {
+ const result = fetchHandler(...params);
+ if (result instanceof Promise) {
+ return result.catch(onError);
+ }
+ return result;
+ } catch (error) {
+ return onError(error as Error);
+ }
+ };
+}
diff --git a/src/adapters/bun.ts b/src/adapters/bun.ts
index 080ba5d..2baafc1 100644
--- a/src/adapters/bun.ts
+++ b/src/adapters/bun.ts
@@ -48,6 +48,7 @@ class BunServer implements Server {
this.serveOptions = {
...resolvePortAndHost(this.options),
reusePort: this.options.reusePort,
+ error: this.options.onError,
...this.options.bun,
tls: {
cert: tls?.cert,
diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts
index 6c47995..bc5c279 100644
--- a/src/adapters/cloudflare.ts
+++ b/src/adapters/cloudflare.ts
@@ -5,6 +5,7 @@ import type {
} from "../types.ts";
import type * as CF from "@cloudflare/workers-types";
import { wrapFetch } from "../_plugin.ts";
+import { wrapFetchOnError } from "../_error.ts";
export const Response: typeof globalThis.Response = globalThis.Response;
@@ -25,7 +26,7 @@ class CloudflareServer implements Server {
const fetchHandler = wrapFetch(
this as unknown as Server,
- this.options.fetch,
+ wrapFetchOnError(this.options.fetch, this.options.onError),
);
this.fetch = (request, env, context) => {
diff --git a/src/adapters/deno.ts b/src/adapters/deno.ts
index 3867252..b828420 100644
--- a/src/adapters/deno.ts
+++ b/src/adapters/deno.ts
@@ -52,6 +52,7 @@ class DenoServer implements Server {
this.serveOptions = {
...resolvePortAndHost(this.options),
reusePort: this.options.reusePort,
+ onError: this.options.onError,
...(tls
? { key: tls.key, cert: tls.cert, passphrase: tls.passphrase }
: {}),
diff --git a/src/adapters/generic.ts b/src/adapters/generic.ts
index a7f225e..1148b03 100644
--- a/src/adapters/generic.ts
+++ b/src/adapters/generic.ts
@@ -1,5 +1,6 @@
import type { Server, ServerHandler, ServerOptions } from "../types.ts";
import { wrapFetch } from "../_plugin.ts";
+import { wrapFetchOnError } from "../_error.ts";
export const Response: typeof globalThis.Response = globalThis.Response;
@@ -17,7 +18,7 @@ class GenericServer implements Server {
const fetchHandler = wrapFetch(
this as unknown as Server,
- this.options.fetch,
+ wrapFetchOnError(this.options.fetch, this.options.onError),
);
this.fetch = (request: Request) => {
diff --git a/src/adapters/node.ts b/src/adapters/node.ts
index 7291c9a..bd779fb 100644
--- a/src/adapters/node.ts
+++ b/src/adapters/node.ts
@@ -11,11 +11,12 @@ import { sendNodeResponse } from "../_node-compat/send.ts";
import { NodeRequest } from "../_node-compat/request.ts";
import {
fmtURL,
+ resolveTLSOptions,
printListening,
resolvePortAndHost,
- resolveTLSOptions,
} from "../_utils.ts";
import { wrapFetch } from "../_plugin.ts";
+import { wrapFetchOnError } from "../_error.ts";
export {
NodeRequest as Request,
@@ -58,7 +59,11 @@ class NodeServer implements Server {
constructor(options: ServerOptions) {
this.options = options;
- const fetchHandler = wrapFetch(this, this.options.fetch);
+ const fetchHandler = wrapFetch(
+ this,
+ wrapFetchOnError(this.options.fetch, this.options.onError),
+ );
+
this.fetch = fetchHandler;
const handler = (
diff --git a/src/adapters/service-worker.ts b/src/adapters/service-worker.ts
index 7a60434..f9067e1 100644
--- a/src/adapters/service-worker.ts
+++ b/src/adapters/service-worker.ts
@@ -1,5 +1,6 @@
import type { Server, ServerOptions, ServerRequest } from "../types.ts";
import { wrapFetch } from "../_plugin.ts";
+import { wrapFetchOnError } from "../_error.ts";
export const Response: typeof globalThis.Response = globalThis.Response;
@@ -28,7 +29,7 @@ class ServiceWorkerServer implements Server {
const fetchHandler = wrapFetch(
this as unknown as Server,
- this.options.fetch,
+ wrapFetchOnError(this.options.fetch, this.options.onError),
);
this.fetch = (request: Request, event: FetchEvent) => {
diff --git a/src/types.ts b/src/types.ts
index b1894f3..43e04b6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -100,6 +100,13 @@ export interface ServerOptions {
passphrase?: string;
};
+ /**
+ * Runtime agnostic error handler (optional).
+ *
+ * @note This handler will take precedence over runtime specific error handlers.
+ */
+ onError?: ErrorHandler;
+
/**
* Node.js server options.
*/
@@ -285,6 +292,8 @@ export interface ServerRequest extends Request {
export type FetchHandler = (request: Request) => Response | Promise;
+export type ErrorHandler = (error: unknown) => Response | Promise;
+
export type BunFetchHandler = (
request: Request,
server?: Bun.Server,
diff --git a/test/_fixture.ts b/test/_fixture.ts
index 7fb135d..932eb0e 100644
--- a/test/_fixture.ts
+++ b/test/_fixture.ts
@@ -29,6 +29,9 @@ export const server: Server = serve({
},
})) satisfies ServerPlugin,
),
+ async onError(err) {
+ return new Response(`onError: ${(err as Error).message}`, { status: 500 });
+ },
async fetch(req) {
const Response =
(globalThis as any).TEST_RESPONSE_CTOR || globalThis.Response;
@@ -70,6 +73,9 @@ export const server: Server = serve({
case "/req-headers-instanceof": {
return new Response(req.headers instanceof Headers ? "yes" : "no");
}
+ case "/error": {
+ throw new Error("test error");
+ }
}
return new Response("404", { status: 404 });
},
diff --git a/test/_tests.ts b/test/_tests.ts
index 927b1c5..f829aa5 100644
--- a/test/_tests.ts
+++ b/test/_tests.ts
@@ -63,6 +63,12 @@ export function addTests(
expect(await response.text()).toMatch(/ip: ::1|ip: 127.0.0.1/);
});
+ test("runtime agnostic error handler (onError)", async () => {
+ const response = await fetch(url("/error"));
+ expect(response.status).toBe(500);
+ expect(await response.text()).toBe("onError: test error");
+ });
+
describe("plugin", () => {
for (const hook of ["req", "res"]) {
for (const type of ["async", "sync"]) {