Skip to content
Open
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
65 changes: 53 additions & 12 deletions src/js/builtins/Bake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
const pageRoutes: number[] = [];
const pageParams: (RouteParams | null)[] = [];
Comment thread
robobun marked this conversation as resolved.
Resolved

async function doGenerateRoute(
type: number,
noClient: boolean,
Expand Down Expand Up @@ -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]);
Comment thread
robobun marked this conversation as resolved.
Resolved
const owner = outputFiles.$get(file);
Comment thread
robobun marked this conversation as resolved.
Resolved
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;
Comment thread
robobun marked this conversation as resolved.
Resolved

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}.`);
Comment thread
robobun marked this conversation as resolved.
Resolved
}
entries.length = kept;

await Promise.all(entries.map(entry => Bun.write(entry[0], entry[1])));
Comment thread
robobun marked this conversation as resolved.
Resolved
}

function callRouteGenerator(
Expand Down
1 change: 1 addition & 0 deletions src/runtime/bake/bake.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Blob | NodeJS.TypedArray | ArrayBufferLike | string | Bun.BlobPart[]>;
// /**
// * For dynamic builds, `partialData` will be provided to `render` to allow
Expand Down
222 changes: 218 additions & 4 deletions test/bake/dev/production.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => ({
// 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<string, string>, dist: Record<string, string> = {}) => ({
"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 };
Expand Down Expand Up @@ -1110,4 +1125,203 @@ export default function Page() {
expect(dist["index.html"]).toContain(`<script type="module"`);
});
});

// An output file is a route pattern with its params filled in, plus a file name from the framework.
// So two pages can meet on one file only when they are rendered. The route scan cannot see it.
describe("output files", () => {
const params = shapes["a sync iterable"];
const html = (text: string) => `{ "/index.html": ${JSON.stringify(text)} }`;
// The page made with `second` asks for its files when the page made with `first` is on disk.
const first = (files: string) => `export default () => (${files});`;
const second = (files: string) => `export default async () => (await written(), ${files});`;

const quote = (file: string) => JSON.stringify(platformPath(file));
const clash = (a: string, b: string, file: string) => {
const [x, y] = [platformPath(a), platformPath(b)].sort();
return `error: Routes ${quote(x)} and ${quote(y)} both generate the output file ${quote(file)}.`;
};
// `pages` has the params of the two pages of the route that generate the file, in string order.
const repeat = (route: string, file: string, ...pages: [string, string] | []) =>
`error: Route ${quote(route)} generates the output file ${quote(file)} more than once` +
(pages.length ? `: for the params ${pages[0]} and ${pages[1]}.` : ".");

// Each case: the pages, the error, and what is in dist after the build. Only the first page is written.
type Clash = [name: string, pages: Record<string, string>, error: string, dist: Record<string, string>];
const clashes: Clash[] = [
...Object.entries(shapes).map(([shape, getParams]): Clash => [
`a static page, then a param page (${shape})`,
{
"routes/first.ts": first(html("static")),
"routes/[id].ts": second(html("param")) + getParams([{ id: "first" }]),
},
clash("routes/first.ts", "routes/[id].ts", "first/index.html"),
{ "first/index.html": "static" },
]),
[
"a param page, then a static page",
{
"routes/first.ts": second(html("static")),
"routes/[id].ts": first(html("param")) + params([{ id: "first" }]),
},
clash("routes/first.ts", "routes/[id].ts", "first/index.html"),
{ "first/index.html": "param" },
],
[
"two param routes",
{
"routes/[id].ts": first(html("id")) + params([{ id: "a" }]),
"routes/[...rest].ts": second(html("rest")) + params([{ rest: ["a"] }]),
},
clash("routes/[id].ts", "routes/[...rest].ts", "a/index.html"),
{ "a/index.html": "id" },
],
[
"a param with a slash",
{
"routes/a/b.ts": first(html("static")),
"routes/[id].ts": second(html("param")) + params([{ id: "a/b" }]),
},
clash("routes/a/b.ts", "routes/[id].ts", "a/b/index.html"),
{ "a/b/index.html": "static" },
],
[
"an empty param beside the index page",
{
"routes/index.ts": first(html("static")),
"routes/[id].ts": second(html("param")) + params([{ id: "" }]),
},
clash("routes/index.ts", "routes/[id].ts", "index.html"),
{ "index.html": "static" },
],
[
"an empty catch-all beside an index page",
{
"routes/docs/index.ts": first(html("static")),
"routes/docs/[...path].ts": second(html("param")) + params([{ path: [] }]),
},
clash("routes/docs/index.ts", "routes/docs/[...path].ts", "docs/index.html"),
{ "docs/index.html": "static" },
],
[
"pages of two routers",
{
"routes/first.ts": first(html("static")),
"more/[id].ts": second(html("param")) + params([{ id: "first" }]),
},
clash("routes/first.ts", "more/[id].ts", "first/index.html"),
{ "first/index.html": "static" },
],
[
"two static pages through their file names",
{
"routes/a/b.ts": first(html("a/b")),
"routes/a.ts": second(`{ "/b/index.html": "a" }`),
},
clash("routes/a/b.ts", "routes/a.ts", "a/b/index.html"),
{ "a/b/index.html": "a/b" },
],
[
"a page whose second file clashes: none of its files is written",
{
"routes/a.ts": first(html("a")),
"routes/b.ts": second(`{ "/index.html": "b", "/../a/index.html": "b" }`),
},
clash("routes/a.ts", "routes/b.ts", "a/index.html"),
{ "a/index.html": "a" },
],
[
"two pages of one route",
{ "routes/[id].ts": first(html("page")) + params([{ id: "x" }, { id: "./x" }]) },
repeat("routes/[id].ts", "x/index.html", `{ id: "./x" }`, `{ id: "x" }`),
{ "x/index.html": "page" },
],
[
"two pages of one route that differ in a param the path does not have",
{
"routes/[id].ts":
`export default async params => (params.lang === "fr" && (await written()), { "/index.html": params.lang });` +
shapes["a pages object"]([
{ id: "x", lang: "en" },
{ id: "x", lang: "fr" },
]),
},
repeat("routes/[id].ts", "x/index.html", `{ id: "x", lang: "en" }`, `{ id: "x", lang: "fr" }`),
{ "x/index.html": "en" },
],
[
"two pages of one route that differ in a param that JSON cannot hold",
{
"routes/[id].ts":
first(html("page")) + `export const getParams = () => [{ id: "x", n: 1n }, { id: "x", n: 2n }];`,
},
repeat("routes/[id].ts", "x/index.html", `{ id: "x", n: 1n }`, `{ id: "x", n: 2n }`),
{ "x/index.html": "page" },
],
[
"two file names of one page",
{ "routes/index.ts": first(`{ "/index.html": "one", "//index.html": "two" }`) },
repeat("routes/index.ts", "index.html"),
{},
],
];

test.concurrent.each(clashes)("fail the build: %s", async (_, pages, error, dist) => {
using dir = tempDir("bake-production-output-clash", app(pages, dist));
expect(await build(String(dir))).toEqual({ errors: [error], stdout: "", exitCode: 1, signalCode: null, dist });
});

Comment thread
robobun marked this conversation as resolved.
Resolved
test.concurrent("pages with their own files are all written, also into an existing dist", async () => {
// A route that yields the same params again renders the page again, and no file is written twice.
const again = `let renders = 0; export default () => ({ "/index.html": "render " + ++renders, ["/render-" + renders + ".txt"]: "" });`;
using dir = tempDir(
"bake-production-output-files",
app({
"routes/twice/[id].ts": again + shapes["a sync iterable"]([{ id: "x" }, { id: "x" }]),
"routes/again/[id].ts": again + shapes["a pages object"]([{ id: "x" }, { id: "x" }]),
"routes/stream/[id].ts":
`let renders = 0; export default params => ({ "/index.html": params.id + " render " + ++renders });` +
shapes["an async iterable"]([{ id: "a" }, { id: "b" }, { id: "a" }]),
// One route path with two file names.
"routes/names/[id].ts":
`export default params => ({ ["/" + params.name + ".html"]: params.name });` +
params([
{ id: "x", name: "a" },
{ id: "x", name: "b" },
]),
// Two routes with one route path and different file names. A page with no files does not need a route path.
"routes/first.ts": first(html("static")),
"routes/[id].ts":
`export default params => (params.id === "first" ? { "/data.json": "param" } : {});` +
params([{ id: "first" }, { id: 1 }]),
}),
);

const built = {
errors: [],
stdout: "done\n",
exitCode: 0,
signalCode: null,
dist: {
"again/x/index.html": "render 1",
"again/x/render-1.txt": "",
"again/x/render-2.txt": "",
"first/data.json": "param",
"first/index.html": "static",
"names/x/a.html": "a",
"names/x/b.html": "b",
"stream/a/index.html": "a render 1",
"stream/b/index.html": "b render 2",
"twice/x/index.html": "render 1",
"twice/x/render-1.txt": "",
"twice/x/render-2.txt": "",
},
};
expect(await build(String(dir))).toEqual(built);
// The second build writes each page again: the bytes of an earlier build do not stay.
for (const [file, text] of Object.entries(built.dist)) {
await Bun.write(path.join(String(dir), "dist", file), text + " of an earlier build");
}
expect(await build(String(dir))).toEqual(built);
});
});
});
Loading