Merge branch 'feature-frontend-env-runtime-config-contract' into develop

This commit is contained in:
donghyeon-ka
2026-07-25 20:46:07 +09:00
8 changed files with 356 additions and 8 deletions
+2 -1
View File
@@ -26,7 +26,8 @@
}, },
"dependencies": { "dependencies": {
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.12.1", "@axe-core/playwright": "4.12.1",
+8
View File
@@ -14,6 +14,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8(react@19.2.8) version: 19.2.8(react@19.2.8)
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@axe-core/playwright': '@axe-core/playwright':
specifier: 4.12.1 specifier: 4.12.1
@@ -1584,6 +1587,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots: snapshots:
'@adobe/css-tools@4.5.0': {} '@adobe/css-tools@4.5.0': {}
@@ -2866,3 +2872,5 @@ snapshots:
yargs-parser: 21.1.1 yargs-parser: 21.1.1
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zod@4.4.3: {}
+13
View File
@@ -0,0 +1,13 @@
{
"APP_ENV": "local",
"API_BASE_URL": "http://localhost:8080",
"REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": false,
"AUTH_MODE": "external",
"CONFIG_SCHEMA_VERSION": "1",
"API_CONTRACT_VERSION": "1",
"RELEASE_MANIFEST_URL": "/release-manifest.json",
"BUILD_ID": "local-build",
"RELEASE_ID": "local-release"
}
+104
View File
@@ -0,0 +1,104 @@
import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.js";
import { validateRuntimeConfig } from "./runtime-config-schema.js";
export class BootConfigError extends Error {
/**
* @param {string} code
* @param {{ buildId: string, configSchemaVersion?: string, releaseId?: string }} safe
*/
constructor(code, safe) {
super("Runtime configuration could not be loaded");
this.name = "BootConfigError";
this.kind = "BOOT_CONFIG_FAILURE";
this.code = code;
this.safe = Object.freeze({
kind: this.kind,
code,
buildId: safe.buildId,
configSchemaVersion: safe.configSchemaVersion,
releaseId: safe.releaseId,
supportReference: `${safe.buildId}:${code}`,
});
}
}
/**
* @param {{
* fetcher?: typeof fetch,
* buildConfig?: ReturnType<typeof getBuildConfig>,
* now?: () => number
* }} [options]
*/
export async function loadRuntimeConfig(options = {}) {
const fetcher = options.fetcher ?? fetch;
const buildConfig = options.buildConfig ?? getBuildConfig();
const now = options.now ?? performance.now.bind(performance);
const startedAt = now();
let response;
try {
response = await fetcher(buildConfig.runtimeConfigUrl, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new BootConfigError("CONFIG_FETCH_FAILED", {
buildId: buildConfig.buildId,
});
}
if (!response.ok) {
throw new BootConfigError("CONFIG_HTTP_FAILED", {
buildId: buildConfig.buildId,
});
}
let rawConfig;
try {
rawConfig = await response.json();
} catch {
throw new BootConfigError("CONFIG_JSON_INVALID", {
buildId: buildConfig.buildId,
});
}
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
throw new BootConfigError("CONFIG_SHAPE_INVALID", {
buildId: buildConfig.buildId,
});
}
try {
assertSafeConfigNames(rawConfig);
} catch {
throw new BootConfigError("CONFIG_SECRET_NAME_REJECTED", {
buildId: buildConfig.buildId,
});
}
const validated = validateRuntimeConfig(rawConfig);
if (!validated.success) {
throw new BootConfigError("CONFIG_SCHEMA_INVALID", {
buildId: buildConfig.buildId,
configSchemaVersion:
typeof rawConfig.CONFIG_SCHEMA_VERSION === "string"
? rawConfig.CONFIG_SCHEMA_VERSION
: undefined,
releaseId: typeof rawConfig.RELEASE_ID === "string" ? rawConfig.RELEASE_ID : undefined,
});
}
if (validated.data.BUILD_ID && validated.data.BUILD_ID !== buildConfig.buildId) {
throw new BootConfigError("CONFIG_BUILD_MISMATCH", {
buildId: buildConfig.buildId,
configSchemaVersion: validated.data.CONFIG_SCHEMA_VERSION,
releaseId: validated.data.RELEASE_ID,
});
}
return Object.freeze({
config: validated.data,
build: buildConfig,
validationDurationMs: now() - startedAt,
});
}
+36 -7
View File
@@ -1,11 +1,24 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
function BootstrapShell() { import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
/** @param {{ environment: string }} props */
function BootstrapShell({ environment }) {
return ( return (
<main> <main>
<h1>Clean Architecture Frontend</h1> <h1>Clean Architecture Frontend</h1>
<p>런타임 계약 불러오는 중입니다.</p> <p>{environment} 런타임 계약 검증되었습니다.</p>
</main>
);
}
/** @param {{ supportReference: string }} props */
function BootErrorShell({ supportReference }) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<p>지원 참조: {supportReference}</p>
</main> </main>
); );
} }
@@ -16,8 +29,24 @@ if (!rootElement) {
throw new Error("Missing #root mount element"); throw new Error("Missing #root mount element");
} }
createRoot(rootElement).render( const root = createRoot(rootElement);
<StrictMode>
<BootstrapShell /> async function boot() {
</StrictMode>, try {
); const runtime = await loadRuntimeConfig();
root.render(
<StrictMode>
<BootstrapShell environment={runtime.config.APP_ENV} />
</StrictMode>,
);
} catch (error) {
const supportReference =
error instanceof BootConfigError
? error.safe.supportReference
: "boot:unknown";
root.render(<BootErrorShell supportReference={supportReference} />);
}
}
void boot();
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
export const runtimeConfigSchema = z
.object({
APP_ENV: z.enum(["local", "development", "staging", "production"]),
API_BASE_URL: z.url(),
REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000),
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
TELEMETRY_ENABLED: z.boolean(),
TELEMETRY_ENDPOINT: z.url().optional(),
AUTH_MODE: z.literal("external"),
CONFIG_SCHEMA_VERSION: version,
API_CONTRACT_VERSION: version,
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
RELEASE_ID: z.string().min(1).optional(),
BUILD_ID: z.string().min(1).optional(),
})
.strict()
.superRefine((config, context) => {
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
context.addIssue({
code: "custom",
path: ["TELEMETRY_ENDPOINT"],
message: "required when telemetry is enabled",
});
}
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
const endpointEntries =
/** @type {Array<[string, string | undefined]>} */ ([
["API_BASE_URL", config.API_BASE_URL],
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
]);
for (const [key, value] of endpointEntries) {
if (value && !local && new URL(value).protocol !== "https:") {
context.addIssue({
code: "custom",
path: [key],
message: "HTTPS is required outside local environments",
});
}
}
});
/** @param {unknown} value */
export function validateRuntimeConfig(value) {
const result = runtimeConfigSchema.safeParse(value);
if (!result.success) {
return {
success: /** @type {false} */ (false),
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
return {
success: /** @type {true} */ (true),
data: structuredClone(result.data),
};
}
+54
View File
@@ -0,0 +1,54 @@
const forbiddenConfigName = /(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i;
export const ENV_REGISTRY = Object.freeze({
VITE_BUILD_ID: build("public-metadata", true, null),
VITE_COMMIT_SHA: build("public-metadata", false, "local"),
VITE_ROUTER_BASE_PATH: build("compile-time", true, "/"),
VITE_RUNTIME_CONFIG_URL: build("compile-time", true, "/config.json"),
APP_ENV: runtime("public", true, null),
API_BASE_URL: runtime("public-sensitive", true, null),
REQUEST_TIMEOUT_MS: runtime("public", false, 10_000),
MAX_RETRY_ATTEMPTS: runtime("public", false, 2),
TELEMETRY_ENABLED: runtime("public", true, false),
TELEMETRY_ENDPOINT: runtime("public-sensitive", false, null),
AUTH_MODE: runtime("public", true, "external"),
CONFIG_SCHEMA_VERSION: runtime("public", true, null),
API_CONTRACT_VERSION: runtime("public", true, null),
RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"),
});
/**
* @param {string} classification
* @param {boolean} required
* @param {unknown} defaultValue
*/
function build(classification, required, defaultValue) {
return Object.freeze({ phase: "build", classification, required, defaultValue });
}
/**
* @param {string} classification
* @param {boolean} required
* @param {unknown} defaultValue
*/
function runtime(classification, required, defaultValue) {
return Object.freeze({ phase: "runtime", classification, required, defaultValue });
}
/** @param {Record<string, unknown>} config */
export function assertSafeConfigNames(config) {
for (const name of Object.keys(config)) {
if (forbiddenConfigName.test(name)) {
throw new Error(`Forbidden client configuration key: ${name}`);
}
}
}
export function getBuildConfig(environment = import.meta.env) {
const buildId = environment.VITE_BUILD_ID || "local-build";
const commitSha = environment.VITE_COMMIT_SHA || "local";
const routerBasePath = environment.VITE_ROUTER_BASE_PATH || "/";
const runtimeConfigUrl = environment.VITE_RUNTIME_CONFIG_URL || "/config.json";
return Object.freeze({ buildId, commitSha, routerBasePath, runtimeConfigUrl });
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { loadRuntimeConfig } from "../../src/bootstrap/load-runtime-config.js";
import { validateRuntimeConfig } from "../../src/bootstrap/runtime-config-schema.js";
import { assertSafeConfigNames } from "../../src/contracts/env.js";
const validConfig = {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 2,
TELEMETRY_ENABLED: false,
AUTH_MODE: "external",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "build-a",
};
describe("runtime configuration boundary", () => {
it.each([
[{ ...validConfig, API_BASE_URL: undefined }, "required key"],
[{ ...validConfig, REQUEST_TIMEOUT_MS: 0 }, "integer range"],
[{ ...validConfig, MAX_RETRY_ATTEMPTS: 3 }, "retry cap"],
[{ ...validConfig, TELEMETRY_ENABLED: "false" }, "ambiguous boolean"],
[{ ...validConfig, CONFIG_SCHEMA_VERSION: "next" }, "version"],
[{ ...validConfig, UNKNOWN_KEY: true }, "unknown key"],
])("rejects invalid config: %s (%s)", (candidate) => {
expect(validateRuntimeConfig(candidate).success).toBe(false);
});
it("rejects secret-like names before schema validation", () => {
expect(() => assertSafeConfigNames({ CLIENT_SECRET: "not-safe" })).toThrow(
"Forbidden client configuration key",
);
});
it("validates a fetched config under the 500ms budget excluding network", async () => {
let current = 100;
const result = await loadRuntimeConfig({
buildConfig: {
buildId: "build-a",
commitSha: "local",
routerBasePath: "/",
runtimeConfigUrl: "/config.json",
},
fetcher: async () => new Response(JSON.stringify(validConfig)),
now: () => (current += 2),
});
expect(result.validationDurationMs).toBeLessThanOrEqual(500);
expect(result.config.API_BASE_URL).toBe("http://localhost:8080");
});
it("returns only safe boot fields on failure", async () => {
await expect(
loadRuntimeConfig({
buildConfig: {
buildId: "build-a",
commitSha: "local",
routerBasePath: "/",
runtimeConfigUrl: "/config.json",
},
fetcher: async () => new Response("{"),
}),
).rejects.toMatchObject({
safe: {
kind: "BOOT_CONFIG_FAILURE",
buildId: "build-a",
},
});
});
});