From d6c1d6bae62a6ef644021eafcb479a0139668848 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 28 Jan 2026 15:06:21 +0100 Subject: [PATCH] env --- instrumentation.ts | 3 - lib/config.ts | 100 ++++++------------------- lib/env.ts | 75 +++++++++++++++++++ lib/load-env.ts | 10 +++ lib/services/create-services.server.ts | 4 +- package-lock.json | 12 ++- package.json | 3 +- 7 files changed, 122 insertions(+), 85 deletions(-) create mode 100644 lib/env.ts create mode 100644 lib/load-env.ts diff --git a/instrumentation.ts b/instrumentation.ts index 2ad52569..ae714898 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -13,9 +13,6 @@ export async function register() { // Initialize server services on boot // We do this AFTER Sentry to ensure errors during service init are caught - const { validateConfig } = await import('@/lib/config'); - validateConfig(); - const { getServerAppServices } = await import('@/lib/services/create-services.server'); getServerAppServices(); } diff --git a/lib/config.ts b/lib/config.ts index 83f58c58..54172605 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,90 +1,56 @@ /** * Centralized configuration management for the application. - * This file defines the schema and provides a type-safe way to access environment variables. + * This file provides a type-safe way to access environment variables. */ -import dotenv from 'dotenv'; -import path from 'path'; - -// Load .env file in development (server-side only) -if (typeof window === 'undefined' && process.env.NODE_ENV !== 'production') { - dotenv.config({ path: path.resolve(process.cwd(), '.env') }); -} - -const getEnv = (key: string, defaultValue?: string): string | undefined => { - // In the browser, we can only access NEXT_PUBLIC_ variables - if (typeof window !== 'undefined') { - if (!key.startsWith('NEXT_PUBLIC_')) { - return defaultValue; - } - return (process.env as any)[key] || defaultValue; - } - - if (typeof process === 'undefined') return defaultValue; - - // In Docker/Production, variables are in process.env - // In local development, they might be in .env - const value = process.env[key]; - - // Check for quoted values (common when passed via SSH/Docker) - if (typeof value === 'string') { - const trimmed = value.trim(); - if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"'))) { - return trimmed.slice(1, -1); - } - return trimmed; - } - - return defaultValue; -}; +import { env } from './env'; export const config = { - env: getEnv('NODE_ENV', 'development'), - isProduction: getEnv('NODE_ENV') === 'production', - isDevelopment: getEnv('NODE_ENV') === 'development', - isTest: getEnv('NODE_ENV') === 'test', + env: env.NODE_ENV, + isProduction: env.NODE_ENV === 'production', + isDevelopment: env.NODE_ENV === 'development', + isTest: env.NODE_ENV === 'test', - baseUrl: getEnv('NEXT_PUBLIC_BASE_URL', 'http://localhost:3000'), + baseUrl: env.NEXT_PUBLIC_BASE_URL, analytics: { umami: { - websiteId: getEnv('NEXT_PUBLIC_UMAMI_WEBSITE_ID'), - scriptUrl: getEnv('NEXT_PUBLIC_UMAMI_SCRIPT_URL', 'https://analytics.infra.mintel.me/script.js'), + websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, + scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, // The proxied path used in the frontend proxyPath: '/stats/script.js', - enabled: Boolean(getEnv('NEXT_PUBLIC_UMAMI_WEBSITE_ID')), + enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID), }, }, errors: { glitchtip: { // Use SENTRY_DSN for both server and client (proxied) - dsn: getEnv('SENTRY_DSN'), + dsn: env.SENTRY_DSN, // The proxied origin used in the frontend proxyPath: '/errors', - enabled: Boolean(getEnv('SENTRY_DSN')), + enabled: Boolean(env.SENTRY_DSN), }, }, cache: { redis: { - url: getEnv('REDIS_URL'), - keyPrefix: getEnv('REDIS_KEY_PREFIX', 'klz:'), - enabled: Boolean(getEnv('REDIS_URL')), + url: env.REDIS_URL, + keyPrefix: env.REDIS_KEY_PREFIX, + enabled: Boolean(env.REDIS_URL), }, }, logging: { - level: getEnv('LOG_LEVEL', 'info'), + level: env.LOG_LEVEL, }, mail: { - host: getEnv('MAIL_HOST'), - port: parseInt(getEnv('MAIL_PORT', '587')!, 10), - user: getEnv('MAIL_USERNAME'), - pass: getEnv('MAIL_PASSWORD'), - from: getEnv('MAIL_FROM'), - recipients: getEnv('MAIL_RECIPIENTS', '')?.split(',').filter(Boolean) || [], + host: env.MAIL_HOST, + port: env.MAIL_PORT, + user: env.MAIL_USERNAME, + pass: env.MAIL_PASSWORD, + from: env.MAIL_FROM, + recipients: env.MAIL_RECIPIENTS, }, } as const; @@ -129,25 +95,3 @@ export function getMaskedConfig() { }, }; } - -/** - * Validates that all required environment variables are set. - * Should be called on server startup. - */ -export function validateConfig() { - if (config.isProduction && typeof window === 'undefined') { - const required = [ - 'NEXT_PUBLIC_BASE_URL', - ]; - - for (const key of required) { - const value = getEnv(key); - if (!value) { - // In Next.js, process.env might not be a real object with enumerable keys in the bundle - // so we check the specific key directly - const rawValue = process.env[key]; - throw new Error(`Missing required environment variable: ${key}. (getEnv: "${value}", process.env: "${rawValue}"). If this is set in your environment, ensure it is not being shadowed or cleared by the container runtime.`); - } - } - } -} diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 00000000..8298fe52 --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,75 @@ +import './load-env'; +import { z } from 'zod'; + +/** + * Environment variable schema. + * This ensures the application has all required configuration at startup. + */ +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + NEXT_PUBLIC_BASE_URL: z.string().url().default('http://localhost:3000'), + + // Analytics + NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(), + NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'), + + // Error Tracking + SENTRY_DSN: z.string().optional(), + + // Cache + REDIS_URL: z.string().optional(), + REDIS_KEY_PREFIX: z.string().default('klz:'), + + // Logging + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), + + // Mail + MAIL_HOST: z.string().optional(), + MAIL_PORT: z.coerce.number().default(587), + MAIL_USERNAME: z.string().optional(), + MAIL_PASSWORD: z.string().optional(), + MAIL_FROM: z.string().optional(), + MAIL_RECIPIENTS: z.string().optional().transform(val => val?.split(',').filter(Boolean) || []), +}); + +export type Env = z.infer; + +/** + * Helper to get environment variables. + * On the client, only NEXT_PUBLIC_ variables are available. + * On the server, all variables are available. + */ +function getRawEnv() { + if (typeof window !== 'undefined') { + // Client-side: only return NEXT_PUBLIC_ variables + // We must explicitly reference them for Next.js inlining to work + return { + NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, + NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, + NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, + }; + } + + // Server-side: return all variables from process.env + return { + NODE_ENV: process.env.NODE_ENV, + NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, + NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, + NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, + SENTRY_DSN: process.env.SENTRY_DSN, + REDIS_URL: process.env.REDIS_URL, + REDIS_KEY_PREFIX: process.env.REDIS_KEY_PREFIX, + LOG_LEVEL: process.env.LOG_LEVEL, + MAIL_HOST: process.env.MAIL_HOST, + MAIL_PORT: process.env.MAIL_PORT, + MAIL_USERNAME: process.env.MAIL_USERNAME, + MAIL_PASSWORD: process.env.MAIL_PASSWORD, + MAIL_FROM: process.env.MAIL_FROM, + MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS, + }; +} + +/** + * Validated environment variables. + */ +export const env = envSchema.parse(getRawEnv()); diff --git a/lib/load-env.ts b/lib/load-env.ts new file mode 100644 index 00000000..9be2e233 --- /dev/null +++ b/lib/load-env.ts @@ -0,0 +1,10 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +/** + * Loads environment variables from .env file in development. + * This must be called before any other module that uses environment variables. + */ +if (typeof window === 'undefined' && process.env.NODE_ENV !== 'production') { + dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +} diff --git a/lib/services/create-services.server.ts b/lib/services/create-services.server.ts index 91b50848..0fe59a4d 100644 --- a/lib/services/create-services.server.ts +++ b/lib/services/create-services.server.ts @@ -6,9 +6,9 @@ import { RedisCacheService } from './cache/redis-cache-service'; import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service'; import { NoopErrorReportingService } from './errors/noop-error-reporting-service'; import { PinoLoggerService } from './logging/pino-logger-service'; -import { config, getMaskedConfig, validateConfig } from '../config'; -let singleton: AppServices | undefined; +import { config, getMaskedConfig } from '../config'; +let singleton: AppServices | undefined; export function getServerAppServices(): AppServices { if (singleton) return singleton; diff --git a/package-lock.json b/package-lock.json index 909f8626..54ad59b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,8 @@ "sharp": "^0.34.5", "svg-to-pdfkit": "^0.1.8", "tailwind-merge": "^3.4.0", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/cli": "^4.1.18", @@ -19031,6 +19032,15 @@ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 8e8dbded..e00e9592 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "sharp": "^0.34.5", "svg-to-pdfkit": "^0.1.8", "tailwind-merge": "^3.4.0", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/cli": "^4.1.18",