192 lines
5.9 KiB
TypeScript
192 lines
5.9 KiB
TypeScript
import { normalizeColorSchemePreference } from "./policies/color-scheme.js";
|
|
import type {
|
|
ApplicationApi,
|
|
ColorSchemePreference,
|
|
RenderFailureReport,
|
|
RouteChangedReport,
|
|
} from "./ports/in/application-api.js";
|
|
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
|
|
import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js";
|
|
|
|
export type { ApplicationApi, ApplicationOutputPorts };
|
|
|
|
/**
|
|
* Builds the driving API consumed by inbound adapters. Concrete output ports
|
|
* remain inside these closures and are never returned to React.
|
|
*/
|
|
export function createApplication(
|
|
outputPorts: ApplicationOutputPorts,
|
|
featureInputs: Readonly<Record<string, unknown>> = {},
|
|
): ApplicationApi {
|
|
const session = Object.freeze({
|
|
getSnapshot: () => outputPorts.session.getState(),
|
|
subscribe: (listener: () => void) =>
|
|
outputPorts.session.subscribe(listener),
|
|
beginSignIn: (returnTo?: string) =>
|
|
outputPorts.session.beginSignIn(returnTo),
|
|
signOut: () => outputPorts.session.signOut(),
|
|
recover: () => outputPorts.session.recover(),
|
|
});
|
|
|
|
const preferences = Object.freeze({
|
|
getColorScheme(): ColorSchemePreference {
|
|
const result = outputPorts.preferences.read("COLOR_SCHEME");
|
|
return normalizeColorSchemePreference(
|
|
result.ok ? result.value : undefined,
|
|
);
|
|
},
|
|
setColorScheme(preference: ColorSchemePreference) {
|
|
const normalized = normalizeColorSchemePreference(preference);
|
|
return outputPorts.preferences.write("COLOR_SCHEME", normalized);
|
|
},
|
|
});
|
|
|
|
const diagnostics = Object.freeze({
|
|
reportRenderFailure(report: RenderFailureReport) {
|
|
try {
|
|
outputPorts.diagnostics.record({
|
|
level: "error",
|
|
eventId: "ui.render.failed",
|
|
context: {
|
|
route_id: report.routeId,
|
|
build_id: report.buildId,
|
|
component_boundary: report.boundaryName,
|
|
},
|
|
});
|
|
} catch {
|
|
// Diagnostics are best-effort and cannot become an application failure.
|
|
}
|
|
try {
|
|
outputPorts.telemetry.emit("ui.render.failed", {
|
|
route_id: report.routeId,
|
|
build_id: report.buildId,
|
|
component_boundary: report.boundaryName,
|
|
});
|
|
} catch {
|
|
// Diagnostics are best-effort and cannot become an application failure.
|
|
}
|
|
},
|
|
reportRouteChanged(report: RouteChangedReport) {
|
|
try {
|
|
outputPorts.diagnostics.record({
|
|
level: "info",
|
|
eventId: "route.changed",
|
|
context: {
|
|
route_id: report.routeId,
|
|
build_id: report.buildId,
|
|
},
|
|
});
|
|
} catch {
|
|
// Diagnostics are best-effort and cannot become navigation failure.
|
|
}
|
|
},
|
|
});
|
|
|
|
const runtime = Object.freeze({
|
|
async getReleaseSummary() {
|
|
const release = await outputPorts.releaseInfo.getCurrent();
|
|
return Object.freeze({
|
|
buildId: release.buildId,
|
|
releaseId: release.releaseId,
|
|
configSchemaVersion: release.configSchemaVersion,
|
|
apiContractVersion: release.apiContractVersion,
|
|
});
|
|
},
|
|
});
|
|
|
|
const recovery = Object.freeze({
|
|
async recoverChunk(input: {
|
|
chunkId: string;
|
|
failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH";
|
|
}) {
|
|
try {
|
|
const current = await outputPorts.releaseInfo.getCurrent();
|
|
const active = await outputPorts.releaseInfo.refresh();
|
|
if (
|
|
current.buildId !== active.buildId ||
|
|
current.releaseId !== active.releaseId
|
|
) {
|
|
const mismatchKind =
|
|
current.buildId !== active.buildId
|
|
? "BUILD_MISMATCH"
|
|
: "RELEASE_MISMATCH";
|
|
try {
|
|
outputPorts.diagnostics.record({
|
|
level: "warn",
|
|
eventId: "release.mismatch.detected",
|
|
context: {
|
|
build_id: current.buildId,
|
|
active_release_id: active.releaseId,
|
|
mismatch_kind: mismatchKind,
|
|
},
|
|
});
|
|
} catch {
|
|
// Recovery remains independent from diagnostics.
|
|
}
|
|
try {
|
|
outputPorts.telemetry.emit("release.mismatch.detected", {
|
|
build_id: current.buildId,
|
|
active_release_id: active.releaseId,
|
|
mismatch_kind: mismatchKind,
|
|
});
|
|
} catch {
|
|
// Recovery remains independent from telemetry.
|
|
}
|
|
}
|
|
if (!active.routeChunks[input.chunkId]) {
|
|
return {
|
|
action: "support" as const,
|
|
reason: "active-chunk-unknown",
|
|
};
|
|
}
|
|
const decision = decideChunkRecovery({
|
|
failureKind: input.failureKind,
|
|
manifestLoaded: true,
|
|
currentBuildId: current.buildId,
|
|
currentReleaseId: current.releaseId,
|
|
activeBuildId: active.buildId,
|
|
activeReleaseId: active.releaseId,
|
|
storage: outputPorts.preferences,
|
|
});
|
|
if (decision.action === "reload-once") {
|
|
try {
|
|
outputPorts.navigation.reload();
|
|
} catch {
|
|
return {
|
|
action: "support" as const,
|
|
reason: "reload-failed",
|
|
};
|
|
}
|
|
}
|
|
return decision;
|
|
} catch {
|
|
return {
|
|
action: "support" as const,
|
|
reason: "manifest-unavailable",
|
|
};
|
|
}
|
|
},
|
|
});
|
|
const installedFeatureInputs = Object.freeze({ ...featureInputs });
|
|
const features = Object.freeze({
|
|
has(featureId: string) {
|
|
return Object.hasOwn(installedFeatureInputs, featureId);
|
|
},
|
|
get(featureId: string) {
|
|
if (!Object.hasOwn(installedFeatureInputs, featureId)) {
|
|
throw new Error(`Application feature is not installed: ${featureId}`);
|
|
}
|
|
return installedFeatureInputs[featureId];
|
|
},
|
|
});
|
|
|
|
return Object.freeze({
|
|
session,
|
|
preferences,
|
|
diagnostics,
|
|
runtime,
|
|
recovery,
|
|
features,
|
|
});
|
|
}
|