This commit is contained in:
@@ -32,6 +32,9 @@ ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|||||||
ENV SENTRY_DSN=$SENTRY_DSN
|
ENV SENTRY_DSN=$SENTRY_DSN
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
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
|
RUN npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export async function register() {
|
|||||||
|
|
||||||
// Initialize server services on boot
|
// Initialize server services on boot
|
||||||
// We do this AFTER Sentry to ensure errors during service init are caught
|
// 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');
|
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||||
getServerAppServices();
|
getServerAppServices();
|
||||||
}
|
}
|
||||||
|
|||||||
169
lib/config.ts
169
lib/config.ts
@@ -2,96 +2,135 @@
|
|||||||
* Centralized configuration management for the application.
|
* Centralized configuration management for the application.
|
||||||
* This file provides a type-safe way to access environment variables.
|
* This file provides a type-safe way to access environment variables.
|
||||||
*/
|
*/
|
||||||
import { env } from './env';
|
import { envSchema, getRawEnv } from './env';
|
||||||
|
|
||||||
|
let memoizedConfig: ReturnType<typeof createConfig> | 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 = {
|
export const config = {
|
||||||
env: env.NODE_ENV,
|
get env() { return getConfig().env; },
|
||||||
isProduction: env.NODE_ENV === 'production',
|
get isProduction() { return getConfig().isProduction; },
|
||||||
isDevelopment: env.NODE_ENV === 'development',
|
get isDevelopment() { return getConfig().isDevelopment; },
|
||||||
isTest: env.NODE_ENV === 'test',
|
get isTest() { return getConfig().isTest; },
|
||||||
|
get baseUrl() { return getConfig().baseUrl; },
|
||||||
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
get analytics() { return getConfig().analytics; },
|
||||||
|
get errors() { return getConfig().errors; },
|
||||||
analytics: {
|
get cache() { return getConfig().cache; },
|
||||||
umami: {
|
get logging() { return getConfig().logging; },
|
||||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
get mail() { return getConfig().mail; },
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get a masked version of the config for logging.
|
* Helper to get a masked version of the config for logging.
|
||||||
*/
|
*/
|
||||||
export function getMaskedConfig() {
|
export function getMaskedConfig() {
|
||||||
|
const c = getConfig();
|
||||||
const mask = (val: string | undefined) => (val ? `***${val.slice(-4)}` : 'not set');
|
const mask = (val: string | undefined) => (val ? `***${val.slice(-4)}` : 'not set');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
env: config.env,
|
env: c.env,
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: c.baseUrl,
|
||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: mask(config.analytics.umami.websiteId),
|
websiteId: mask(c.analytics.umami.websiteId),
|
||||||
scriptUrl: config.analytics.umami.scriptUrl,
|
scriptUrl: c.analytics.umami.scriptUrl,
|
||||||
enabled: config.analytics.umami.enabled,
|
enabled: c.analytics.umami.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
glitchtip: {
|
glitchtip: {
|
||||||
dsn: mask(config.errors.glitchtip.dsn),
|
dsn: mask(c.errors.glitchtip.dsn),
|
||||||
enabled: config.errors.glitchtip.enabled,
|
enabled: c.errors.glitchtip.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
redis: {
|
redis: {
|
||||||
url: mask(config.cache.redis.url),
|
url: mask(c.cache.redis.url),
|
||||||
keyPrefix: config.cache.redis.keyPrefix,
|
keyPrefix: c.cache.redis.keyPrefix,
|
||||||
enabled: config.cache.redis.enabled,
|
enabled: c.cache.redis.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
logging: {
|
logging: {
|
||||||
level: config.logging.level,
|
level: c.logging.level,
|
||||||
},
|
},
|
||||||
mail: {
|
mail: {
|
||||||
host: config.mail.host,
|
host: c.mail.host,
|
||||||
port: config.mail.port,
|
port: c.mail.port,
|
||||||
user: mask(config.mail.user),
|
user: mask(c.mail.user),
|
||||||
from: config.mail.from,
|
from: c.mail.from,
|
||||||
recipients: config.mail.recipients,
|
recipients: c.mail.recipients,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
64
lib/env.ts
64
lib/env.ts
@@ -1,51 +1,50 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to treat empty strings as undefined.
|
||||||
|
*/
|
||||||
|
const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment variable schema.
|
* Environment variable schema.
|
||||||
*/
|
*/
|
||||||
const envSchema = z.object({
|
export const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
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
|
// Analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'),
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(preprocessEmptyString, z.string().url().default('https://analytics.infra.mintel.me/script.js')),
|
||||||
|
|
||||||
// Error Tracking
|
// Error Tracking
|
||||||
SENTRY_DSN: z.string().optional(),
|
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
|
||||||
// Cache
|
// Cache
|
||||||
REDIS_URL: z.string().optional(),
|
REDIS_URL: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
REDIS_KEY_PREFIX: z.string().default('klz:'),
|
REDIS_KEY_PREFIX: z.preprocess(preprocessEmptyString, z.string().default('klz:')),
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
|
||||||
// Mail
|
// Mail
|
||||||
MAIL_HOST: z.string().optional(),
|
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_PORT: z.coerce.number().default(587),
|
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
||||||
MAIL_USERNAME: z.string().optional(),
|
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_PASSWORD: z.string().optional(),
|
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_FROM: z.string().optional(),
|
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_RECIPIENTS: z.string().optional().transform(val => val?.split(',').filter(Boolean) || []),
|
MAIL_RECIPIENTS: z.preprocess(
|
||||||
|
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||||
|
z.array(z.string()).default([])
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get environment variables.
|
* Collects all environment variables from the process.
|
||||||
|
* Explicitly references NEXT_PUBLIC_ variables for Next.js inlining.
|
||||||
*/
|
*/
|
||||||
function getRawEnv() {
|
export 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
|
|
||||||
return {
|
return {
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
@@ -63,14 +62,3 @@ function getRawEnv() {
|
|||||||
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
|
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({});
|
|
||||||
|
|||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -17,7 +17,6 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"framer-motion": "^12.27.1",
|
"framer-motion": "^12.27.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.3",
|
||||||
@@ -9406,18 +9405,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"framer-motion": "^12.27.1",
|
"framer-motion": "^12.27.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.3",
|
||||||
|
|||||||
17
scripts/validate-env.ts
Normal file
17
scripts/validate-env.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user