Skip to content

feat: add runtime agnostic error handler #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Apr 23, 2025
23 changes: 23 additions & 0 deletions docs/1.guide/5.options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<pre>${error}\n${error.stack}</pre>`, {
headers: { "Content-Type": "text/html" },
});
},
});
```

## Runtime specific options

### Node.js
Expand Down
10 changes: 9 additions & 1 deletion playground/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ serve({
// tls: { cert: "server.crt", key: "server.key" },
fetch(_request) {
return new Response(
`
/*html */ `
<h1>👋 Hello there</h1>
Learn more: <a href="https://srvx.h3.dev/" target="_blank">srvx.h3.dev</a>
`,
Expand All @@ -15,4 +15,12 @@ serve({
},
);
},
onError(error) {
return new Response(
/*html */ `<body style="background-color:blue;color:white;padding:2em;"><pre>${error.stack || error}</pre></body>`,
{
headers: { "Content-Type": "text/html" },
},
);
},
});
19 changes: 19 additions & 0 deletions src/_error.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
1 change: 1 addition & 0 deletions src/adapters/bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class BunServer implements Server<BunFetchHandler> {
this.serveOptions = {
...resolvePortAndHost(this.options),
reusePort: this.options.reusePort,
error: this.options.onError,
...this.options.bun,
tls: {
cert: tls?.cert,
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,7 +26,7 @@ class CloudflareServer implements Server<CloudflareFetchHandler> {

const fetchHandler = wrapFetch(
this as unknown as Server,
this.options.fetch,
wrapFetchOnError(this.options.fetch, this.options.onError),
);

this.fetch = (request, env, context) => {
Expand Down
1 change: 1 addition & 0 deletions src/adapters/deno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class DenoServer implements Server<DenoFetchHandler> {
this.serveOptions = {
...resolvePortAndHost(this.options),
reusePort: this.options.reusePort,
onError: this.options.onError,
...(tls
? { key: tls.key, cert: tls.cert, passphrase: tls.passphrase }
: {}),
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/generic.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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) => {
Expand Down
9 changes: 7 additions & 2 deletions src/adapters/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = (
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/service-worker.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -28,7 +29,7 @@ class ServiceWorkerServer implements Server<ServiceWorkerHandler> {

const fetchHandler = wrapFetch(
this as unknown as Server,
this.options.fetch,
wrapFetchOnError(this.options.fetch, this.options.onError),
);

this.fetch = (request: Request, event: FetchEvent) => {
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -285,6 +292,8 @@ export interface ServerRequest extends Request {

export type FetchHandler = (request: Request) => Response | Promise<Response>;

export type ErrorHandler = (error: unknown) => Response | Promise<Response>;

export type BunFetchHandler = (
request: Request,
server?: Bun.Server,
Expand Down
6 changes: 6 additions & 0 deletions test/_fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
},
Expand Down
6 changes: 6 additions & 0 deletions test/_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) {
Expand Down