From 758cd4e4f074d4864d23261ac8af844abba841a1 Mon Sep 17 00:00:00 2001 From: Edwin Hernandez Date: Fri, 25 Apr 2025 17:05:45 -0500 Subject: [PATCH 1/2] refactor: Update package.json scripts and improve environment variable validation --- package.json | 17 ++-- pnpm-lock.yaml | 20 +--- src/api-docs/openAPIDocumentGenerator.ts | 4 +- src/common/middleware/errorHandler.ts | 2 +- src/common/middleware/requestLogger.ts | 112 ++++++++--------------- src/common/utils/envConfig.ts | 35 +++++-- src/common/utils/httpHandlers.ts | 4 +- tsconfig.json | 6 +- 8 files changed, 88 insertions(+), 112 deletions(-) diff --git a/package.json b/package.json index 1df5025c..d6c53d6e 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,9 @@ "private": true, "scripts": { "build": "tsc && tsup", - "start:dev": "tsx watch --clear-screen=false src/index.ts | pino-pretty", + "start:dev": "node --import=tsx --watch src/index.ts", "start:prod": "node dist/index.js", - "lint": "biome lint", - "lint:fix": "biome lint --fix", + "lint": "biome lint --fix", "format": "biome format --write", "test": "vitest run", "test:cov": "vitest run --coverage", @@ -22,7 +21,6 @@ "@asteasolutions/zod-to-openapi": "7.3.0", "cors": "2.8.5", "dotenv": "16.5.0", - "envalid": "8.0.0", "express": "5.1.0", "express-rate-limit": "7.5.0", "helmet": "8.1.0", @@ -48,10 +46,15 @@ "vitest": "3.1.2" }, "tsup": { - "entry": ["src", "!src/**/__tests__/**", "!src/**/*.test.*"], - "splitting": false, + "entry": ["src/index.ts"], + "outDir": "dist", + "format": ["esm", "cjs"], + "target": "es2020", "sourcemap": true, - "clean": true + "clean": true, + "dts": true, + "splitting": false, + "skipNodeModulesBundle": true }, "packageManager": "pnpm@10.9.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5332989a..821ea454 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: dotenv: specifier: 16.5.0 version: 16.5.0 - envalid: - specifier: 8.0.0 - version: 8.0.0 express: specifier: 5.1.0 version: 5.1.0 @@ -64,7 +61,7 @@ importers: specifier: 3.1.2 version: 3.1.2(vitest@3.1.2(@types/node@22.15.2)(tsx@4.19.3)(yaml@2.7.1)) pino-pretty: - specifier: 13.0.0 + specifier: ^13.0.0 version: 13.0.0 supertest: specifier: 7.1.0 @@ -76,7 +73,7 @@ importers: specifier: 4.19.3 version: 4.19.3 typescript: - specifier: 5.8.3 + specifier: ^5.8.3 version: 5.8.3 vite-tsconfig-paths: specifier: 5.1.4 @@ -739,10 +736,6 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - envalid@8.0.0: - resolution: {integrity: sha512-PGeYJnJB5naN0ME6SH8nFcDj9HVbLpYIfg1p5lAyM9T4cH2lwtu2fLbozC/bq+HUUOIFxhX/LP0/GmlqPHT4tQ==} - engines: {node: '>=8.12'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1379,9 +1372,6 @@ packages: typescript: optional: true - tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - tsup@8.4.0: resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} engines: {node: '>=18'} @@ -2066,10 +2056,6 @@ snapshots: dependencies: once: 1.4.0 - envalid@8.0.0: - dependencies: - tslib: 2.6.2 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2762,8 +2748,6 @@ snapshots: optionalDependencies: typescript: 5.8.3 - tslib@2.6.2: {} - tsup@8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.3)(yaml@2.7.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.3) diff --git a/src/api-docs/openAPIDocumentGenerator.ts b/src/api-docs/openAPIDocumentGenerator.ts index 3d7fe150..4b16ea51 100644 --- a/src/api-docs/openAPIDocumentGenerator.ts +++ b/src/api-docs/openAPIDocumentGenerator.ts @@ -3,7 +3,9 @@ import { OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-open import { healthCheckRegistry } from "@/api/healthCheck/healthCheckRouter"; import { userRegistry } from "@/api/user/userRouter"; -export function generateOpenAPIDocument() { +export type OpenAPIDocument = ReturnType; + +export function generateOpenAPIDocument(): OpenAPIDocument { const registry = new OpenAPIRegistry([healthCheckRegistry, userRegistry]); const generator = new OpenApiGeneratorV3(registry.definitions); diff --git a/src/common/middleware/errorHandler.ts b/src/common/middleware/errorHandler.ts index 32a72205..0eeb442f 100644 --- a/src/common/middleware/errorHandler.ts +++ b/src/common/middleware/errorHandler.ts @@ -10,4 +10,4 @@ const addErrorToRequestLog: ErrorRequestHandler = (err, _req, res, next) => { next(err); }; -export default () => [unexpectedRequest, addErrorToRequestLog]; +export default (): [RequestHandler, ErrorRequestHandler] => [unexpectedRequest, addErrorToRequestLog]; diff --git a/src/common/middleware/requestLogger.ts b/src/common/middleware/requestLogger.ts index dc59b027..d6c7e6e0 100644 --- a/src/common/middleware/requestLogger.ts +++ b/src/common/middleware/requestLogger.ts @@ -1,90 +1,58 @@ import { randomUUID } from "node:crypto"; -import type { IncomingMessage, ServerResponse } from "node:http"; -import type { Request, RequestHandler, Response } from "express"; -import { StatusCodes, getReasonPhrase } from "http-status-codes"; -import type { LevelWithSilent } from "pino"; -import { type CustomAttributeKeys, type Options, pinoHttp } from "pino-http"; +import type { NextFunction, Request, Response } from "express"; +import { StatusCodes } from "http-status-codes"; +import pino from "pino"; +import pinoHttp from "pino-http"; import { env } from "@/common/utils/envConfig"; -enum LogLevel { - Fatal = "fatal", - Error = "error", - Warn = "warn", - Info = "info", - Debug = "debug", - Trace = "trace", - Silent = "silent", -} +const logger = pino({ + level: env.isProduction ? "info" : "debug", + transport: env.isProduction ? undefined : { target: "pino-pretty" }, +}); -type PinoCustomProps = { - request: Request; - response: Response; - error: Error; - responseBody: unknown; +const getLogLevel = (status: number) => { + if (status >= StatusCodes.INTERNAL_SERVER_ERROR) return "error"; + if (status >= StatusCodes.BAD_REQUEST) return "warn"; + return "info"; }; -const requestLogger = (options?: Options): RequestHandler[] => { - const pinoOptions: Options = { - enabled: env.isProduction, - customProps: customProps as unknown as Options["customProps"], - redact: [], - genReqId, - customLogLevel, - customSuccessMessage, - customReceivedMessage: (req) => `request received: ${req.method}`, - customErrorMessage: (_req, res) => `request errored with status code: ${res.statusCode}`, - customAttributeKeys, - ...options, - }; - return [responseBodyMiddleware, pinoHttp(pinoOptions)]; -}; +const addRequestId = (req: Request, res: Response, next: NextFunction) => { + const existingId = req.headers["x-request-id"] as string; + const requestId = existingId || randomUUID(); + + // Set for downstream use + req.headers["x-request-id"] = requestId; + res.setHeader("X-Request-Id", requestId); -const customAttributeKeys: CustomAttributeKeys = { - req: "request", - res: "response", - err: "error", - responseTime: "timeTaken", + next(); }; -const customProps = (req: Request, res: Response): PinoCustomProps => ({ - request: req, - response: res, - error: res.locals.err, - responseBody: res.locals.responseBody, +const httpLogger = pinoHttp({ + logger, + genReqId: (req) => req.headers["x-request-id"] as string, + customLogLevel: (_req, res) => getLogLevel(res.statusCode), + customSuccessMessage: (req) => `${req.method} ${req.url} completed`, + customErrorMessage: (_req, res) => `Request failed with status code: ${res.statusCode}`, + // Only log response bodies in development + serializers: { + req: (req) => ({ + method: req.method, + url: req.url, + id: req.id, + }), + }, }); -const responseBodyMiddleware: RequestHandler = (_req, res, next) => { - const isNotProduction = !env.isProduction; - if (isNotProduction) { +const captureResponseBody = (req: Request, res: Response, next: NextFunction) => { + if (!env.isProduction) { const originalSend = res.send; - res.send = (content) => { - res.locals.responseBody = content; - res.send = originalSend; - return originalSend.call(res, content); + res.send = function (body) { + res.locals.responseBody = body; + return originalSend.call(this, body); }; } next(); }; -const customLogLevel = (_req: IncomingMessage, res: ServerResponse, err?: Error): LevelWithSilent => { - if (err || res.statusCode >= StatusCodes.INTERNAL_SERVER_ERROR) return LogLevel.Error; - if (res.statusCode >= StatusCodes.BAD_REQUEST) return LogLevel.Warn; - if (res.statusCode >= StatusCodes.MULTIPLE_CHOICES) return LogLevel.Silent; - return LogLevel.Info; -}; - -const customSuccessMessage = (req: IncomingMessage, res: ServerResponse) => { - if (res.statusCode === StatusCodes.NOT_FOUND) return getReasonPhrase(StatusCodes.NOT_FOUND); - return `${req.method} completed`; -}; - -const genReqId = (req: IncomingMessage, res: ServerResponse) => { - const existingID = req.id ?? req.headers["x-request-id"]; - if (existingID) return existingID; - const id = randomUUID(); - res.setHeader("X-Request-Id", id); - return id; -}; - -export default requestLogger(); +export default [addRequestId, captureResponseBody, httpLogger]; diff --git a/src/common/utils/envConfig.ts b/src/common/utils/envConfig.ts index 38062021..389f2f0c 100644 --- a/src/common/utils/envConfig.ts +++ b/src/common/utils/envConfig.ts @@ -1,13 +1,32 @@ import dotenv from "dotenv"; -import { cleanEnv, host, num, port, str, testOnly } from "envalid"; +import { z } from "zod"; dotenv.config(); -export const env = cleanEnv(process.env, { - NODE_ENV: str({ devDefault: testOnly("test"), choices: ["development", "production", "test"] }), - HOST: host({ devDefault: testOnly("localhost") }), - PORT: port({ devDefault: testOnly(3000) }), - CORS_ORIGIN: str({ devDefault: testOnly("http://localhost:3000") }), - COMMON_RATE_LIMIT_MAX_REQUESTS: num({ devDefault: testOnly(1000) }), - COMMON_RATE_LIMIT_WINDOW_MS: num({ devDefault: testOnly(1000) }), +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("production"), + + HOST: z.string().min(1).default("localhost"), + + PORT: z.coerce.number().int().positive().default(8080), + + CORS_ORIGIN: z.string().url().default("http://localhost:8080"), + + COMMON_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(1000), + + COMMON_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(1000), }); + +const parsedEnv = envSchema.safeParse(process.env); + +if (!parsedEnv.success) { + console.error("❌ Invalid environment variables:", parsedEnv.error.format()); + throw new Error("Invalid environment variables"); +} + +export const env = { + ...parsedEnv.data, + isDevelopment: parsedEnv.data.NODE_ENV === "development", + isProduction: parsedEnv.data.NODE_ENV === "production", + isTest: parsedEnv.data.NODE_ENV === "test", +}; diff --git a/src/common/utils/httpHandlers.ts b/src/common/utils/httpHandlers.ts index 3aa603a5..51f1eb99 100644 --- a/src/common/utils/httpHandlers.ts +++ b/src/common/utils/httpHandlers.ts @@ -4,9 +4,9 @@ import type { ZodError, ZodSchema } from "zod"; import { ServiceResponse } from "@/common/models/serviceResponse"; -export const validateRequest = (schema: ZodSchema) => (req: Request, res: Response, next: NextFunction) => { +export const validateRequest = (schema: ZodSchema) => async (req: Request, res: Response, next: NextFunction) => { try { - schema.parse({ body: req.body, query: req.query, params: req.params }); + await schema.parseAsync({ body: req.body, query: req.query, params: req.params }); next(); } catch (err) { const errorMessage = `Invalid input: ${(err as ZodError).errors.map((e) => e.message).join(", ")}`; diff --git a/tsconfig.json b/tsconfig.json index 61093f64..2138c834 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { "target": "ESNext", - "module": "CommonJS", - "baseUrl": ".", + "module": "ESNext", + "baseUrl": "./src", "paths": { - "@/*": ["src/*"] + "@/*": ["*"] }, "moduleResolution": "Node", "outDir": "dist", From abcd7232dd425dd838cbb9a9f69807bed8bb05ad Mon Sep 17 00:00:00 2001 From: Edwin Hernandez Date: Fri, 25 Apr 2025 17:11:22 -0500 Subject: [PATCH 2/2] fix: Update pnpm-lock.yaml to specify exact versions for pino-pretty and typescript --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 821ea454..5c636c87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,7 +61,7 @@ importers: specifier: 3.1.2 version: 3.1.2(vitest@3.1.2(@types/node@22.15.2)(tsx@4.19.3)(yaml@2.7.1)) pino-pretty: - specifier: ^13.0.0 + specifier: 13.0.0 version: 13.0.0 supertest: specifier: 7.1.0 @@ -73,7 +73,7 @@ importers: specifier: 4.19.3 version: 4.19.3 typescript: - specifier: ^5.8.3 + specifier: 5.8.3 version: 5.8.3 vite-tsconfig-paths: specifier: 5.1.4