RuntimeHttpContract는 슬롯 네 개를 명시한 주입 지점인데, 그 슬롯이 비어 있다는 사실과 비운 채 되돌리면 모든 요청이 실패한다는 사실이 코드 어디에도 없었다. V3는 설치된 feature 계약에서 오퍼레이션을 받으므로 이 슬롯과 무관하다는 것도 함께 적는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
562 lines
18 KiB
TypeScript
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,
|
|
});
|
|
}
|