Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-hooks-observe.md
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions apps/tests/src/e2e/server-function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
30 changes: 30 additions & 0 deletions apps/tests/src/routes/server-function-on-error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<span
id="server-fn-test"
onClick={evt => {
const el = evt.target as HTMLElement;
serverFnThrowsReplaceable().then(
() => {
el.textContent = "no error";
},
err => {
el.textContent = err instanceof Error ? err.message : String(err);
},
);
}}
>
Click me
</span>
</main>
);
}
17 changes: 17 additions & 0 deletions apps/tests/src/server-fn-error.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions apps/tests/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export default defineConfig({
serialization: {
plugins: "src/seroval-plugins.ts",
},
serverFunctions: {
onError: "src/server-fn-error.ts",
},
env: {
server: {
load() {
Expand Down
1 change: 1 addition & 0 deletions packages/start/src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerFunctionsOptions, "filter">;
serverFunctions?: Pick<ServerFunctionsOptions, "filter"> & {
/**
* 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) =>
Expand Down
7 changes: 7 additions & 0 deletions packages/start/src/config/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`) {
Expand Down Expand Up @@ -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");
Expand Down
8 changes: 8 additions & 0 deletions packages/start/src/fns/error-handler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
89 changes: 89 additions & 0 deletions packages/start/src/fns/handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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(() => ({})),
Expand All @@ -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(),
}));
Expand All @@ -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<string, string> = {},
setCookies: string[] = [],
Expand Down Expand Up @@ -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");
});
});
2 changes: 2 additions & 0 deletions packages/start/src/fns/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/start/src/server/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/start/src/virtual.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ declare module "solid-start:middleware" {
declare module "solid-start:seroval-plugins" {
export default SerovalPlugins as import("seroval").Plugin<any, any>[];
}

declare module "solid-start:server-fn-error-handler" {
export default ServerFunctionErrorHandler as
| import("./fns/error-handler.ts").ServerFunctionErrorHandler
| undefined;
}
Loading