diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index ab2bc7ea..c76c1b86 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,5 +1,4 @@ import { database } from "@init/db/client" -import { Fault } from "@init/error/fault" import { kv } from "@init/kv/client" import { getLogger, LoggerCategory } from "@init/observability/logger" import { honoLogger } from "@init/observability/logger/integrations" @@ -52,19 +51,11 @@ app.use(async (c, next) => { }) app.onError((error, c) => { - if (Fault.isFault(error)) { - c.var.logger.error(error.flatten(), { - cause: error.cause, - context: error.context, - debug: error.debug, - tag: error.tag, - }) - } - if (error instanceof HTTPException) { return error.getResponse() } + c.var.logger.error(error.message) captureException(error) return c.text("Internal Server Error", 500) diff --git a/apps/api/src/shared/utils.ts b/apps/api/src/shared/utils.ts index 16bec2ee..e939ac74 100644 --- a/apps/api/src/shared/utils.ts +++ b/apps/api/src/shared/utils.ts @@ -1,8 +1,5 @@ -import { extend } from "@init/error/fault" -import { TRPCError } from "@trpc/server" import { getContext } from "hono/context-storage" import { createFactory } from "hono/factory" -import { HTTPException } from "hono/http-exception" import type { AppContext } from "#shared/types.ts" /** @@ -15,7 +12,3 @@ export const factory = createFactory() * A utility function to get the context from the request. */ export const context = getContext - -// Extended error classes with Fault functionality -export const HTTPFault = extend(HTTPException) -export const TRPCFault = extend(TRPCError) diff --git a/apps/app/src/shared/env.ts b/apps/app/src/shared/env.ts index 667ea08a..ef514c32 100644 --- a/apps/app/src/shared/env.ts +++ b/apps/app/src/shared/env.ts @@ -1,7 +1,7 @@ import { createEnv } from "@init/env" import { auth, db, node } from "@init/env/presets" import { REACT_PUBLIC_ENV_PREFIX } from "@init/utils/constants" -import { isCI, isProduction } from "@init/utils/environment" +import { isCI } from "@init/utils/environment" import * as z from "@init/utils/schema" export default createEnv({ diff --git a/apps/app/src/shared/server/middleware.ts b/apps/app/src/shared/server/middleware.ts index d8946527..779f3d39 100644 --- a/apps/app/src/shared/server/middleware.ts +++ b/apps/app/src/shared/server/middleware.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto" -import { Fault } from "@init/error/fault" +import { UnauthenticatedError, UnauthorizedError } from "@init/error" import { createMiddleware } from "@tanstack/react-start" import { authClient } from "#shared/auth.ts" import { logger } from "#shared/logger.ts" @@ -22,18 +22,11 @@ export const withLogger = createMiddleware() export const requireSession = createMiddleware() .middleware([withRequestId]) - .server(async ({ next, context }) => { + .server(async ({ next }) => { const { data: session } = await authClient.getSession() if (!session) { - throw Fault.create("auth.unauthenticated") - .withDescription( - "User is not authenticated.", - "You are not authenticated. Please sign in to continue." - ) - .withContext({ - requestId: context.requestId, - }) + throw new UnauthenticatedError() } return next({ context: { session } }) @@ -45,15 +38,7 @@ export const requireAdmin = createMiddleware() const { user } = context.session if (user.role !== "admin") { - throw Fault.create("auth.unauthorized") - .withDescription( - "User is not an admin.", - "You are not an admin. Please contact support if you believe this is an error." - ) - .withContext({ - requestId: context.requestId, - userId: context.session.user.id, - }) + throw new UnauthorizedError({ userId: context.session.user.id }) } return next() diff --git a/apps/app/src/shared/server/serialization.ts b/apps/app/src/shared/server/serialization.ts deleted file mode 100644 index 76ffa651..00000000 --- a/apps/app/src/shared/server/serialization.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Fault } from "@init/error/fault" -import { createSerializationAdapter } from "@tanstack/react-router" - -export const faultSerializer = createSerializationAdapter({ - // @ts-expect-error - unknown type - fromSerializable: (value) => Fault.fromSerializable(value), - key: "fault", - test: (value) => Fault.isFault(value), - toSerializable: (value) => Fault.toSerializable(value), -}) diff --git a/apps/app/src/start.ts b/apps/app/src/start.ts index 8054530b..335c87f9 100644 --- a/apps/app/src/start.ts +++ b/apps/app/src/start.ts @@ -1,8 +1,6 @@ import { createStart } from "@tanstack/react-start" -import { faultSerializer } from "#shared/server/serialization.ts" export const startInstance = createStart(() => ({ functionMiddleware: [], requestMiddleware: [], - serializationAdapters: [faultSerializer], })) diff --git a/bun.lock b/bun.lock index 26cbba54..b2b2744c 100644 --- a/bun.lock +++ b/bun.lock @@ -380,7 +380,7 @@ "packages/error": { "name": "@init/error", "dependencies": { - "faultier": "1.1.0", + "better-result": "2.6.0", }, "devDependencies": { "@tooling/tsconfig": "workspace:*", @@ -2498,6 +2498,8 @@ "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], + "better-result": ["better-result@2.6.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-hv31uYHmjFf3zIJgj5V0mT2ZOUg8zDVfd6Pv0iKKGcEAtjukxJofNa0q91cft+YqwHBSQAld5i9zbwgI9alZnA=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -3050,8 +3052,6 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "faultier": ["faultier@1.1.0", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-2LLTfDUMGlJDhFX4g/sJ1kvOtT73wBqiQx5CVrpHS/AgNkjEAFavJiiflIAgegD7DZpE/SgVdOcty3px69OK6A=="], - "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], "fbjs": ["fbjs@3.0.5", "", { "dependencies": { "cross-fetch": "^3.1.5", "fbjs-css-vars": "^1.0.0", "loose-envify": "^1.0.0", "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", "ua-parser-js": "^1.0.35" } }, "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg=="], @@ -5382,6 +5382,8 @@ "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "better-result/@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "boxen/camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], @@ -6192,6 +6194,10 @@ "better-opn/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "better-result/@clack/prompts/@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], + + "better-result/@clack/prompts/picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "boxen/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "boxen/wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], diff --git a/packages/backend/src/functions/shared/convex.ts b/packages/backend/src/functions/shared/convex.ts index 665d820a..488df60d 100644 --- a/packages/backend/src/functions/shared/convex.ts +++ b/packages/backend/src/functions/shared/convex.ts @@ -1,4 +1,4 @@ -import { Fault } from "@init/error/fault" +import { UnauthenticatedError, UnauthorizedError } from "@init/error" import { getLogger, LoggerCategory } from "@init/observability/logger" import { customAction, @@ -28,7 +28,7 @@ async function validateIdentity(ctx: QueryCtx | MutationCtx | ActionCtx) { const identity = await ctx.auth.getUserIdentity() if (!identity) { - throw Fault.create("auth.unauthenticated").withDescription("User is not authenticated") + throw new UnauthenticatedError() } return identity @@ -38,13 +38,11 @@ async function validateAdmin(ctx: QueryCtx | MutationCtx | ActionCtx) { const identity = await ctx.auth.getUserIdentity() if (!identity) { - throw Fault.create("auth.unauthenticated").withDescription("User is not authenticated") + throw new UnauthenticatedError() } if (identity.role !== "admin") { - throw Fault.create("auth.unauthorized").withDescription("User is not authorized").withContext({ - userId: identity.tokenIdentifier, - }) + throw new UnauthorizedError({ userId: identity.tokenIdentifier }) } return identity diff --git a/packages/email/src/client.ts b/packages/email/src/client.ts index 369ff332..ef57f812 100644 --- a/packages/email/src/client.ts +++ b/packages/email/src/client.ts @@ -1,6 +1,6 @@ import type { ReactNode } from "react" import { resend } from "@init/env/presets" -import { Fault } from "@init/error/fault" +import { SendEmailError, BatchSendEmailError } from "@init/error" import { logger } from "@init/observability/logger" import { type DurationInput, milliseconds } from "@init/utils/duration" import { singleton } from "@init/utils/singleton" @@ -51,14 +51,12 @@ export async function sendEmail(body: ReactNode, params: EmailSendParams) { }) if (error) { - throw Fault.wrap(error) - .withTag("email.send_failed") - .withContext({ - emails, - from, - subject, - text: await render(body, { plainText: true }), - }) + throw new SendEmailError({ + emails, + from, + subject, + text: await render(body, { plainText: true }), + }) } return data @@ -104,13 +102,11 @@ export async function batchEmails(payload: Array emails), - from: env.EMAIL_FROM, - subject: payload.map(({ subject }) => subject).join(", "), - }) + throw new BatchSendEmailError({ + emails: payload.flatMap(({ emails }) => emails), + from: env.EMAIL_FROM, + subject: payload.map(({ subject }) => subject).join(", "), + }) } return data.data ?? [] diff --git a/packages/error/package.json b/packages/error/package.json index 99366459..0195bbb3 100644 --- a/packages/error/package.json +++ b/packages/error/package.json @@ -4,7 +4,8 @@ "license": "MIT", "type": "module", "exports": { - "./fault": "./src/fault.ts" + ".": "./src/index.ts", + "./*": "./src/*.ts" }, "scripts": { "bump:deps": "bun update --interactive", @@ -12,7 +13,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "faultier": "1.1.0" + "better-result": "2.6.0" }, "devDependencies": { "@tooling/tsconfig": "workspace:*", diff --git a/packages/error/src/auth.ts b/packages/error/src/auth.ts new file mode 100644 index 00000000..5a4a099f --- /dev/null +++ b/packages/error/src/auth.ts @@ -0,0 +1,7 @@ +import { TaggedError } from "better-result" + +export class UnauthenticatedError extends TaggedError("AuthenticationError")() {} + +export class UnauthorizedError extends TaggedError("AuthorizationError")<{ userId: string }>() {} + +export type AuthenticationError = UnauthenticatedError | UnauthorizedError diff --git a/packages/error/src/email.ts b/packages/error/src/email.ts new file mode 100644 index 00000000..25c984ef --- /dev/null +++ b/packages/error/src/email.ts @@ -0,0 +1,16 @@ +import { TaggedError } from "better-result" + +export class SendEmailError extends TaggedError("SendEmailError")<{ + emails: string[] + subject: string + from?: string + text: string +}>() {} + +export class BatchSendEmailError extends TaggedError("BatchSendEmailError")<{ + emails: string[] + subject: string + from?: string +}>() {} + +export type EmailError = SendEmailError | BatchSendEmailError diff --git a/packages/error/src/fault.ts b/packages/error/src/fault.ts deleted file mode 100644 index 99b10e67..00000000 --- a/packages/error/src/fault.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Authentication errors -export type AuthenticationError = { - "auth.unauthenticated": { requestId?: string } - "auth.unauthorized": { requestId?: string; userId?: string } -} - -export type EmailError = { - "email.send_failed": { - emails: string[] - subject: string - from?: string - text: string - } - "email.batch_send_failed": { - emails: string[] - subject: string - from?: string - } -} - -export type DurationError = { - "duration.invalid_parse_input": { value: string } - "duration.invalid_format_input": { value: unknown } -} - -export type AssertError = { - "assert.unreachable": { value: unknown } - "assert.condition_failed": { condition: string } -} - -declare module "faultier" { - interface FaultRegistry extends AuthenticationError, EmailError, DurationError, AssertError {} -} - -export { Fault } from "faultier" -export { extend } from "faultier/extend" diff --git a/packages/error/src/index.ts b/packages/error/src/index.ts new file mode 100644 index 00000000..78243c72 --- /dev/null +++ b/packages/error/src/index.ts @@ -0,0 +1,9 @@ +import type { AuthenticationError } from "./auth" +import type { EmailError } from "./email" +import type { UtilityError } from "./utils" + +export type AppError = AuthenticationError | EmailError | UtilityError + +export * from "./auth" +export * from "./email" +export * from "./utils" diff --git a/packages/error/src/utils.ts b/packages/error/src/utils.ts new file mode 100644 index 00000000..713b2b49 --- /dev/null +++ b/packages/error/src/utils.ts @@ -0,0 +1,33 @@ +import { TaggedError } from "better-result" + +export class InvalidDurationParseInputError extends TaggedError("InvalidDurationParseInputError")<{ + message: string + value: string +}>() { + constructor(props: { value: string }) { + super({ ...props, message: `Unable to parse duration from input: "${props.value}"` }) + } +} + +export class InvalidDurationFormatInputError extends TaggedError( + "InvalidDurationFormatInputError" +)<{ message: string }>() { + constructor() { + super({ message: "Invalid duration format provided" }) + } +} + +export class AssertUnreachableError extends TaggedError("AssertUnreachableError")<{ + value: unknown +}>() {} + +export class AssertConditionFailedError extends TaggedError("AssertConditionFailedError")<{ + message: string + condition: string +}>() {} + +export type DurationError = InvalidDurationParseInputError | InvalidDurationFormatInputError + +export type AssertError = AssertUnreachableError | AssertConditionFailedError + +export type UtilityError = DurationError | AssertError diff --git a/packages/utils/src/assert.ts b/packages/utils/src/assert.ts index 83b4eedd..05bb6634 100644 --- a/packages/utils/src/assert.ts +++ b/packages/utils/src/assert.ts @@ -1,13 +1,11 @@ -import { Fault } from "@init/error/fault" +import { AssertConditionFailedError, AssertUnreachableError } from "@init/error" /** * Asserts that a value is never, and throws an error if it is. Use this to make * sure that all cases in a `switch` statement are handled. */ export function assertUnreachable(x: never): never { - throw Fault.create("assert.unreachable") - .withDescription(`Case not handled: ${String(x)}`, "An unexpected error occurred.") - .withContext({ value: x }) + throw new AssertUnreachableError({ value: x }) } /** @@ -15,9 +13,7 @@ export function assertUnreachable(x: never): never { */ export function throwUnless(condition: boolean, message: string): asserts condition is true { if (!condition) { - throw Fault.create("assert.condition_failed") - .withDescription(message, "An unexpected error occurred.") - .withContext({ condition: "throwUnless" }) + throw new AssertConditionFailedError({ condition: "throwUnless", message }) } } @@ -26,8 +22,6 @@ export function throwUnless(condition: boolean, message: string): asserts condit */ export function throwIf(condition: boolean, message: string): asserts condition is false { if (condition) { - throw Fault.create("assert.condition_failed") - .withDescription(message, "An unexpected error occurred.") - .withContext({ condition: "throwIf" }) + throw new AssertConditionFailedError({ condition: "throwIf", message }) } } diff --git a/packages/utils/src/duration.ts b/packages/utils/src/duration.ts index 3a45f972..57c01d93 100644 --- a/packages/utils/src/duration.ts +++ b/packages/utils/src/duration.ts @@ -1,5 +1,5 @@ // Taken from: https://github.com/vercel/ms/blob/main/src/index.ts -import { Fault } from "@init/error/fault" +import { InvalidDurationFormatInputError, InvalidDurationParseInputError } from "@init/error" import { assertUnreachable } from "./assert" const s = 1000 @@ -82,24 +82,15 @@ export function seconds(value: DurationInput | number, options?: Options): numbe */ export function parse(str: string): number { if (typeof str !== "string") { - throw Fault.create("duration.invalid_parse_input") - .withDescription( - "Value provided to parse() must be a string with length between 1 and 99", - "Invalid duration format provided." - ) - .withContext({ value: str }) + throw new InvalidDurationFormatInputError() } const length = str.length if (length === 0 || length > 100) { - throw Fault.create("duration.invalid_parse_input") - .withDescription( - "Value provided to parse() must be a string with length between 1 and 99", - "Invalid duration format provided." - ) - .withContext({ value: str }) + throw new InvalidDurationParseInputError({ value: str }) } + const match = /^(?-?\d*\.?\d+) *(?milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|months?|mo|years?|yrs?|y)?$/i.exec( str @@ -245,12 +236,7 @@ function fmtLong(ms: number): DurationInput { */ export function format(ms: number, options?: Options): string { if (typeof ms !== "number" || !Number.isFinite(ms)) { - throw Fault.create("duration.invalid_format_input") - .withDescription( - "Value provided to format() must be a finite number", - "Invalid duration value provided." - ) - .withContext({ value: ms }) + throw new InvalidDurationFormatInputError() } return options?.long ? fmtLong(ms) : fmtShort(ms)