import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { DEPLOYMENT_TARGETS, isDeploymentTarget, type DeploymentTarget, } from "../src/contracts/deployment-admission.ts"; import { runtimeConfigV2ArtifactSchema } from "../src/contracts/release-artifacts.ts"; /** * §6.4. Materializes `dist/config.json` from the profile the build declares. * * `public/` is copied verbatim into `dist/`, so before this step the runtime * document that shipped with every build was the local one — `APP_ENV: local`, * `AUTH_MODE: demo`, a loopback API — regardless of what the build was for. * The profile is the source of truth instead, and the only values a deployment * may inject are the ones it actually owns: its endpoints and its identity. * * The result is validated against the same V2 schema the browser will apply, so * an override cannot produce a document that only fails at boot. */ const PROFILE_DIRECTORY = "config/runtime"; const OUTPUT_PATH = "dist/config.json"; /** * Deployment-supplied values. Everything else is fixed by the profile so a * deployment cannot quietly widen what was reviewed. */ const OVERRIDES = Object.freeze({ API_BASE_URL: "RUNTIME_API_BASE_URL", TELEMETRY_ENDPOINT: "RUNTIME_TELEMETRY_ENDPOINT", } as const); export async function generateRuntimeConfig( target: DeploymentTarget, environment: NodeJS.ProcessEnv = process.env, ): Promise> { const profilePath = path.join(PROFILE_DIRECTORY, `${target}.json`); const source: unknown = JSON.parse(await readFile(profilePath, "utf8")); if (source === null || typeof source !== "object" || Array.isArray(source)) { throw new TypeError(`${profilePath}: runtime profile must be an object`); } const draft: Record = { ...(source as Record) }; if (draft["APP_ENV"] !== target) { throw new Error( `${profilePath}: declares APP_ENV ${String(draft["APP_ENV"])}, expected ${target}`, ); } for (const [field, variable] of Object.entries(OVERRIDES)) { const supplied = environment[variable]; if (supplied !== undefined && supplied !== "") draft[field] = supplied; } const buildId = environment["VITE_BUILD_ID"] ?? "local-build"; const releaseId = environment["RELEASE_ID"] ?? "local-release"; draft["BUILD_ID"] = buildId; draft["RELEASE_ID"] = releaseId; const parsed = runtimeConfigV2ArtifactSchema.safeParse(draft); if (!parsed.success) { const issues = parsed.error.issues .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("\n "); throw new Error(`${profilePath}: runtime config is invalid\n ${issues}`); } return draft; } function resolveTarget(environment: NodeJS.ProcessEnv): DeploymentTarget { const declared = environment["APP_PROFILE"] ?? "local"; if (!isDeploymentTarget(declared)) { throw new Error( `APP_PROFILE must be one of ${DEPLOYMENT_TARGETS.join(", ")}; received ${declared}`, ); } return declared; } async function main(): Promise { const target = resolveTarget(process.env); const config = await generateRuntimeConfig(target); await writeFile(OUTPUT_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8"); process.stdout.write( `runtime config: ${target} profile written to ${OUTPUT_PATH} ` + `(APP_ENV=${String(config["APP_ENV"])}, AUTH_MODE=${String(config["AUTH_MODE"])})\n`, ); } if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) { await main(); }