138 lines
3.8 KiB
TypeScript
138 lines
3.8 KiB
TypeScript
/**
|
||
* Runtime Environment Validation
|
||
*
|
||
* Runs once on server startup (via Next.js instrumentation).
|
||
* Validates that required env vars are present BEFORE the app serves requests.
|
||
*
|
||
* Design:
|
||
* - Build time: all env vars are optional (so `next build` works without them)
|
||
* - Runtime: this module checks process.env directly and fails fast with clear messages
|
||
* - Feature-gated: optional features only require their vars when explicitly enabled
|
||
*/
|
||
|
||
type EnvRule = {
|
||
key: string;
|
||
required: boolean;
|
||
reason: string;
|
||
};
|
||
|
||
type FeatureGate = {
|
||
name: string;
|
||
/** The env var that enables this feature (if set and non-empty, feature is "on") */
|
||
gate: string;
|
||
vars: string[];
|
||
};
|
||
|
||
/** Always required in production */
|
||
const REQUIRED_VARS: EnvRule[] = [
|
||
{
|
||
key: "DATABASE_URL",
|
||
required: true,
|
||
reason: "App cannot start without a database connection",
|
||
},
|
||
{
|
||
key: "BETTER_AUTH_SECRET",
|
||
required: true,
|
||
reason: "Auth sessions will be insecure without a proper secret",
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Feature-gated vars: only required when the gate var is set.
|
||
* This lets you skip entire features (S3, Stripe, email) without errors.
|
||
*/
|
||
const FEATURE_GATES: FeatureGate[] = [
|
||
{
|
||
name: "S3 Storage",
|
||
gate: "S3_BUCKET",
|
||
vars: ["S3_ENDPOINT", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"],
|
||
},
|
||
{
|
||
name: "Stripe Billing",
|
||
gate: "STRIPE_SECRET_KEY",
|
||
vars: ["STRIPE_WEBHOOK_SECRET"],
|
||
},
|
||
{
|
||
name: "Resend Email",
|
||
gate: "RESEND_API_KEY",
|
||
vars: ["EMAIL_FROM"],
|
||
},
|
||
{
|
||
name: "Nodemailer Email",
|
||
gate: "NODEMAILER_HOST",
|
||
vars: ["NODEMAILER_PORT", "NODEMAILER_USER", "NODEMAILER_PASSWORD", "EMAIL_FROM"],
|
||
},
|
||
];
|
||
|
||
/** Vars that trigger a warning (not a crash) if missing */
|
||
const WARN_VARS = [
|
||
{ key: "BETTER_AUTH_TRUSTED_ORIGINS", reason: "CSRF protection may reject external requests" },
|
||
{ key: "EMAIL_FROM", reason: "Emails will use 'noreply@example.com' as sender" },
|
||
];
|
||
|
||
function getEnv(key: string): string | undefined {
|
||
const val = process.env[key];
|
||
if (!val || val.trim() === "") return undefined;
|
||
return val;
|
||
}
|
||
|
||
export function validateRuntimeEnv(): void {
|
||
// Skip in development or when explicitly disabled
|
||
if (process.env.NODE_ENV !== "production") return;
|
||
if (process.env.SKIP_ENV_VALIDATION === "1" || process.env.SKIP_ENV_VALIDATION === "true") return;
|
||
|
||
const errors: string[] = [];
|
||
const warnings: string[] = [];
|
||
|
||
// Check required vars
|
||
for (const rule of REQUIRED_VARS) {
|
||
if (!getEnv(rule.key)) {
|
||
errors.push(` ${rule.key} — ${rule.reason}`);
|
||
}
|
||
}
|
||
|
||
// Check feature-gated vars
|
||
for (const feature of FEATURE_GATES) {
|
||
if (getEnv(feature.gate)) {
|
||
for (const varName of feature.vars) {
|
||
if (!getEnv(varName)) {
|
||
errors.push(` ${varName} — Required when ${feature.name} is enabled (${feature.gate} is set)`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check warning vars
|
||
for (const rule of WARN_VARS) {
|
||
if (!getEnv(rule.key)) {
|
||
warnings.push(` ${rule.key} — ${rule.reason}`);
|
||
}
|
||
}
|
||
|
||
// Print warnings
|
||
if (warnings.length > 0) {
|
||
console.warn(
|
||
`\n⚠️ Environment warnings:\n${warnings.join("\n")}\n`
|
||
);
|
||
}
|
||
|
||
// Fail on errors
|
||
if (errors.length > 0) {
|
||
const msg = [
|
||
"",
|
||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
||
"❌ Missing required environment variables:",
|
||
"",
|
||
...errors,
|
||
"",
|
||
"The app cannot start safely without these.",
|
||
"Set them in your docker-compose or .env file.",
|
||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
||
"",
|
||
].join("\n");
|
||
|
||
// Throw instead of process.exit — works in both Node and Edge runtimes
|
||
throw new Error(msg);
|
||
}
|
||
}
|