Skip to content

Commit 10d6bfb

Browse files
authored
Support partial manifests in build output (#15388)
1 parent 98a5c20 commit 10d6bfb

11 files changed

Lines changed: 310 additions & 16 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@cloudflare/build-output-utils": minor
3+
"@cloudflare/config": minor
4+
---
5+
6+
Support partial manifests in the experimental Build Output Specification
7+
8+
Build output producers now declare whether their module inventory is complete. `readBuildOutput()` resolves partial manifests by discovering `.js`, `.mjs`, and `.map` files while preserving explicit module type overrides.

‎packages/build-output-utils/src/__tests__/read.test.ts‎

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { readBuildOutput } from "../read";
1414
import { writeSettingsConfig, writeWorkerConfig } from "../write";
1515
import type { ParsedOutputWorkerConfig } from "@cloudflare/config";
1616

17-
const manifest: ParsedOutputWorkerConfig["manifest"] = {
17+
const completeManifest: ParsedOutputWorkerConfig["manifest"] = {
18+
type: "complete",
1819
mainModule: "index.js",
1920
modules: { "index.js": { type: "esm" } },
2021
};
@@ -34,6 +35,18 @@ function inputWorkerConfig(name: string) {
3435
});
3536
}
3637

38+
async function writeBundleFiles(
39+
root: string,
40+
files: Record<string, string>
41+
): Promise<void> {
42+
const bundleDir = getWorkerBundleDir(root);
43+
for (const [fileName, contents] of Object.entries(files)) {
44+
const filePath = path.join(bundleDir, fileName);
45+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
46+
await fsp.writeFile(filePath, contents);
47+
}
48+
}
49+
3750
/**
3851
* Seed a Worker into the Build Output Specification tree, optionally creating
3952
* the `bundle/` and `assets/` directories on disk.
@@ -62,7 +75,7 @@ async function seedWorker(
6275
await writeWorkerConfig({
6376
root,
6477
config: inputWorkerConfig(name),
65-
manifest: hasBundle ? manifest : undefined,
78+
manifest: hasBundle ? completeManifest : undefined,
6679
workerDirectoryName,
6780
});
6881
if (bundleDir) {
@@ -95,7 +108,7 @@ describe("readBuildOutput", () => {
95108
expect(output.workers.default.assetsDir).toBeUndefined();
96109

97110
expect(output.workers.default.config.name).toBe("my-worker");
98-
expect(output.workers.default.config.manifest).toEqual(manifest);
111+
expect(output.workers.default.config.manifest).toEqual(completeManifest);
99112
expect(output.workers.default.config).not.toHaveProperty("entrypoint");
100113
});
101114

@@ -116,6 +129,103 @@ describe("readBuildOutput", () => {
116129
);
117130
});
118131

132+
it("resolves a partial manifest from bundle files and explicit overrides", async ({
133+
expect,
134+
}) => {
135+
const root = process.cwd();
136+
await seedWorker(root);
137+
await writeWorkerConfig({
138+
root,
139+
config: inputWorkerConfig("my-worker"),
140+
manifest: {
141+
type: "partial",
142+
mainModule: "index.js",
143+
modules: {
144+
"chunks/worker.mjs": { type: "cjs" },
145+
"data.txt": { type: "text" },
146+
},
147+
},
148+
});
149+
await writeBundleFiles(root, {
150+
"index.js": "module.exports = {};",
151+
"chunks/worker.mjs": "export default {};",
152+
"chunks/worker.mjs.map": "{}",
153+
"data.txt": "data",
154+
"ignored.json": "{}",
155+
});
156+
157+
const { manifest: resolvedManifest } = (await readBuildOutput(root)).workers
158+
.default.config;
159+
160+
expect(resolvedManifest).toEqual({
161+
type: "complete",
162+
mainModule: "index.js",
163+
modules: {
164+
"chunks/worker.mjs": { type: "cjs" },
165+
"chunks/worker.mjs.map": { type: "sourcemap" },
166+
"data.txt": { type: "text" },
167+
"index.js": { type: "esm" },
168+
},
169+
});
170+
});
171+
172+
it("does not scan bundle files for a complete manifest", async ({
173+
expect,
174+
}) => {
175+
const root = process.cwd();
176+
await seedWorker(root);
177+
await writeBundleFiles(root, {
178+
"index.js": "export default {};",
179+
"unlisted.js": "export default {};",
180+
});
181+
182+
const { manifest: resolvedManifest } = (await readBuildOutput(root)).workers
183+
.default.config;
184+
185+
expect(resolvedManifest).toEqual(completeManifest);
186+
});
187+
188+
it("throws when a partial manifest's main module cannot be resolved", async ({
189+
expect,
190+
}) => {
191+
const root = process.cwd();
192+
await seedWorker(root);
193+
await writeWorkerConfig({
194+
root,
195+
config: inputWorkerConfig("my-worker"),
196+
manifest: {
197+
type: "partial",
198+
mainModule: "missing.js",
199+
modules: {},
200+
},
201+
});
202+
203+
await expect(readBuildOutput(root)).rejects.toThrow(
204+
/partial manifest .* has main module "missing\.js", but it was not found as an ES module/
205+
);
206+
});
207+
208+
it("throws when a partial manifest's main module is a source map", async ({
209+
expect,
210+
}) => {
211+
const root = process.cwd();
212+
await seedWorker(root);
213+
await writeWorkerConfig({
214+
root,
215+
config: inputWorkerConfig("my-worker"),
216+
manifest: {
217+
type: "partial",
218+
mainModule: "index.js.map",
219+
modules: {},
220+
},
221+
});
222+
await writeBundleFiles(root, { "index.js.map": "{}" });
223+
224+
await expect(readBuildOutput(root)).rejects.toThrow(
225+
/partial manifest .* has main module "index\.js\.map", but it was not found as an ES module/
226+
);
227+
});
228+
119229
it("resolves the assets directory when present and leaves bundle undefined for assets-only Workers", async ({
120230
expect,
121231
}) => {

‎packages/build-output-utils/src/__tests__/write.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,10 @@ describe("writeWorkerConfig", () => {
9797
}) => {
9898
const root = process.cwd();
9999
const manifest = {
100+
type: "complete",
100101
mainModule: "index.js",
101-
modules: { "index.js": { type: "esm" as const } },
102-
};
102+
modules: { "index.js": { type: "esm" } },
103+
} as const;
103104

104105
await writeWorkerConfig({ root, config: parsedWorkerConfig, manifest });
105106

‎packages/build-output-utils/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ export type {
2222
BuildOutput,
2323
BuildOutputWorker,
2424
BuildOutputWorkers,
25+
ResolvedOutputWorkerConfig,
2526
} from "./read";

‎packages/build-output-utils/src/read.ts‎

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from "node:fs";
22
import * as fsp from "node:fs/promises";
3+
import * as path from "node:path";
34
import { OutputSettingsSchema, OutputWorkerSchema } from "@cloudflare/config";
45
import { BuildOutputError } from "./errors";
56
import {
@@ -12,15 +13,33 @@ import {
1213
getWorkersDir,
1314
} from "./paths";
1415
import type {
16+
ModuleType,
1517
ParsedOutputSettingsConfig,
1618
ParsedOutputWorkerConfig,
1719
} from "@cloudflare/config";
1820

21+
type ManifestModules = NonNullable<
22+
ParsedOutputWorkerConfig["manifest"]
23+
>["modules"];
24+
25+
type CompleteManifest = Omit<
26+
NonNullable<ParsedOutputWorkerConfig["manifest"]>,
27+
"type"
28+
> & { type: "complete" };
29+
30+
/** A schema-validated Worker config whose manifest has been fully resolved. */
31+
export type ResolvedOutputWorkerConfig = Omit<
32+
ParsedOutputWorkerConfig,
33+
"manifest"
34+
> & {
35+
manifest?: CompleteManifest;
36+
};
37+
1938
interface BuildOutputWorkerBase {
2039
/** Absolute path to the Worker's `config.json`. */
2140
configPath: string;
22-
/** The parsed, schema-validated Worker config, including its `manifest`. */
23-
config: ParsedOutputWorkerConfig;
41+
/** The parsed Worker config, including its fully resolved `manifest`. */
42+
config: ResolvedOutputWorkerConfig;
2443
}
2544

