Skip to content

Commit 4c44f8f

Browse files
committed
fix(compose): the compose manifest resolves through the pin's version directory (#826)
The boot SSR compose read manifest.gen.json from the mutable slot root while the composition engine loaded pinned hashed bytes — the same generation skew #826 fixed for plugin chunks, surfacing as ComposeDigestMismatchError (the parity guard refusing to serve). Dir-based pins now stamp a pin-resolved manifestUrl so the compose manifest reads from the same frozen directory as the entry and chunks; legacy pins keep the slot-root fixed name (their manifestIntegrity SRI check still guards it).
1 parent c171d96 commit 4c44f8f

6 files changed

Lines changed: 95 additions & 3 deletions

File tree

‎host/src/services/ui-compose.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function uiSources(config: RuntimeConfig): UiSource[] {
116116
manifestUrl:
117117
config.ui.source === "local"
118118
? undefined
119-
: `${config.ui.url.replace(/\/$/, "")}/${MANIFEST_FILENAME}`,
119+
: (config.ui.manifestUrl ?? `${config.ui.url.replace(/\/$/, "")}/${MANIFEST_FILENAME}`),
120120
manifestIntegrity: config.ui.manifestIntegrity,
121121
browserManifestUrl: coreUrls.browserManifest,
122122
uiEntry: coreUrls.ui,
@@ -146,7 +146,11 @@ export function uiSources(config: RuntimeConfig): UiSource[] {
146146
localRoot: ui.localPath ? resolveLocalRoot(ui.localPath) : undefined,
147147
// Remote sources publish their manifest at a known URL; local remotes are
148148
// read straight from the workspace (src/manifest.gen.json) — no URL.
149-
manifestUrl: ui.url ? `${ui.url.replace(/\/$/, "")}/${MANIFEST_FILENAME}` : undefined,
149+
// Pinned remotes resolve it through the immutable version directory
150+
// (#826); the slot-root fixed name is the legacy fallback.
151+
manifestUrl: ui.url
152+
? (ui.manifestUrl ?? `${ui.url.replace(/\/$/, "")}/${MANIFEST_FILENAME}`)
153+
: undefined,
150154
manifestIntegrity: ui.manifestIntegrity,
151155
browserManifestUrl: urls.browserManifest,
152156
uiEntry: urls.ui,

‎host/tests/integration/ui-compose.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@ describe("uiSources", () => {
228228
};
229229
expect(uiSources(config).map((source) => source.key)).toEqual(["ui"]);
230230
});
231+
232+
it("prefers the pin-resolved compose manifest URL over the slot-root fixed name (#826)", () => {
233+
const config = configWithPlugin();
234+
config.ui.manifestUrl =
235+
"https://cdn.example.com/base-ui/versions/0123456789abcdef/manifest.gen.json";
236+
config.plugins!.auth!.ui!.manifestUrl =
237+
"https://cdn.example.com/auth-ui/versions/0123456789abcdef/manifest.gen.json";
238+
const sources = uiSources(config);
239+
expect(sources.find((source) => source.key === "ui")?.manifestUrl).toBe(
240+
"https://cdn.example.com/base-ui/versions/0123456789abcdef/manifest.gen.json",
241+
);
242+
expect(sources.find((source) => source.key === "auth")?.manifestUrl).toBe(
243+
"https://cdn.example.com/auth-ui/versions/0123456789abcdef/manifest.gen.json",
244+
);
245+
});
231246
});
232247

