Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@
".xml",
]);
const MINIMUM_COMPRESSION_BYTES = 512;
const FRONTEND_APP_INPUT = "src/main.tsx";
const SCRIPT_TAG_PATTERN = /<script\b[^>]*><\/script(?:\s+[^>]*)?>/giu;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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 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 @@
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"></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"
);
expect(await fs.readFile(path.join(outdir, "index.html"), "utf8")).toContain(
'src="/assets/application.js"'
);
});

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