Skip to content

bake: fail bun build --app when two pages generate the same output file - #47357

Open
robobun wants to merge 11 commits into
robobun/cc3f1bce/bake-prerender-error-deliveryfrom
robobun/cc3f1bce/bake-prerender-output-collision
Open

robobun wants to merge 11 commits into
robobun/cc3f1bce/bake-prerender-error-deliveryfrom
robobun/cc3f1bce/bake-prerender-output-collision

Conversation

@robobun

@robobun robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #47356.

Problem

  • bun build --app writes two pages to one output file with no message. routes/first.ts and routes/[id].ts with { id: "first" } both write dist/first/index.html. The build exits with code 0.
  • renderRoutesForProdStatic (src/js/builtins/Bake.ts:96) calls Bun.write for each file and records no path. The route scan sees no clash: /first and /:id differ.

Fix

  • The write loop claims each output file in a Map before the page's first write. A second claim fails the build: error: Routes "routes/[id].ts" and "routes/first.ts" both generate the output file "first/index.html".
  • The same route with equal params is the same page: its file is written once.
  • Verified: test/bake/dev/production.test.ts (output files): 16 cases, and each fails without this change.
  • Self-reviewed: 21 concerns raised, 20 addressed (Notes).

Background

  • The output file of a page is its route pattern with the params filled in, plus a file name from the framework (/index.html).
  • Considered a warning where the static page wins, as in the dev server: two param routes have no order. bun build and next build (Pages Router) fail such builds.

Downsides

  • Behavior change (canary or feature flag): 68 of 102 probe builds now exit with code 1. In 4 of them the pages share one file with equal bytes.
  • Still silent: paths that differ only in case, a page file over a client chunk, .. in a param.
  • Per output file with no clash: +1.3k user instructions (JIT off), +103 to +231 B held until the build ends. Builtin text: +1300 B.
Notes

