Files
clean-architecture-frontend…/src/bootstrap/runtime-adapters.ts
T
DongHyeonkaandClaude Opus 5 5d9ad4f74a docs: say what a product must inject to use the V2 transport
RuntimeHttpContract는 슬롯 네 개를 명시한 주입 지점인데, 그 슬롯이 비어
있다는 사실과 비운 채 되돌리면 모든 요청이 실패한다는 사실이 코드 어디에도
없었다. V3는 설치된 feature 계약에서 오퍼레이션을 받으므로 이 슬롯과
무관하다는 것도 함께 적는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:58:36 +09:00

562 lines
18 KiB
TypeScript

import {
createDemoSessionAdapter,
createExternalAuthSessionAdapter,
createUnavailableSessionAdapter,
type ExternalSessionOwner,
} from "../adapters/auth/index.ts";
import { createDiagnosticsAdapter } from "../adapters/diagnostics/index.ts";
import {
createContractHttpExecutor,
createHttpClient,
type HttpExecutionObservation,
} from "../adapters/http/index.ts";
import {
attemptBucket,
durationBucket,
statusGroup,
type DiagnosticRecordInput,
} from "../contracts/diagnostics.ts";
import type { TelemetryEventName } from "../contracts/telemetry.ts";
import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts";
import {
createConditionalValidatorStore,
createQueryClient,
createServerStateScopeRuntime,
createTanStackCacheCoordinator,
} from "../adapters/query-cache/index.ts";
import { createBrowserStorageAdapter } from "../adapters/storage/index.ts";
import { createBrowserMutationIntentFactory } from "../adapters/platform/index.ts";
import { createTelemetryAdapter } from "../adapters/telemetry/index.ts";
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
import {
createRestProviderProfile,
INSTALLED_REST_AUTH_PROFILES,
} from "../contracts/rest-profiles.ts";
import type { ClockPort } from "../application/ports/clock-port.ts";
import type { MutationIntent } from "../contracts/mutation-intent.ts";
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
import {
INVALIDATION_REGISTRY,
INVALIDATION_TOPIC_VERSIONS,
} from "../features/installed-feature-contracts.ts";
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
import {
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
} from "../features/installed-product-manifest.ts";
import {
activeProductFeatureIds,
resolveProductFeatures,
} from "../contracts/product-features.ts";
import { describeRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
import {
fetchReleaseManifest,
type ReleaseManifest,
} from "./load-release-manifest.ts";
import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts";
import { createServerStateGenerationStore } from "./server-state-generation-store.ts";
import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../features/installed-contract-contributions.ts";
import {
indexInvalidationRegistry,
indexInvalidationTopicVersions,
} from "../contracts/query-invalidation.ts";
type HttpClientDependencies = Parameters<typeof createHttpClient>[0];
/**
* 제품이 V2 전송을 쓰려면 채워야 하는 슬롯 네 개.
*
* 이 템플릿은 넷 다 비워서 출하한다. 기본값인
* `contracts/api-operations.ts`의 `API_OPERATIONS`는 빈 객체이고
* `adapters/http/resource-mapper.ts`의 `mapOperationPayload`는 항상
* `MAPPING_INVARIANT_REJECTED`를 반환한다. 그래서 **이 레포 상태 그대로는
* V2로 어떤 오퍼레이션도 실행되지 않는다** — 그건 결함이 아니라 제품이
* 채우는 자리다.
*
* V2를 rollback/compatibility 경로로 쓰려는 제품은 이 네 슬롯을 주입해야
* 한다. 주입하지 않은 채 V3에서 되돌리면 모든 요청이 실패한다.
* V3(`createContractHttpExecutor`)는 설치된 feature 계약에서 오퍼레이션을
* 받으므로 이 슬롯과 무관하게 동작한다.
*/
export type RuntimeHttpContract = Pick<
HttpClientDependencies,
"getOperation" | "validatePayload" | "validateRequest" | "mapPayload"
>;
type RuntimeHttpContext = Readonly<{
runtime: RuntimeConfigLoadResult;
authSession: AuthSessionPort;
fetcher?: typeof fetch;
clock?: ClockPort;
scheduler?: HttpClientDependencies["scheduler"];
diagnostics?: HttpClientDependencies["diagnostics"];
telemetry?: HttpClientDependencies["telemetry"];
}>;
export type RuntimeAdaptersContext = Readonly<{
runtime: RuntimeConfigLoadResult;
release: Readonly<ReleaseManifest>;
host?: Record<string, unknown>;
fetcher?: typeof fetch;
}>;
/**
* §5.2. The composition root is where a manifest becomes release info. A V2
* manifest states contract identity as a verified contract set and a V1
* manifest as the legacy scalar; the application layer sees one shape and never
* branches on the schema version to find the identity.
*/
function toReleaseInfo(manifest: Readonly<ReleaseManifest>): ReleaseInfo {
const { contractSet, legacyApiContractVersion, ...rest } =
structuredClone(manifest);
return Object.freeze({
...rest,
...(legacyApiContractVersion === undefined
? {}
: { apiContractVersion: legacyApiContractVersion }),
...(contractSet === null
? {}
: { contractSetDigest: contractSet.setDigest }),
});
}
const EXTERNAL_OWNER_METHODS = Object.freeze([
"readState",
"subscribe",
"beginSignIn",
"signOut",
"attachCredential",
"recoverSession",
"notifyUnauthenticated",
] as const);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object";
}
function externalOwnerFrom(
host: Record<string, unknown>,
): ExternalSessionOwner | null {
const candidate = host.__CA_FRONTEND_AUTH_OWNER__;
if (!isRecord(candidate)) return null;
return EXTERNAL_OWNER_METHODS.every(
(name) => typeof candidate[name] === "function",
)
? (candidate as ExternalSessionOwner)
: null;
}
function hostValue(
host: Record<string, unknown>,
property: string,
): unknown {
try {
return Reflect.get(host, property);
} catch {
return undefined;
}
}
function storageOrUndefined(value: unknown): Storage | undefined {
if (!value || typeof value !== "object") return undefined;
const candidate = value as Record<string, unknown>;
try {
return ["getItem", "setItem", "removeItem"].every(
(method) => typeof Reflect.get(candidate, method) === "function",
)
? (value as Storage)
: undefined;
} catch {
return undefined;
}
}
/**
* Runtime-aware transport factory. Feature gateway composition calls this
* factory when a registered API capability is installed.
*/
export function createRuntimeHttpClient(
context: RuntimeHttpContext,
contract: RuntimeHttpContract = {},
) {
return createHttpClient({
baseUrl: context.runtime.config.API_BASE_URL,
timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS,
maxRetryAttempts: context.runtime.config.MAX_RETRY_ATTEMPTS,
authSession: context.authSession,
providerProfile: createRestProviderProfile(
"PRIMARY_API",
context.runtime.config.API_BASE_URL,
["omit"],
),
fetcher: context.fetcher,
clock: context.clock,
scheduler: context.scheduler,
diagnostics: context.diagnostics,
telemetry: context.telemetry,
...contract,
});
}
/**
* VD-07. Exactly one diagnostic per logical V3 execution and exactly one
* `api.request.failed` telemetry event per terminal non-abort failure.
*
* The projection is closed: only registered context keys and bucketed values
* reach the sinks, and neither sink can change the HTTP outcome, because the
* caller invokes this inside the executor's isolated observation boundary.
*/
export function createHttpObservationProjector(
sinks: Readonly<{
diagnostics: Readonly<{ record(input: DiagnosticRecordInput): void }>;
telemetry: Readonly<{
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void;
}>;
}>,
): (observation: HttpExecutionObservation) => void {
return (observation) => {
const safeAttributes = {
route_id: observation.routeId,
operation_id: observation.operationId,
error_kind: observation.errorKind,
http_status_group: statusGroup(observation.status),
attempt_count_bucket: attemptBucket(observation.attemptCount),
duration_bucket: durationBucket(observation.durationMs),
};
try {
sinks.diagnostics.record({
level: observation.outcome === "SUCCESS" ? "info" : "warn",
eventId: "http.request.completed",
context: {
...safeAttributes,
operation: observation.diagnosticsOperation,
outcome: observation.outcome,
},
});
} catch {
// Diagnostics cannot change a contract execution outcome.
}
if (!isTerminalNonAbortFailure(observation)) return;
try {
sinks.telemetry.emit("api.request.failed", { ...safeAttributes });
} catch {
// Telemetry cannot change a contract execution outcome.
}
};
}
/**
* LIVE-05. Cancellation and scope fencing are caller- or generation-owned
* decisions, not API failures: they produce a diagnostic once and never
* `api.request.failed`.
*
* A `DEADLINE` owner is the opposite case. Nobody asked for it — the API did
* not answer inside the contract's own budget — so excluding it would hide
* exactly the outage this event exists to report.
*/
const CALLER_OWNED_CANCELLATION: ReadonlySet<string> = new Set([
"CALLER",
"ROUTE_TRANSITION",
"SCOPE_FENCE",
"APPLICATION_SHUTDOWN",
]);
function isTerminalNonAbortFailure(
observation: HttpExecutionObservation,
): boolean {
if (observation.outcome === "SUCCESS") return false;
if (observation.outcome === "CANCELLED") return false;
if (
observation.cancellationOwner !== undefined &&
CALLER_OWNED_CANCELLATION.has(observation.cancellationOwner)
) {
return false;
}
return !(
observation.outcome === "CONTRACT_VIOLATION" &&
observation.errorKind === "SCOPE_FENCED"
);
}
export async function createRuntimeAdapters(
context: RuntimeAdaptersContext,
) {
const host =
context.host ?? (globalThis as unknown as Record<string, unknown>);
const config = context.runtime.config;
const mutationIntentFactory = createBrowserMutationIntentFactory();
const externalOwner = externalOwnerFrom(host);
const authSession =
config.AUTH_MODE === "demo"
? createDemoSessionAdapter()
: externalOwner
? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter();
const diagnostics = createDiagnosticsAdapter();
const telemetry = createTelemetryAdapter({
enabled: config.TELEMETRY_ENABLED,
endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher,
onDrop(event) {
const attributes =
event.attributes && typeof event.attributes === "object"
? event.attributes
: {};
diagnostics.record({
level: "warn",
eventId: "telemetry.delivery.dropped",
context: attributes,
});
},
});
const invalidationIndex = indexInvalidationRegistry(INVALIDATION_REGISTRY);
const invalidationTopicVersions = indexInvalidationTopicVersions(
INVALIDATION_REGISTRY,
INVALIDATION_TOPIC_VERSIONS,
);
const conditionalValidators = createConditionalValidatorStore();
const serverStateGeneration = createServerStateGenerationStore(() => {
const queryClient = createQueryClient({ diagnostics });
const crossContextInvalidation =
createBrowserCrossContextInvalidationFromHost({
...(context.host === undefined ? {} : { host: context.host }),
cacheEpoch: `release.${context.release.releaseId}`,
topics: [...invalidationTopicVersions].map(([topic, topicVersion]) =>
Object.freeze({
topic,
topicVersion,
}),
),
observe(observation) {
if (
observation.outcome !== "FAILED" &&
observation.outcome !== "DEGRADED"
) {
return;
}
diagnostics.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation: observation.operation,
outcome: observation.outcome,
reason: observation.reason,
},
});
},
});
const queryInvalidation = createTanStackCacheCoordinator({
queryClient,
invalidationIndex,
topicVersions: invalidationTopicVersions,
crossContext: crossContextInvalidation,
diagnostics,
});
return Object.freeze({
queryClient,
queryInvalidation,
crossContextStatus: () =>
crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY",
});
});
const serverStateScope = createServerStateScopeRuntime({
session: authSession,
queryInvalidation: {
resetLocal: () => serverStateGeneration.resetCurrent(),
},
participants: [
{
order: 4,
label: "conditional-validators",
close: () => conditionalValidators.clear(),
},
],
activateNextGeneration: () => serverStateGeneration.activateNext(),
});
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(hostValue(host, "localStorage")),
sessionStorage: storageOrUndefined(
hostValue(host, "sessionStorage"),
),
diagnostics,
});
const releaseInfo = Object.freeze({
async getCurrent() {
return toReleaseInfo(context.release);
},
async refresh() {
return toReleaseInfo(
await fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
fetcher: context.fetcher,
buildId: context.release.buildId,
releaseId: context.release.releaseId,
}),
);
},
});
const runtimeCapabilities = Object.freeze({
getSnapshot() {
return describeRuntimeCapabilities(
INSTALLED_RUNTIME_CAPABILITIES,
config.CAPABILITY_OVERRIDES,
);
},
});
/**
* §3.5. The two halves of the feature answer meet here and nowhere else: the
* manifest says what the build compiled in, the runtime document says what is
* switched off. Neither can add to the other.
*/
const productFeatureStatuses = resolveProductFeatures(
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
config.FEATURE_OVERRIDES,
);
const activeFeatureIds = new Set(
activeProductFeatureIds(productFeatureStatuses),
);
const productFeatures = Object.freeze({
getSnapshot() {
return productFeatureStatuses;
},
isActive(featureId: string) {
return activeFeatureIds.has(featureId);
},
});
const navigation = Object.freeze({
reload() {
const location = host.location;
if (!isRecord(location) || typeof location.reload !== "function") {
throw new Error("Browser reload is unavailable");
}
location.reload();
},
});
const contractHttp = createContractHttpExecutor({
baseUrl: config.API_BASE_URL,
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
// §6.1. `REQUEST_TIMEOUT_MS` was declared, validated and then dropped on the
// floor here: every V3 operation ran on its contract's own 10s deadline and
// the deployment dial did nothing. It is a ceiling, so it can tighten an
// operation but never loosen one.
requestDeadlineCeilingMs: config.REQUEST_TIMEOUT_MS,
fetcher: context.fetcher,
// §7.7. The installed registry owns Fetch credentials and the exact
// credential-header sets; this collaborator only supplies proof headers.
authProfiles: INSTALLED_REST_AUTH_PROFILES,
async attachCredentials(operation, authContext) {
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
const state = authSession.getState();
if (state === "integration-failed") {
return Object.freeze({ kind: "UNAVAILABLE" as const });
}
if (state !== "authenticated") {
return Object.freeze({ kind: "UNAUTHENTICATED" as const });
}
try {
const patch = await authSession.credentialPatch(
{
origin: new URL(config.API_BASE_URL).origin,
method: operation.method,
operationId: operation.operationId,
},
authContext,
);
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
return Object.freeze({
kind: "READY" as const,
headers: patch.headers,
});
} catch {
return Object.freeze({ kind: "UNAVAILABLE" as const });
}
},
observe: createHttpObservationProjector({ diagnostics, telemetry }),
});
const contractOperations = Object.freeze({
async execute(
operationId: string,
input: unknown,
executionContext: Readonly<{
routeId: string;
signal?: AbortSignal;
intent?: MutationIntent;
}>,
) {
const operation =
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
if (!operation) {
return Object.freeze({
kind: "CONTRACT_VIOLATION" as const,
effect: "NOT_STARTED" as const,
violation: Object.freeze({
kind: "FINAL_REQUEST_INVARIANT_FAILED" as const,
operation: "REQUEST" as const,
}),
});
}
const outcome = await contractHttp.execute(operation, input, {
routeId: executionContext.routeId,
scope: serverStateScope.getSnapshot(),
...(executionContext.signal === undefined
? {}
: { signal: executionContext.signal }),
...(executionContext.intent === undefined
? {}
: { intent: executionContext.intent }),
});
if (outcome.kind === "UNAUTHENTICATED") {
authSession.onUnauthenticated();
}
return outcome;
},
});
const featureInputs = createInstalledFeatureInputs({
contractOperations,
});
return Object.freeze({
outputPorts: Object.freeze({
session: authSession,
preferences: storage,
diagnostics,
telemetry,
releaseInfo,
runtimeCapabilities,
productFeatures,
navigation,
}),
infrastructure: Object.freeze({
get queryClient() {
return serverStateGeneration.getSnapshot().queryClient;
},
get queryInvalidation() {
return serverStateGeneration.getSnapshot().queryInvalidation;
},
serverStateGeneration,
serverStateScope,
mutationIntentFactory,
conditionalValidators,
crossContextInvalidationStatus: () =>
serverStateGeneration.getSnapshot().crossContextStatus(),
dispose() {
// N-04. Telemetry is torn down first: it must stop scheduling and
// delivering before the diagnostics and state dependencies it observes
// are destroyed.
telemetry.dispose();
conditionalValidators.clear();
serverStateScope.dispose();
serverStateGeneration.dispose();
},
}),
featureInputs,
});
}