refactor(config): add env parsing helpers and tighten startup validation

This commit is contained in:
2026-03-29 18:47:50 +08:00
parent 3ce15d9762
commit 114d6ff2f5
3 changed files with 117 additions and 3 deletions

View File

@@ -9,6 +9,9 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schem
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_REQUIRED=false
REDIS_CONNECT_TIMEOUT_MS=5000
REDIS_STARTUP_RETRIES=10
# JWT Configuration
JWT_SECRET=replace-with-strong-random-secret

View File

@@ -12,13 +12,24 @@ import { RedisModule } from './database/redis.module';
import { WsModule } from './ws/ws.module';
import { FriendsModule } from './friends/friends.module';
import { DollsModule } from './dolls/dolls.module';
import { parseRedisRequired } from './common/config/env.utils';
/**
* Validates required environment variables.
* Throws an error if any required variables are missing or invalid.
* Returns the validated config.
*/
function validateEnvironment(config: Record<string, any>): Record<string, any> {
function getOptionalEnvString(
config: Record<string, unknown>,
key: string,
): string | undefined {
const value = config[key];
return typeof value === 'string' ? value : undefined;
}
function validateEnvironment(
config: Record<string, unknown>,
): Record<string, unknown> {
const requiredVars = ['JWT_SECRET', 'DATABASE_URL'];
const missingVars = requiredVars.filter((varName) => !config[varName]);
@@ -30,10 +41,44 @@ function validateEnvironment(config: Record<string, any>): Record<string, any> {
}
// Validate PORT if provided
if (config.PORT && isNaN(Number(config.PORT))) {
if (config.PORT !== undefined && !Number.isFinite(Number(config.PORT))) {
throw new Error('PORT must be a valid number');
}
if (config.NODE_ENV === 'production') {
if (
typeof config.JWT_SECRET !== 'string' ||
config.JWT_SECRET.length < 32
) {
throw new Error(
'JWT_SECRET must be at least 32 characters in production',
);
}
}
const redisRequired = parseRedisRequired({
nodeEnv: getOptionalEnvString(config, 'NODE_ENV'),
redisRequired: getOptionalEnvString(config, 'REDIS_REQUIRED'),
});
if (redisRequired && !config.REDIS_HOST) {
throw new Error(
'REDIS_REQUIRED is enabled but REDIS_HOST is not configured',
);
}
const redisConnectTimeout = getOptionalEnvString(
config,
'REDIS_CONNECT_TIMEOUT_MS',
);
if (
redisConnectTimeout !== undefined &&
(!Number.isFinite(Number(redisConnectTimeout)) ||
Number(redisConnectTimeout) <= 0)
) {
throw new Error('REDIS_CONNECT_TIMEOUT_MS must be a positive number');
}
validateOptionalProvider(config, 'GOOGLE');
validateOptionalProvider(config, 'DISCORD');
@@ -41,7 +86,7 @@ function validateEnvironment(config: Record<string, any>): Record<string, any> {
}
function validateOptionalProvider(
config: Record<string, any>,
config: Record<string, unknown>,
provider: 'GOOGLE' | 'DISCORD',
): void {
const vars = [

View File

@@ -0,0 +1,66 @@
export function parseBoolean(
value: string | undefined,
fallback: boolean,
): boolean {
if (value === undefined) {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
return false;
}
return fallback;
}
export function parsePositiveInteger(
value: string | undefined,
fallback: number,
): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.floor(parsed);
}
export function parseCsvList(value: string | undefined): string[] {
if (!value) {
return [];
}
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
export function isLikelyHttpOrigin(origin: string): boolean {
try {
const parsed = new URL(origin);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
export function parseRedisRequired(config: {
nodeEnv?: string;
redisRequired?: string;
}): boolean {
if (config.redisRequired === undefined) {
return config.nodeEnv === 'production';
}
return parseBoolean(config.redisRequired, false);
}