Skip to content

Commit bac24b2

Browse files
authored
feat: add a server function error handler (#2262)
1 parent f15724b commit bac24b2

13 files changed

Lines changed: 200 additions & 1 deletion

File tree

.changeset/great-hooks-observe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": minor
3+
---
4+
5+
Add a `serverFunctions.onError` option naming a module that observes and replaces what a server function threw, before it is serialized into the response

apps/tests/src/e2e/server-function.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,17 @@ test.describe("server-function", () => {
155155
);
156156
}).toPass({ timeout: 15000 });
157157
});
158+
159+
test("should send the error the onError module returns in place of the thrown one", async ({
160+
page,
161+
}) => {
162+
await page.goto("http://localhost:3000/server-function-on-error");
163+
// Retry the click until it registers post-hydration (clicks aren't auto-retried).
164+
await expect(async () => {
165+
await page.locator("#server-fn-test").click();
166+
await expect(page.locator("#server-fn-test")).toContainText("replaced by onError", {
167+
timeout: 1000,
168+
});
169+
}).toPass({ timeout: 15000 });
170+
});
158171
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
async function serverFnThrowsReplaceable() {
2+
"use server";
3+
4+
// `src/server-fn-error.ts` swaps this one out for a different error before it
5+
// is serialized, which is what the client below should end up displaying.
6+
throw new Error("replace me");
7+
}
8+
9+
export default function App() {
10+
return (
11+
<main>
12+
<span
13+
id="server-fn-test"
14+
onClick={evt => {
15+
const el = evt.target as HTMLElement;
16+
serverFnThrowsReplaceable().then(
17+
() => {
18+
el.textContent = "no error";
19+
},
20+
err => {
21+
el.textContent = err instanceof Error ? err.message : String(err);
22+
},
23+
);
24+
}}
25+
>
26+
Click me
27+
</span>
28+
</main>
29+
);
30+
}

apps/tests/src/server-fn-error.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { ServerFunctionErrorHandler } from "@solidjs/start/server";
2+
3+
/**
4+
* Wired up through `serverFunctions.onError`. Unlike `seroval-plugins.ts` this
5+
* module is bundled into the server only, so it may reach for server-only code.
6+
*
7+
* Returning `undefined` sends whatever was thrown, which is what leaves the
8+
* other server-function error tests in this app seeing their own errors.
9+
*/
10+
const onServerFunctionError: ServerFunctionErrorHandler = thrown => {
11+
if (thrown instanceof Error && thrown.message === "replace me") {
12+
return new Error("replaced by onError");
13+
}
14+
return undefined;
15+
};
16+
17+
export default onServerFunctionError;

