diff --git a/frontend/lib/env/runtime-env.generated.ts b/frontend/lib/env/runtime-env.generated.ts new file mode 100644 index 00000000..a84be208 --- /dev/null +++ b/frontend/lib/env/runtime-env.generated.ts @@ -0,0 +1,3 @@ +import 'server-only'; + +export const RUNTIME_ENV: Readonly> = {}; diff --git a/frontend/lib/env/server-env.ts b/frontend/lib/env/server-env.ts index 5923e043..a144ddee 100644 --- a/frontend/lib/env/server-env.ts +++ b/frontend/lib/env/server-env.ts @@ -1,5 +1,7 @@ import 'server-only'; +import { RUNTIME_ENV } from './runtime-env.generated'; + type NetlifyEnv = { get?: (key: string) => string | undefined; }; @@ -25,9 +27,34 @@ function readFromNetlifyEnv(key: string): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +const GENERATED_FALLBACK_KEYS = new Set([ + 'APP_ENV', + 'CONTEXT', + 'NETLIFY', + 'DATABASE_URL', + 'DATABASE_URL_LOCAL', + 'AUTH_SECRET', + 'CSRF_SECRET', +]); + + +function canUseGeneratedFallback(key: string): boolean { + return GENERATED_FALLBACK_KEYS.has(key); +} + export function readServerEnv(key: string): string | undefined { const fromProcess = process.env[key]?.trim(); if (fromProcess) return fromProcess; - return readFromNetlifyEnv(key); + const fromNetlify = readFromNetlifyEnv(key); + if (fromNetlify) return fromNetlify; + + if (!canUseGeneratedFallback(key)) return undefined; + return readFromGeneratedRuntimeEnv(key); + } + +function readFromGeneratedRuntimeEnv(key: string): string | undefined { + const value = RUNTIME_ENV[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} \ No newline at end of file diff --git a/frontend/scripts/generate-env-runtime.mjs b/frontend/scripts/generate-env-runtime.mjs new file mode 100644 index 00000000..c965acbe --- /dev/null +++ b/frontend/scripts/generate-env-runtime.mjs @@ -0,0 +1,56 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = process.cwd(); +const examplePath = resolve(root, '.env.example'); +const outputPath = resolve(root, 'lib/env/runtime-env.generated.ts'); +const appEnv = (process.env.APP_ENV ?? '').trim().toLowerCase(); +const isDevelop = appEnv === 'develop'; + +const keyRegex = /^([A-Z][A-Z0-9_]*)=/; + +const keys = Array.from( + new Set( + readFileSync(examplePath, 'utf8') + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(line => { + const match = line.match(keyRegex); + return match ? match[1] : null; + }) + .filter(Boolean) + ) +); + +const entries = []; + +for (const key of keys) { + const value = process.env[key]; + if (typeof value !== 'string' || value.length === 0) continue; + entries.push([key, value]); +} + +if (!isDevelop) { + const fileContent = `import 'server-only'; + +export const RUNTIME_ENV: Readonly> = {}; +`; + writeFileSync(outputPath, fileContent, 'utf8'); + console.log(`[env] skipped runtime env generation (APP_ENV=${appEnv || ''})`); + process.exit(0); +} + +const objectBody = entries + .map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)},`) + .join('\n'); + +const fileContent = `import 'server-only'; + +export const RUNTIME_ENV: Readonly> = { +${objectBody} +}; +`; + +writeFileSync(outputPath, fileContent, 'utf8'); +console.log(`[env] generated runtime-env.generated.ts with ${entries.length} keys`); diff --git a/netlify.toml b/netlify.toml index 12f80de1..185f8f2b 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,6 @@ [build] base = "frontend" - command = "npm ci --include=optional && npm run build" + command = "npm ci --include=optional && node scripts/generate-env-runtime.mjs && npm run build" publish = ".next" [build.environment]