feat: compose executable frontend runtime

This commit is contained in:
donghyeon-ka
2026-07-25 23:42:39 +09:00
parent 9200c80149
commit 9a120e6d45
15 changed files with 505 additions and 21 deletions
+14 -5
View File
@@ -4,14 +4,23 @@ import { createApplication } from "../application/create-application.js";
* This is the only module allowed to join concrete adapters to application
* ports. Boot phases are explicit so failures can stop before product mount.
*
* @template Config
* @template Release
* @template {Parameters<typeof createApplication>[0]} Ports
* @param {{
* loadConfig(): Promise<Record<string, unknown>>,
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>,
* loadConfig(): Promise<Config>,
* loadRelease(config: Config): Promise<Release>,
* createAdapters(context: {
* config: Record<string, unknown>,
* release: Record<string, unknown>
* }): Promise<Parameters<typeof createApplication>[0]>
* config: Config,
* release: Release
* }): Promise<Ports>
* }} factories
* @returns {Promise<Readonly<{
* config: Config,
* release: Release,
* ports: Ports,
* application: ReturnType<typeof createApplication>
* }>>}
*/
export async function createCompositionRoot(factories) {
const config = await factories.loadConfig();
@@ -0,0 +1,32 @@
import { createCompositionRoot } from "./composition-root.js";
import { loadReleaseManifest } from "./load-release-manifest.js";
import { loadRuntimeConfig } from "./load-runtime-config.js";
import { createRuntimeAdapters } from "./runtime-adapters.js";
/**
* @param {{
* fetcher?: typeof fetch,
* host?: Record<string, unknown>
* }} [dependencies]
*/
export function createRuntimeComposition(dependencies = {}) {
return createCompositionRoot({
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
loadRelease: (runtime) =>
loadReleaseManifest(
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
{ fetcher: dependencies.fetcher },
),
createAdapters: ({ config: runtime, release }) =>
createRuntimeAdapters({
runtime:
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
release:
/** @type {Awaited<ReturnType<typeof loadReleaseManifest>>} */ (
release
),
fetcher: dependencies.fetcher,
host: dependencies.host,
}),
});
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
const releaseManifestSchema = z
.object({
schemaVersion: z.literal(1),
appVersion: z.string().min(1),
buildId: z.string().min(1),
commitSha: z.string().min(1),
configSchemaVersion: version,
apiContractVersion: version,
assetManifestHash: z.string().min(1),
releaseId: z.string().min(1),
builtAt: z.string().min(1),
})
.strict();
export class ReleaseManifestError extends Error {
/** @param {string} code @param {{buildId: string, releaseId?: string}} safe */
constructor(code, safe) {
super("Release manifest could not be loaded");
this.name = "ReleaseManifestError";
this.kind = "RELEASE_MANIFEST_FAILURE";
this.code = code;
this.safe = Object.freeze({
kind: this.kind,
code,
buildId: safe.buildId,
releaseId: safe.releaseId,
supportReference: `${safe.buildId}:${code}`,
});
}
}
/**
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
* @param {{fetcher?: typeof fetch}} [options]
*/
export async function loadReleaseManifest(runtime, options = {}) {
const fetcher = options.fetcher ?? fetch;
let response;
try {
response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
if (!response.ok) {
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
let raw;
try {
raw = await response.json();
} catch {
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
const parsed = releaseManifestSchema.safeParse(raw);
if (!parsed.success) {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
const manifest = parsed.data;
const mismatches = [];
if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId");
if (
runtime.config.BUILD_ID &&
manifest.buildId !== runtime.config.BUILD_ID
) {
mismatches.push("runtimeBuildId");
}
if (
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
) {
mismatches.push("configSchemaVersion");
}
if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) {
mismatches.push("apiContractVersion");
}
if (
runtime.config.RELEASE_ID &&
manifest.releaseId !== runtime.config.RELEASE_ID
) {
mismatches.push("releaseId");
}
if (mismatches.length > 0) {
throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
return Object.freeze(structuredClone(manifest));
}
+7 -6
View File
@@ -1,10 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
import { createRuntimeComposition } from "./create-runtime-composition.js";
import { BootConfigError } from "./load-runtime-config.js";
import { ReleaseManifestError } from "./load-release-manifest.js";
import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root");
@@ -17,18 +18,18 @@ const root = createRoot(rootElement);
async function boot() {
try {
const runtime = await loadRuntimeConfig();
const composition = await createRuntimeComposition();
root.render(
<StrictMode>
<AppRouter
authSession={createAnonymousSessionAdapter()}
basename={runtime.build.routerBasePath}
authSession={composition.ports.authSession}
basename={composition.config.build.routerBasePath}
/>
</StrictMode>,
);
} catch (error) {
const safe =
error instanceof BootConfigError
error instanceof BootConfigError || error instanceof ReleaseManifestError
? error.safe
: { supportReference: "boot:unknown" };
+94
View File
@@ -0,0 +1,94 @@
import {
createDemoSessionAdapter,
createExternalAuthSessionAdapter,
createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
/**
* @param {Record<string, unknown>} host
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0] | null}
*/
function externalOwnerFrom(host) {
const candidate = host.__CA_FRONTEND_AUTH_OWNER__;
if (!candidate || typeof candidate !== "object") return null;
const owner = /** @type {Record<string, unknown>} */ (candidate);
const required = [
"readState",
"subscribe",
"beginSignIn",
"signOut",
"attachCredential",
"recoverSession",
"notifyUnauthenticated",
];
return required.every((name) => typeof owner[name] === "function")
? /** @type {Parameters<typeof createExternalAuthSessionAdapter>[0]} */ (
candidate
)
: null;
}
/** @param {unknown} value */
function storageOrUndefined(value) {
return typeof Storage !== "undefined" && value instanceof Storage
? value
: undefined;
}
/**
* @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
* release: Awaited<ReturnType<typeof import("./load-release-manifest.js").loadReleaseManifest>>,
* host?: Record<string, unknown>,
* fetcher?: typeof fetch
* }} context
*/
export async function createRuntimeAdapters(context) {
const host = context.host ?? /** @type {Record<string, unknown>} */ (globalThis);
const config = context.runtime.config;
const externalOwner = externalOwnerFrom(host);
const authSession =
config.AUTH_MODE === "demo"
? createDemoSessionAdapter()
: externalOwner
? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter();
const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
});
const telemetry = createTelemetryAdapter({
enabled: config.TELEMETRY_ENABLED,
endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher,
});
const http = createHttpClient({
baseUrl: config.API_BASE_URL,
authSession,
fetcher: context.fetcher,
});
const releaseInfo = Object.freeze({
async getCurrent() {
return structuredClone(context.release);
},
});
return Object.freeze({
authSession,
cache,
http,
queryClient,
releaseInfo,
storage,
telemetry,
});
}
+8 -1
View File
@@ -10,7 +10,7 @@ export const runtimeConfigSchema = z
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"),
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"),
@@ -28,6 +28,13 @@ export const runtimeConfigSchema = z
}
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 =
/** @type {Array<[string, string | undefined]>} */ ([
["API_BASE_URL", config.API_BASE_URL],