apps/tests/vite.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export default defineConfig({
1111
serialization: {
1212
plugins: "src/seroval-plugins.ts",
1313
},
14+
serverFunctions: {
15+
onError: "src/server-fn-error.ts",
16+
},
1417
env: {
1518
server: {
1619
load() {

packages/start/src/config/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const VIRTUAL_MODULES = {
99
middleware: "solid-start:middleware",
1010
serovalPlugins: "solid-start:seroval-plugins",
1111
serverFnManifest: "solid-start:server-fn-manifest",
12+
serverFnErrorHandler: "solid-start:server-fn-error-handler",
1213
clientEntry: "solid-start:client-entry",
1314
serverEntry: "solid-start:server-entry",
1415
app: "solid-start:app",

packages/start/src/config/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,24 @@ export interface SolidStartOptions {
144144
* Options controlling which files are processed as server functions
145145
* (inclusion / exclusion filters for the `"use server"` transform).
146146
*/
147-
serverFunctions?: Pick<ServerFunctionsOptions, "filter">;
147+
serverFunctions?: Pick<ServerFunctionsOptions, "filter"> & {
148+
/**
149+
* Path to a module whose default export is called with whatever a server
150+
* function threw, before it is serialized into the response. Return a
151+
* value to send it in place of what was thrown, or `undefined` to send the
152+
* original.
153+
*
154+
* Naming the module here rather than registering a handler at runtime
155+
* keeps the app in sole control of it: no dependency can reach into the
156+
* running server and take over reporting.
157+
*
158+
* The module is bundled into the server only, so it may import server-only
159+
* code such as a monitoring SDK.
160+
*
161+
* @example "src/server-fn-error.ts"
162+
*/
163+
onError?: string;
164+
};
148165
}
149166

150167
const absolute = (path: string, root: string) =>

packages/start/src/config/manifest.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export function manifest(start: SolidStartOptions): PluginOption {
4141
if (plugins) return await this.resolve(plugins);
4242
return `\0${VIRTUAL_MODULES.serovalPlugins}`;
4343
}
44+
if (id === VIRTUAL_MODULES.serverFnErrorHandler) {
45+
const onError = start.serverFunctions?.onError;
46+
if (onError) return await this.resolve(onError);
47+
return `\0${VIRTUAL_MODULES.serverFnErrorHandler}`;
48+
}
4449
},
4550
async load(id) {
4651
if (id === `\0${VIRTUAL_MODULES.clientViteManifest}`) {
@@ -94,6 +99,8 @@ export function manifest(start: SolidStartOptions): PluginOption {
9499
return `export const clientViteManifest = ${JSON.stringify(clientViteManifest)};`;
95100
} else if (id === `\0${VIRTUAL_MODULES.middleware}`) return "export default {};";
96101
else if (id === `\0${VIRTUAL_MODULES.serovalPlugins}`) return "export default [];";
102+
else if (id === `\0${VIRTUAL_MODULES.serverFnErrorHandler}`)
103+
return "export default undefined;";
97104
else if (id.startsWith("/@manifest")) {
98105
if (this.environment.mode !== "dev")
99106
throw new Error("@manifest queries are only allowed in dev");
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import onServerFunctionError from "solid-start:server-fn-error-handler";
2+
3+
export type ServerFunctionErrorHandler = (thrown: unknown) => unknown;
4+
5+
/** @internal */
6+
export function applyServerFunctionErrorHandler(thrown: unknown): unknown {
7+
return onServerFunctionError?.(thrown) ?? thrown;
8+
}

packages/start/src/fns/handler.spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, it, vi, beforeEach } from "vitest";
22
import { parseCookies } from "h3";
33
import type { FetchEvent } from "../server/types.ts";
4+
import { getFetchEvent } from "../server/fetchEvent.ts";
5+
import { getServerFunction } from "./registration.ts";
46

57
vi.mock("h3", () => ({
68
parseCookies: vi.fn(() => ({})),
@@ -16,6 +18,16 @@ vi.mock("solid-js/web/storage", () => ({
1618

1719
vi.mock("solid-start:server-fn-manifest", () => ({}));
1820

21+
const configuredErrorHandler = vi.hoisted(() => ({
22+
current: undefined as ((thrown: unknown) => unknown) | undefined,
23+
}));
24+
25+
vi.mock("solid-start:server-fn-error-handler", () => ({
26+
get default() {
27+
return configuredErrorHandler.current;
28+
},
29+
}));
30+
1931
vi.mock("../server/handler.ts", () => ({
2032
createPageEvent: vi.fn(),
2133
}));
@@ -25,6 +37,16 @@ vi.mock("../server/fetchEvent.ts", () => ({
2537
mergeResponseHeaders: vi.fn(),
2638
}));
2739

40+
vi.mock("./registration.ts", () => ({
41+
getServerFunction: vi.fn(),
42+
hasServerFunction: vi.fn(() => true),
43+
}));
44+
45+
vi.mock("./serialization.ts", () => ({
46+
serializeToJSONStream: vi.fn(() => "serialized"),
47+
serializeToJSStream: vi.fn(() => "serialized"),
48+
}));
49+
2850
function createMockFetchEvent(
2951
headers: Record<string, string> = {},
3052
setCookies: string[] = [],
@@ -158,3 +180,70 @@ describe("createSingleFlightHeaders", () => {
158180
expect(() => createSingleFlightHeaders(sourceEvent, { some: "value" })).not.toThrow();
159181
});
160182
});
183+
184+
describe("the configured server function error handler", () => {
185+
const callThrowing = async (thrown: unknown) => {
186+
const request = new Request("http://localhost/_server", {
187+
method: "POST",
188+
headers: { "X-Server-Id": "fn", "X-Server-Instance": "server-fn:1" },
189+
});
190+
const h3Event = { res: { headers: new Headers(), status: 200 } };
191+
vi.mocked(getFetchEvent).mockReturnValue({
192+
request,
193+
response: { headers: { getSetCookie: () => [] } },
194+
nativeEvent: h3Event,
195+
locals: {},
196+
} as unknown as FetchEvent);
197+
vi.mocked(getServerFunction).mockReturnValue(() => {
198+
throw thrown;
199+
});
200+
const { handleServerFunction } = await import("./handler.ts");
201+
await handleServerFunction(h3Event as never);
202+
return h3Event;
203+
};
204+
205+
beforeEach(() => {
206+
vi.clearAllMocks();
207+
configuredErrorHandler.current = undefined;
208+
});
209+
210+
it("passes the thrown value to the handler", async () => {
211+
const thrown = new Error("boom");
212+
configuredErrorHandler.current = vi.fn(() => undefined);
213+
214+
await callThrowing(thrown);
215+
216+
expect(configuredErrorHandler.current).toHaveBeenCalledWith(thrown);
217+
});
218+
219+
it("serializes the replacement the handler returns", async () => {
220+
configuredErrorHandler.current = () => new Error("replaced");
221+
222+
const h3Event = await callThrowing(new Error("boom"));
223+
224+
expect(h3Event.res.headers.get("X-Error")).toBe("replaced");
225+
});
226+
227+
it("treats a Response the handler returns as control flow", async () => {
228+
configuredErrorHandler.current = () => new Response(null, { status: 403 });
229+
230+
const h3Event = await callThrowing(new Error("boom"));
231+
232+
expect(h3Event.res.status).toBe(403);
233+
expect(h3Event.res.headers.get("X-Error")).toBe("true");
234+
});
235+
236+
it("keeps the original error when the handler returns nothing", async () => {
237+
configuredErrorHandler.current = () => undefined;
238+
239+
const h3Event = await callThrowing(new Error("boom"));
240+
241+
expect(h3Event.res.headers.get("X-Error")).toBe("boom");
242+
});
243+
244+
it("leaves the response untouched when no handler is configured", async () => {
245+
const h3Event = await callThrowing(new Error("boom"));
246+
247+
expect(h3Event.res.headers.get("X-Error")).toBe("boom");
248+
});
249+
});

0 commit comments

Comments
 (0)