nitrojs / nitrojs/nitro

Prerender: two routes resolving to the same output file are neither deduped nor written atomically

Open
#4,487 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

v2
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.