refactor: 리펙토링
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
|
||||
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
|
||||
import { createCompositionRoot } from "./composition-root.ts";
|
||||
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
||||
import { loadRuntimeConfig } from "./load-runtime-config.ts";
|
||||
import {
|
||||
createOptionalRuntimeHost,
|
||||
type OptionalRuntimeHost,
|
||||
} from "./optional-runtime-host.ts";
|
||||
import { createRuntimeAdapters } from "./runtime-adapters.ts";
|
||||
|
||||
export type RuntimeCompositionDependencies = Readonly<{
|
||||
@@ -8,10 +14,16 @@ export type RuntimeCompositionDependencies = Readonly<{
|
||||
host?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
export function createRuntimeComposition(
|
||||
/**
|
||||
* §6.7 steps 7 and 14. The static capability selection is compiled against the
|
||||
* runtime overrides, and the optional host objects are created without any
|
||||
* start side effect. Nothing observable happens until the first committed React
|
||||
* effect calls `optional.startAfterMount()`.
|
||||
*/
|
||||
export async function createRuntimeComposition(
|
||||
dependencies: RuntimeCompositionDependencies = {},
|
||||
) {
|
||||
return createCompositionRoot({
|
||||
const root = await createCompositionRoot({
|
||||
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
||||
loadRelease: (runtime) =>
|
||||
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
||||
@@ -23,6 +35,36 @@ export function createRuntimeComposition(
|
||||
host: dependencies.host,
|
||||
}),
|
||||
});
|
||||
|
||||
const capabilities = resolveRuntimeCapabilities(
|
||||
INSTALLED_RUNTIME_CAPABILITIES,
|
||||
root.config.config.CAPABILITY_OVERRIDES,
|
||||
);
|
||||
const optional: OptionalRuntimeHost = createOptionalRuntimeHost({
|
||||
capabilities,
|
||||
routerBasePath: root.config.build.routerBasePath,
|
||||
buildId: root.release.buildId,
|
||||
...(dependencies.host
|
||||
? {
|
||||
host: dependencies.host as Parameters<
|
||||
typeof createOptionalRuntimeHost
|
||||
>[0]["host"],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
...root,
|
||||
capabilities,
|
||||
optional,
|
||||
async dispose(): Promise<void> {
|
||||
// §20.3. Optional runtime first, then the base infrastructure: the query
|
||||
// cache is cleared only after realtime and worker admission has closed,
|
||||
// so a late effect cannot repopulate a cleared cache.
|
||||
await optional.stop("APPLICATION_SHUTDOWN");
|
||||
root.infrastructure.dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type RuntimeComposition = Awaited<
|
||||
|
||||
@@ -1,41 +1,77 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
verifyContractSet,
|
||||
type ContractSet,
|
||||
type ContractSetFailureCode,
|
||||
} from "../contracts/contract-set.ts";
|
||||
import type { ContractSetPackage } from "../contracts/contract-set-canonical.ts";
|
||||
import {
|
||||
releaseManifestV1ArtifactSchema,
|
||||
releaseManifestV2ArtifactSchema,
|
||||
type ReleaseManifestV1Artifact,
|
||||
type ReleaseManifestV2Artifact,
|
||||
} from "../contracts/release-artifacts.ts";
|
||||
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../features/installed-contract-contributions.ts";
|
||||
import {
|
||||
BOOT_JSON_POLICIES,
|
||||
readBoundedBootJson,
|
||||
type BootLoadFailure,
|
||||
} from "./read-bounded-boot-json.ts";
|
||||
import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts";
|
||||
|
||||
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
|
||||
export 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),
|
||||
routeChunks: z.record(z.string().min(1), z.string().min(1)),
|
||||
})
|
||||
.strict();
|
||||
/** §5.8. Retained for one compatibility window; carries the removed scalar. */
|
||||
export const releaseManifestV1Schema = releaseManifestV1ArtifactSchema;
|
||||
|
||||
/** §5.2. The frontend build's compiled external contract package set. */
|
||||
export const releaseManifestV2Schema = releaseManifestV2ArtifactSchema;
|
||||
|
||||
export type ReleaseManifestV1 = ReleaseManifestV1Artifact;
|
||||
export type ReleaseManifestV2 = ReleaseManifestV2Artifact;
|
||||
|
||||
/**
|
||||
* The composition-facing manifest. A V1 document is normalized onto it with a
|
||||
* null contract set so downstream runtime never branches on schema version.
|
||||
*/
|
||||
export type ReleaseManifest = Readonly<{
|
||||
schemaVersion: 1 | 2;
|
||||
appVersion: string;
|
||||
buildId: string;
|
||||
commitSha: string;
|
||||
configSchemaVersion: string;
|
||||
assetManifestHash: string;
|
||||
releaseId: string;
|
||||
builtAt: string;
|
||||
routeChunks: Readonly<Record<string, string>>;
|
||||
contractSet: ContractSet | null;
|
||||
legacyApiContractVersion?: string;
|
||||
}>;
|
||||
|
||||
export type ReleaseManifest = z.output<typeof releaseManifestSchema>;
|
||||
export type ReleaseManifestErrorCode =
|
||||
| "MANIFEST_BUILD_MISMATCH"
|
||||
| "MANIFEST_PROTOCOL_PAIR_MISMATCH"
|
||||
| "MANIFEST_CONFIG_SCHEMA_MISMATCH"
|
||||
| "MANIFEST_API_CONTRACT_MISMATCH"
|
||||
| "MANIFEST_RELEASE_MISMATCH"
|
||||
| "MANIFEST_ASSET_MISMATCH"
|
||||
| "MANIFEST_FETCH_FAILED"
|
||||
| "MANIFEST_TIMEOUT"
|
||||
| "MANIFEST_HTTP_FAILED"
|
||||
| "MANIFEST_CONTENT_TYPE_INVALID"
|
||||
| "MANIFEST_BODY_TOO_LARGE"
|
||||
| "MANIFEST_UTF8_INVALID"
|
||||
| "MANIFEST_JSON_INVALID"
|
||||
| "MANIFEST_SCHEMA_INVALID";
|
||||
| "MANIFEST_SCHEMA_INVALID"
|
||||
| ContractSetFailureCode;
|
||||
|
||||
export type ReleaseManifestFailureKind =
|
||||
| "BUILD_MISMATCH"
|
||||
| "PROTOCOL_PAIR_MISMATCH"
|
||||
| "CONFIG_MISMATCH"
|
||||
| "API_CONTRACT_MISMATCH"
|
||||
| "RELEASE_MISMATCH"
|
||||
| "ASSET_MISMATCH"
|
||||
| "CONTRACT_SET_MISMATCH"
|
||||
| "RELEASE_MANIFEST_FAILURE";
|
||||
|
||||
export type ReleaseManifestSafe = Readonly<{
|
||||
kind: ReleaseManifestFailureKind;
|
||||
code: ReleaseManifestErrorCode;
|
||||
@@ -55,6 +91,8 @@ function failureKindFor(
|
||||
switch (code) {
|
||||
case "MANIFEST_BUILD_MISMATCH":
|
||||
return "BUILD_MISMATCH";
|
||||
case "MANIFEST_PROTOCOL_PAIR_MISMATCH":
|
||||
return "PROTOCOL_PAIR_MISMATCH";
|
||||
case "MANIFEST_CONFIG_SCHEMA_MISMATCH":
|
||||
return "CONFIG_MISMATCH";
|
||||
case "MANIFEST_API_CONTRACT_MISMATCH":
|
||||
@@ -64,7 +102,9 @@ function failureKindFor(
|
||||
case "MANIFEST_ASSET_MISMATCH":
|
||||
return "ASSET_MISMATCH";
|
||||
default:
|
||||
return "RELEASE_MANIFEST_FAILURE";
|
||||
return code.startsWith("CONTRACT_")
|
||||
? "CONTRACT_SET_MISMATCH"
|
||||
: "RELEASE_MANIFEST_FAILURE";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,63 +128,111 @@ export class ReleaseManifestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const READ_FAILURE_CODE: Readonly<
|
||||
Record<BootLoadFailure, ReleaseManifestErrorCode>
|
||||
> = Object.freeze({
|
||||
FETCH_FAILED: "MANIFEST_FETCH_FAILED",
|
||||
TIMEOUT: "MANIFEST_TIMEOUT",
|
||||
HTTP_STATUS_INVALID: "MANIFEST_HTTP_FAILED",
|
||||
CONTENT_TYPE_INVALID: "MANIFEST_CONTENT_TYPE_INVALID",
|
||||
BODY_TOO_LARGE: "MANIFEST_BODY_TOO_LARGE",
|
||||
UTF8_INVALID: "MANIFEST_UTF8_INVALID",
|
||||
JSON_INVALID: "MANIFEST_JSON_INVALID",
|
||||
SHAPE_INVALID: "MANIFEST_SCHEMA_INVALID",
|
||||
SECRET_NAME_REJECTED: "MANIFEST_SCHEMA_INVALID",
|
||||
SCHEMA_INVALID: "MANIFEST_SCHEMA_INVALID",
|
||||
BUILD_MISMATCH: "MANIFEST_BUILD_MISMATCH",
|
||||
RELEASE_MISMATCH: "MANIFEST_RELEASE_MISMATCH",
|
||||
ASSET_MISMATCH: "MANIFEST_ASSET_MISMATCH",
|
||||
CONTRACT_SET_MISMATCH: "CONTRACT_SET_DIGEST_MISMATCH",
|
||||
});
|
||||
|
||||
export type FetchReleaseManifestOptions = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
buildId: string;
|
||||
releaseId?: string;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Fetches and validates the active manifest without imposing the current
|
||||
* build tuple. Chunk recovery uses this no-store view to detect a new release.
|
||||
* Fetches and validates the active manifest without imposing the current build
|
||||
* tuple. Chunk recovery uses this no-store view to detect a new release.
|
||||
*/
|
||||
export async function fetchReleaseManifest(
|
||||
url: string,
|
||||
options: FetchReleaseManifestOptions,
|
||||
): Promise<Readonly<ReleaseManifest>> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} catch {
|
||||
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", options);
|
||||
): Promise<ReleaseManifest> {
|
||||
const outcome = await readBoundedBootJson(
|
||||
url,
|
||||
BOOT_JSON_POLICIES.RELEASE_MANIFEST,
|
||||
{
|
||||
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
},
|
||||
);
|
||||
if (!outcome.ok) {
|
||||
throw new ReleaseManifestError(READ_FAILURE_CODE[outcome.failure], options);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options);
|
||||
|
||||
if (outcome.value.schemaVersion === 2) {
|
||||
const parsed = releaseManifestV2Schema.safeParse(outcome.value);
|
||||
if (!parsed.success) {
|
||||
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options);
|
||||
}
|
||||
return Object.freeze(structuredClone(parsed.data));
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await response.json();
|
||||
} catch {
|
||||
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", options);
|
||||
}
|
||||
const parsed = releaseManifestSchema.safeParse(raw);
|
||||
|
||||
const parsed = releaseManifestV1Schema.safeParse(outcome.value);
|
||||
if (!parsed.success) {
|
||||
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options);
|
||||
}
|
||||
return Object.freeze(structuredClone(parsed.data));
|
||||
const { apiContractVersion, ...rest } = structuredClone(parsed.data);
|
||||
return Object.freeze({
|
||||
...rest,
|
||||
contractSet: null,
|
||||
legacyApiContractVersion: apiContractVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export type LoadReleaseManifestOptions = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
expectedAssetManifestHash?: string;
|
||||
expectedContractSetPackages?: readonly ContractSetPackage[];
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export async function loadReleaseManifest(
|
||||
runtime: RuntimeConfigLoadResult,
|
||||
options: LoadReleaseManifestOptions = {},
|
||||
): Promise<Readonly<ReleaseManifest>> {
|
||||
): Promise<ReleaseManifest> {
|
||||
const identity: ReleaseManifestSafeInput = {
|
||||
buildId: runtime.build.buildId,
|
||||
...(runtime.config.RELEASE_ID
|
||||
? { releaseId: runtime.config.RELEASE_ID }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const manifest = await fetchReleaseManifest(
|
||||
runtime.config.RELEASE_MANIFEST_URL,
|
||||
{
|
||||
fetcher: options.fetcher,
|
||||
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
buildId: runtime.build.buildId,
|
||||
releaseId: runtime.config.RELEASE_ID,
|
||||
...(runtime.config.RELEASE_ID
|
||||
? { releaseId: runtime.config.RELEASE_ID }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
|
||||
const expectedManifestVersion = runtime.configSchema === "V1" ? 1 : 2;
|
||||
if (manifest.schemaVersion !== expectedManifestVersion) {
|
||||
throw new ReleaseManifestError(
|
||||
"MANIFEST_PROTOCOL_PAIR_MISMATCH",
|
||||
identity,
|
||||
);
|
||||
}
|
||||
|
||||
// §6.7 steps 5-6, in order: build, config, release, assets, then contractSet.
|
||||
let mismatchCode: ReleaseManifestErrorCode | null = null;
|
||||
if (manifest.buildId !== runtime.build.buildId) {
|
||||
mismatchCode = "MANIFEST_BUILD_MISMATCH";
|
||||
@@ -164,7 +252,11 @@ export async function loadReleaseManifest(
|
||||
}
|
||||
if (
|
||||
!mismatchCode &&
|
||||
manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION
|
||||
runtime.configSchema === "V1" &&
|
||||
(manifest.legacyApiContractVersion === undefined ||
|
||||
runtime.config.LEGACY_API_CONTRACT_VERSION === undefined ||
|
||||
manifest.legacyApiContractVersion !==
|
||||
runtime.config.LEGACY_API_CONTRACT_VERSION)
|
||||
) {
|
||||
mismatchCode = "MANIFEST_API_CONTRACT_MISMATCH";
|
||||
}
|
||||
@@ -183,10 +275,20 @@ export async function loadReleaseManifest(
|
||||
mismatchCode = "MANIFEST_ASSET_MISMATCH";
|
||||
}
|
||||
if (mismatchCode) {
|
||||
throw new ReleaseManifestError(mismatchCode, {
|
||||
buildId: runtime.build.buildId,
|
||||
releaseId: runtime.config.RELEASE_ID,
|
||||
});
|
||||
throw new ReleaseManifestError(mismatchCode, identity);
|
||||
}
|
||||
|
||||
if (manifest.schemaVersion === 2 && manifest.contractSet) {
|
||||
const verification = await verifyContractSet({
|
||||
expected:
|
||||
options.expectedContractSetPackages ??
|
||||
(EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[]),
|
||||
manifest: manifest.contractSet,
|
||||
});
|
||||
if (!verification.ok) {
|
||||
throw new ReleaseManifestError(verification.code, identity);
|
||||
}
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.ts";
|
||||
import {
|
||||
BOOT_JSON_POLICIES,
|
||||
readBoundedBootJson,
|
||||
type BootLoadFailure,
|
||||
} from "./read-bounded-boot-json.ts";
|
||||
import {
|
||||
validateRuntimeConfig,
|
||||
type RuntimeConfig,
|
||||
} from "./runtime-config-schema.ts";
|
||||
|
||||
/**
|
||||
* §6.6. A safe boot error carries the failure kind, the build identity and a
|
||||
* support reference. It never carries a URL, a response body, a validation
|
||||
* value, an endpoint hostname or a stack trace.
|
||||
*/
|
||||
export type BootConfigSafe = Readonly<{
|
||||
kind: "BOOT_CONFIG_FAILURE";
|
||||
code: string;
|
||||
@@ -43,58 +53,57 @@ export type RuntimeConfigLoadOptions = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
buildConfig?: ReturnType<typeof getBuildConfig>;
|
||||
now?: () => number;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type RuntimeConfigLoadResult = Readonly<{
|
||||
config: RuntimeConfig;
|
||||
configSchema: "V1" | "V2";
|
||||
build: ReturnType<typeof getBuildConfig>;
|
||||
validationDurationMs: number;
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
const READ_FAILURE_CODE: Readonly<Record<BootLoadFailure, string>> =
|
||||
Object.freeze({
|
||||
FETCH_FAILED: "CONFIG_FETCH_FAILED",
|
||||
TIMEOUT: "CONFIG_TIMEOUT",
|
||||
HTTP_STATUS_INVALID: "CONFIG_HTTP_FAILED",
|
||||
CONTENT_TYPE_INVALID: "CONFIG_CONTENT_TYPE_INVALID",
|
||||
BODY_TOO_LARGE: "CONFIG_BODY_TOO_LARGE",
|
||||
UTF8_INVALID: "CONFIG_UTF8_INVALID",
|
||||
JSON_INVALID: "CONFIG_JSON_INVALID",
|
||||
SHAPE_INVALID: "CONFIG_SHAPE_INVALID",
|
||||
SECRET_NAME_REJECTED: "CONFIG_SECRET_NAME_REJECTED",
|
||||
SCHEMA_INVALID: "CONFIG_SCHEMA_INVALID",
|
||||
BUILD_MISMATCH: "CONFIG_BUILD_MISMATCH",
|
||||
RELEASE_MISMATCH: "CONFIG_RELEASE_MISMATCH",
|
||||
ASSET_MISMATCH: "CONFIG_ASSET_MISMATCH",
|
||||
CONTRACT_SET_MISMATCH: "CONFIG_CONTRACT_SET_MISMATCH",
|
||||
});
|
||||
|
||||
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 outcome = await readBoundedBootJson(
|
||||
buildConfig.runtimeConfigUrl,
|
||||
BOOT_JSON_POLICIES.RUNTIME_CONFIG,
|
||||
{
|
||||
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!outcome.ok) {
|
||||
throw new BootConfigError(READ_FAILURE_CODE[outcome.failure], {
|
||||
buildId: buildConfig.buildId,
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = now();
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(buildConfig.runtimeConfigUrl, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} catch {
|
||||
throw new BootConfigError("CONFIG_FETCH_FAILED", {
|
||||
buildId: buildConfig.buildId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BootConfigError("CONFIG_HTTP_FAILED", {
|
||||
buildId: buildConfig.buildId,
|
||||
});
|
||||
}
|
||||
|
||||
let rawConfig: unknown;
|
||||
try {
|
||||
rawConfig = await response.json();
|
||||
} catch {
|
||||
throw new BootConfigError("CONFIG_JSON_INVALID", {
|
||||
buildId: buildConfig.buildId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isRecord(rawConfig)) {
|
||||
throw new BootConfigError("CONFIG_SHAPE_INVALID", {
|
||||
buildId: buildConfig.buildId,
|
||||
});
|
||||
}
|
||||
const rawConfig = outcome.value;
|
||||
|
||||
try {
|
||||
assertSafeConfigNames(rawConfig);
|
||||
@@ -119,7 +128,10 @@ export async function loadRuntimeConfig(
|
||||
});
|
||||
}
|
||||
|
||||
if (validated.data.BUILD_ID && validated.data.BUILD_ID !== buildConfig.buildId) {
|
||||
if (
|
||||
validated.data.BUILD_ID &&
|
||||
validated.data.BUILD_ID !== buildConfig.buildId
|
||||
) {
|
||||
throw new BootConfigError("CONFIG_BUILD_MISMATCH", {
|
||||
buildId: buildConfig.buildId,
|
||||
configSchemaVersion: validated.data.CONFIG_SCHEMA_VERSION,
|
||||
@@ -127,8 +139,11 @@ export async function loadRuntimeConfig(
|
||||
});
|
||||
}
|
||||
|
||||
// §6.10: the validated snapshot is frozen. Nothing re-reads or mutates it;
|
||||
// a kill-switch change only takes effect on a new boot.
|
||||
return Object.freeze({
|
||||
config: validated.data,
|
||||
configSchema: validated.schema,
|
||||
build: buildConfig,
|
||||
validationDurationMs: now() - startedAt,
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ async function boot(): Promise<void> {
|
||||
initializeColorScheme(composition.application.preferences);
|
||||
root.render(<RuntimeApplication composition={composition} />);
|
||||
import.meta.hot?.dispose(() => {
|
||||
composition.infrastructure.dispose();
|
||||
void composition.dispose();
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const safe =
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
createBrowserLifecycleRuntime,
|
||||
type BrowserLifecycleRuntime,
|
||||
} from "../adapters/platform/browser-lifecycle.ts";
|
||||
import type {
|
||||
ResolvedRuntimeCapabilities,
|
||||
RuntimeHealth,
|
||||
RuntimeStopReason,
|
||||
} from "../contracts/runtime-capabilities.ts";
|
||||
import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts";
|
||||
import { createServiceWorkerRuntimeHost } from "./register-service-worker.ts";
|
||||
|
||||
/**
|
||||
* §3.4 / §20.3. Optional runtime host.
|
||||
*
|
||||
* Creating this object has no side effect: no listener, timer, network call,
|
||||
* IndexedDB open or worker is created until `startAfterMount()` runs from the
|
||||
* first committed React effect. `stop()` unwinds in reverse order.
|
||||
*/
|
||||
|
||||
export type OptionalRuntimeHost = Readonly<{
|
||||
readonly realtime: null;
|
||||
readonly webWorkers: null;
|
||||
readonly serviceWorker: ServiceWorkerRuntimeHost | null;
|
||||
readonly offlineCommands: null;
|
||||
browserLifecycle(): BrowserLifecycleRuntime | null;
|
||||
health(): Readonly<Record<string, RuntimeHealth>>;
|
||||
startAfterMount(): Promise<void>;
|
||||
stop(reason?: RuntimeStopReason): Promise<void>;
|
||||
}>;
|
||||
|
||||
export type OptionalRuntimeHostInput = Readonly<{
|
||||
capabilities: ResolvedRuntimeCapabilities;
|
||||
routerBasePath: string;
|
||||
buildId: string;
|
||||
/** Explicit host seam for deterministic lifecycle tests and platform shells. */
|
||||
serviceWorkerHost?: ServiceWorkerRuntimeHost | null;
|
||||
browserLifecycleHost?: Parameters<typeof createBrowserLifecycleRuntime>[0];
|
||||
host?: Parameters<typeof createServiceWorkerRuntimeHost>[0]["host"];
|
||||
blockers?: readonly (() => boolean)[];
|
||||
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
|
||||
}>;
|
||||
|
||||
export function createOptionalRuntimeHost(
|
||||
input: OptionalRuntimeHostInput,
|
||||
): OptionalRuntimeHost {
|
||||
const { capabilities } = input;
|
||||
|
||||
const serviceWorker = Object.hasOwn(input, "serviceWorkerHost")
|
||||
? (input.serviceWorkerHost ?? null)
|
||||
: createServiceWorkerRuntimeHost({
|
||||
capabilities,
|
||||
routerBasePath: input.routerBasePath,
|
||||
buildId: input.buildId,
|
||||
...(input.host ? { host: input.host } : {}),
|
||||
...(input.blockers ? { blockers: input.blockers } : {}),
|
||||
...(input.observe ? { observe: input.observe } : {}),
|
||||
});
|
||||
|
||||
let lifecycle: BrowserLifecycleRuntime | null = null;
|
||||
let started = false;
|
||||
let startPromise: Promise<void> | null = null;
|
||||
let stopPromise: Promise<void> | null = null;
|
||||
let stopRequested = false;
|
||||
const health: Record<string, RuntimeHealth> = {
|
||||
realtime: capabilities.realtime.length === 0 ? "DISABLED" : "UNAVAILABLE",
|
||||
webWorkers: capabilities.webWorkers.length === 0 ? "DISABLED" : "UNAVAILABLE",
|
||||
serviceWorker: serviceWorker ? "UNAVAILABLE" : "DISABLED",
|
||||
offlineCommands: capabilities.offlineCommands ? "UNAVAILABLE" : "DISABLED",
|
||||
};
|
||||
|
||||
/**
|
||||
* §3.4 start order:
|
||||
* 1. offline foreground browser lifecycle observer
|
||||
* 2. realtime runtime
|
||||
* 3. no Web Worker prewarm
|
||||
* 4. Service Worker active/cleanup controller
|
||||
*/
|
||||
async function startAfterMount(): Promise<void> {
|
||||
// A stopped host is terminal. Its children (notably the Service Worker page
|
||||
// controller) own one-shot listeners and timers and cannot be resurrected.
|
||||
if (stopPromise) return stopPromise;
|
||||
if (startPromise) return startPromise;
|
||||
startPromise = (async () => {
|
||||
// 1. The lifecycle observer is the single window listener owner. It is
|
||||
// only created when something downstream can actually consume it.
|
||||
if (serviceWorker || capabilities.realtime.length > 0) {
|
||||
lifecycle = createBrowserLifecycleRuntime(input.browserLifecycleHost);
|
||||
}
|
||||
|
||||
// 2. Realtime stays NOT_SELECTED until a product contribution exists
|
||||
// (§13.6), so there is nothing to start and nothing to observe.
|
||||
|
||||
// 3. Web Workers are created lazily on first task; there is no prewarm.
|
||||
|
||||
// 4. Service Worker registration or the exact-owned cleanup action.
|
||||
if (serviceWorker) {
|
||||
const outcome = await serviceWorker.start();
|
||||
// `stop()` may have fenced this generation while start was awaiting a
|
||||
// browser operation. A late result must never reactivate health.
|
||||
if (!stopRequested) {
|
||||
health.serviceWorker =
|
||||
outcome.kind === "ACTIVE"
|
||||
? "AVAILABLE"
|
||||
: outcome.kind === "DISABLED"
|
||||
? "DISABLED"
|
||||
: outcome.kind === "INCOMPATIBLE"
|
||||
? "INCOMPATIBLE"
|
||||
: "DEGRADED";
|
||||
}
|
||||
}
|
||||
if (!stopRequested) started = true;
|
||||
})();
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
async function stop(
|
||||
reason: RuntimeStopReason = "APPLICATION_SHUTDOWN",
|
||||
): Promise<void> {
|
||||
void reason;
|
||||
stopRequested = true;
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = (async () => {
|
||||
// Serialize teardown behind any in-flight browser registration. Cleanup
|
||||
// then observes the final acquired resources and unwinds them exactly once.
|
||||
await startPromise?.catch(() => {});
|
||||
// Reverse of the start order.
|
||||
if (serviceWorker && (started || startPromise)) {
|
||||
await serviceWorker.stop().catch(() => {});
|
||||
}
|
||||
if (serviceWorker) {
|
||||
health.serviceWorker = "DISABLED";
|
||||
}
|
||||
lifecycle?.dispose();
|
||||
lifecycle = null;
|
||||
started = false;
|
||||
startPromise = null;
|
||||
})();
|
||||
return stopPromise;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
realtime: null,
|
||||
webWorkers: null,
|
||||
serviceWorker,
|
||||
offlineCommands: null,
|
||||
browserLifecycle: () => lifecycle,
|
||||
health: () => Object.freeze({ ...health }),
|
||||
startAfterMount,
|
||||
stop,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* §6.4–§6.5. The only boot-time JSON reader.
|
||||
*
|
||||
* `response.json()` and unbounded `response.text()` are prohibited: a hostile or
|
||||
* misconfigured origin must not be able to amplify boot memory, and an HTML
|
||||
* error page must not reach `JSON.parse` as if it were configuration.
|
||||
*/
|
||||
|
||||
export type BootJsonOperation = "RUNTIME_CONFIG" | "RELEASE_MANIFEST";
|
||||
|
||||
export interface BootJsonPolicy {
|
||||
readonly operation: BootJsonOperation;
|
||||
readonly maximumBytes: number;
|
||||
readonly totalDeadlineMs: 5_000;
|
||||
}
|
||||
|
||||
export const BOOT_JSON_POLICIES = Object.freeze({
|
||||
RUNTIME_CONFIG: Object.freeze({
|
||||
operation: "RUNTIME_CONFIG" as const,
|
||||
maximumBytes: 65_536,
|
||||
totalDeadlineMs: 5_000 as const,
|
||||
}),
|
||||
RELEASE_MANIFEST: Object.freeze({
|
||||
operation: "RELEASE_MANIFEST" as const,
|
||||
maximumBytes: 1_048_576,
|
||||
totalDeadlineMs: 5_000 as const,
|
||||
}),
|
||||
} satisfies Readonly<Record<BootJsonOperation, BootJsonPolicy>>);
|
||||
|
||||
export type BootLoadFailure =
|
||||
| "FETCH_FAILED"
|
||||
| "TIMEOUT"
|
||||
| "HTTP_STATUS_INVALID"
|
||||
| "CONTENT_TYPE_INVALID"
|
||||
| "BODY_TOO_LARGE"
|
||||
| "UTF8_INVALID"
|
||||
| "JSON_INVALID"
|
||||
| "SHAPE_INVALID"
|
||||
| "SECRET_NAME_REJECTED"
|
||||
| "SCHEMA_INVALID"
|
||||
| "BUILD_MISMATCH"
|
||||
| "RELEASE_MISMATCH"
|
||||
| "ASSET_MISMATCH"
|
||||
| "CONTRACT_SET_MISMATCH";
|
||||
|
||||
export type BootJsonOutcome =
|
||||
| Readonly<{ ok: true; value: Readonly<Record<string, unknown>> }>
|
||||
| Readonly<{ ok: false; failure: BootLoadFailure }>;
|
||||
|
||||
export type ReadBoundedBootJsonOptions = Readonly<{
|
||||
fetcher?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
function isJsonMediaType(headerValue: string | null): boolean {
|
||||
if (!headerValue) return false;
|
||||
const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
return essence === "application/json" || essence.endsWith("+json");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function readBoundedBootJson(
|
||||
url: string,
|
||||
policy: BootJsonPolicy,
|
||||
options: ReadBoundedBootJsonOptions = {},
|
||||
): Promise<BootJsonOutcome> {
|
||||
if (options.signal?.aborted) return fail("FETCH_FAILED");
|
||||
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, policy.totalDeadlineMs);
|
||||
const forwardAbort = () => controller.abort();
|
||||
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED");
|
||||
}
|
||||
|
||||
if (response.status !== 200 || response.redirected) {
|
||||
await discard(response);
|
||||
return fail("HTTP_STATUS_INVALID");
|
||||
}
|
||||
if (!isJsonMediaType(response.headers.get("content-type"))) {
|
||||
await discard(response);
|
||||
return fail("CONTENT_TYPE_INVALID");
|
||||
}
|
||||
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > policy.maximumBytes) {
|
||||
await discard(response);
|
||||
return fail("BODY_TOO_LARGE");
|
||||
}
|
||||
|
||||
const bytes = await readBoundedBytes(response, policy.maximumBytes, controller);
|
||||
if (bytes === "TOO_LARGE") return fail("BODY_TOO_LARGE");
|
||||
if (bytes === "STREAM_FAILED") return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED");
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return fail("UTF8_INVALID");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return fail("JSON_INVALID");
|
||||
}
|
||||
if (!isRecord(parsed)) return fail("SHAPE_INVALID");
|
||||
|
||||
return Object.freeze({ ok: true as const, value: parsed });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedBytes(
|
||||
response: Response,
|
||||
maximumBytes: number,
|
||||
controller: AbortController,
|
||||
): Promise<Uint8Array | "TOO_LARGE" | "STREAM_FAILED"> {
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
// A body-less 200 cannot satisfy any boot document.
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > maximumBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
controller.abort();
|
||||
return "TOO_LARGE";
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
await reader.cancel().catch(() => {});
|
||||
return "STREAM_FAILED";
|
||||
}
|
||||
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function discard(response: Response): Promise<void> {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// Cancelling an already-settled body is not a boot failure.
|
||||
}
|
||||
}
|
||||
|
||||
function fail(failure: BootLoadFailure): BootJsonOutcome {
|
||||
return Object.freeze({ ok: false as const, failure });
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
ResolvedRuntimeCapabilities,
|
||||
} from "../contracts/runtime-capabilities.ts";
|
||||
import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts";
|
||||
import { createServiceWorkerPageController } from "../adapters/service-worker/service-worker-page-controller.ts";
|
||||
|
||||
/**
|
||||
* §17.5. Composition-side factory. The host object is created without any
|
||||
* side effect; `start()` is only called from the post-mount runtime starter,
|
||||
* after Runtime Config, the release manifest and the contract set have all
|
||||
* validated and React has committed its first render.
|
||||
*/
|
||||
|
||||
export type ServiceWorkerHostInput = Readonly<{
|
||||
capabilities: ResolvedRuntimeCapabilities;
|
||||
routerBasePath: string;
|
||||
buildId: string;
|
||||
host?: Readonly<{
|
||||
location?: Pick<Location, "origin">;
|
||||
navigator?: Pick<Navigator, "serviceWorker">;
|
||||
caches?: CacheStorage;
|
||||
document?: Pick<Document, "visibilityState">;
|
||||
}>;
|
||||
blockers?: readonly (() => boolean)[];
|
||||
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
|
||||
}>;
|
||||
|
||||
export function createServiceWorkerRuntimeHost(
|
||||
input: ServiceWorkerHostInput,
|
||||
): ServiceWorkerRuntimeHost | null {
|
||||
const { capabilities } = input;
|
||||
|
||||
// §3.6. `null` selection with no disable-cleanup obligation means zero
|
||||
// registration lookups and zero Cache Storage access: no host at all.
|
||||
if (!capabilities.serviceWorker && !capabilities.serviceWorkerDisabledCleanup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host =
|
||||
input.host ??
|
||||
(globalThis as unknown as NonNullable<ServiceWorkerHostInput["host"]>);
|
||||
const container = host?.navigator?.serviceWorker;
|
||||
const origin = host?.location?.origin;
|
||||
if (!origin) return null;
|
||||
|
||||
return createServiceWorkerPageController({
|
||||
selection: capabilities.serviceWorker,
|
||||
disabledCleanup: capabilities.serviceWorkerDisabledCleanup,
|
||||
routerBasePath: input.routerBasePath,
|
||||
origin,
|
||||
buildId: input.buildId,
|
||||
...(container ? { container } : {}),
|
||||
...(host?.caches ? { caches: host.caches } : {}),
|
||||
...(input.blockers ? { blockers: input.blockers } : {}),
|
||||
...(input.observe ? { observe: input.observe } : {}),
|
||||
});
|
||||
}
|
||||
@@ -6,23 +6,32 @@ import {
|
||||
} from "../adapters/auth/external-session-adapter.ts";
|
||||
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts";
|
||||
import { createHttpClient } from "../adapters/http/client.ts";
|
||||
import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts";
|
||||
import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts";
|
||||
import { createTanStackCacheCoordinator } from "../adapters/query-cache/tanstack-cache-coordinator.ts";
|
||||
import {
|
||||
createTanStackCacheCoordinator,
|
||||
type InstalledQueryInvalidationDefinition,
|
||||
} 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 type { ReleaseInfo } from "../application/ports/release-info-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 { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.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";
|
||||
|
||||
type HttpClientDependencies = Parameters<typeof createHttpClient>[0];
|
||||
export type RuntimeHttpContract = Pick<
|
||||
@@ -47,6 +56,26 @@ export type RuntimeAdaptersContext = Readonly<{
|
||||
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",
|
||||
@@ -155,48 +184,69 @@ export async function createRuntimeAdapters(
|
||||
});
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
// The composition root states the registry shape it consumes rather than
|
||||
// inferring it from whichever features happen to be installed, so a build
|
||||
// with zero installed features still type-checks.
|
||||
const queryRegistry: Readonly<
|
||||
Record<string, InstalledQueryInvalidationDefinition>
|
||||
> = QUERY_REGISTRY;
|
||||
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: Object.values(queryRegistry).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,
|
||||
crossContext: crossContextInvalidation,
|
||||
diagnostics,
|
||||
});
|
||||
return Object.freeze({
|
||||
queryClient,
|
||||
queryInvalidation,
|
||||
crossContextStatus: () =>
|
||||
crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY",
|
||||
});
|
||||
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();
|
||||
queryInvalidation: {
|
||||
resetLocal: () => serverStateGeneration.resetCurrent(),
|
||||
},
|
||||
participants: [
|
||||
{
|
||||
order: 4,
|
||||
label: "conditional-validators",
|
||||
close: () => conditionalValidators.clear(),
|
||||
},
|
||||
],
|
||||
activateNextGeneration: () => serverStateGeneration.activateNext(),
|
||||
});
|
||||
const storage = createBrowserStorageAdapter({
|
||||
localStorage: storageOrUndefined(hostValue(host, "localStorage")),
|
||||
@@ -207,14 +257,24 @@ export async function createRuntimeAdapters(
|
||||
});
|
||||
const releaseInfo = Object.freeze({
|
||||
async getCurrent() {
|
||||
return structuredClone(context.release);
|
||||
return toReleaseInfo(context.release);
|
||||
},
|
||||
async refresh() {
|
||||
return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, {
|
||||
fetcher: context.fetcher,
|
||||
buildId: context.release.buildId,
|
||||
releaseId: context.release.releaseId,
|
||||
});
|
||||
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,
|
||||
);
|
||||
},
|
||||
});
|
||||
const navigation = Object.freeze({
|
||||
@@ -226,18 +286,105 @@ export async function createRuntimeAdapters(
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
const contractHttp = createContractHttpExecutor({
|
||||
baseUrl: config.API_BASE_URL,
|
||||
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
|
||||
fetcher: context.fetcher,
|
||||
async attachCredentials(operation) {
|
||||
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,
|
||||
});
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "READY" as const,
|
||||
headers: patch.headers,
|
||||
credentials: "omit" as const,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
}
|
||||
},
|
||||
observe(observation) {
|
||||
try {
|
||||
diagnostics.record({
|
||||
level:
|
||||
observation.outcome === "SUCCESS" ? "info" : "warn",
|
||||
eventId: "http.request.completed",
|
||||
context: {
|
||||
operation_id: observation.diagnosticsOperation,
|
||||
outcome: observation.outcome,
|
||||
attempts: observation.attempts,
|
||||
certainty: observation.certainty,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics cannot change a contract execution outcome.
|
||||
}
|
||||
},
|
||||
});
|
||||
let contractExecutionSequence = 0;
|
||||
const contractOperations = Object.freeze({
|
||||
async execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
executionContext: Readonly<{ signal?: AbortSignal }> = {},
|
||||
) {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
contractExecutionSequence += 1;
|
||||
const intentId = `http-intent-${contractExecutionSequence}`;
|
||||
const isCommand = operation.contract.commandEffect !== null;
|
||||
const requiresKey = operation.contract.retrySemantics === "KEYED";
|
||||
const outcome = await contractHttp.execute(operation, input, {
|
||||
scope: serverStateScope.getSnapshot(),
|
||||
...(executionContext.signal === undefined
|
||||
? {}
|
||||
: { signal: executionContext.signal }),
|
||||
...(isCommand
|
||||
? {
|
||||
intent: Object.freeze({
|
||||
intentId,
|
||||
startedBy: "USER" as const,
|
||||
...(requiresKey
|
||||
? { idempotencyKey: `http-key-${contractExecutionSequence}` }
|
||||
: {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (outcome.kind === "UNAUTHENTICATED") {
|
||||
authSession.onUnauthenticated();
|
||||
}
|
||||
return outcome;
|
||||
},
|
||||
});
|
||||
const featureInputs = createInstalledFeatureInputs({
|
||||
createHttpClient: (contract) =>
|
||||
createRuntimeHttpClient(
|
||||
{
|
||||
runtime: context.runtime,
|
||||
authSession,
|
||||
fetcher: context.fetcher,
|
||||
diagnostics,
|
||||
telemetry,
|
||||
},
|
||||
contract,
|
||||
),
|
||||
contractOperations,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
@@ -247,20 +394,25 @@ export async function createRuntimeAdapters(
|
||||
diagnostics,
|
||||
telemetry,
|
||||
releaseInfo,
|
||||
runtimeCapabilities,
|
||||
navigation,
|
||||
}),
|
||||
infrastructure: Object.freeze({
|
||||
queryClient,
|
||||
queryInvalidation,
|
||||
get queryClient() {
|
||||
return serverStateGeneration.getSnapshot().queryClient;
|
||||
},
|
||||
get queryInvalidation() {
|
||||
return serverStateGeneration.getSnapshot().queryInvalidation;
|
||||
},
|
||||
serverStateGeneration,
|
||||
serverStateScope,
|
||||
conditionalValidators,
|
||||
crossContextInvalidationStatus: () =>
|
||||
crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY",
|
||||
serverStateGeneration.getSnapshot().crossContextStatus(),
|
||||
dispose() {
|
||||
unsubscribeConditionalScope();
|
||||
conditionalValidators.clear();
|
||||
serverStateScope.dispose();
|
||||
queryInvalidation.dispose();
|
||||
serverStateGeneration.dispose();
|
||||
},
|
||||
}),
|
||||
featureInputs,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { StrictMode, useEffect } 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 { ServerStateGenerationProvider } from "../presentation/adapters/query/server-state-generation-provider.tsx";
|
||||
import { AppRouter } from "../presentation/routes/app-router.tsx";
|
||||
import type { RuntimeComposition } from "./create-runtime-composition.ts";
|
||||
|
||||
@@ -11,27 +9,44 @@ 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.
|
||||
*/
|
||||
/**
|
||||
* §6.7 step 16 / §17.5. Optional runtime starts from the first committed
|
||||
* effect, never during render and never during composition. StrictMode double
|
||||
* invocation is safe: `startAfterMount` returns the same in-flight promise.
|
||||
*/
|
||||
function PostMountRuntimeStarter({
|
||||
composition,
|
||||
}: Readonly<{ composition: RuntimeComposition }>) {
|
||||
useEffect(() => {
|
||||
void composition.optional.startAfterMount();
|
||||
}, [composition]);
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
<ServerStateGenerationProvider
|
||||
store={composition.infrastructure.serverStateGeneration}
|
||||
scope={composition.infrastructure.serverStateScope}
|
||||
transitionFallback={
|
||||
<div
|
||||
aria-busy="true"
|
||||
className="state-surface state-surface--loading"
|
||||
data-scope-state="scope-transition"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ApplicationProvider application={composition.application}>
|
||||
<PostMountRuntimeStarter composition={composition} />
|
||||
<AppRouter
|
||||
basename={composition.config.build.routerBasePath}
|
||||
buildId={composition.release.buildId}
|
||||
/>
|
||||
</ApplicationProvider>
|
||||
</ServerStateGenerationProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +1,138 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
runtimeConfigV1ArtifactSchema,
|
||||
runtimeConfigV2ArtifactSchema,
|
||||
type CapabilityOverrideArtifact,
|
||||
type RuntimeConfigV1Artifact,
|
||||
type RuntimeConfigV2Artifact,
|
||||
} from "../contracts/release-artifacts.ts";
|
||||
|
||||
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
|
||||
export { isValidReleaseManifestUrl } from "../contracts/release-artifacts.ts";
|
||||
|
||||
export const runtimeConfigSchema = z
|
||||
.object({
|
||||
APP_ENV: z.enum(["local", "development", "staging", "production"]),
|
||||
API_BASE_URL: z.url(),
|
||||
REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000),
|
||||
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
|
||||
TELEMETRY_ENABLED: z.boolean(),
|
||||
TELEMETRY_ENDPOINT: z.url().optional(),
|
||||
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"),
|
||||
RELEASE_ID: z.string().min(1).optional(),
|
||||
BUILD_ID: z.string().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((config, context) => {
|
||||
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["TELEMETRY_ENDPOINT"],
|
||||
message: "required when telemetry is enabled",
|
||||
});
|
||||
}
|
||||
export type CapabilityOverrides = CapabilityOverrideArtifact;
|
||||
|
||||
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 = [
|
||||
["API_BASE_URL", config.API_BASE_URL],
|
||||
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
|
||||
] as const;
|
||||
/**
|
||||
* §6.1. Runtime Config V2 is deployment and browser operational setting only.
|
||||
* `API_CONTRACT_VERSION` is gone: a scalar cannot describe a multi-package
|
||||
* contract set, and Release Manifest V2 `contractSet` owns that meaning.
|
||||
*/
|
||||
export const runtimeConfigV2Schema = runtimeConfigV2ArtifactSchema;
|
||||
|
||||
for (const [key, value] of endpointEntries) {
|
||||
if (value && !local && new URL(value).protocol !== "https:") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: "HTTPS is required outside local environments",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
/**
|
||||
* §5.8 / §24.6 RC-2. The V1 reader is retained for one compatibility window so
|
||||
* a release never swaps source shape, manifest shape and runtime behaviour at
|
||||
* the same time. Only a V1 document may carry the scalar contract version.
|
||||
*/
|
||||
export const runtimeConfigV1Schema = runtimeConfigV1ArtifactSchema;
|
||||
|
||||
export type RuntimeConfigV2 = RuntimeConfigV2Artifact;
|
||||
export type RuntimeConfigV1 = RuntimeConfigV1Artifact;
|
||||
|
||||
/**
|
||||
* The composition-facing shape. V1 documents are normalized onto it so the rest
|
||||
* of the runtime never branches on config schema version.
|
||||
*/
|
||||
export type RuntimeConfig = Readonly<{
|
||||
APP_ENV: RuntimeConfigV2["APP_ENV"];
|
||||
API_BASE_URL: string;
|
||||
REQUEST_TIMEOUT_MS: number;
|
||||
MAX_RETRY_ATTEMPTS: number;
|
||||
TELEMETRY_ENABLED: boolean;
|
||||
TELEMETRY_ENDPOINT?: string;
|
||||
AUTH_MODE: RuntimeConfigV2["AUTH_MODE"];
|
||||
CONFIG_SCHEMA_VERSION: string;
|
||||
RELEASE_MANIFEST_URL: string;
|
||||
RELEASE_ID?: string;
|
||||
BUILD_ID?: string;
|
||||
CAPABILITY_OVERRIDES: CapabilityOverrides;
|
||||
/** Present only while a V1 document is still accepted. */
|
||||
LEGACY_API_CONTRACT_VERSION?: string;
|
||||
}>;
|
||||
|
||||
export type RuntimeConfig = z.output<typeof runtimeConfigSchema>;
|
||||
export type RuntimeConfigValidation =
|
||||
| Readonly<{ success: true; data: RuntimeConfig }>
|
||||
| Readonly<{ success: true; data: RuntimeConfig; schema: "V1" | "V2" }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
issues: readonly Readonly<{ path: string; code: string }>[];
|
||||
}>;
|
||||
|
||||
export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
|
||||
const result = runtimeConfigSchema.safeParse(value);
|
||||
const DEFAULT_OVERRIDES: CapabilityOverrides = Object.freeze({
|
||||
REALTIME: "DEFAULT" as const,
|
||||
WEB_WORKER: "DEFAULT" as const,
|
||||
SERVICE_WORKER: "DEFAULT" as const,
|
||||
OFFLINE_COMMANDS: "DEFAULT" as const,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
})),
|
||||
};
|
||||
function canonicalUrl(value: string): string {
|
||||
return new URL(value).href;
|
||||
}
|
||||
|
||||
export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
|
||||
const declared =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>).CONFIG_SCHEMA_VERSION
|
||||
: undefined;
|
||||
|
||||
// §5.8: no precedence between V1 and V2. The declared version selects exactly
|
||||
// one parser, and a V2 document carrying the removed scalar is rejected.
|
||||
if (declared !== "1" && declared !== "2.0") {
|
||||
return Object.freeze({
|
||||
success: false as const,
|
||||
issues: Object.freeze([
|
||||
Object.freeze({
|
||||
path: "CONFIG_SCHEMA_VERSION",
|
||||
code: "unsupported_value",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: structuredClone(result.data),
|
||||
};
|
||||
const isV2 = declared === "2.0";
|
||||
const result = isV2
|
||||
? runtimeConfigV2Schema.safeParse(value)
|
||||
: runtimeConfigV1Schema.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
return Object.freeze({
|
||||
success: false as const,
|
||||
issues: Object.freeze(
|
||||
result.error.issues.map((issue) =>
|
||||
Object.freeze({ path: issue.path.join("."), code: issue.code }),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = result.data;
|
||||
const normalized: RuntimeConfig = Object.freeze({
|
||||
APP_ENV: parsed.APP_ENV,
|
||||
API_BASE_URL: canonicalUrl(parsed.API_BASE_URL),
|
||||
REQUEST_TIMEOUT_MS: parsed.REQUEST_TIMEOUT_MS,
|
||||
MAX_RETRY_ATTEMPTS: parsed.MAX_RETRY_ATTEMPTS,
|
||||
TELEMETRY_ENABLED: parsed.TELEMETRY_ENABLED,
|
||||
...(parsed.TELEMETRY_ENDPOINT
|
||||
? { TELEMETRY_ENDPOINT: canonicalUrl(parsed.TELEMETRY_ENDPOINT) }
|
||||
: {}),
|
||||
AUTH_MODE: parsed.AUTH_MODE,
|
||||
CONFIG_SCHEMA_VERSION: parsed.CONFIG_SCHEMA_VERSION,
|
||||
RELEASE_MANIFEST_URL: parsed.RELEASE_MANIFEST_URL,
|
||||
...(parsed.RELEASE_ID ? { RELEASE_ID: parsed.RELEASE_ID } : {}),
|
||||
...(parsed.BUILD_ID ? { BUILD_ID: parsed.BUILD_ID } : {}),
|
||||
CAPABILITY_OVERRIDES: Object.freeze({
|
||||
...(isV2
|
||||
? (parsed as RuntimeConfigV2).CAPABILITY_OVERRIDES
|
||||
: DEFAULT_OVERRIDES),
|
||||
}),
|
||||
...(isV2
|
||||
? {}
|
||||
: {
|
||||
LEGACY_API_CONTRACT_VERSION: (parsed as RuntimeConfigV1)
|
||||
.API_CONTRACT_VERSION,
|
||||
}),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
success: true as const,
|
||||
data: normalized,
|
||||
schema: isV2 ? ("V2" as const) : ("V1" as const),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { CrossContextInvalidationStatus } from "../adapters/cross-context-invalidation/index.ts";
|
||||
import type { QueryInvalidationCoordinator } from "../contracts/query-invalidation.ts";
|
||||
|
||||
export type ServerStateGenerationResources = Readonly<{
|
||||
queryClient: QueryClient;
|
||||
queryInvalidation: QueryInvalidationCoordinator;
|
||||
crossContextStatus(): CrossContextInvalidationStatus;
|
||||
}>;
|
||||
|
||||
export type ServerStateGenerationSnapshot =
|
||||
ServerStateGenerationResources & Readonly<{ generation: number }>;
|
||||
|
||||
export type ServerStateGenerationStore = Readonly<{
|
||||
getSnapshot(): ServerStateGenerationSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
resetCurrent(): Promise<void>;
|
||||
activateNext(): void;
|
||||
dispose(): void;
|
||||
}>;
|
||||
|
||||
export function createServerStateGenerationStore(
|
||||
createResources: (generation: number) => ServerStateGenerationResources,
|
||||
): ServerStateGenerationStore {
|
||||
const listeners = new Set<() => void>();
|
||||
let disposed = false;
|
||||
let current = snapshot(1, createResources(1));
|
||||
|
||||
function publish(): void {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// Provider defects cannot change generation ownership.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
getSnapshot: () => current,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
async resetCurrent() {
|
||||
if (disposed) throw new TypeError("Server-state generations are disposed.");
|
||||
await current.queryInvalidation.resetLocal();
|
||||
},
|
||||
activateNext() {
|
||||
if (disposed) throw new TypeError("Server-state generations are disposed.");
|
||||
const previous = current;
|
||||
previous.queryInvalidation.dispose();
|
||||
previous.queryClient.clear();
|
||||
const generation = previous.generation + 1;
|
||||
current = snapshot(generation, createResources(generation));
|
||||
publish();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
listeners.clear();
|
||||
current.queryInvalidation.dispose();
|
||||
current.queryClient.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
generation: number,
|
||||
resources: ServerStateGenerationResources,
|
||||
): ServerStateGenerationSnapshot {
|
||||
return Object.freeze({ generation, ...resources });
|
||||
}
|
||||
Reference in New Issue
Block a user