import { TRPCError } from "@trpc/server" import { getResponseBody } from "./utils/getResponseBody" type CustomCause = { message: string errorDetails: Record } type ResponseLike = { status: number statusText: string body: string | Record url?: string } type TRPCCause = ResponseLike | CustomCause | Error | string export function isCustomCause(cause: unknown): cause is CustomCause { return ( !!cause && typeof cause === "object" && "message" in cause && "errorDetails" in cause && (cause as CustomCause).errorDetails !== undefined ) } export function isTRPCError(error: unknown): error is TRPCError { return error instanceof Error && error.name === "TRPCError" } export function gatewayTimeout(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "GATEWAY_TIMEOUT", cause: harmonizeCause(cause, message), }) } export function unauthorizedError(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "UNAUTHORIZED", cause: harmonizeCause(cause, message), }) } export function forbiddenError(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "FORBIDDEN", cause: harmonizeCause(cause, message), }) } export function conflictError(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "CONFLICT", cause: harmonizeCause(cause, message), }) } export function badRequestError(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "BAD_REQUEST", cause: harmonizeCause(cause, message), }) } export function notFoundError(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "NOT_FOUND", cause: harmonizeCause(cause, message), }) } export function unprocessableContent(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "UNPROCESSABLE_CONTENT", cause: harmonizeCause(cause, message), }) } export function internalServerError(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause: harmonizeCause(cause, message), }) } export const SESSION_EXPIRED = "SESSION_EXPIRED" export class SessionExpiredError extends Error {} export function sessionExpiredError() { return new TRPCError({ code: "UNAUTHORIZED", message: SESSION_EXPIRED, cause: new SessionExpiredError(SESSION_EXPIRED), }) } export const PUBLIC_UNAUTHORIZED = "PUBLIC_UNAUTHORIZED" export class PublicUnauthorizedError extends Error {} export function publicUnauthorizedError() { return new TRPCError({ code: "UNAUTHORIZED", message: PUBLIC_UNAUTHORIZED, cause: new PublicUnauthorizedError(PUBLIC_UNAUTHORIZED), }) } export function httpStatusByErrorCode(error: TRPCError) { switch (error.code) { case "BAD_REQUEST": return 400 case "UNAUTHORIZED": return 401 case "FORBIDDEN": return 403 case "NOT_FOUND": return 404 case "CONFLICT": return 409 case "UNPROCESSABLE_CONTENT": return 422 case "GATEWAY_TIMEOUT": return 504 case "INTERNAL_SERVER_ERROR": default: return 500 } } function errorCodeByHttpStatus(status: number): TRPCError["code"] { switch (status) { case 400: return "BAD_REQUEST" case 401: return "UNAUTHORIZED" case 403: return "FORBIDDEN" case 404: return "NOT_FOUND" case 409: return "CONFLICT" case 422: return "UNPROCESSABLE_CONTENT" case 504: return "GATEWAY_TIMEOUT" case 500: default: return "INTERNAL_SERVER_ERROR" } } export function serverErrorByStatus( status: number, cause: CustomCause | Error | string ): TRPCError export function serverErrorByStatus( status: number, cause: ResponseLike, message: string ): TRPCError export function serverErrorByStatus( status: number, cause?: TRPCCause, message?: string ) { switch (status) { case 401: return unauthorizedError(cause, message) case 403: return forbiddenError(cause, message) case 404: return notFoundError(cause, message) case 409: return conflictError(cause, message) case 422: return unprocessableContent(cause, message) case 500: return internalServerError(cause, message) case 504: return gatewayTimeout(cause, message) default: return internalServerError(cause, message) } } function harmonizeCause( cause: TRPCCause | undefined, message: string = "" ): CustomCause | Error | undefined { if (!cause) { return undefined } if (isResponseLike(cause)) { return { message: message || `HTTP Error ${cause.status}: ${cause.statusText}`, errorDetails: { status: cause.status, statusText: cause.statusText || errorCodeByHttpStatus(cause.status), body: truncate(cause.body, 200), // Avoids issues in Sentry with large bodies url: cause.url, }, } satisfies CustomCause } if (typeof cause === "string") { return { message: cause, errorDetails: {} } satisfies CustomCause } return cause } export async function extractResponseDetails( response: Response ): Promise { const body = await getResponseBody(response) return { status: response.status, statusText: response.statusText, body, url: response.url, } } function isResponseLike(cause: TRPCCause): cause is ResponseLike { if (typeof cause !== "object" || !cause) { return false } if (!("status" in cause) || typeof cause.status !== "number") { return false } if (!("statusText" in cause) || typeof cause.statusText !== "string") { return false } if (!("body" in cause)) { return false } return true } function truncate( str: string | Record, maxLength: number ): string { if (typeof str !== "string") { str = JSON.stringify(str) } if (str.length <= maxLength) { return str } const originalLength = str.length return ( str.slice(0, maxLength) + `... [truncated, original length: ${originalLength}]` ) }