Skip to content

Commit 82ba40c

Browse files
authored
refactor: Improve env validation, build, and logging (#405)
* refactor: Update package.json scripts and improve environment variable validation * fix: Update pnpm-lock.yaml to specify exact versions for pino-pretty and typescript
1 parent 91d64b3 commit 82ba40c

File tree

8 files changed

+86
-110
lines changed

8 files changed

+86
-110
lines changed

package.json

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@
99
"private": true,
1010
"scripts": {
1111
"build": "tsc && tsup",
12-
"start:dev": "tsx watch --clear-screen=false src/index.ts | pino-pretty",
12+
"start:dev": "node --import=tsx --watch src/index.ts",
1313
"start:prod": "node dist/index.js",
14-
"lint": "biome lint",
15-
"lint:fix": "biome lint --fix",
14+
"lint": "biome lint --fix",
1615
"format": "biome format --write",
1716
"test": "vitest run",
1817
"test:cov": "vitest run --coverage",
@@ -22,7 +21,6 @@
2221
"@asteasolutions/zod-to-openapi": "7.3.0",
2322
"cors": "2.8.5",
2423
"dotenv": "16.5.0",
25-
"envalid": "8.0.0",
2624
"express": "5.1.0",
2725
"express-rate-limit": "7.5.0",
2826
"helmet": "8.1.0",
@@ -48,10 +46,15 @@
4846
"vitest": "3.1.2"
4947
},
5048
"tsup": {
51-
"entry": ["src", "!src/**/__tests__/**", "!src/**/*.test.*"],
52-
"splitting": false,
49+
"entry": ["src/index.ts"],
50+
"outDir": "dist",
51+
"format": ["esm", "cjs"],
52+
"target": "es2020",
5353
"sourcemap": true,
54-
"clean": true
54+
"clean": true,
55+
"dts": true,
56+
"splitting": false,
57+
"skipNodeModulesBundle": true
5558
},
5659
"packageManager": "[email protected]"
5760
}

pnpm-lock.yaml

Lines changed: 0 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api-docs/openAPIDocumentGenerator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-open
33
import { healthCheckRegistry } from "@/api/healthCheck/healthCheckRouter";
44
import { userRegistry } from "@/api/user/userRouter";
55

