Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/frontendBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
assertFrontendBundleBudgets,
measureFrontendBundle,
writeFrontendHtmlAppEntrypoint,
writePrecompressedFrontendAssets,
} from "./frontendBuildArtifacts";
import reactCompilerPlugin from "./reactCompilerPlugin";
Expand Down Expand Up @@ -83,6 +84,7 @@ export async function buildFrontend({
if (!result.metafile) {
throw new Error("Frontend build did not produce bundle metadata");
}
await writeFrontendHtmlAppEntrypoint(result.metafile, resolvedOutdir);

await writeFile(
path.join(resolvedOutdir, "build-identity.json"),
Expand Down
81 changes: 74 additions & 7 deletions scripts/frontendBuildArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const COMPRESSIBLE_EXTENSIONS = new Set([
".xml",
]);
const MINIMUM_COMPRESSION_BYTES = 512;
const FRONTEND_APP_INPUT = "src/main.tsx";
const SCRIPT_TAG_PATTERN = /<script\b[^>]*>[\s\S]*?<\/script(?:\s+[^>]*)?>/giu;
const SCRIPT_SOURCE_ATTRIBUTE_PATTERN = /\bsrc=(["'])([^"']+)\1/iu;
const MODULE_SCRIPT_TYPE_PATTERN = /\btype=(["'])module\1/iu;

export interface FrontendBundleMeasurements {
initialJavaScriptGzipBytes: number;
Expand Down Expand Up @@ -76,10 +80,75 @@ function resolvedOutput(outdir: string, outputKey: string) {
};
}

function isIndexEntryPoint(entryPoint?: string): boolean {
if (!entryPoint) return false;
const normalized = entryPoint.replaceAll("\\", "/").replace(/^\.\//u, "");
return normalized === "index.html" || normalized.endsWith("/index.html");
function isFrontendAppInput(inputKey: string): boolean {
const normalized = normalizedOutputKey(inputKey);
return (
normalized === FRONTEND_APP_INPUT || normalized.endsWith(`/${FRONTEND_APP_INPUT}`)
);
}

/** Resolves the single JavaScript output that owns the application bootstrap. */
export function frontendAppOutputKey(metafile: Bun.BuildMetafile): string {
const candidates = Object.entries(metafile.outputs)
.filter(
([outputKey, output]) =>
path.extname(outputKey) === ".js" &&
Object.keys(output.inputs).some((inputKey) =>
isFrontendAppInput(inputKey)
)
)
.map(([outputKey]) => outputKey);
if (candidates.length !== 1) {
throw new Error(
`Frontend build metadata must contain exactly one ${FRONTEND_APP_INPUT} output; found ${candidates.length}`
);
}
return candidates[0]!;
}

/**
* Works around Bun selecting an unrelated split chunk for the generated HTML
* module script when metafile output is enabled.
*/
export async function writeFrontendHtmlAppEntrypoint(
metafile: Bun.BuildMetafile,
outdir: string
): Promise<string> {
const appOutput = resolvedOutput(outdir, frontendAppOutputKey(metafile));
const publicPath = `/${appOutput.relativePath}`;
const indexPath = path.join(path.resolve(outdir), "index.html");
const html = await readFile(indexPath, "utf8");
const moduleScripts = html
.matchAll(SCRIPT_TAG_PATTERN)
.filter(
([script]) =>
MODULE_SCRIPT_TYPE_PATTERN.test(script) &&
SCRIPT_SOURCE_ATTRIBUTE_PATTERN.test(script)
)
.toArray();
if (moduleScripts.length !== 1) {
throw new Error(
`Frontend index must contain exactly one module script with a source; found ${moduleScripts.length}`
);
}
const [script] = moduleScripts[0]!;
const source = script.match(SCRIPT_SOURCE_ATTRIBUTE_PATTERN)?.[2];
if (!source) {
throw new Error("Frontend index module script has no source");
}
if (source !== publicPath) {
const correctedScript = script.replace(
SCRIPT_SOURCE_ATTRIBUTE_PATTERN,
() => `src="${publicPath}"`
);
const scriptIndex = moduleScripts[0]!.index;
const correctedHtml =
html.slice(0, scriptIndex) +
correctedScript +
html.slice(scriptIndex + script.length);
await writeFile(indexPath, correctedHtml);
}
return publicPath;
}

/**
Expand All @@ -98,9 +167,7 @@ export function initialFrontendOutputKeys(metafile: Bun.BuildMetafile): Set<stri
Object.hasOwn(outputs, candidate)
? candidate
: keyByNormalizedPath.get(normalizedOutputKey(candidate));
const pending = Object.entries(outputs)
.filter(([, output]) => isIndexEntryPoint(output.entryPoint))
.map(([outputKey]) => outputKey);
const pending = [frontendAppOutputKey(metafile)];
const initialOutputKeys = new Set<string>();

while (pending.length > 0) {
Expand Down
81 changes: 69 additions & 12 deletions src/test/frontendBuildArtifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { afterEach, describe, expect, it } from "bun:test";
import {
assertFrontendBundleBudgets,
FRONTEND_BUNDLE_BUDGETS,
frontendAppOutputKey,
initialFrontendOutputKeys,
measureFrontendBundle,
writeFrontendHtmlAppEntrypoint,
writePrecompressedFrontendAssets,
} from "../../scripts/frontendBuildArtifacts";

Expand Down Expand Up @@ -50,7 +52,7 @@ describe("frontend build artifacts", () => {
"./assets/entry.js": {
bytes: entryContents.length,
cssBundle: "./assets/styles.css",
entryPoint: "index.html",
entryPoint: "src/main.tsx",
exports: [],
imports: [
{
Expand All @@ -62,7 +64,11 @@ describe("frontend build artifacts", () => {
path: "./assets/lazy.js",
},
],
inputs: {},
inputs: {
"src/main.tsx": {
bytesInOutput: entryContents.length,
},
},
},
"./assets/lazy.js": {
bytes: lazyContents.length,
Expand Down Expand Up @@ -93,12 +99,7 @@ describe("frontend build artifacts", () => {
} satisfies Bun.BuildMetafile;

expect(initialFrontendOutputKeys(metafile)).toEqual(
new Set([
"./assets/entry.js",
"./assets/shared.js",
"./assets/styles.css",
"./index.html",
])
new Set(["./assets/entry.js", "./assets/shared.js", "./assets/styles.css"])
);

const metrics = await measureFrontendBundle(metafile, outdir);
Expand Down Expand Up @@ -146,10 +147,14 @@ describe("frontend build artifacts", () => {
outputs: {
[outputKey]: {
bytes: entryContents.length,
entryPoint: "index.html",
entryPoint: "src/main.tsx",
exports: [],
imports: [],
inputs: {},
inputs: {
"src/main.tsx": {
bytesInOutput: entryContents.length,
},
},
},
},
} satisfies Bun.BuildMetafile;
Expand All @@ -163,7 +168,59 @@ describe("frontend build artifacts", () => {
]);
});

it("fails closed when build metadata has no initial JavaScript graph", async () => {
it("repairs a generated HTML entrypoint that targets an unrelated chunk", async () => {
const outdir = await temporaryOutputRoot();
await Promise.all([
fs.writeFile(
path.join(outdir, "index.html"),
'<div id="root"></div><script type="module" crossorigin src="/assets/unrelated.js">void 0;</script data-generated>'
),
fs.writeFile(
path.join(outdir, "assets", "application.js"),
"document.querySelector('#root');\n"
),
fs.writeFile(
path.join(outdir, "assets", "unrelated.js"),
"export const unrelated = true;\n"
),
]);
const metafile = {
inputs: {},
outputs: {
"./assets/application.js": {
bytes: 33,
entryPoint: "src/main.tsx",
exports: [],
imports: [],
inputs: {
"src/main.tsx": {
bytesInOutput: 33,
},
},
},
"./assets/unrelated.js": {
bytes: 31,
exports: [],
imports: [],
inputs: {
"src/components/ui/Switch.tsx": {
bytesInOutput: 31,
},
},
},
},
} satisfies Bun.BuildMetafile;

expect(frontendAppOutputKey(metafile)).toBe("./assets/application.js");
await expect(writeFrontendHtmlAppEntrypoint(metafile, outdir)).resolves.toBe(
"/assets/application.js"
);
const correctedHtml = await fs.readFile(path.join(outdir, "index.html"), "utf8");
expect(correctedHtml).toContain('src="/assets/application.js"');
expect(correctedHtml).toContain("void 0;</script data-generated>");
});

it("fails closed when build metadata has no application entrypoint", async () => {
const outdir = await temporaryOutputRoot();
const metafile = {
inputs: {},
Expand All @@ -179,7 +236,7 @@ describe("frontend build artifacts", () => {
} satisfies Bun.BuildMetafile;

await expect(measureFrontendBundle(metafile, outdir)).rejects.toThrow(
"initial JavaScript graph"
"exactly one src/main.tsx output"
);
});

Expand Down