Prerender: two routes resolving to the same output file are neither deduped nor written atomically
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 11.2k
- Forks
- 899
- Avg merge
- 2d 24m
- Merged PRs (30d)
- 40
Description
Environment
- Nitro: 2.13.4
- Node: v24.11.1
- OS: Linux 6.8.0-136-generic, 16 cores
- Package manager: pnpm 10.30.0
Relevant config (full file in the reproduction):
preset: 'static',
prerender: {
crawlLinks: false,
routes: ['/other/index.html', '/other'],
concurrency: cpus().length * 4, // Nitro's default is 1; see below
},
Reproduction
https://github.com/Eschricht/nitro-prerender-file-collision
pnpm install
node repro.mjs
nitropack is the only dependency — no framework is involved. (Noting this because
the template steers framework-related reports to nuxt/nuxt. There is a Nuxt-side
aspect, covered at the end, but the two defects below reproduce with Nitro alone.)
Describe the bug
Two prerender routes can resolve to the same output file. Nitro does not detect
this — it renders both and writes both to that one path:
| route | resolved fileName |
render size |
|---|---|---|
/other/index.html |
other/index.html |
300 bytes |
/other |
other/index.html |
256 bytes |
/other resolves that way via autoSubfolderIndex, which defaults to true:
const htmlPath = route.endsWith("/") || nitro.options.prerender.autoSubfolderIndex
? joinURL(route, "index.html")
: route + ".html";
/other/index.html ends with .html, so it is used verbatim. Both land on
other/index.html.
Deduplication is keyed on the route string, never on the resolved fileName
(src/prerender/prerender.ts):
const generatedRoutes = new Set();
if (generatedRoutes.has(route) || skippedRoutes.has(route)) return false;
generatedRoutes.add(route);
...
await writeFile(filePath, dataBuff!);
This produces two distinct problems.
1. One render is silently discarded, and which one is arbitrary
Both routes are rendered and both are logged as prerendered successfully. Only one
survives in the output, and which one depends on completion order, so the file's
content varies between otherwise identical builds. This is present at every
concurrency, including Nitro's default of 1.
2. Above concurrency 1, the file can tear
fs.writeFile truncates at open, not at write. Both writers open the path
(each truncating), then each writes at offset 0. When the shorter write lands last,
the file keeps the longer writer's length and the longer writer's trailing bytes
survive:
<!DOCTYPE html><html><head><link rel="canonical" href="https://example.com/other"><meta property="og:url" content="https://example.com/other"></head><body><h1>/other</h1><script type="application/json" id="__DATA__">{"path":"/other"}</script></body></html>:"/other/index.html"}</script></body></html>
That is the complete 256-byte /other render, followed by bytes 256..300 of the
300-byte /other/index.html render. The leak begins mid-JSON-string because it is
a byte offset, not a token boundary. A browser renders those 44 bytes as visible
text on the page.
Measured 3 of 14 builds torn in the reproduction. The build exits 0 and nothing
warns, in either case.
Additional context
The write mechanism, independent of Nitro
const a = await fsp.open('f', 'w') // O_TRUNC at open
const b = await fsp.open('f', 'w') // O_TRUNC at open
await a.write(long, 0, long.length, 0) // 300 bytes
await b.write(short, 0, short.length, 0) // 256 bytes at offset 0
// -> 300-byte file: short content + 44 stale bytes of long content
Suggested fixes
- Dedup prerender routes by resolved output
fileName. At minimum, warn when two
routes target one file — that alone would make this self-diagnosing. - Write via temp file +
rename. Overlapping writers then produce last-one-wins
rather than a torn file. Worth doing independently of the dedup change, since it
removes the corruption for any future path that reaches the same state.
Reproduction scaffolding, disclosed
Three knobs in the repro exist only to make an intermittent race observable in few
builds; none causes the bug, and the README documents each. Briefly: route order
(the longer render must be issued first for the shorter write to land last), a
rendezvous barrier (the two colliding renders must reach writeFile in the same
event-loop tick — real handlers do heavy variable work so their completions cluster
naturally, whereas this trivial handler's order otherwise collapses to route
insertion order), and filler routes for threadpool load.
For what it's worth, the same bug needed none of that scaffolding in a real app —
see below.
Where this bites in practice
Frameworks generate the colliding pair without the user asking. Nuxt adds a
prerender route /index.html whenever ssr: false, on top of the page route /
— both resolve to index.html — and raises prerender.concurrency from Nitro's
1 to cpus().length * 4.
In an ordinary Nuxt SPA, with no scaffolding of any kind, this tore roughly 1
build in 10, silently, and shipped to a production static deploy. The leaked bytes
were a fragment of Nuxt's __NUXT_DATA__ script cut mid-attribute, which the
browser rendered as visible text. SPA hosting that rewrites unmatched paths to
/index.html then shows it on every route lacking its own static file, which
presents as "random pages" rather than "one corrupt file" — it took a while to
recognise as a build problem at all.
There may be a Nuxt-side question too — whether /index.html should be enqueued at
all when / is already prerendered — but it seems secondary to me: if Nitro dedups
by resolved output path, the extra route becomes harmless duplicate work rather than
a source of corruption. The concurrency override itself is noted as an undocumented
divergence from Nitro's defaults in nuxt/nuxt#30067, though neither that issue nor
any other I could find covers this collision. Happy to file separately on
nuxt/nuxt if you'd prefer it handled there.
Logs
# both routes are prerendered, both reported successful, both write other/index.html
[nitro] ℹ Prerendering 42 routes
[nitro] ├─ /other/index.html (5ms)
[nitro] ├─ /other (5ms)
[nitro] ℹ Prerendered 42 routes in 0.296 seconds
[nitro] ✔ Generated public .output/public
# repro.mjs inspecting .output/public/other/index.html across builds
run 1: 300 bytes, winner /other/index.html 1 __DATA__ tag(s), 0 trailing byte(s)
run 2: 256 bytes, winner /other 1 __DATA__ tag(s), 0 trailing byte(s) # same input, different output
run 3: 300 bytes, winner /other 1 __DATA__ tag(s), 44 trailing byte(s) <-- TORN
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the reproduction with pnpm install and node repro.mjs, then inspect src/prerender/prerender.ts, where route deduplication is keyed by the route string before the output path is written. Trace the prerender write path and verify behavior for /other/index.html and /other. Done means colliding routes are handled deterministically and concurrent writes cannot leave a torn output file.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- backend, build-system
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100