refactor: 리펙토링
This commit is contained in:
@@ -1,79 +1,138 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
runtimeConfigV1ArtifactSchema,
|
||||
runtimeConfigV2ArtifactSchema,
|
||||
type CapabilityOverrideArtifact,
|
||||
type RuntimeConfigV1Artifact,
|
||||
type RuntimeConfigV2Artifact,
|
||||
} from "../contracts/release-artifacts.ts";
|
||||
|
||||
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
|
||||
export { isValidReleaseManifestUrl } from "../contracts/release-artifacts.ts";
|
||||
|
||||
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.enum(["external", "demo"]),
|
||||
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",
|
||||
});
|
||||
}
|
||||
export type CapabilityOverrides = CapabilityOverrideArtifact;
|
||||
|
||||
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
|
||||
if (!local && config.AUTH_MODE === "demo") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["AUTH_MODE"],
|
||||
message: "demo authentication is limited to local environments",
|
||||
});
|
||||
}
|
||||
const endpointEntries = [
|
||||
["API_BASE_URL", config.API_BASE_URL],
|
||||
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
|
||||
] as const;
|
||||
/**
|
||||
* §6.1. Runtime Config V2 is deployment and browser operational setting only.
|
||||
* `API_CONTRACT_VERSION` is gone: a scalar cannot describe a multi-package
|
||||
* contract set, and Release Manifest V2 `contractSet` owns that meaning.
|
||||
*/
|
||||
export const runtimeConfigV2Schema = runtimeConfigV2ArtifactSchema;
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
/**
|
||||
* §5.8 / §24.6 RC-2. The V1 reader is retained for one compatibility window so
|
||||
* a release never swaps source shape, manifest shape and runtime behaviour at
|
||||
* the same time. Only a V1 document may carry the scalar contract version.
|
||||
*/
|
||||
export const runtimeConfigV1Schema = runtimeConfigV1ArtifactSchema;
|
||||
|
||||
export type RuntimeConfigV2 = RuntimeConfigV2Artifact;
|
||||
export type RuntimeConfigV1 = RuntimeConfigV1Artifact;
|
||||
|
||||
/**
|
||||
* The composition-facing shape. V1 documents are normalized onto it so the rest
|
||||
* of the runtime never branches on config schema version.
|
||||
*/
|
||||
export type RuntimeConfig = Readonly<{
|
||||
APP_ENV: RuntimeConfigV2["APP_ENV"];
|
||||
API_BASE_URL: string;
|
||||
REQUEST_TIMEOUT_MS: number;
|
||||
MAX_RETRY_ATTEMPTS: number;
|
||||
TELEMETRY_ENABLED: boolean;
|
||||
TELEMETRY_ENDPOINT?: string;
|
||||
AUTH_MODE: RuntimeConfigV2["AUTH_MODE"];
|
||||
CONFIG_SCHEMA_VERSION: string;
|
||||
RELEASE_MANIFEST_URL: string;
|
||||
RELEASE_ID?: string;
|
||||
BUILD_ID?: string;
|
||||
CAPABILITY_OVERRIDES: CapabilityOverrides;
|
||||
/** Present only while a V1 document is still accepted. */
|
||||
LEGACY_API_CONTRACT_VERSION?: string;
|
||||
}>;
|
||||
|
||||
export type RuntimeConfig = z.output<typeof runtimeConfigSchema>;
|
||||
export type RuntimeConfigValidation =
|
||||
| Readonly<{ success: true; data: RuntimeConfig }>
|
||||
| Readonly<{ success: true; data: RuntimeConfig; schema: "V1" | "V2" }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
issues: readonly Readonly<{ path: string; code: string }>[];
|
||||
}>;
|
||||
|
||||
export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
|
||||
const result = runtimeConfigSchema.safeParse(value);
|
||||
const DEFAULT_OVERRIDES: CapabilityOverrides = Object.freeze({
|
||||
REALTIME: "DEFAULT" as const,
|
||||
WEB_WORKER: "DEFAULT" as const,
|
||||
SERVICE_WORKER: "DEFAULT" as const,
|
||||
OFFLINE_COMMANDS: "DEFAULT" as const,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
})),
|
||||
};
|
||||
function canonicalUrl(value: string): string {
|
||||
return new URL(value).href;
|
||||
}
|
||||
|
||||
export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
|
||||
const declared =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>).CONFIG_SCHEMA_VERSION
|
||||
: undefined;
|
||||
|
||||
// §5.8: no precedence between V1 and V2. The declared version selects exactly
|
||||
// one parser, and a V2 document carrying the removed scalar is rejected.
|
||||
if (declared !== "1" && declared !== "2.0") {
|
||||
return Object.freeze({
|
||||
success: false as const,
|
||||
issues: Object.freeze([
|
||||
Object.freeze({
|
||||
path: "CONFIG_SCHEMA_VERSION",
|
||||
code: "unsupported_value",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: structuredClone(result.data),
|
||||
};
|
||||
const isV2 = declared === "2.0";
|
||||
const result = isV2
|
||||
? runtimeConfigV2Schema.safeParse(value)
|
||||
: runtimeConfigV1Schema.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
return Object.freeze({
|
||||
success: false as const,
|
||||
issues: Object.freeze(
|
||||
result.error.issues.map((issue) =>
|
||||
Object.freeze({ path: issue.path.join("."), code: issue.code }),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = result.data;
|
||||
const normalized: RuntimeConfig = Object.freeze({
|
||||
APP_ENV: parsed.APP_ENV,
|
||||
API_BASE_URL: canonicalUrl(parsed.API_BASE_URL),
|
||||
REQUEST_TIMEOUT_MS: parsed.REQUEST_TIMEOUT_MS,
|
||||
MAX_RETRY_ATTEMPTS: parsed.MAX_RETRY_ATTEMPTS,
|
||||
TELEMETRY_ENABLED: parsed.TELEMETRY_ENABLED,
|
||||
...(parsed.TELEMETRY_ENDPOINT
|
||||
? { TELEMETRY_ENDPOINT: canonicalUrl(parsed.TELEMETRY_ENDPOINT) }
|
||||
: {}),
|
||||
AUTH_MODE: parsed.AUTH_MODE,
|
||||
CONFIG_SCHEMA_VERSION: parsed.CONFIG_SCHEMA_VERSION,
|
||||
RELEASE_MANIFEST_URL: parsed.RELEASE_MANIFEST_URL,
|
||||
...(parsed.RELEASE_ID ? { RELEASE_ID: parsed.RELEASE_ID } : {}),
|
||||
...(parsed.BUILD_ID ? { BUILD_ID: parsed.BUILD_ID } : {}),
|
||||
CAPABILITY_OVERRIDES: Object.freeze({
|
||||
...(isV2
|
||||
? (parsed as RuntimeConfigV2).CAPABILITY_OVERRIDES
|
||||
: DEFAULT_OVERRIDES),
|
||||
}),
|
||||
...(isV2
|
||||
? {}
|
||||
: {
|
||||
LEGACY_API_CONTRACT_VERSION: (parsed as RuntimeConfigV1)
|
||||
.API_CONTRACT_VERSION,
|
||||
}),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
success: true as const,
|
||||
data: normalized,
|
||||
schema: isV2 ? ("V2" as const) : ("V1" as const),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user