Files
clean-architecture-frontend…/src/bootstrap/load-release-manifest.js
T

139 lines
4.0 KiB
JavaScript

import { z } from "zod";
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();
export class ReleaseManifestError extends Error {
/** @param {string} code @param {{buildId: string, releaseId?: string}} safe */
constructor(code, safe) {
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.code = code;
this.safe = Object.freeze({
kind: this.kind,
code,
buildId: safe.buildId,
releaseId: safe.releaseId,
supportReference: `${safe.buildId}:${code}`,
});
}
}
/**
* 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) {
const fetcher = options.fetcher ?? fetch;
let response;
try {
response = await fetcher(url, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", options);
}
if (!response.ok) {
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options);
}
let raw;
try {
raw = await response.json();
} catch {
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", options);
}
const parsed = releaseManifestSchema.safeParse(raw);
if (!parsed.success) {
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";
}
if (
!mismatchCode &&
runtime.config.BUILD_ID &&
manifest.buildId !== runtime.config.BUILD_ID
) {
mismatchCode = "MANIFEST_BUILD_MISMATCH";
}
if (
!mismatchCode &&
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
) {
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
) {
mismatchCode = "MANIFEST_RELEASE_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 manifest;
}