diff --git a/.changeset/great-hooks-observe.md b/.changeset/great-hooks-observe.md new file mode 100644 index 000000000..bd974d890 --- /dev/null +++ b/.changeset/great-hooks-observe.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": minor +--- + +Add a `serverFunctions.onError` option naming a module that observes and replaces what a server function threw, before it is serialized into the response diff --git a/apps/tests/src/e2e/server-function.test.ts b/apps/tests/src/e2e/server-function.test.ts index 672092e62..abb9861f7 100644 --- a/apps/tests/src/e2e/server-function.test.ts +++ b/apps/tests/src/e2e/server-function.test.ts @@ -155,4 +155,17 @@ test.describe("server-function", () => { ); }).toPass({ timeout: 15000 }); }); + + test("should send the error the onError module returns in place of the thrown one", async ({ + page, + }) => { + await page.goto("http://localhost:3000/server-function-on-error"); + // Retry the click until it registers post-hydration (clicks aren't auto-retried). + await expect(async () => { + await page.locator("#server-fn-test").click(); + await expect(page.locator("#server-fn-test")).toContainText("replaced by onError", { + timeout: 1000, + }); + }).toPass({ timeout: 15000 }); + }); }); diff --git a/apps/tests/src/routes/server-function-on-error.tsx b/apps/tests/src/routes/server-function-on-error.tsx new file mode 100644 index 000000000..d74125a32 --- /dev/null +++ b/apps/tests/src/routes/server-function-on-error.tsx @@ -0,0 +1,30 @@ +async function serverFnThrowsReplaceable() { + "use server"; + + // `src/server-fn-error.ts` swaps this one out for a different error before it + // is serialized, which is what the client below should end up displaying. + throw new Error("replace me"); +} + +export default function App() { + return ( +
+ { + const el = evt.target as HTMLElement; + serverFnThrowsReplaceable().then( + () => { + el.textContent = "no error"; + }, + err => { + el.textContent = err instanceof Error ? err.message : String(err); + }, + ); + }} + > + Click me + +
+ ); +} diff --git a/apps/tests/src/server-fn-error.ts b/apps/tests/src/server-fn-error.ts new file mode 100644 index 000000000..25d08c3ff --- /dev/null +++ b/apps/tests/src/server-fn-error.ts @@ -0,0 +1,17 @@ +import type { ServerFunctionErrorHandler } from "@solidjs/start/server"; + +/** + * Wired up through `serverFunctions.onError`. Unlike `seroval-plugins.ts` this + * module is bundled into the server only, so it may reach for server-only code. + * + * Returning `undefined` sends whatever was thrown, which is what leaves the + * other server-function error tests in this app seeing their own errors. + */ +const onServerFunctionError: ServerFunctionErrorHandler = thrown => { + if (thrown instanceof Error && thrown.message === "replace me") { + return new Error("replaced by onError"); + } + return undefined; +}; + +export default onServerFunctionError; diff --git a/apps/tests/vite.config.ts b/apps/tests/vite.config.ts index dfb3367fe..e7baed41b 100644 --- a/apps/tests/vite.config.ts +++ b/apps/tests/vite.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ serialization: { plugins: "src/seroval-plugins.ts", }, + serverFunctions: { + onError: "src/server-fn-error.ts", + }, env: { server: { load() { diff --git a/packages/start/src/config/constants.ts b/packages/start/src/config/constants.ts index 33bdd12a2..c81847133 100644 --- a/packages/start/src/config/constants.ts +++ b/packages/start/src/config/constants.ts @@ -9,6 +9,7 @@ export const VIRTUAL_MODULES = { middleware: "solid-start:middleware", serovalPlugins: "solid-start:seroval-plugins", serverFnManifest: "solid-start:server-fn-manifest", + serverFnErrorHandler: "solid-start:server-fn-error-handler", clientEntry: "solid-start:client-entry", serverEntry: "solid-start:server-entry", app: "solid-start:app", diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 73527bf8d..b29609f2c 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -144,7 +144,24 @@ export interface SolidStartOptions { * Options controlling which files are processed as server functions * (inclusion / exclusion filters for the `"use server"` transform). */ - serverFunctions?: Pick; + serverFunctions?: Pick & { + /** + * Path to a module whose default export is called with whatever a server + * function threw, before it is serialized into the response. Return a + * value to send it in place of what was thrown, or `undefined` to send the + * original. + * + * Naming the module here rather than registering a handler at runtime + * keeps the app in sole control of it: no dependency can reach into the + * running server and take over reporting. + * + * The module is bundled into the server only, so it may import server-only + * code such as a monitoring SDK. + * + * @example "src/server-fn-error.ts" + */ + onError?: string; + }; } const absolute = (path: string, root: string) => diff --git a/packages/start/src/config/manifest.ts b/packages/start/src/config/manifest.ts index 5ffa2041b..6f90c2f54 100644 --- a/packages/start/src/config/manifest.ts +++ b/packages/start/src/config/manifest.ts @@ -41,6 +41,11 @@ export function manifest(start: SolidStartOptions): PluginOption { if (plugins) return await this.resolve(plugins); return `\0${VIRTUAL_MODULES.serovalPlugins}`; } + if (id === VIRTUAL_MODULES.serverFnErrorHandler) { + const onError = start.serverFunctions?.onError; + if (onError) return await this.resolve(onError); + return `\0${VIRTUAL_MODULES.serverFnErrorHandler}`; + } }, async load(id) { if (id === `\0${VIRTUAL_MODULES.clientViteManifest}`) { @@ -94,6 +99,8 @@ export function manifest(start: SolidStartOptions): PluginOption { return `export const clientViteManifest = ${JSON.stringify(clientViteManifest)};`; } else if (id === `\0${VIRTUAL_MODULES.middleware}`) return "export default {};"; else if (id === `\0${VIRTUAL_MODULES.serovalPlugins}`) return "export default [];"; + else if (id === `\0${VIRTUAL_MODULES.serverFnErrorHandler}`) + return "export default undefined;"; else if (id.startsWith("/@manifest")) { if (this.environment.mode !== "dev") throw new Error("@manifest queries are only allowed in dev"); diff --git a/packages/start/src/fns/error-handler.ts b/packages/start/src/fns/error-handler.ts new file mode 100644 index 000000000..ce98ee4e2 --- /dev/null +++ b/packages/start/src/fns/error-handler.ts @@ -0,0 +1,8 @@ +import onServerFunctionError from "solid-start:server-fn-error-handler"; + +export type ServerFunctionErrorHandler = (thrown: unknown) => unknown; + +/** @internal */ +export function applyServerFunctionErrorHandler(thrown: unknown): unknown { + return onServerFunctionError?.(thrown) ?? thrown; +} diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index 16d2726cc..f4a44324d 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { parseCookies } from "h3"; import type { FetchEvent } from "../server/types.ts"; +import { getFetchEvent } from "../server/fetchEvent.ts"; +import { getServerFunction } from "./registration.ts"; vi.mock("h3", () => ({ parseCookies: vi.fn(() => ({})), @@ -16,6 +18,16 @@ vi.mock("solid-js/web/storage", () => ({ vi.mock("solid-start:server-fn-manifest", () => ({})); +const configuredErrorHandler = vi.hoisted(() => ({ + current: undefined as ((thrown: unknown) => unknown) | undefined, +})); + +vi.mock("solid-start:server-fn-error-handler", () => ({ + get default() { + return configuredErrorHandler.current; + }, +})); + vi.mock("../server/handler.ts", () => ({ createPageEvent: vi.fn(), })); @@ -25,6 +37,16 @@ vi.mock("../server/fetchEvent.ts", () => ({ mergeResponseHeaders: vi.fn(), })); +vi.mock("./registration.ts", () => ({ + getServerFunction: vi.fn(), + hasServerFunction: vi.fn(() => true), +})); + +vi.mock("./serialization.ts", () => ({ + serializeToJSONStream: vi.fn(() => "serialized"), + serializeToJSStream: vi.fn(() => "serialized"), +})); + function createMockFetchEvent( headers: Record = {}, setCookies: string[] = [], @@ -158,3 +180,70 @@ describe("createSingleFlightHeaders", () => { expect(() => createSingleFlightHeaders(sourceEvent, { some: "value" })).not.toThrow(); }); }); + +describe("the configured server function error handler", () => { + const callThrowing = async (thrown: unknown) => { + const request = new Request("http://localhost/_server", { + method: "POST", + headers: { "X-Server-Id": "fn", "X-Server-Instance": "server-fn:1" }, + }); + const h3Event = { res: { headers: new Headers(), status: 200 } }; + vi.mocked(getFetchEvent).mockReturnValue({ + request, + response: { headers: { getSetCookie: () => [] } }, + nativeEvent: h3Event, + locals: {}, + } as unknown as FetchEvent); + vi.mocked(getServerFunction).mockReturnValue(() => { + throw thrown; + }); + const { handleServerFunction } = await import("./handler.ts"); + await handleServerFunction(h3Event as never); + return h3Event; + }; + + beforeEach(() => { + vi.clearAllMocks(); + configuredErrorHandler.current = undefined; + }); + + it("passes the thrown value to the handler", async () => { + const thrown = new Error("boom"); + configuredErrorHandler.current = vi.fn(() => undefined); + + await callThrowing(thrown); + + expect(configuredErrorHandler.current).toHaveBeenCalledWith(thrown); + }); + + it("serializes the replacement the handler returns", async () => { + configuredErrorHandler.current = () => new Error("replaced"); + + const h3Event = await callThrowing(new Error("boom")); + + expect(h3Event.res.headers.get("X-Error")).toBe("replaced"); + }); + + it("treats a Response the handler returns as control flow", async () => { + configuredErrorHandler.current = () => new Response(null, { status: 403 }); + + const h3Event = await callThrowing(new Error("boom")); + + expect(h3Event.res.status).toBe(403); + expect(h3Event.res.headers.get("X-Error")).toBe("true"); + }); + + it("keeps the original error when the handler returns nothing", async () => { + configuredErrorHandler.current = () => undefined; + + const h3Event = await callThrowing(new Error("boom")); + + expect(h3Event.res.headers.get("X-Error")).toBe("boom"); + }); + + it("leaves the response untouched when no handler is configured", async () => { + const h3Event = await callThrowing(new Error("boom")); + + expect(h3Event.res.headers.get("X-Error")).toBe("boom"); + }); +}); diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index 2683087a9..135ce497c 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -15,6 +15,7 @@ import { BODY_FORMAT_KEY, BodyFormat, extractBody, getHeadersAndBody } from "./s import "solid-start:server-fn-manifest"; import { getServerFunction, hasServerFunction } from "./registration.ts"; +import { applyServerFunctionErrorHandler } from "./error-handler.ts"; import type { FetchEvent, PageEvent } from "../server/types.ts"; import { getExpectedRedirectStatus } from "../server/util.ts"; @@ -126,6 +127,7 @@ export async function handleServerFunction(h3Event: H3Event) { } return serializeToJSONStream(result); } catch (x) { + x = applyServerFunctionErrorHandler(x); if (x instanceof Response) { if (singleFlight && instance) { x = await handleSingleFlight(event, x); diff --git a/packages/start/src/server/index.tsx b/packages/start/src/server/index.tsx index f7b1be5e3..1f904de6e 100644 --- a/packages/start/src/server/index.tsx +++ b/packages/start/src/server/index.tsx @@ -3,6 +3,7 @@ export { getServerFunctionMeta } from "../shared/serverFunction.ts"; export { StartServer } from "./StartServer.tsx"; export { decorateHandler, decorateMiddleware } from "./fetchEvent.ts"; export { createHandler } from "./handler.ts"; +export type { ServerFunctionErrorHandler } from "../fns/error-handler.ts"; export type { APIEvent, diff --git a/packages/start/src/virtual.d.ts b/packages/start/src/virtual.d.ts index d62ced0cd..c7b55d783 100644 --- a/packages/start/src/virtual.d.ts +++ b/packages/start/src/virtual.d.ts @@ -29,3 +29,9 @@ declare module "solid-start:middleware" { declare module "solid-start:seroval-plugins" { export default SerovalPlugins as import("seroval").Plugin[]; } + +declare module "solid-start:server-fn-error-handler" { + export default ServerFunctionErrorHandler as + | import("./fns/error-handler.ts").ServerFunctionErrorHandler + | undefined; +}