2645
/**
@@ -81,7 +100,8 @@ export interface BuildOutput {
81100
*
82101
* Reads the optional top-level settings `config.json`, then reads and
83102
* schema-validates the Worker's `config.json` and resolves its
84-
* `bundle/` / `assets/` directories.
103+
* `bundle/` / `assets/` directories. Partial manifests are resolved into
104+
* complete manifests using the files in `bundle/`.
85105
*
86106
* @throws {BuildOutputError} if the top-level `config.json` is invalid, or if
87107
* the Worker config is missing, is not valid JSON, or fails schema validation.
@@ -155,18 +175,20 @@ async function readWorker(
155175
}
156176

157177
if (hasBundleDir) {
178+
const config = await resolveManifest(result.data, configPath, bundleDir);
158179
return {
159180
configPath,
160-
config: result.data,
181+
config,
161182
bundleDir,
162183
assetsDir: hasAssetsDir ? assetsDir : undefined,
163184
};
164185
}
165186

166187
if (hasAssetsDir) {
188+
const { manifest: _manifest, ...config } = result.data;
167189
return {
168190
configPath,
169-
config: result.data,
191+
config,
170192
bundleDir: undefined,
171193
assetsDir,
172194
};
@@ -177,6 +199,78 @@ async function readWorker(
177199
);
178200
}
179201

202+
/** Resolve the manifest against the bundle contents. */
203+
async function resolveManifest(
204+
config: ParsedOutputWorkerConfig,
205+
configPath: string,
206+
bundleDir: string
207+
): Promise<ResolvedOutputWorkerConfig> {
208+
const { manifest, ...workerConfig } = config;
209+
if (manifest === undefined) {
210+
return workerConfig;
211+
}
212+
if (manifest.type === "complete") {
213+
return {
214+
...workerConfig,
215+
manifest: { ...manifest, type: "complete" },
216+
};
217+
}
218+
219+
const inferredModules = await scanModules(bundleDir);
220+
if (inferredModules[manifest.mainModule]?.type !== "esm") {
221+
throw new BuildOutputError(
222+
`partial manifest at ${configPath} has main module "${manifest.mainModule}", but it was not found as an ES module in the bundle.`
223+
);
224+
}
225+
226+
const modules = {
227+
...inferredModules,
228+
...manifest.modules,
229+
};
230+
231+
return {
232+
...workerConfig,
233+
manifest: {
234+
type: "complete",
235+
mainModule: manifest.mainModule,
236+
modules,
237+
},
238+
};
239+
}
240+
241+
/** Infer JavaScript modules and source maps from a bundle directory. */
242+
async function scanModules(bundleDir: string): Promise<ManifestModules> {
243+
const modules: ManifestModules = {};
244+
const entries = await fsp.readdir(bundleDir, {
245+
recursive: true,
246+
withFileTypes: true,
247+
});
248+
249+
for (const entry of entries) {
250+
const type = entry.isFile() ? inferModuleType(entry.name) : undefined;
251+
if (type !== undefined) {
252+
const modulePath = path
253+
.relative(bundleDir, path.join(entry.parentPath, entry.name))
254+
.split(path.sep)
255+
.join("/");
256+
modules[modulePath] = { type };
257+
}
258+
}
259+
260+
return modules;
261+
}
262+
263+
/** Infer the module type for extensions supported by partial manifests. */
264+
function inferModuleType(modulePath: string): ModuleType | undefined {
265+
switch (path.extname(modulePath)) {
266+
case ".js":
267+
case ".mjs":
268+
return "esm";
269+
case ".map":
270+
return "sourcemap";
271+
}
272+
}
273+
180274
/**
181275
* Read and schema-validate the optional top-level `config.json` holding the
182276
* project-level settings shared by every Worker, including the mode the build

0 commit comments

Comments
 (0)