feat: 기능 추가 과정중
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
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]} OutputPorts
|
||||
* @template Infrastructure
|
||||
* @param {{
|
||||
* loadConfig(): Promise<Config>,
|
||||
* loadRelease(config: Config): Promise<Release>,
|
||||
* createAdapters(context: {
|
||||
* config: Config,
|
||||
* release: Release
|
||||
* }): Promise<{
|
||||
* outputPorts: OutputPorts,
|
||||
* infrastructure: Infrastructure,
|
||||
* featureInputs?: Readonly<Record<string, unknown>>
|
||||
* }>
|
||||
* }} factories
|
||||
* @returns {Promise<Readonly<{
|
||||
* config: Config,
|
||||
* release: Release,
|
||||
* infrastructure: Infrastructure,
|
||||
* application: ReturnType<typeof createApplication>
|
||||
* }>>}
|
||||
*/
|
||||
export async function createCompositionRoot(factories) {
|
||||
const config = await factories.loadConfig();
|
||||
const release = await factories.loadRelease(config);
|
||||
const adapters = await factories.createAdapters({ config, release });
|
||||
const application = createApplication(
|
||||
adapters.outputPorts,
|
||||
adapters.featureInputs,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
config,
|
||||
release,
|
||||
infrastructure: adapters.infrastructure,
|
||||
application,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createApplication } from "../application/create-application.ts";
|
||||
import type {
|
||||
ApplicationApi,
|
||||
ApplicationFeatureInputs,
|
||||
} from "../application/ports/in/application-api.ts";
|
||||
import type { ApplicationOutputPorts } from "../application/ports/out/application-output-ports.ts";
|
||||
|
||||
export type CompositionRoot<Config, Release, Infrastructure> = Readonly<{
|
||||
config: Config;
|
||||
release: Release;
|
||||
infrastructure: Infrastructure;
|
||||
application: ApplicationApi;
|
||||
}>;
|
||||
|
||||
type AdapterBundle<Infrastructure> = Readonly<{
|
||||
outputPorts: ApplicationOutputPorts;
|
||||
infrastructure: Infrastructure;
|
||||
featureInputs?: Readonly<Partial<ApplicationFeatureInputs>>;
|
||||
}>;
|
||||
|
||||
type CompositionFactories<Config, Release, Infrastructure> = Readonly<{
|
||||
loadConfig(): Promise<Config>;
|
||||
loadRelease(config: Config): Promise<Release>;
|
||||
createAdapters(context: Readonly<{
|
||||
config: Config;
|
||||
release: Release;
|
||||
}>): Promise<AdapterBundle<Infrastructure>>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* This is the only module allowed to join concrete adapters to application
|
||||
* ports. Boot phases are explicit so failures can stop before product mount.
|
||||
*/
|
||||
export async function createCompositionRoot<Config, Release, Infrastructure>(
|
||||
factories: CompositionFactories<Config, Release, Infrastructure>,
|
||||
): Promise<CompositionRoot<Config, Release, Infrastructure>> {
|
||||
const config = await factories.loadConfig();
|
||||
const release = await factories.loadRelease(config);
|
||||
const adapters = await factories.createAdapters({ config, release });
|
||||
const application = createApplication(
|
||||
adapters.outputPorts,
|
||||
adapters.featureInputs,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
config,
|
||||
release,
|
||||
infrastructure: adapters.infrastructure,
|
||||
application,
|
||||
});
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createCompositionRoot } from "./composition-root.ts";
|
||||
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
||||
import { loadRuntimeConfig } from "./load-runtime-config.ts";
|
||||
import { createRuntimeAdapters } from "./runtime-adapters.ts";
|
||||
|
||||
export type RuntimeCompositionDependencies = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
host?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
export function createRuntimeComposition(
|
||||
dependencies: RuntimeCompositionDependencies = {},
|
||||
) {
|
||||
return createCompositionRoot({
|
||||
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
||||
loadRelease: (runtime) =>
|
||||
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
||||
createAdapters: ({ config: runtime, release }) =>
|
||||
createRuntimeAdapters({
|
||||
runtime,
|
||||
release,
|
||||
fetcher: dependencies.fetcher,
|
||||
host: dependencies.host,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export type RuntimeComposition = Awaited<
|
||||
ReturnType<typeof createRuntimeComposition>
|
||||
>;
|
||||
+13
-12
@@ -1,24 +1,25 @@
|
||||
import {
|
||||
normalizeColorSchemePreference,
|
||||
resolveColorScheme,
|
||||
} from "../application/policies/color-scheme.js";
|
||||
} from "../application/policies/color-scheme.ts";
|
||||
import type { ApplicationApi } from "../application/ports/in/application-api.ts";
|
||||
|
||||
/**
|
||||
* Applies the persisted public preference before React paints.
|
||||
*
|
||||
* @param {Pick<import("../application/ports/in/application-api.js").ApplicationApi["preferences"], "getColorScheme">} preferences
|
||||
* @param {{
|
||||
* documentElement?: HTMLElement,
|
||||
* matchMedia?: (query: string) => MediaQueryList
|
||||
* }} [browser]
|
||||
*/
|
||||
export function initializeColorScheme(preferences, browser = {}) {
|
||||
type ColorSchemeBrowser = Readonly<{
|
||||
documentElement?: HTMLElement;
|
||||
matchMedia?: (query: string) => Pick<MediaQueryList, "matches">;
|
||||
}>;
|
||||
|
||||
/** Applies the persisted public preference before React paints. */
|
||||
export function initializeColorScheme(
|
||||
preferences: Pick<ApplicationApi["preferences"], "getColorScheme">,
|
||||
browser: ColorSchemeBrowser = {},
|
||||
) {
|
||||
const documentElement = browser.documentElement ?? document.documentElement;
|
||||
const matchMedia =
|
||||
browser.matchMedia ??
|
||||
(typeof window.matchMedia === "function"
|
||||
? window.matchMedia.bind(window)
|
||||
: () => /** @type {MediaQueryList} */ ({ matches: false }));
|
||||
: () => ({ matches: false }));
|
||||
const preference = normalizeColorSchemePreference(
|
||||
preferences.getColorScheme(),
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts";
|
||||
|
||||
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
|
||||
export const releaseManifestSchema = z
|
||||
.object({
|
||||
@@ -16,19 +18,65 @@ export const releaseManifestSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ReleaseManifest = z.output<typeof releaseManifestSchema>;
|
||||
export type ReleaseManifestErrorCode =
|
||||
| "MANIFEST_BUILD_MISMATCH"
|
||||
| "MANIFEST_CONFIG_SCHEMA_MISMATCH"
|
||||
| "MANIFEST_API_CONTRACT_MISMATCH"
|
||||
| "MANIFEST_RELEASE_MISMATCH"
|
||||
| "MANIFEST_ASSET_MISMATCH"
|
||||
| "MANIFEST_FETCH_FAILED"
|
||||
| "MANIFEST_HTTP_FAILED"
|
||||
| "MANIFEST_JSON_INVALID"
|
||||
| "MANIFEST_SCHEMA_INVALID";
|
||||
export type ReleaseManifestFailureKind =
|
||||
| "BUILD_MISMATCH"
|
||||
| "CONFIG_MISMATCH"
|
||||
| "API_CONTRACT_MISMATCH"
|
||||
| "RELEASE_MISMATCH"
|
||||
| "ASSET_MISMATCH"
|
||||
| "RELEASE_MANIFEST_FAILURE";
|
||||
export type ReleaseManifestSafe = Readonly<{
|
||||
kind: ReleaseManifestFailureKind;
|
||||
code: ReleaseManifestErrorCode;
|
||||
buildId: string;
|
||||
releaseId?: string;
|
||||
supportReference: string;
|
||||
}>;
|
||||
|
||||
type ReleaseManifestSafeInput = Readonly<{
|
||||
buildId: string;
|
||||
releaseId?: string;
|
||||
}>;
|
||||
|
||||
function failureKindFor(
|
||||
code: ReleaseManifestErrorCode,
|
||||
): ReleaseManifestFailureKind {
|
||||
switch (code) {
|
||||
case "MANIFEST_BUILD_MISMATCH":
|
||||
return "BUILD_MISMATCH";
|
||||
case "MANIFEST_CONFIG_SCHEMA_MISMATCH":
|
||||
return "CONFIG_MISMATCH";
|
||||
case "MANIFEST_API_CONTRACT_MISMATCH":
|
||||
return "API_CONTRACT_MISMATCH";
|
||||
case "MANIFEST_RELEASE_MISMATCH":
|
||||
return "RELEASE_MISMATCH";
|
||||
case "MANIFEST_ASSET_MISMATCH":
|
||||
return "ASSET_MISMATCH";
|
||||
default:
|
||||
return "RELEASE_MANIFEST_FAILURE";
|
||||
}
|
||||
}
|
||||
|
||||
export class ReleaseManifestError extends Error {
|
||||
/** @param {string} code @param {{buildId: string, releaseId?: string}} safe */
|
||||
constructor(code, safe) {
|
||||
readonly kind: ReleaseManifestFailureKind;
|
||||
readonly code: ReleaseManifestErrorCode;
|
||||
readonly safe: ReleaseManifestSafe;
|
||||
|
||||
constructor(code: ReleaseManifestErrorCode, safe: ReleaseManifestSafeInput) {
|
||||
super("Release manifest could not be loaded");
|
||||
this.name = "ReleaseManifestError";
|
||||
this.kind =
|
||||
{
|
||||
MANIFEST_BUILD_MISMATCH: "BUILD_MISMATCH",
|
||||
MANIFEST_CONFIG_SCHEMA_MISMATCH: "CONFIG_MISMATCH",
|
||||
MANIFEST_API_CONTRACT_MISMATCH: "API_CONTRACT_MISMATCH",
|
||||
MANIFEST_RELEASE_MISMATCH: "RELEASE_MISMATCH",
|
||||
MANIFEST_ASSET_MISMATCH: "ASSET_MISMATCH",
|
||||
}[code] ?? "RELEASE_MANIFEST_FAILURE";
|
||||
this.kind = failureKindFor(code);
|
||||
this.code = code;
|
||||
this.safe = Object.freeze({
|
||||
kind: this.kind,
|
||||
@@ -40,20 +88,22 @@ export class ReleaseManifestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type FetchReleaseManifestOptions = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
buildId: string;
|
||||
releaseId?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Fetches and validates the active manifest without imposing the current
|
||||
* build tuple. Chunk recovery uses this no-store view to detect a new release.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {{
|
||||
* fetcher?: typeof fetch,
|
||||
* buildId: string,
|
||||
* releaseId?: string
|
||||
* }} options
|
||||
*/
|
||||
export async function fetchReleaseManifest(url, options) {
|
||||
export async function fetchReleaseManifest(
|
||||
url: string,
|
||||
options: FetchReleaseManifestOptions,
|
||||
): Promise<Readonly<ReleaseManifest>> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
let response;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, {
|
||||
cache: "no-store",
|
||||
@@ -65,7 +115,7 @@ export async function fetchReleaseManifest(url, options) {
|
||||
if (!response.ok) {
|
||||
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options);
|
||||
}
|
||||
let raw;
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await response.json();
|
||||
} catch {
|
||||
@@ -78,11 +128,15 @@ export async function fetchReleaseManifest(url, options) {
|
||||
return Object.freeze(structuredClone(parsed.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
|
||||
* @param {{fetcher?: typeof fetch, expectedAssetManifestHash?: string}} [options]
|
||||
*/
|
||||
export async function loadReleaseManifest(runtime, options = {}) {
|
||||
export type LoadReleaseManifestOptions = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
expectedAssetManifestHash?: string;
|
||||
}>;
|
||||
|
||||
export async function loadReleaseManifest(
|
||||
runtime: RuntimeConfigLoadResult,
|
||||
options: LoadReleaseManifestOptions = {},
|
||||
): Promise<Readonly<ReleaseManifest>> {
|
||||
const manifest = await fetchReleaseManifest(
|
||||
runtime.config.RELEASE_MANIFEST_URL,
|
||||
{
|
||||
@@ -91,7 +145,7 @@ export async function loadReleaseManifest(runtime, options = {}) {
|
||||
releaseId: runtime.config.RELEASE_ID,
|
||||
},
|
||||
);
|
||||
let mismatchCode = null;
|
||||
let mismatchCode: ReleaseManifestErrorCode | null = null;
|
||||
if (manifest.buildId !== runtime.build.buildId) {
|
||||
mismatchCode = "MANIFEST_BUILD_MISMATCH";
|
||||
}
|
||||
@@ -1,15 +1,32 @@
|
||||
import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.js";
|
||||
import { validateRuntimeConfig } from "./runtime-config-schema.js";
|
||||
import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.ts";
|
||||
import {
|
||||
validateRuntimeConfig,
|
||||
type RuntimeConfig,
|
||||
} from "./runtime-config-schema.ts";
|
||||
|
||||
export type BootConfigSafe = Readonly<{
|
||||
kind: "BOOT_CONFIG_FAILURE";
|
||||
code: string;
|
||||
buildId: string;
|
||||
configSchemaVersion?: string;
|
||||
releaseId?: string;
|
||||
supportReference: string;
|
||||
}>;
|
||||
|
||||
type BootConfigSafeInput = Readonly<{
|
||||
buildId: string;
|
||||
configSchemaVersion?: string;
|
||||
releaseId?: string;
|
||||
}>;
|
||||
|
||||
export class BootConfigError extends Error {
|
||||
/**
|
||||
* @param {string} code
|
||||
* @param {{ buildId: string, configSchemaVersion?: string, releaseId?: string }} safe
|
||||
*/
|
||||
constructor(code, safe) {
|
||||
readonly kind = "BOOT_CONFIG_FAILURE" as const;
|
||||
readonly code: string;
|
||||
readonly safe: BootConfigSafe;
|
||||
|
||||
constructor(code: string, safe: BootConfigSafeInput) {
|
||||
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,
|
||||
@@ -22,20 +39,31 @@ export class BootConfigError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* fetcher?: typeof fetch,
|
||||
* buildConfig?: ReturnType<typeof getBuildConfig>,
|
||||
* now?: () => number
|
||||
* }} [options]
|
||||
*/
|
||||
export async function loadRuntimeConfig(options = {}) {
|
||||
export type RuntimeConfigLoadOptions = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
buildConfig?: ReturnType<typeof getBuildConfig>;
|
||||
now?: () => number;
|
||||
}>;
|
||||
|
||||
export type RuntimeConfigLoadResult = Readonly<{
|
||||
config: RuntimeConfig;
|
||||
build: ReturnType<typeof getBuildConfig>;
|
||||
validationDurationMs: number;
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function loadRuntimeConfig(
|
||||
options: RuntimeConfigLoadOptions = {},
|
||||
): Promise<RuntimeConfigLoadResult> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const buildConfig = options.buildConfig ?? getBuildConfig();
|
||||
const now = options.now ?? performance.now.bind(performance);
|
||||
const startedAt = now();
|
||||
|
||||
let response;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(buildConfig.runtimeConfigUrl, {
|
||||
cache: "no-store",
|
||||
@@ -53,7 +81,7 @@ export async function loadRuntimeConfig(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
let rawConfig;
|
||||
let rawConfig: unknown;
|
||||
try {
|
||||
rawConfig = await response.json();
|
||||
} catch {
|
||||
@@ -62,7 +90,7 @@ export async function loadRuntimeConfig(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
|
||||
if (!isRecord(rawConfig)) {
|
||||
throw new BootConfigError("CONFIG_SHAPE_INVALID", {
|
||||
buildId: buildConfig.buildId,
|
||||
});
|
||||
@@ -84,7 +112,10 @@ export async function loadRuntimeConfig(options = {}) {
|
||||
typeof rawConfig.CONFIG_SCHEMA_VERSION === "string"
|
||||
? rawConfig.CONFIG_SCHEMA_VERSION
|
||||
: undefined,
|
||||
releaseId: typeof rawConfig.RELEASE_ID === "string" ? rawConfig.RELEASE_ID : undefined,
|
||||
releaseId:
|
||||
typeof rawConfig.RELEASE_ID === "string"
|
||||
? rawConfig.RELEASE_ID
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { recordBootFailure } from "../adapters/diagnostics/bounded-diagnostics.js";
|
||||
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
|
||||
import { createRuntimeComposition } from "./create-runtime-composition.js";
|
||||
import { initializeColorScheme } from "./initialize-color-scheme.js";
|
||||
import { BootConfigError } from "./load-runtime-config.js";
|
||||
import { ReleaseManifestError } from "./load-release-manifest.js";
|
||||
import { RuntimeApplication } from "./runtime-application.jsx";
|
||||
import { recordBootFailure } from "../adapters/diagnostics/bounded-diagnostics.ts";
|
||||
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.tsx";
|
||||
import "../presentation/styles/theme.css";
|
||||
import { createRuntimeComposition } from "./create-runtime-composition.ts";
|
||||
import { initializeColorScheme } from "./initialize-color-scheme.ts";
|
||||
import { ReleaseManifestError } from "./load-release-manifest.ts";
|
||||
import { BootConfigError } from "./load-runtime-config.ts";
|
||||
import { RuntimeApplication } from "./runtime-application.tsx";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
@@ -17,14 +17,17 @@ if (!rootElement) {
|
||||
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
async function boot() {
|
||||
async function boot(): Promise<void> {
|
||||
try {
|
||||
const composition = await createRuntimeComposition();
|
||||
document.documentElement.dataset.buildId = composition.release.buildId;
|
||||
document.documentElement.dataset.releaseId = composition.release.releaseId;
|
||||
initializeColorScheme(composition.application.preferences);
|
||||
root.render(<RuntimeApplication composition={composition} />);
|
||||
} catch (error) {
|
||||
import.meta.hot?.dispose(() => {
|
||||
composition.infrastructure.dispose();
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const safe =
|
||||
error instanceof BootConfigError || error instanceof ReleaseManifestError
|
||||
? error.safe
|
||||
@@ -1,167 +0,0 @@
|
||||
import {
|
||||
createDemoSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
createUnavailableSessionAdapter,
|
||||
} from "../adapters/auth/external-session-adapter.js";
|
||||
import { createHttpClient } from "../adapters/http/client.js";
|
||||
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.js";
|
||||
import {
|
||||
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";
|
||||
import { fetchReleaseManifest } from "./load-release-manifest.js";
|
||||
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-aware transport factory. Feature gateway composition calls this
|
||||
* factory when a registered API capability is installed.
|
||||
*
|
||||
* @param {{
|
||||
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
|
||||
* authSession: import("../application/ports/auth-session-port.js").AuthSessionPort,
|
||||
* fetcher?: typeof fetch,
|
||||
* clock?: import("../application/ports/clock-port.js").ClockPort,
|
||||
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
|
||||
* diagnostics?: Parameters<typeof createHttpClient>[0]["diagnostics"],
|
||||
* telemetry?: Parameters<typeof createHttpClient>[0]["telemetry"]
|
||||
* }} context
|
||||
*/
|
||||
export function createRuntimeHttpClient(context, contract = {}) {
|
||||
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,
|
||||
fetcher: context.fetcher,
|
||||
clock: context.clock,
|
||||
scheduler: context.scheduler,
|
||||
diagnostics: context.diagnostics,
|
||||
telemetry: context.telemetry,
|
||||
...contract,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 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: /** @type {Record<string, unknown>} */ (attributes),
|
||||
});
|
||||
},
|
||||
});
|
||||
const queryClient = createQueryClient({ diagnostics });
|
||||
const storage = createBrowserStorageAdapter({
|
||||
localStorage: storageOrUndefined(host.localStorage),
|
||||
sessionStorage: storageOrUndefined(host.sessionStorage),
|
||||
diagnostics,
|
||||
});
|
||||
const releaseInfo = Object.freeze({
|
||||
async getCurrent() {
|
||||
return structuredClone(context.release);
|
||||
},
|
||||
async refresh() {
|
||||
return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
|
||||
fetcher: context.fetcher,
|
||||
buildId: context.release.buildId,
|
||||
releaseId: context.release.releaseId,
|
||||
});
|
||||
},
|
||||
});
|
||||
const navigation = Object.freeze({
|
||||
reload() {
|
||||
const location =
|
||||
/** @type {{reload?: () => void} | undefined} */ (host.location);
|
||||
if (typeof location?.reload !== "function") {
|
||||
throw new Error("Browser reload is unavailable");
|
||||
}
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
const featureInputs = createInstalledFeatureInputs({
|
||||
createHttpClient: (contract) =>
|
||||
createRuntimeHttpClient(
|
||||
{
|
||||
runtime: context.runtime,
|
||||
authSession,
|
||||
fetcher: context.fetcher,
|
||||
diagnostics,
|
||||
telemetry,
|
||||
},
|
||||
contract,
|
||||
),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
outputPorts: Object.freeze({
|
||||
session: authSession,
|
||||
preferences: storage,
|
||||
diagnostics,
|
||||
telemetry,
|
||||
releaseInfo,
|
||||
navigation,
|
||||
}),
|
||||
infrastructure: Object.freeze({
|
||||
queryClient,
|
||||
}),
|
||||
featureInputs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
createDemoSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
createUnavailableSessionAdapter,
|
||||
type ExternalSessionOwner,
|
||||
} from "../adapters/auth/external-session-adapter.ts";
|
||||
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts";
|
||||
import { createHttpClient } from "../adapters/http/client.ts";
|
||||
import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts";
|
||||
import { createTanStackCacheCoordinator } from "../adapters/query-cache/tanstack-cache-coordinator.ts";
|
||||
import { createQueryClient } from "../adapters/query-cache/tanstack-query-cache.ts";
|
||||
import { createServerStateScopeRuntime } from "../adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import { createConditionalValidatorStore } from "../adapters/query-cache/conditional-validator-store.ts";
|
||||
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts";
|
||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
|
||||
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
|
||||
import { createRestProviderProfile } from "../contracts/rest-profiles.ts";
|
||||
import type { ClockPort } from "../application/ports/clock-port.ts";
|
||||
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
|
||||
import { QUERY_REGISTRY } from "../features/installed-feature-contracts.ts";
|
||||
import {
|
||||
fetchReleaseManifest,
|
||||
type ReleaseManifest,
|
||||
} from "./load-release-manifest.ts";
|
||||
import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts";
|
||||
|
||||
type HttpClientDependencies = Parameters<typeof createHttpClient>[0];
|
||||
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;
|
||||
}>;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createRuntimeAdapters(
|
||||
context: RuntimeAdaptersContext,
|
||||
) {
|
||||
const host =
|
||||
context.host ?? (globalThis as unknown as Record<string, unknown>);
|
||||
const config = context.runtime.config;
|
||||
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 queryClient = createQueryClient({ diagnostics });
|
||||
const crossContextInvalidation =
|
||||
createBrowserCrossContextInvalidationFromHost({
|
||||
...(context.host === undefined ? {} : { host: context.host }),
|
||||
cacheEpoch: `release.${context.release.releaseId}`,
|
||||
topics: Object.values(QUERY_REGISTRY).map((definition) =>
|
||||
Object.freeze({
|
||||
topic: definition.invalidationTopic,
|
||||
topicVersion: definition.version,
|
||||
}),
|
||||
),
|
||||
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,
|
||||
queryRegistry: QUERY_REGISTRY,
|
||||
crossContext: crossContextInvalidation,
|
||||
diagnostics,
|
||||
});
|
||||
const serverStateScope = createServerStateScopeRuntime({
|
||||
session: authSession,
|
||||
queryInvalidation,
|
||||
});
|
||||
const conditionalValidators = createConditionalValidatorStore();
|
||||
const unsubscribeConditionalScope = serverStateScope.subscribe(() => {
|
||||
conditionalValidators.clear();
|
||||
});
|
||||
const storage = createBrowserStorageAdapter({
|
||||
localStorage: storageOrUndefined(hostValue(host, "localStorage")),
|
||||
sessionStorage: storageOrUndefined(
|
||||
hostValue(host, "sessionStorage"),
|
||||
),
|
||||
diagnostics,
|
||||
});
|
||||
const releaseInfo = Object.freeze({
|
||||
async getCurrent() {
|
||||
return structuredClone(context.release);
|
||||
},
|
||||
async refresh() {
|
||||
return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
|
||||
fetcher: context.fetcher,
|
||||
buildId: context.release.buildId,
|
||||
releaseId: context.release.releaseId,
|
||||
});
|
||||
},
|
||||
});
|
||||
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 featureInputs = createInstalledFeatureInputs({
|
||||
createHttpClient: (contract) =>
|
||||
createRuntimeHttpClient(
|
||||
{
|
||||
runtime: context.runtime,
|
||||
authSession,
|
||||
fetcher: context.fetcher,
|
||||
diagnostics,
|
||||
telemetry,
|
||||
},
|
||||
contract,
|
||||
),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
outputPorts: Object.freeze({
|
||||
session: authSession,
|
||||
preferences: storage,
|
||||
diagnostics,
|
||||
telemetry,
|
||||
releaseInfo,
|
||||
navigation,
|
||||
}),
|
||||
infrastructure: Object.freeze({
|
||||
queryClient,
|
||||
queryInvalidation,
|
||||
serverStateScope,
|
||||
conditionalValidators,
|
||||
crossContextInvalidationStatus: () =>
|
||||
crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY",
|
||||
dispose() {
|
||||
unsubscribeConditionalScope();
|
||||
conditionalValidators.clear();
|
||||
serverStateScope.dispose();
|
||||
queryInvalidation.dispose();
|
||||
},
|
||||
}),
|
||||
featureInputs,
|
||||
});
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { ApplicationProvider } from "../presentation/providers/application-provider.js";
|
||||
import { AppRouter } from "../presentation/routes/app-router.jsx";
|
||||
|
||||
/**
|
||||
* Production provider tree. Tests import this component so the validated
|
||||
* composition is proven against the same provider order used by main.
|
||||
*
|
||||
* @param {{
|
||||
* composition: Awaited<ReturnType<typeof import("./create-runtime-composition.js").createRuntimeComposition>>
|
||||
* }} props
|
||||
*/
|
||||
export function RuntimeApplication({ composition }) {
|
||||
return (
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={composition.infrastructure.queryClient}>
|
||||
<ApplicationProvider application={composition.application}>
|
||||
<AppRouter
|
||||
basename={composition.config.build.routerBasePath}
|
||||
buildId={composition.release.buildId}
|
||||
/>
|
||||
</ApplicationProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
|
||||
import { ApplicationProvider } from "../presentation/providers/application-provider.tsx";
|
||||
import { QueryInvalidationProvider } from "../presentation/adapters/query/query-invalidation-provider.tsx";
|
||||
import { ServerStateScopeProvider } from "../presentation/adapters/query/server-state-scope-provider.tsx";
|
||||
import { AppRouter } from "../presentation/routes/app-router.tsx";
|
||||
import type { RuntimeComposition } from "./create-runtime-composition.ts";
|
||||
|
||||
/**
|
||||
* Production provider tree. Tests import this component so the validated
|
||||
* composition is proven against the same provider order used by main.
|
||||
*/
|
||||
export function RuntimeApplication({
|
||||
composition,
|
||||
}: Readonly<{ composition: RuntimeComposition }>) {
|
||||
return (
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={composition.infrastructure.queryClient}>
|
||||
<ServerStateScopeProvider
|
||||
runtime={composition.infrastructure.serverStateScope}
|
||||
>
|
||||
<QueryInvalidationProvider
|
||||
coordinator={composition.infrastructure.queryInvalidation}
|
||||
>
|
||||
<ApplicationProvider application={composition.application}>
|
||||
<AppRouter
|
||||
basename={composition.config.build.routerBasePath}
|
||||
buildId={composition.release.buildId}
|
||||
/>
|
||||
</ApplicationProvider>
|
||||
</QueryInvalidationProvider>
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
@@ -35,11 +35,10 @@ export const runtimeConfigSchema = z
|
||||
message: "demo authentication is limited to local environments",
|
||||
});
|
||||
}
|
||||
const endpointEntries =
|
||||
/** @type {Array<[string, string | undefined]>} */ ([
|
||||
["API_BASE_URL", config.API_BASE_URL],
|
||||
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
|
||||
]);
|
||||
const endpointEntries = [
|
||||
["API_BASE_URL", config.API_BASE_URL],
|
||||
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
|
||||
] as const;
|
||||
|
||||
for (const [key, value] of endpointEntries) {
|
||||
if (value && !local && new URL(value).protocol !== "https:") {
|
||||
@@ -52,13 +51,20 @@ export const runtimeConfigSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function validateRuntimeConfig(value) {
|
||||
export type RuntimeConfig = z.output<typeof runtimeConfigSchema>;
|
||||
export type RuntimeConfigValidation =
|
||||
| Readonly<{ success: true; data: RuntimeConfig }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
issues: readonly Readonly<{ path: string; code: string }>[];
|
||||
}>;
|
||||
|
||||
export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
|
||||
const result = runtimeConfigSchema.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: /** @type {false} */ (false),
|
||||
success: false,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
@@ -67,7 +73,7 @@ export function validateRuntimeConfig(value) {
|
||||
}
|
||||
|
||||
return {
|
||||
success: /** @type {true} */ (true),
|
||||
success: true,
|
||||
data: structuredClone(result.data),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user