diff --git a/docs/api-reference/veryfront/release-assets.md b/docs/api-reference/veryfront/release-assets.md index 847b332f21..28bdc60359 100644 --- a/docs/api-reference/veryfront/release-assets.md +++ b/docs/api-reference/veryfront/release-assets.md @@ -66,34 +66,34 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | Name | Description | Source | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `clearCachedReleaseAssetManifests` | Clear cached manifest bodies while keeping registered fetchers intact. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L567) | -| `clearReleaseAssetManifestCache` | Clear the cache and fetcher registry (tests / adapter teardown). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L577) | +| `clearCachedReleaseAssetManifests` | Clear cached manifest bodies while keeping registered fetchers intact. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L587) | +| `clearReleaseAssetManifestCache` | Clear the cache and fetcher registry (tests / adapter teardown). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L597) | | `contentTypeForExtension` | Resolve the content type for an extension, or null if not allowed. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L96) | | `describeReadyReleaseAssetManifestRejection` | Explain why a ready manifest response was rejected. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L381) | -| `getReadyManifestForRender` | Return a ready manifest for `releaseId` if one is cached, else null. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L257) | -| `getReadyManifestForRenderAsync` | Await a ready manifest for rendering when release-manifest consumption is enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L358) | +| `getReadyManifestForRender` | Return a ready manifest for `releaseId` if one is cached, else null. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L258) | +| `getReadyManifestForRenderAsync` | Await a ready manifest for rendering when release-manifest consumption is enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L359) | | `hasImmutableReleaseAssetDependencies` | True only when manifest dependency entries are safe immutable rewrite targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L283) | | `isAllowedReleaseAssetContentType` | True when the value is a valid allowlisted release asset content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L105) | -| `isReleaseAssetManifestEnabled` | True when production manifest consumption is enabled via env flag. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L202) | +| `isReleaseAssetManifestEnabled` | True when production manifest consumption is enabled via env flag. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L203) | | `isSafeBoundedText` | Check that an untrusted value is a non-empty, trimmed string within `maxLength` that contains no control characters. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L80) | | `isValidContentHash` | Validate a content hash is exactly 64 lowercase hex characters. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L114) | | `normalizeManifestModuleKey` | Normalize a logical module path to the manifest's key convention. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/html-consumption.ts#L27) | | `parseReadyReleaseAssetManifestResponse` | Parse an untrusted ready response without executing accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L337) | | `parseReleaseAssetManifest` | Parse an untrusted manifest without requiring a registered schema extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L322) | | `readUntrustedOwnDataProperty` | Read an own data property from an untrusted value without invoking accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L425) | -| `registerManifestFetcherForRelease` | Register a project-scoped manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L158) | +| `registerManifestFetcherForRelease` | Register a project-scoped manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L159) | | `releaseAssetUrl` | Map a 64-hex content hash + extension to its public asset URL. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L85) | | `resolveManifestModuleUrl` | Resolve a module URL through the manifest. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/html-consumption.ts#L42) | | `resolveManifestRoutePreloadUrls` | Resolve the route closure module URLs for preload hints from the manifest. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/html-consumption.ts#L64) | | `routeForPage` | Derive a route path from a page module logical path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/route-path.ts#L50) | -| `unregisterManifestFetcherForRelease` | Remove the manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L196) | +| `unregisterManifestFetcherForRelease` | Remove the manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L197) | ### Types | Name | Description | Source | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `ImmutableReleaseAssetManifest` | Manifest whose dependency entries name uploaded content-addressed assets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L278) | -| `ReadyManifestReadOptions` | Controls revalidation behavior for awaited manifest reads. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L76) | +| `ReadyManifestReadOptions` | Controls revalidation behavior for awaited manifest reads. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L77) | | `ReadyReleaseAssetManifestResponse` | Strict ready response with a generation-matched validated manifest body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L306) | | `ReleaseAssetContentType` | MIME types accepted for immutable release asset uploads and responses. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L24) | | `ReleaseAssetCssEntry` | Content-addressed CSS entry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L272) | @@ -101,9 +101,9 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | `ReleaseAssetEntry` | Content-addressed JavaScript module entry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L270) | | `ReleaseAssetExtension` | File extensions supported by the immutable release asset endpoint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L22) | | `ReleaseAssetManifest` | Validated, immutable release asset manifest v2 body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L266) | -| `ReleaseAssetManifestFetchContext` | Cancellation context passed to a release-scoped manifest fetcher. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L91) | -| `ReleaseAssetManifestFetcher` | Fetcher used to retrieve a manifest for a release. Registered per-releaseId by the runtime adapter that owns that release, so the correct project-scoped token is always used. Returns null when the manifest is unavailable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L108) | -| `ReleaseAssetManifestFetcherCleanup` | Idempotent cleanup for one fetcher registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L116) | +| `ReleaseAssetManifestFetchContext` | Cancellation context passed to a release-scoped manifest fetcher. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L92) | +| `ReleaseAssetManifestFetcher` | Fetcher used to retrieve a manifest for a release. Registered per-releaseId by the runtime adapter that owns that release, so the correct project-scoped token is always used. Returns null when the manifest is unavailable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L109) | +| `ReleaseAssetManifestFetcherCleanup` | Idempotent cleanup for one fetcher registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L117) | | `ReleaseAssetManifestResponse` | Response shape for the GET asset-manifest endpoint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L299) | | `ReleaseAssetManifestState` | Manifest lifecycle states (DB-owned; mirrored here for runtime checks). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L290) | | `ReleaseAssetRouteEntry` | Per-route module and CSS closure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L274) | diff --git a/src/release-assets/manifest-cache.test.ts b/src/release-assets/manifest-cache.test.ts index 6301868a2a..29c2059d72 100644 --- a/src/release-assets/manifest-cache.test.ts +++ b/src/release-assets/manifest-cache.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { clearCachedReleaseAssetManifests, @@ -76,6 +76,71 @@ describe("release asset manifest fetcher ownership", () => { assertEquals(calls, ["newer"]); }); + it("reports why a ready manifest was rejected instead of only refusing it", async () => { + // The failure this pins: assets built by a different framework version are + // published as `ready` and refused here, which takes a site's whole client + // bundle offline. Before this, the only operator-visible signal was a 503 + // and a timing mark, so version skew and a corrupt payload looked alike. + Deno.env.set("VERYFRONT_RELEASE_ASSET_MANIFEST", "1"); + const skewed = manifest("release-skew", 1); + registerManifestFetcherForRelease("release-skew", () => + Promise.resolve({ + state: "ready", + manifest_version: 1, + manifest: { + ...skewed, + schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION + 1, + }, + })); + + const originalError = console.error; + let logged = ""; + console.error = (...values: unknown[]) => { + logged += values.map((value) => typeof value === "string" ? value : JSON.stringify(value)) + .join(" "); + }; + + try { + assertEquals(await getReadyManifestForRenderAsync("release-skew"), null); + } finally { + console.error = originalError; + } + + assertStringIncludes(logged, "release-skew"); + assertStringIncludes(logged, "manifest schema version"); + assertStringIncludes(logged, "built by a different framework version"); + }); + + it("reports a ready response whose envelope has no usable manifest_version", async () => { + // `state` is normalized to "invalid" when the envelope is unusable, so + // classifying on it routed this rejection to debug, silencing one of the + // exact reasons the diagnostic exists to surface. Classification follows + // the publisher's claimed state instead. + Deno.env.set("VERYFRONT_RELEASE_ASSET_MANIFEST", "1"); + registerManifestFetcherForRelease("release-envelope", () => + Promise.resolve({ + state: "ready", + manifest_version: -1, + manifest: manifest("release-envelope", 1), + } as unknown as ReturnType)); + + const originalError = console.error; + let logged = ""; + console.error = (...values: unknown[]) => { + logged += values.map((value) => typeof value === "string" ? value : JSON.stringify(value)) + .join(" "); + }; + + try { + assertEquals(await getReadyManifestForRenderAsync("release-envelope"), null); + } finally { + console.error = originalError; + } + + assertStringIncludes(logged, "release-envelope"); + assertStringIncludes(logged, "no usable manifest_version"); + }); + it("keeps cache identities distinct for delimiter-shaped release IDs", async () => { Deno.env.set("VERYFRONT_RELEASE_ASSET_MANIFEST", "1"); const calls: string[] = []; diff --git a/src/release-assets/manifest-cache.ts b/src/release-assets/manifest-cache.ts index e83ced2abe..d27610a08b 100644 --- a/src/release-assets/manifest-cache.ts +++ b/src/release-assets/manifest-cache.ts @@ -30,6 +30,7 @@ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import { markRequestProfilePhase, profilePhase } from "#veryfront/observability"; import { RELEASE_ASSET_MANIFEST_ENV_FLAG, RELEASE_ASSET_MANIFEST_LIMITS } from "./constants.ts"; import { + describeReadyReleaseAssetManifestRejection, parseReadyReleaseAssetManifestResponse, type ReleaseAssetManifest, } from "./manifest-schema.ts"; @@ -450,8 +451,27 @@ function fetchManifest(releaseId: string): Promise }); return manifest; } else { - if (state === "ready") markManifestDecision("fetch_ready_invalid"); - else markManifestDecision(`fetch_${manifestState}`); + // Classify on what the publisher claimed, not on the derived `state`. + // A response that says `ready` with an unusable `manifest_version` is + // normalized to "invalid" above, so keying on `state` would route the + // envelope-level rejections to debug — silencing exactly the reasons + // this diagnostic exists to surface. + if (rawState === "ready") { + markManifestDecision("fetch_ready_invalid"); + // A manifest the publisher calls ready but this build cannot read is + // an operator problem, not a wait: browser modules are refused for + // the whole release until someone acts. Say why. Without this the + // only signal is a 503 and a timing mark, which cannot distinguish + // framework version skew from a corrupt payload. + logger.error("Release manifest is ready upstream but failed validation", { + releaseId, + reason: describeReadyReleaseAssetManifestRejection(result, releaseId), + }); + } else { + markManifestDecision(`fetch_${manifestState}`); + // Any other state is the release legitimately not being ready yet. + logger.debug("Release manifest is not ready", { releaseId, state: manifestState }); + } markManifestDecision("fetch_not_ready"); evictReadyManifests(releaseId, active.token); cacheNonReadyManifest(releaseId, active.token);