From 1e90451b0c108081ecd6cb506777ced45e6161ac Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Thu, 24 Apr 2025 07:21:23 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 3 +- apps/api/src/lib/env.ts | 7 +- apps/api/src/lib/errors.ts | 30 ++++++- apps/api/src/lib/exit-hooks.ts | 2 + apps/api/src/lib/utils.ts | 28 ++++++- pnpm-lock.yaml | 147 ++++++++++++++------------------- 6 files changed, 123 insertions(+), 94 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 76099e7a..acf19fb1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -46,7 +46,8 @@ "postgres": "^3.4.5", "restore-cursor": "catalog:", "type-fest": "catalog:", - "zod": "catalog:" + "zod": "catalog:", + "zod-validation-error": "^3.4.0" }, "devDependencies": { "@types/jsonwebtoken": "^9.0.9", diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index c33c1386..a877f540 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -2,6 +2,8 @@ import 'dotenv/config' import { z } from 'zod' +import { parseZodSchema } from './utils' + export const envSchema = z.object({ NODE_ENV: z .enum(['development', 'test', 'production']) @@ -10,8 +12,11 @@ export const envSchema = z.object({ JWT_SECRET: z.string(), PORT: z.number().default(3000) }) +export type Env = z.infer // eslint-disable-next-line no-process-env -export const env = envSchema.parse(process.env) +export const env = parseZodSchema(envSchema, process.env, { + error: 'Invalid environment variables' +}) export const isProd = env.NODE_ENV === 'production' diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index 3201a874..d19d1ab7 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -1,17 +1,41 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status' +import { fromError } from 'zod-validation-error' -export class HttpError extends Error { +export class BaseError extends Error { + constructor({ message, cause }: { message: string; cause?: unknown }) { + super(message, { cause }) + + // Ensure the name of this error is the same as the class name + this.name = this.constructor.name + + // Set stack trace to caller + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor) + } + } +} + +export class HttpError extends BaseError { readonly statusCode: ContentfulStatusCode constructor({ statusCode = 500, - message + message, + cause }: { statusCode?: ContentfulStatusCode message: string + cause?: unknown }) { - super(message) + super({ message, cause }) this.statusCode = statusCode } } + +export class ZodValidationError extends BaseError { + constructor({ prefix, cause }: { prefix?: string; cause: unknown }) { + const error = fromError(cause, { prefix }) + super({ message: error.message, cause }) + } +} diff --git a/apps/api/src/lib/exit-hooks.ts b/apps/api/src/lib/exit-hooks.ts index ac8b8170..092646e7 100644 --- a/apps/api/src/lib/exit-hooks.ts +++ b/apps/api/src/lib/exit-hooks.ts @@ -37,4 +37,6 @@ export function initExitHooks({ wait: timeoutMs } ) + + // TODO: On Node.js, log unhandledRejection, uncaughtException, and warning events } diff --git a/apps/api/src/lib/utils.ts b/apps/api/src/lib/utils.ts index 0964d249..5ab8536d 100644 --- a/apps/api/src/lib/utils.ts +++ b/apps/api/src/lib/utils.ts @@ -1,6 +1,9 @@ import { createHash, randomUUID } from 'node:crypto' -import { HttpError } from './errors' +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import type { ZodSchema } from 'zod' + +import { HttpError, ZodValidationError } from './errors' export function sha256(input: string = randomUUID()) { return createHash('sha256').update(input).digest('hex') @@ -9,12 +12,12 @@ export function sha256(input: string = randomUUID()) { export function assert(expr: unknown, message?: string): asserts expr export function assert( expr: unknown, - statusCode?: number, + statusCode?: ContentfulStatusCode, message?: string ): asserts expr export function assert( expr: unknown, - statusCodeOrMessage?: number | string, + statusCodeOrMessage?: ContentfulStatusCode | string, message = 'Internal assertion failed' ): asserts expr { if (expr) { @@ -27,3 +30,22 @@ export function assert( throw new Error(statusCodeOrMessage ?? message) } } + +export function parseZodSchema( + schema: ZodSchema, + input: unknown, + { + error + }: { + error?: string + } = {} +): T { + try { + return schema.parse(input) + } catch (err) { + throw new ZodValidationError({ + prefix: error, + cause: err + }) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0d576c9..3ddfeb78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,15 +6,60 @@ settings: catalogs: default: + '@fisch0920/config': + specifier: ^1.0.4 + version: 1.0.4 + '@types/node': + specifier: ^22.14.1 + version: 22.14.1 + del-cli: + specifier: ^6.0.0 + version: 6.0.0 + dotenv: + specifier: ^16.5.0 + version: 16.5.0 + eslint: + specifier: ^9.25.1 + version: 9.25.1 exit-hook: specifier: ^4.0.0 version: 4.0.0 + lint-staged: + specifier: ^15.5.1 + version: 15.5.1 + npm-run-all2: + specifier: ^7.0.2 + version: 7.0.2 + only-allow: + specifier: ^1.2.1 + version: 1.2.1 + prettier: + specifier: ^3.5.3 + version: 3.5.3 restore-cursor: specifier: ^5.1.0 version: 5.1.0 + simple-git-hooks: + specifier: ^2.12.1 + version: 2.12.1 + tsup: + specifier: ^8.4.0 + version: 8.4.0 + tsx: + specifier: ^4.19.3 + version: 4.19.3 + turbo: + specifier: ^2.5.0 + version: 2.5.0 type-fest: specifier: ^4.40.0 version: 4.40.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.1.2 + version: 3.1.2 zod: specifier: ^3.24.3 version: 3.24.3 @@ -116,6 +161,9 @@ importers: zod: specifier: 'catalog:' version: 3.24.3 + zod-validation-error: + specifier: ^3.4.0 + version: 3.4.0(zod@3.24.3) devDependencies: '@types/jsonwebtoken': specifier: ^9.0.9 @@ -771,10 +819,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/scope-manager@8.29.0': - resolution: {integrity: sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.31.0': resolution: {integrity: sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -786,33 +830,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/types@8.29.0': - resolution: {integrity: sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.31.0': resolution: {integrity: sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.29.0': - resolution: {integrity: sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/typescript-estree@8.31.0': resolution: {integrity: sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/utils@8.29.0': - resolution: {integrity: sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/utils@8.31.0': resolution: {integrity: sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -820,10 +847,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/visitor-keys@8.29.0': - resolution: {integrity: sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.31.0': resolution: {integrity: sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1532,14 +1555,6 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - fdir@6.4.3: - resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - fdir@6.4.4: resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} peerDependencies: @@ -2666,10 +2681,6 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.12: - resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} @@ -2979,6 +2990,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + zod@3.24.3: resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} @@ -3508,11 +3525,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.29.0': - dependencies: - '@typescript-eslint/types': 8.29.0 - '@typescript-eslint/visitor-keys': 8.29.0 - '@typescript-eslint/scope-manager@8.31.0': dependencies: '@typescript-eslint/types': 8.31.0 @@ -3529,24 +3541,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.29.0': {} - '@typescript-eslint/types@8.31.0': {} - '@typescript-eslint/typescript-estree@8.29.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/types': 8.29.0 - '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.1 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.31.0(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.31.0 @@ -3561,17 +3557,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.29.0(eslint@9.25.1)(typescript@5.8.3)': - dependencies: - '@eslint-community/eslint-utils': 4.5.1(eslint@9.25.1) - '@typescript-eslint/scope-manager': 8.29.0 - '@typescript-eslint/types': 8.29.0 - '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.8.3) - eslint: 9.25.1 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.31.0(eslint@9.25.1)(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.5.1(eslint@9.25.1) @@ -3583,11 +3568,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.29.0': - dependencies: - '@typescript-eslint/types': 8.29.0 - eslint-visitor-keys: 4.2.0 - '@typescript-eslint/visitor-keys@8.31.0': dependencies: '@typescript-eslint/types': 8.31.0 @@ -4285,8 +4265,8 @@ snapshots: eslint-plugin-testing-library@7.1.1(eslint@9.25.1)(typescript@5.8.3): dependencies: - '@typescript-eslint/scope-manager': 8.29.0 - '@typescript-eslint/utils': 8.29.0(eslint@9.25.1)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.31.0 + '@typescript-eslint/utils': 8.31.0(eslint@9.25.1)(typescript@5.8.3) eslint: 9.25.1 transitivePeerDependencies: - supports-color @@ -4420,10 +4400,6 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.4.3(picomatch@4.0.2): - optionalDependencies: - picomatch: 4.0.2 - fdir@6.4.4(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -5588,11 +5564,6 @@ snapshots: tinyexec@0.3.2: {} - tinyglobby@0.2.12: - dependencies: - fdir: 6.4.3(picomatch@4.0.2) - picomatch: 4.0.2 - tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) @@ -5645,7 +5616,7 @@ snapshots: source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 tree-kill: 1.2.2 optionalDependencies: postcss: 8.5.3 @@ -5939,4 +5910,8 @@ snapshots: yocto-queue@0.1.0: {} + zod-validation-error@3.4.0(zod@3.24.3): + dependencies: + zod: 3.24.3 + zod@3.24.3: {}