Where it came from. No user reported this. Another session found it by reading the code (the body of #47146 lists it under "Not in this PR"). The #bake Discord channel was not searched. --app needs a canary build or the feature flag.

Repro. A framework with no dependency, on a debug build of main 11c3671:

app.ts      const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" };
            export default { app: { framework: { fileSystemRouterTypes: [fsr] } } };
server.ts   export async function prerender(meta) { return { files: { "/index.html": String(meta.pageModule.default()) } }; }
            export function getParams() { return [{ id: "first" }]; }
routes/first.ts   export default () => "routes/first.ts";
routes/[id].ts    export default () => "routes/[id].ts";

bun build --app ./app.ts: exit code 0, done, no error line. In 200 runs dist/first/index.html held the param page 198 times and the static page 2 times. A file watcher shows two writes to that path: a pool thread writes the static page, then the main thread writes the param page. The same happens for two param routes ([id] with a, [...rest] with ["a"]), a param with a slash (a/b beside routes/a/b.ts), an empty param beside index.ts, an empty catch-all beside docs/index.ts, and with the built-in React framework (pages/first.tsx beside pages/[id].tsx whose getStaticPaths yields first).

With this PR each of them exits with code 1 and prints the error line. The second page is not written.

The rule.

  • The key of the Map is the string that goes to Bun.write: path.join(outBase, filled route path + file name). So every prerender write goes through the one check, for each router type, each getParams form, and each file name of the framework.
  • Another route on that file: error with both source files, in string order. Route indexes follow the order of the directory scan, and the two pages can arrive in either order.
  • The same route with other params on that file: error: Route "routes/[id].ts" generates the output file "x/index.html" more than once: for the params { id: "./x" } and { id: "x" }. The two params are in string order, because the two pages can arrive in either order. Bun.inspect formats them, so a BigInt or a cycle in the params does not hide the error.
  • One page with two file names on one path: error: Route "routes/index.ts" generates the output file "index.html" more than once.
  • The same route with equal params (Bun.deepEquals): the page is rendered again, and a file it already has is not written again. A getStaticPaths that returns a path twice builds as before. The first shape of this PR compared the filled route path and not the params. The review showed that it then kept one of two different pages without a message, and not the same one in each run with { pages }.
  • A page claims all its files before its first write. A page that clashes writes none of its files.
  • A page with no files does not fill in its route path, as on main.

Why the write loop. File names exist only after the render, so no earlier place sees every clash. The other designs that the design review weighed:

  • A claim per URL before the render. It fails builds where no file has two producers: two routes on one URL with different file names, and a page with no files.
  • A check against the dev router (FrameworkRouter::match_slow). It needs the first host function in bake, and the unsorted dynamic matcher gives a narrow route's URLs to a catch-all by hash order, which fails valid builds.
  • Collect all files, then write. It holds every rendered value until the end of the build.
  • A check in Bun.write or in the file system. dist/ is not emptied between builds, so an existing file is not a clash.

Policy.

  • bun build: Multiple files share the same output path for entry points and chunks (src/bundler/linker_context/generateChunksInParallel.rs:444, src/bundler/transpiler.rs:2757), also when the bytes are equal. It does not check copied assets (bundler: add copied assets to the duplicate output path check #46655 is open for that).
  • next build, Pages Router: detectConflictingPaths in packages/next/src/build/utils.ts fails the build. Inside one page it keeps the first entry of a repeated path and drops the rest before the render (build/static-paths/pages.ts). Here a page gets its params object, so only equal params count as the same page.
  • Astro warns and keeps the route with the higher priority (prerenderConflictBehavior: 'warn'). Bake has no priority between two param routes (router/mod.rs: "TODO: no code to sort this data structure").
  • The dev server serves the static page for /first. The static build now fails on the same tree. A failure can become a warning later without breaking a build. The reverse cannot.

Exit codes. 102 probe builds, each built 3 to 5 times on the base (#47356) and on this PR:

  • 68 go from exit code 0 to 1. 10 are two routes on one file. 58 are one route: two file names of one page on one path, or two pages with other params on one file.
  • 4 of the 58 lose nothing on the base: each page also writes one shared file with the same bytes. They fail now. The claim cannot compare the values.
  • 15 of the 58 are routes with a param name outside \w, such as [cat-item]. The fill on Bake.ts:108 reads :cat-item as :cat, so their pages share one path. On the base those builds exit 0 and lose pages. Now they fail on the clash. The fill is not changed here. bake: consolidate the open robobun dev-server, HMR runtime, production build and router fixes #39488 builds the path from route parts.
  • 9 keep exit code 0 and write a repeated page once. The base wrote it twice.
  • 25 are the same: builds with no shared file, and builds that fail for another reason.

Cost on a build with no clash. The base is #47356. Debug builds, linux x64.

  • Builtin text: 3335 B to 4635 B in release mode, 5368 B to 6656 B in debug mode.
  • Host functions: 0 added (JS2NATIVE_COUNT 425 to 425, no JSC_DEFINE_HOST_FUNCTION in src/runtime/bake/*.cpp).
  • GC cells per prerendered param page: 24 to 24 with 1 file, 33 to 28 with 2 files. Promises per page: 3 to 3 and 4 to 4. (heapStats().objectTypeCounts over 30 and over 60 pages, JIT off.) The path is filled in once per page and not once per file.
  • User instructions for 1 Map get and 1 set per output file: +1.3k with the JIT off, +0.6k with it (instructions:u, 1e6 paths of 52 characters, release 1.4.3).
  • Held until the build ends, per output file: +103 B to +157 B when getParams returns an array, +141 B to +231 B when it is a generator (the claim then keeps each params object). Heap size after a full GC at the last page, 500 to 4000 pages, 1 and 2 files per page.
  • Writes per output path, most in 20 runs: 2 to 1 in 9 fixtures with a clash or a repeat, 1 to 1 in a fixture with none.
  • Order of getParams requests, prerender calls and Bun.write calls, and the microtask turns between them: the same in the three getParams forms (100 pages, 1 and 2 files per page).
  • On a clash: 2 renders before and after, 2 page writes to 1.

Tests. Block output files, with a framework that has no dependency.

  • 15 clash cases through test.concurrent.each. Each expects the one error line, no done, exit code 1, and the files of dist. In each case the second page waits until the first page is on disk, so the files of dist are exact.
  • One case with builds that must keep working, built twice: a repeated page in each getParams form, one route path with two file names, two routes on one route path with different file names, and a page with no files. Between the builds the test changes each file in dist.
  • On a build of the base (bake: fail bun build --app when a page fails to prerender #47356) all 16 fail. With this PR all 16 pass.
  • The new block spawns 17 builds and takes 7 s to 12 s on a debug build on my machine.

Not in this PR. Each of these still exits with code 0.

  • Two paths that differ only in case, on a case-insensitive file system. The key is byte-exact, as in bun build.
  • Two paths that a link in an existing dist/ maps to one file.
  • A page file over a file that the bundler wrote to dist/ (client chunks, production/mod.rs:720).
  • .. in a param or a file name writes outside dist/.
  • A generator that changes a params object and yields the same object again. The claim keeps the object, not a copy.
  • A route pattern collision (about.ts beside about/index.ts) prints an error and still exits 0 until bake: fail bun build --app when two files match the same route pattern #47146.
  • Only the first clash of a build is reported. A failed build leaves the pages that were written before it.

Later. The rule is a rule of renderRoutesForProdStatic, the writer of bun build --app. #46018 (bun bake build) was closed "while the Bake design is reworked". A new writer needs its own rule.

Self-review. The first shape was one PR. The review found 30 concerns, 21 about this part. Changed: the split into two PRs, equal params as the test for the same page, the comment on what a clashing page writes, the JSDoc, three test gaps (a page written by the async iterable arm, a repeat with { pages } and other params, a second build over changed files), and this text. Not done: no issue was opened for the items under "Not in this PR".

After the first push. A review comment asked for the params in the error for two pages of one route (REVIEW.md: an error message echoes the rejected value). The error has them now. A second comment showed that JSON.stringify throws for a BigInt or a cycle in the params, so Bun.inspect formats them. The error for two routes is not changed: it names both source files and the output file.


no test proof · iteration 5 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bake/dev/production.test.ts

…file

`renderRoutesForProdStatic` wrote each file of a page with `Bun.write`
and kept no record of the paths. A static page and a param page that
`getParams` fills in to the same path both wrote
`dist/<path>/index.html`. The build printed `done` and exited with
code 0. One page was lost.

The write loop now claims each output file before the first write of
the page. A second claim by a page of another route, or by a page of
the same route with other params, fails the build and names the routes
and the file. A route that yields the same params again renders the
same page again, and that file is not written again.
@robobun
robobun requested a review from alii as a code owner October 29, 2026 10:54
@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:50 AM PT - Oct 30th, 2026

❌ @robobun, your commit fde3a2a has 1 failures in Build #135690 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47357

That installs a local version of the PR into your bun-47357 executable, so you can run:

bun-47357 --bun

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Stacked on #47356: merge that one first.

CI is red. The one test that failed is a test of main that this PR does not change. Two builds of this tree each failed on one test of test/aot/parallel/node-http/, with "it does not exit and print as plain Bun does":

  • Build 135690, finished: objects-closed-through-others.ts, on darwin 27 aarch64 and on darwin any x64 (shard 1 of 4). The other 3 shards of darwin any x64 passed, and so did the other 11 test lanes.
  • Build 135571: objects-closed-in-other-ways.ts on darwin any x64. Each other job passed.

Why this is not from this PR:

I will not push again to start CI. In each of the two builds, every test lane on Linux (with the ASAN lane) and on Windows passed, and so did darwin any aarch64. In build 135690 all 4 shards of darwin any x64 ran to the end. No bake test is in the failure list or the flaky list of either build, on any lane.

How I reproduced it (debug build of main 11c3671, linux x64):

  • A framework with no dependency: routes/first.ts beside routes/[id].ts, and getParams returns [{ id: "first" }]. bun build --app ./app.ts exits with code 0, prints done, and prints no error line. In 200 runs dist/first/index.html held the param page 198 times and the static page 2 times.
  • The built-in React framework: pages/first.tsx beside pages/[id].tsx whose getStaticPaths yields first. Exit code 0, and dist/first/ holds the param page.
  • The 16 cases of the new output files block fail on the base of this PR (bake: fail bun build --app when a page fails to prerender #47356). All 16 pass with this PR.

One decision is for a maintainer: the build now exits with code 1 when two pages generate the same output file. #47146 asks the same question for route pattern collisions.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/js/builtins/Bake.ts Resolved
Comment thread src/js/builtins/Bake.ts Resolved
Comment thread src/js/builtins/Bake.ts Resolved
Comment thread src/js/builtins/Bake.ts Resolved
Comment thread src/js/builtins/Bake.ts Resolved
Comment thread src/js/builtins/Bake.ts Resolved
…bobun/cc3f1bce/bake-prerender-output-collision
Comment thread src/js/builtins/Bake.ts Resolved
Comment thread src/runtime/bake/bake.d.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Comment thread src/js/builtins/Bake.ts Resolved
…bobun/cc3f1bce/bake-prerender-output-collision
…put file

The error for two pages of one route on one output file has the params of the
two pages, in string order:

  Route "routes/[id].ts" generates the output file "x/index.html" more than
  once: for the params {"id":"./x"} and {"id":"x"}.

The error for two routes sorts its two source files with `sort()`.
…bobun/cc3f1bce/bake-prerender-output-collision

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/js/builtins/Bake.ts Resolved
`JSON.stringify` throws for a BigInt and for a cycle, and the build then
failed with that TypeError and not with the clash. `Bun.inspect` with
`compact` gives one line for each value.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/bake/dev/production.test.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant