From 9c1b57c72b99b6a883d5e069275c2989f3d95df2 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:51:06 -0600 Subject: [PATCH] chore: env validation, shutdown hooks, logger noise reduction - add Zod-based env validation schema; fail fast on missing/invalid vars - suppress NestJS framework boot logs (InstanceLoader, RouterExplorer, RoutesResolver) - enable graceful shutdown hooks --- server/src/app.module.ts | 2 ++ server/src/common/logger.config.ts | 12 ++++++++++ server/src/config/env.validation.ts | 36 +++++++++++++++++++++++++++++ server/src/main.ts | 1 + 4 files changed, 51 insertions(+) create mode 100644 server/src/config/env.validation.ts diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 2f0e697c7..fdde19bb7 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -5,6 +5,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { LoggerModule } from 'nestjs-pino'; import { appConfig, authConfig, dbConfig, externalApiConfig, mailerConfig, storageConfig } from './config/config'; +import { validateEnv } from './config/env.validation'; import { loggerConfig } from './common/logger.config'; import { CommonModule } from './common/common.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; @@ -44,6 +45,7 @@ import { UserModule } from './modules/user/user.module'; LoggerModule.forRoot(loggerConfig), ConfigModule.forRoot({ isGlobal: true, + validate: validateEnv, load: [appConfig, dbConfig, authConfig, storageConfig, mailerConfig, externalApiConfig], }), ScheduleModule.forRoot(), diff --git a/server/src/common/logger.config.ts b/server/src/common/logger.config.ts index 6920e048a..e63eb23be 100644 --- a/server/src/common/logger.config.ts +++ b/server/src/common/logger.config.ts @@ -3,9 +3,21 @@ import type { IncomingMessage, ServerResponse } from 'http'; const isDev = process.env.NODE_ENV !== 'production'; +const FRAMEWORK_CONTEXTS = new Set(['InstanceLoader', 'RouterExplorer', 'RoutesResolver']); + export const loggerConfig: Params = { pinoHttp: { level: isDev ? 'debug' : 'info', + hooks: { + logMethod(inputArgs, method) { + const first = inputArgs[0]; + if (first !== null && typeof first === 'object' && 'context' in first && FRAMEWORK_CONTEXTS.has((first as Record).context as string)) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return method.apply(this, inputArgs); + }, + }, customProps: () => ({ context: 'HTTP' }), customSuccessMessage: (req: IncomingMessage, res: ServerResponse, responseTime: number) => { return `${req.method} ${req.url} ${res.statusCode} +${Math.round(responseTime)}ms`; diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts new file mode 100644 index 000000000..c5b2cd7cc --- /dev/null +++ b/server/src/config/env.validation.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3000), + DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'), + JWT_SECRET: z + .string() + .min(16, 'JWT_SECRET must be at least 16 characters') + .refine( + (val) => process.env.NODE_ENV !== 'production' || val !== 'change-me-in-production', + 'JWT_SECRET must be changed from the default value in production', + ), + JWT_EXPIRES_IN: z.string().default('15m'), + JWT_REFRESH_EXPIRES_IN: z.string().default('7d'), + BOOKS_PATH: z.string().default('/data/books'), + STAGING_PATH: z.string().optional(), + CLIENT_URL: z.string().url().optional(), + GOOGLE_BOOKS_API_KEY: z.string().default(''), + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().int().positive().default(587), + SMTP_SECURE: z.string().optional(), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + SMTP_FROM: z.string().default('noreply@projectx.local'), + APP_URL: z.string().url().default('http://localhost:5173'), +}); + +export function validateEnv(config: Record) { + const result = envSchema.safeParse(config); + if (!result.success) { + const errors = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); + throw new Error(`Environment validation failed:\n${errors}`); + } + return result.data; +} diff --git a/server/src/main.ts b/server/src/main.ts index 5a2c8cf61..4fa2706de 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -119,6 +119,7 @@ async function bootstrap() { }); } + app.enableShutdownHooks(); await app.listen(process.env.PORT ?? 3000, '0.0.0.0'); }