diff --git a/src/js/builtins/Bake.ts b/src/js/builtins/Bake.ts index e45639c4ef25..ebf49c941836 100644 --- a/src/js/builtins/Bake.ts +++ b/src/js/builtins/Bake.ts @@ -56,10 +56,15 @@ export async function renderRoutesForProdStatic( paramInformation, styles, }); - const { join: pathJoin } = require("node:path"); + const { join: pathJoin, relative: pathRelative } = require("node:path"); let loadedModules = new Array(allServerFiles.length); + // The page of each output file, by the path handed to `Bun.write`. A page is a route index and its params. + const outputFiles = new Map(); + const pageRoutes: number[] = []; + const pageParams: (RouteParams | null)[] = []; + async function doGenerateRoute( type: number, noClient: boolean, @@ -93,18 +98,54 @@ export async function renderRoutesForProdStatic( throw new Error(`Route ${JSON.stringify(sourceRouteFiles[i])} cannot be pre-rendered to a static page.`); } - await Promise.all( - Object.entries(files).map(([key, value]) => { - if (params != null) { - $assert(patterns[i].includes(`:`)); - const newKey = patterns[i].replace(/:(\w+)/g, (_, p1) => - typeof params[p1] === "string" ? params[p1] : params[p1].join("/"), - ); - return Bun.write(pathJoin(outBase, newKey + key), value); + const entries = Object.entries(files); + if (entries.length === 0) return; + + let routePath = patterns[i]; + if (params != null) { + $assert(routePath.includes(`:`)); + routePath = routePath.replace(/:(\w+)/g, (_, p1) => + typeof params[p1] === "string" ? params[p1] : params[p1].join("/"), + ); + } + const page = pageRoutes.length; + $arrayPush(pageRoutes, i); + $arrayPush(pageParams, params); + + // Every file of the page is claimed before the first write, so a page that clashes writes none of its files. + let kept = 0; + for (let j = 0; j < entries.length; j++) { + const entry = entries[j]; + const file = pathJoin(outBase, routePath + entry[0]); + const owner = outputFiles.$get(file); + if (owner === undefined) { + outputFiles.$set(file, page); + entry[0] = file; + entries[kept++] = entry; + continue; + } + const ownerRoute = pageRoutes[owner]; + // A route that yields the same params again renders the same page again. The file is not written again. + if (ownerRoute === i && owner !== page && Bun.deepEquals(pageParams[owner], params)) continue; + + const output = JSON.stringify(pathRelative(outBase, file)); + // The two pages can arrive in either order, and route indexes follow the order of the directory scan. + if (ownerRoute === i) { + let pages = ""; + if (owner !== page) { + const [a, b] = [pageParams[owner], params].map(p => Bun.inspect(p, { compact: true })).sort(); + pages = `: for the params ${a} and ${b}`; } - return Bun.write(pathJoin(outBase, patterns[i] + key), value); - }), - ); + throw new Error( + `Route ${JSON.stringify(sourceRouteFiles[i])} generates the output file ${output} more than once${pages}.`, + ); + } + const [a, b] = [sourceRouteFiles[ownerRoute], sourceRouteFiles[i]].sort(); + throw new Error(`Routes ${JSON.stringify(a)} and ${JSON.stringify(b)} both generate the output file ${output}.`); + } + entries.length = kept; + + await Promise.all(entries.map(entry => Bun.write(entry[0], entry[1]))); } function callRouteGenerator( diff --git a/src/runtime/bake/bake.d.ts b/src/runtime/bake/bake.d.ts index 0a0149fc9621..ea1ed2bcd536 100644 --- a/src/runtime/bake/bake.d.ts +++ b/src/runtime/bake/bake.d.ts @@ -442,6 +442,7 @@ declare module "bun" { }; interface PrerenderResult { + /** The files of this page. A static build fails if another page generates one of them too. */ files?: Record; // /** // * For dynamic builds, `partialData` will be provided to `render` to allow diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 1c14c800572c..f523c79e103e 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -896,13 +896,28 @@ export default function IndexPage() { return { errors, stdout, exitCode, signalCode: proc.signalCode, dist }; } - // A framework with no dependency. A page module returns its own files and its own params. - const app = (pages: Record) => ({ + // A framework with no dependency, with one router for each top directory of `pages`. A page module returns its + // own files and its own params. `written()` resolves when each file of `dist` is on disk. + const app = (pages: Record, dist: Record = {}) => ({ "app.ts": ` - const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }; - export default { app: { framework: { fileSystemRouterTypes: [fsr] } } }; + const fsr = root => ({ root, style: "nextjs-pages", serverEntryPoint: "./server.ts" }); + const roots = ${JSON.stringify([...new Set(Object.keys(pages).map(file => file.split("/")[0]))])}; + export default { app: { framework: { fileSystemRouterTypes: roots.map(fsr) } } }; `, "server.ts": ` + import { readFileSync } from "node:fs"; + const dist = ${JSON.stringify(dist)}; + const read = file => { + try { + return readFileSync("dist/" + file, "utf8"); + } catch {} + }; + globalThis.written = async () => { + for (let polls = 0; Object.keys(dist).some(file => read(file) !== dist[file]); polls++) { + if (polls === 2000) throw new Error("the first page is not in dist"); + await Bun.sleep(1); + } + }; export function prerender(meta) { const files = meta.pageModule.default(meta.params); return files instanceof Promise ? files.then(files => ({ files })) : { files }; @@ -1110,4 +1125,203 @@ export default function Page() { expect(dist["index.html"]).toContain(`