feat: execute route and release recovery contracts

This commit is contained in:
donghyeon-ka
2026-07-26 14:26:39 +09:00
parent a33e93d4d4
commit ce0040e407
49 changed files with 1761 additions and 373 deletions
+65 -34
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
const releaseManifestSchema = z
export const releaseManifestSchema = z
.object({
schemaVersion: z.literal(1),
appVersion: z.string().min(1),
@@ -12,6 +12,7 @@ const releaseManifestSchema = z
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();
@@ -20,7 +21,14 @@ export class ReleaseManifestError extends Error {
constructor(code, safe) {
super("Release manifest could not be loaded");
this.name = "ReleaseManifestError";
this.kind = "RELEASE_MANIFEST_FAILURE";
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.code = code;
this.safe = Object.freeze({
kind: this.kind,
@@ -33,75 +41,98 @@ export class ReleaseManifestError extends Error {
}
/**
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
* @param {{fetcher?: typeof fetch}} [options]
* 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 loadReleaseManifest(runtime, options = {}) {
export async function fetchReleaseManifest(url, options) {
const fetcher = options.fetcher ?? fetch;
let response;
try {
response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, {
response = await fetcher(url, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", options);
}
if (!response.ok) {
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options);
}
let raw;
try {
raw = await response.json();
} catch {
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", options);
}
const parsed = releaseManifestSchema.safeParse(raw);
if (!parsed.success) {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", 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 = {}) {
const manifest = await fetchReleaseManifest(
runtime.config.RELEASE_MANIFEST_URL,
{
fetcher: options.fetcher,
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
},
);
let mismatchCode = null;
if (manifest.buildId !== runtime.build.buildId) {
mismatchCode = "MANIFEST_BUILD_MISMATCH";
}
const manifest = parsed.data;
const mismatches = [];
if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId");
if (
!mismatchCode &&
runtime.config.BUILD_ID &&
manifest.buildId !== runtime.config.BUILD_ID
) {
mismatches.push("runtimeBuildId");
mismatchCode = "MANIFEST_BUILD_MISMATCH";
}
if (
!mismatchCode &&
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
) {
mismatches.push("configSchemaVersion");
}
if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) {
mismatches.push("apiContractVersion");
mismatchCode = "MANIFEST_CONFIG_SCHEMA_MISMATCH";
}
if (
!mismatchCode &&
manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION
) {
mismatchCode = "MANIFEST_API_CONTRACT_MISMATCH";
}
if (
!mismatchCode &&
runtime.config.RELEASE_ID &&
manifest.releaseId !== runtime.config.RELEASE_ID
) {
mismatches.push("releaseId");
mismatchCode = "MANIFEST_RELEASE_MISMATCH";
}
if (mismatches.length > 0) {
throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", {
if (
!mismatchCode &&
options.expectedAssetManifestHash &&
manifest.assetManifestHash !== options.expectedAssetManifestHash
) {
mismatchCode = "MANIFEST_ASSET_MISMATCH";
}
if (mismatchCode) {
throw new ReleaseManifestError(mismatchCode, {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
return Object.freeze(structuredClone(manifest));
return manifest;
}
+19
View File
@@ -9,6 +9,7 @@ import {
} 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";
/**
* @param {Record<string, unknown>} host
@@ -97,6 +98,23 @@ export async function createRuntimeAdapters(context) {
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();
},
});
return Object.freeze({
@@ -105,6 +123,7 @@ export async function createRuntimeAdapters(context) {
preferences: storage,
diagnostics: telemetry,
releaseInfo,
navigation,
}),
infrastructure: Object.freeze({
queryClient,