6-
export function generateOpenAPIDocument() {
6+
export type OpenAPIDocument = ReturnType<OpenApiGeneratorV3["generateDocument"]>;
7+
8+
export function generateOpenAPIDocument(): OpenAPIDocument {
79
const registry = new OpenAPIRegistry([healthCheckRegistry, userRegistry]);
810
const generator = new OpenApiGeneratorV3(registry.definitions);
911

src/common/middleware/errorHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ const addErrorToRequestLog: ErrorRequestHandler = (err, _req, res, next) => {
1010
next(err);
1111
};
1212

13-
export default () => [unexpectedRequest, addErrorToRequestLog];
13+
export default (): [RequestHandler, ErrorRequestHandler] => [unexpectedRequest, addErrorToRequestLog];
Lines changed: 40 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,58 @@
11
import { randomUUID } from "node:crypto";
2-
import type { IncomingMessage, ServerResponse } from "node:http";
3-
import type { Request, RequestHandler, Response } from "express";
4-
import { StatusCodes, getReasonPhrase } from "http-status-codes";
5-
import type { LevelWithSilent } from "pino";
6-
import { type CustomAttributeKeys, type Options, pinoHttp } from "pino-http";
2+
import type { NextFunction, Request, Response } from "express";
3+
import { StatusCodes } from "http-status-codes";
4+
import pino from "pino";
5+
import pinoHttp from "pino-http";
76

87
import { env } from "@/common/utils/envConfig";
98

10-
enum LogLevel {
11-
Fatal = "fatal",
12-
Error = "error",
13-
Warn = "warn",
14-
Info = "info",
15-
Debug = "debug",
16-
Trace = "trace",
17-
Silent = "silent",
18-
}
9+
const logger = pino({
10+
level: env.isProduction ? "info" : "debug",
11+
transport: env.isProduction ? undefined : { target: "pino-pretty" },
12+
});
1913

20-
type PinoCustomProps = {
21-
request: Request;
22-
response: Response;
23-
error: Error;
24-
responseBody: unknown;
14+
const getLogLevel = (status: number) => {
15+
if (status >= StatusCodes.INTERNAL_SERVER_ERROR) return "error";
16+
if (status >= StatusCodes.BAD_REQUEST) return "warn";
17+
return "info";
2518
};
2619

27-
const requestLogger = (options?: Options): RequestHandler[] => {
28-
const pinoOptions: Options = {
29-
enabled: env.isProduction,
30-
customProps: customProps as unknown as Options["customProps"],
31-
redact: [],
32-
genReqId,
33-
customLogLevel,
34-
customSuccessMessage,
35-
customReceivedMessage: (req) => `request received: ${req.method}`,
36-
customErrorMessage: (_req, res) => `request errored with status code: ${res.statusCode}`,
37-
customAttributeKeys,
38-
...options,
39-
};
40-
return [responseBodyMiddleware, pinoHttp(pinoOptions)];
41-
};
20+
const addRequestId = (req: Request, res: Response, next: NextFunction) => {
21+
const existingId = req.headers["x-request-id"] as string;
22+
const requestId = existingId || randomUUID();
23+
24+
// Set for downstream use
25+
req.headers["x-request-id"] = requestId;
26+
res.setHeader("X-Request-Id", requestId);
4227

43-
const customAttributeKeys: CustomAttributeKeys = {
44-
req: "request",
45-
res: "response",
46-
err: "error",
47-
responseTime: "timeTaken",
28+
next();
4829
};
4930

50-
const customProps = (req: Request, res: Response): PinoCustomProps => ({
51-
request: req,
52-
response: res,
53-
error: res.locals.err,
54-
responseBody: res.locals.responseBody,
31+
const httpLogger = pinoHttp({
32+
logger,
33+
genReqId: (req) => req.headers["x-request-id"] as string,
34+
customLogLevel: (_req, res) => getLogLevel(res.statusCode),
35+
customSuccessMessage: (req) => `${req.method} ${req.url} completed`,
36+
customErrorMessage: (_req, res) => `Request failed with status code: ${res.statusCode}`,
37+
// Only log response bodies in development
38+
serializers: {
39+
req: (req) => ({
40+
method: req.method,
41+
url: req.url,
42+
id: req.id,
43+
}),
44+
},
5545
});
5646

57-
const responseBodyMiddleware: RequestHandler = (_req, res, next) => {
58-
const isNotProduction = !env.isProduction;
59-
if (isNotProduction) {
47+
const captureResponseBody = (req: Request, res: Response, next: NextFunction) => {
48+
if (!env.isProduction) {
6049
const originalSend = res.send;
61-
res.send = (content) => {
62-
res.locals.responseBody = content;
63-
res.send = originalSend;
64-
return originalSend.call(res, content);
50+
res.send = function (body) {
51+
res.locals.responseBody = body;
52+
return originalSend.call(this, body);
6553
};
6654
}
6755
next();
6856
};
6957

70-
const customLogLevel = (_req: IncomingMessage, res: ServerResponse<IncomingMessage>, err?: Error): LevelWithSilent => {
71-
if (err || res.statusCode >= StatusCodes.INTERNAL_SERVER_ERROR) return LogLevel.Error;
72-
if (res.statusCode >= StatusCodes.BAD_REQUEST) return LogLevel.Warn;
73-
if (res.statusCode >= StatusCodes.MULTIPLE_CHOICES) return LogLevel.Silent;
74-
return LogLevel.Info;
75-
};
76-
77-
const customSuccessMessage = (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => {
78-
if (res.statusCode === StatusCodes.NOT_FOUND) return getReasonPhrase(StatusCodes.NOT_FOUND);
79-
return `${req.method} completed`;
80-
};
81-
82-
const genReqId = (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => {
83-
const existingID = req.id ?? req.headers["x-request-id"];
84-
if (existingID) return existingID;
85-
const id = randomUUID();
86-
res.setHeader("X-Request-Id", id);
87-
return id;
88-
};
89-
90-
export default requestLogger();
58+
export default [addRequestId, captureResponseBody, httpLogger];

src/common/utils/envConfig.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
import dotenv from "dotenv";
2-
import { cleanEnv, host, num, port, str, testOnly } from "envalid";
2+
import { z } from "zod";
33

44
dotenv.config();
55

6-
export const env = cleanEnv(process.env, {
7-
NODE_ENV: str({ devDefault: testOnly("test"), choices: ["development", "production", "test"] }),
8-
HOST: host({ devDefault: testOnly("localhost") }),
9-
PORT: port({ devDefault: testOnly(3000) }),
10-
CORS_ORIGIN: str({ devDefault: testOnly("http://localhost:3000") }),
11-
COMMON_RATE_LIMIT_MAX_REQUESTS: num({ devDefault: testOnly(1000) }),
12-
COMMON_RATE_LIMIT_WINDOW_MS: num({ devDefault: testOnly(1000) }),
6+
const envSchema = z.object({
7+
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
8+
9+
HOST: z.string().min(1).default("localhost"),
10+
11+
PORT: z.coerce.number().int().positive().default(8080),
12+
13+
CORS_ORIGIN: z.string().url().default("http://localhost:8080"),
14+
15+
COMMON_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(1000),
16+
17+
COMMON_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(1000),
1318
});
19+
20+
const parsedEnv = envSchema.safeParse(process.env);
21+
22+
if (!parsedEnv.success) {
23+
console.error("❌ Invalid environment variables:", parsedEnv.error.format());
24+
throw new Error("Invalid environment variables");
25+
}
26+
27+
export const env = {
28+
...parsedEnv.data,
29+
isDevelopment: parsedEnv.data.NODE_ENV === "development",
30+
isProduction: parsedEnv.data.NODE_ENV === "production",
31+
isTest: parsedEnv.data.NODE_ENV === "test",
32+
};

src/common/utils/httpHandlers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import type { ZodError, ZodSchema } from "zod";
44

55
import { ServiceResponse } from "@/common/models/serviceResponse";
66

7-
export const validateRequest = (schema: ZodSchema) => (req: Request, res: Response, next: NextFunction) => {
7+
export const validateRequest = (schema: ZodSchema) => async (req: Request, res: Response, next: NextFunction) => {
88
try {
9-
schema.parse({ body: req.body, query: req.query, params: req.params });
9+
await schema.parseAsync({ body: req.body, query: req.query, params: req.params });
1010
next();
1111
} catch (err) {
1212
const errorMessage = `Invalid input: ${(err as ZodError).errors.map((e) => e.message).join(", ")}`;

tsconfig.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"compilerOptions": {
33
"target": "ESNext",
4-
"module": "CommonJS",
5-
"baseUrl": ".",
4+
"module": "ESNext",
5+
"baseUrl": "./src",
66
"paths": {
7-
"@/*": ["src/*"]
7+
"@/*": ["*"]
88
},
99
"moduleResolution": "Node",
1010
"outDir": "dist",

0 commit comments

Comments
 (0)