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"]) {