fix(core-shared): match domain errors by name across module graphs
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled
defineErrorMiddleware matched by instanceof only; the custom server's tsx-bound DI graph and the Next-compiled route handler each load their own copy of every error class, so mapped domain errors (e.g. UnauthenticatedError -> UNAUTHORIZED) fell through and surfaced as raw 500s — hit in production by a downstream app built from this template. Match on the canonical error name (probed from the constructor's string-literal this.name) with instanceof as the fast path; nameless classes stay identity-only so plain Errors never translate. ADR-027 records the decision and the now-load-bearing this.name convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,21 @@ class FooBadRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Structurally identical to FooNotFoundError but a distinct class identity —
|
||||
// simulates the production failure where the DI-bound feature modules and the
|
||||
// route-handler bundle each load their own copy of an error module, so the
|
||||
// thrown instance fails `instanceof` against the registered constructor.
|
||||
class CrossRealmFooNotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "FooNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
// Never sets `this.name` — probes as the inherited "Error". Registering it
|
||||
// must not cause every plain Error to match by name.
|
||||
class AnonymousDomainError extends Error {}
|
||||
|
||||
const errorRouter = t.router({
|
||||
notFound: t.procedure
|
||||
.use(
|
||||
@@ -43,6 +58,21 @@ const errorRouter = t.router({
|
||||
.query(() => {
|
||||
throw new Error("plain");
|
||||
}),
|
||||
crossRealm: t.procedure
|
||||
.use(defineErrorMiddleware([[FooNotFoundError, "NOT_FOUND"]]))
|
||||
.query(() => {
|
||||
throw new CrossRealmFooNotFoundError("other copy");
|
||||
}),
|
||||
namelessMapped: t.procedure
|
||||
.use(defineErrorMiddleware([[AnonymousDomainError, "CONFLICT"]]))
|
||||
.query(() => {
|
||||
throw new Error("plain");
|
||||
}),
|
||||
namelessInstance: t.procedure
|
||||
.use(defineErrorMiddleware([[AnonymousDomainError, "CONFLICT"]]))
|
||||
.query(() => {
|
||||
throw new AnonymousDomainError("actual instance");
|
||||
}),
|
||||
});
|
||||
|
||||
describe("defineErrorMiddleware", () => {
|
||||
@@ -75,6 +105,31 @@ describe("defineErrorMiddleware", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("matches by error name when the instance comes from a different module graph", async () => {
|
||||
// Production regression: tsx-bound DI modules and the webpack-bundled
|
||||
// route handler hold separate copies of the error classes, so identity
|
||||
// (`instanceof`) fails and mapped errors surfaced as 500s.
|
||||
const caller = errorRouter.createCaller({});
|
||||
await expect(caller.crossRealm()).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "other copy",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not name-match plain Errors against a class that never set a custom name", async () => {
|
||||
const caller = errorRouter.createCaller({});
|
||||
await expect(caller.namelessMapped()).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
});
|
||||
|
||||
it("still matches a nameless class by identity", async () => {
|
||||
const caller = errorRouter.createCaller({});
|
||||
await expect(caller.namelessInstance()).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the original error as the cause", async () => {
|
||||
const caller = errorRouter.createCaller({});
|
||||
try {
|
||||
|
||||
@@ -20,17 +20,37 @@ type ErrorCtor = new (...args: never[]) => Error;
|
||||
*
|
||||
* Owned by features: each feature passes its own constructors in.
|
||||
* core-shared never enumerates feature-specific error classes.
|
||||
*
|
||||
* Matching is by `instanceof` OR by canonical error name: the DI-bound
|
||||
* feature modules and the app's route-handler bundle can each load their
|
||||
* own copy of an error class (separate module graphs), so identity checks
|
||||
* fail across them and mapped errors would surface as 500s. Every domain
|
||||
* error sets `this.name` from a string literal in its constructor — that
|
||||
* string survives duplication and minification, so the middleware probes
|
||||
* one instance per constructor to learn its canonical name and matches
|
||||
* `cause.name` against it. A class that never sets a custom name probes
|
||||
* as the inherited "Error"; name-matching is disabled for it (identity
|
||||
* only), otherwise every plain Error would match.
|
||||
*/
|
||||
export function defineErrorMiddleware(
|
||||
map: ReadonlyArray<readonly [ErrorCtor, TRPC_ERROR_CODE_KEY]>,
|
||||
) {
|
||||
const entries = map.map(([Ctor, code]) => {
|
||||
const probe = new (Ctor as new (...args: unknown[]) => Error)("__probe__");
|
||||
return {
|
||||
Ctor,
|
||||
name: probe.name === "Error" ? undefined : probe.name,
|
||||
code,
|
||||
};
|
||||
});
|
||||
return t.middleware(async ({ next }) => {
|
||||
const result = await next();
|
||||
if (!result.ok) {
|
||||
const cause = result.error.cause;
|
||||
if (cause instanceof Error) {
|
||||
for (const [Ctor, code] of map) {
|
||||
if (cause instanceof Ctor) {
|
||||
for (const { Ctor, name, code } of entries) {
|
||||
const matchesName = name !== undefined && cause.name === name;
|
||||
if (matchesName || cause instanceof Ctor) {
|
||||
throw new TRPCError({ code, message: cause.message, cause });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user