From 859d034ed792726cd288a025b10ff366f2253901 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 28 Jan 2026 15:26:36 +0100 Subject: [PATCH] env --- Dockerfile | 3 + instrumentation.ts | 3 + lib/config.ts | 169 ++++++++++++++++++++++++---------------- lib/env.ts | 64 +++++++-------- package-lock.json | 13 ---- package.json | 1 - scripts/validate-env.ts | 17 ++++ 7 files changed, 153 insertions(+), 117 deletions(-) create mode 100644 scripts/validate-env.ts diff --git a/Dockerfile b/Dockerfile index 6bf6fb22..70ebdb9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,9 @@ ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL ENV SENTRY_DSN=$SENTRY_DSN ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL +# Validate environment variables during build +RUN npx tsx scripts/validate-env.ts + RUN npm run build # Production image, copy all the files and run next diff --git a/instrumentation.ts b/instrumentation.ts index ae714898..e45d4fac 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -13,6 +13,9 @@ export async function register() { // Initialize server services on boot // We do this AFTER Sentry to ensure errors during service init are caught + const { getConfig } = await import('@/lib/config'); + getConfig(); // Trigger validation + const { getServerAppServices } = await import('@/lib/services/create-services.server'); getServerAppServices(); } diff --git a/lib/config.ts b/lib/config.ts index 54172605..e68035ce 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -2,96 +2,135 @@ * Centralized configuration management for the application. * This file provides a type-safe way to access environment variables. */ -import { env } from './env'; +import { envSchema, getRawEnv } from './env'; +let memoizedConfig: ReturnType | undefined; + +/** + * Creates and validates the configuration object. + * Throws if validation fails. + */ +function createConfig() { + const env = envSchema.parse(getRawEnv()); + + return { + env: env.NODE_ENV, + isProduction: env.NODE_ENV === 'production', + isDevelopment: env.NODE_ENV === 'development', + isTest: env.NODE_ENV === 'test', + + baseUrl: env.NEXT_PUBLIC_BASE_URL, + + analytics: { + umami: { + 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(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID), + }, + }, + + errors: { + glitchtip: { + // Use SENTRY_DSN for both server and client (proxied) + dsn: env.SENTRY_DSN, + // The proxied origin used in the frontend + proxyPath: '/errors', + enabled: Boolean(env.SENTRY_DSN), + }, + }, + + cache: { + redis: { + url: env.REDIS_URL, + keyPrefix: env.REDIS_KEY_PREFIX, + enabled: Boolean(env.REDIS_URL), + }, + }, + + logging: { + level: env.LOG_LEVEL, + }, + + mail: { + 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; +} + +/** + * Returns the validated configuration. + * Memoizes the result after the first call. + */ +export function getConfig() { + if (!memoizedConfig) { + memoizedConfig = createConfig(); + } + return memoizedConfig; +} + +/** + * Exported config object for convenience. + * Uses getters to ensure it's only initialized when accessed. + */ export const config = { - env: env.NODE_ENV, - isProduction: env.NODE_ENV === 'production', - isDevelopment: env.NODE_ENV === 'development', - isTest: env.NODE_ENV === 'test', - - baseUrl: env.NEXT_PUBLIC_BASE_URL, - - analytics: { - umami: { - 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(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID), - }, - }, - - errors: { - glitchtip: { - // Use SENTRY_DSN for both server and client (proxied) - dsn: env.SENTRY_DSN, - // The proxied origin used in the frontend - proxyPath: '/errors', - enabled: Boolean(env.SENTRY_DSN), - }, - }, - - cache: { - redis: { - url: env.REDIS_URL, - keyPrefix: env.REDIS_KEY_PREFIX, - enabled: Boolean(env.REDIS_URL), - }, - }, - - logging: { - level: env.LOG_LEVEL, - }, - - mail: { - 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; + get env() { return getConfig().env; }, + get isProduction() { return getConfig().isProduction; }, + get isDevelopment() { return getConfig().isDevelopment; }, + get isTest() { return getConfig().isTest; }, + get baseUrl() { return getConfig().baseUrl; }, + get analytics() { return getConfig().analytics; }, + get errors() { return getConfig().errors; }, + get cache() { return getConfig().cache; }, + get logging() { return getConfig().logging; }, + get mail() { return getConfig().mail; }, +}; /** * Helper to get a masked version of the config for logging. */ export function getMaskedConfig() { + const c = getConfig(); const mask = (val: string | undefined) => (val ? `***${val.slice(-4)}` : 'not set'); return { - env: config.env, - baseUrl: config.baseUrl, + env: c.env, + baseUrl: c.baseUrl, analytics: { umami: { - websiteId: mask(config.analytics.umami.websiteId), - scriptUrl: config.analytics.umami.scriptUrl, - enabled: config.analytics.umami.enabled, + websiteId: mask(c.analytics.umami.websiteId), + scriptUrl: c.analytics.umami.scriptUrl, + enabled: c.analytics.umami.enabled, }, }, errors: { glitchtip: { - dsn: mask(config.errors.glitchtip.dsn), - enabled: config.errors.glitchtip.enabled, + dsn: mask(c.errors.glitchtip.dsn), + enabled: c.errors.glitchtip.enabled, }, }, cache: { redis: { - url: mask(config.cache.redis.url), - keyPrefix: config.cache.redis.keyPrefix, - enabled: config.cache.redis.enabled, + url: mask(c.cache.redis.url), + keyPrefix: c.cache.redis.keyPrefix, + enabled: c.cache.redis.enabled, }, }, logging: { - level: config.logging.level, + level: c.logging.level, }, mail: { - host: config.mail.host, - port: config.mail.port, - user: mask(config.mail.user), - from: config.mail.from, - recipients: config.mail.recipients, + host: c.mail.host, + port: c.mail.port, + user: mask(c.mail.user), + from: c.mail.from, + recipients: c.mail.recipients, }, }; } diff --git a/lib/env.ts b/lib/env.ts index 1db2f190..911d8f16 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -1,51 +1,50 @@ import { z } from 'zod'; +/** + * Helper to treat empty strings as undefined. + */ +const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val); + /** * Environment variable schema. */ -const envSchema = z.object({ +export const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - NEXT_PUBLIC_BASE_URL: z.string().url().default('http://localhost:3000'), + NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()), // 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'), + NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()), + NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(preprocessEmptyString, z.string().url().default('https://analytics.infra.mintel.me/script.js')), // Error Tracking - SENTRY_DSN: z.string().optional(), + SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()), // Cache - REDIS_URL: z.string().optional(), - REDIS_KEY_PREFIX: z.string().default('klz:'), + REDIS_URL: z.preprocess(preprocessEmptyString, z.string().optional()), + REDIS_KEY_PREFIX: z.preprocess(preprocessEmptyString, 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) || []), + MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()), + MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)), + MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()), + MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()), + MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()), + MAIL_RECIPIENTS: z.preprocess( + (val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val), + z.array(z.string()).default([]) + ), }); +export type Env = z.infer; + /** - * Helper to get environment variables. + * Collects all environment variables from the process. + * Explicitly references NEXT_PUBLIC_ variables for Next.js inlining. */ -function getRawEnv() { - const isServer = typeof window === 'undefined'; - - if (!isServer) { - // Client-side: only return NEXT_PUBLIC_ variables - 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 +export function getRawEnv() { return { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, @@ -63,14 +62,3 @@ function getRawEnv() { MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS, }; } - -/** - * Validated environment variables. - * We use safeParse during build to avoid crashing the build process. - */ -const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build'; -const parsed = isBuildTime - ? envSchema.safeParse(getRawEnv()) - : { success: true, data: envSchema.parse(getRawEnv()) }; - -export const env = parsed.success ? parsed.data : envSchema.parse({}); diff --git a/package-lock.json b/package-lock.json index 54ad59b5..237a1128 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "axios": "^1.13.2", "cheerio": "^1.1.2", "clsx": "^2.1.1", - "dotenv": "^17.2.3", "framer-motion": "^12.27.1", "gray-matter": "^4.0.3", "i18next": "^25.7.3", @@ -9406,18 +9405,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index e00e9592..b93d992f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "axios": "^1.13.2", "cheerio": "^1.1.2", "clsx": "^2.1.1", - "dotenv": "^17.2.3", "framer-motion": "^12.27.1", "gray-matter": "^4.0.3", "i18next": "^25.7.3", diff --git a/scripts/validate-env.ts b/scripts/validate-env.ts new file mode 100644 index 00000000..6ec2031c --- /dev/null +++ b/scripts/validate-env.ts @@ -0,0 +1,17 @@ +import { envSchema, getRawEnv } from '../lib/env'; + +/** + * Simple script to validate environment variables. + * If validation fails, this script will exit with code 1. + */ +try { + const env = envSchema.parse(getRawEnv()); + console.log('✅ Environment variables validated successfully.'); + console.log('Base URL:', env.NEXT_PUBLIC_BASE_URL); +} catch (error) { + console.error('❌ Environment validation failed.'); + if (error instanceof Error) { + console.error(error.message); + } + process.exit(1); +}