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