233248
describe("composeUi", () => {

‎packages/everything-dev/src/services/deploy/version-manifest.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export function composeWorkspaceVersionManifest(input: {
9292
*/
9393

9494
export interface ResolvedRemoteVersion {
95+
/** The remote's production base (slot root — unprefixed by the version dir). */
96+
base: string;
9597
/** Absolute URL of the content-hashed container entry. */
9698
entryUrl: string;
9799
entryIntegrity: string;
@@ -137,6 +139,7 @@ function remoteVersionFromManifest(
137139
const fileBase =
138140
manifest.dir && !options?.ignoreDir ? `${trimmedBase}/${manifest.dir}` : trimmedBase;
139141
return {
142+
base: trimmedBase,
140143
entryUrl: `${fileBase}/${manifest.entry}`,
141144
entryIntegrity: manifest.entryIntegrity,
142145
...(manifest.dir ? { dir: manifest.dir } : {}),

‎packages/everything-dev/src/services/resolution/runtime.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,13 @@ async function deriveVersionManifestFields(
404404
ui.ssrEntryUrl = r.ssrEntryUrl;
405405
ui.ssrIntegrity = r.ssrIntegrity ?? ui.ssrIntegrity;
406406
ui.manifestIntegrity = r.files?.[MANIFEST_FILENAME] ?? ui.manifestIntegrity;
407+
// The compose manifest rides the pin's immutable version directory (#826)
408+
// — reading the fixed name at the mutable slot root re-introduces the
409+
// generation skew the parity check exists to catch. Legacy pins (no dir)
410+
// leave it unset: the compose falls back to the slot root.
411+
if (r.dir) {
412+
ui.manifestUrl = `${r.base.replace(/\/$/, "")}/${r.dir}/${MANIFEST_FILENAME}`;
413+
}
407414
ui.sharedVersions = r.sharedVersions;
408415
};
409416

‎packages/everything-dev/src/types.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ const PluginRuntimeUiSchema = z.object({
151151
* manifest against it so a host resolving an older pin never silently
152152
* mixes newer manifest bytes into its composed tree */
153153
manifestIntegrity: z.string().optional(),
154+
/** The compose manifest URL resolved through the pin's immutable version
155+
* directory (#826) — absent for local slots and legacy pins, which read
156+
* the fixed name at the slot root */
157+
manifestUrl: z.string().optional(),
154158
/** Shared-singleton `name → version` the pinned build embedded (#546) —
155159
* the host's composition guard cross-compares it across UI sources before
156160
* loading, so a platform skew fails with a named error instead of the
@@ -456,6 +460,9 @@ export const RuntimeConfigSchema = z.object({
456460
/** SRI of the pinned compose manifest (manifest.gen.json) — verified by
457461
* the host compose on every fetch (see PluginRuntimeUiSchema) */
458462
manifestIntegrity: z.string().optional(),
463+
/** The compose manifest URL resolved through the pin's immutable version
464+
* directory (#826) — see PluginRuntimeUiSchema */
465+
manifestUrl: z.string().optional(),
459466
/** Shared-singleton `name → version` the pinned build embedded (#546) —
460467
* see PluginRuntimeUiSchema; mirrors the runtime slot type */
461468
sharedVersions: z.record(z.string(), z.string()).optional(),

‎packages/everything-dev/tests/unit/version-manifest-deploy.test.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import { createHash } from "node:crypto";
12
import { describe, expect, it } from "vitest";
2-
import { composeWorkspaceVersionManifest } from "../../src/services/deploy/version-manifest";
3+
import {
4+
composeWorkspaceVersionManifest,
5+
resolveRemoteVersion,
6+
} from "../../src/services/deploy/version-manifest";
37

48
const webReport = {
59
entry: "remoteEntry.cf712c39e56dc37f.js",
@@ -148,3 +152,55 @@ describe("composeWorkspaceVersionManifest", () => {
148152
expect(same.version).toBe(manifest.version);
149153
});
150154
});
155+
156+
describe("resolveRemoteVersion — version directory resolution (#826)", () => {
157+
const base = "https://cdn.test/bundles/a.test/gw.test/ui/";
158+
const manifestBody = JSON.stringify({
159+
version: "8f3ac1d2feedbeef",
160+
entry: "remoteEntry.83ba437f70018e7d.js",
161+
entryIntegrity: "sha384-entry",
162+
dir: "versions/0123456789abcdef",
163+
files: { "manifest.gen.json": "sha384-compose" },
164+
});
165+
const digest = {
166+
manifest: "versions/8f3ac1d2feedbeef.json",
167+
integrity: `sha384-${createHash("sha384").update(manifestBody).digest("base64")}`,
168+
};
169+
170+
it("resolves artifact coordinates through the version directory and exposes base + dir", async () => {
171+
const fetchImpl = async (input: string | URL | Request): Promise<Response> => {
172+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
173+
return url.endsWith("versions/8f3ac1d2feedbeef.json")
174+
? new Response(manifestBody, { status: 200 })
175+
: new Response("not found", { status: 404 });
176+
};
177+
const resolved = await resolveRemoteVersion({ base, digest, fetchImpl });
178+
expect(resolved.base).toBe(base.replace(/\/$/, ""));
179+
expect(resolved.dir).toBe("versions/0123456789abcdef");
180+
expect(resolved.entryUrl).toBe(
181+
`${base}versions/0123456789abcdef/remoteEntry.83ba437f70018e7d.js`,
182+
);
183+
});
184+
185+
it("a legacy manifest (no dir) resolves against the slot root", async () => {
186+
const legacy = JSON.stringify({
187+
version: "8f3ac1d2feedbeef",
188+
entry: "remoteEntry.83ba437f70018e7d.js",
189+
entryIntegrity: "sha384-entry",
190+
files: { "manifest.gen.json": "sha384-compose" },
191+
});
192+
const legacyDigest = {
193+
manifest: "versions/8f3ac1d2feedbeef.json",
194+
integrity: `sha384-${createHash("sha384").update(legacy).digest("base64")}`,
195+
};
196+
const fetchImpl = async (input: string | URL | Request): Promise<Response> => {
197+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
198+
return url.endsWith("versions/8f3ac1d2feedbeef.json")
199+
? new Response(legacy, { status: 200 })
200+
: new Response("not found", { status: 404 });
201+
};
202+
const resolved = await resolveRemoteVersion({ base, digest: legacyDigest, fetchImpl });
203+
expect(resolved.dir).toBeUndefined();
204+
expect(resolved.entryUrl).toBe(`${base}remoteEntry.83ba437f70018e7d.js`);
205+
});
206+
});

0 commit comments

Comments
 (0)