diff --git a/src/prerender/prerender.ts b/src/prerender/prerender.ts index 43e6c351af..b6f8fb56ea 100644 --- a/src/prerender/prerender.ts +++ b/src/prerender/prerender.ts @@ -111,6 +111,11 @@ export async function prerender(nitro: Nitro) { const failedRoutes = new Set(); const skippedRoutes = new Set(); const displayedLengthWarns = new Set(); + // Output path -> the route that produced it. Two routes can resolve to one file + // (`/other` and `/other/index.html` both become `other/index.html`), which is only + // known once each of them has been rendered. Keyed by the resolved path rather + // than `fileName`, so aliases pointing at one file still collide. + const routeByOutputFile = new Map(); const publicAssetBases: string[] = nitro.options.publicAssets .filter( @@ -295,7 +300,18 @@ export async function prerender(nitro: Nitro) { // Write to the disk const filePath = join(nitro.options.output.publicDir, _route.fileName); - if (canWriteToDisk(_route) && filePath.startsWith(nitro.options.output.publicDir)) { + const writtenBy = routeByOutputFile.get(filePath); + if (writtenBy !== undefined) { + // Writing again would replace the first route's output with this one, so the + // build result would depend on which of the two rendered first + nitro.logger.warn( + `Routes \`${writtenBy}\` and \`${route}\` both prerender to \`${_route.fileName}\`. Keeping the output of \`${writtenBy}\`.` + ); + _route.skip = true; + } else if (canWriteToDisk(_route) && filePath.startsWith(nitro.options.output.publicDir)) { + // Claim the file before awaiting, so a concurrent render of a route resolving + // to the same file sees it as taken + routeByOutputFile.set(filePath, route); await writeFile(filePath, dataBuff!); nitro._prerenderedRoutes!.push(_route); } else { diff --git a/test/prerender/collision.test.ts b/test/prerender/collision.test.ts new file mode 100644 index 0000000000..97177f4c94 --- /dev/null +++ b/test/prerender/collision.test.ts @@ -0,0 +1,61 @@ +import { mkdir, readFile, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { join } from "pathe"; +import { build, copyPublicAssets, createNitro, prepare, prerender } from "nitro/builder"; +import { afterAll, describe, expect, it } from "vitest"; + +const fixtureDir = fileURLToPath(new URL("./fixture", import.meta.url)); +const tmpDir = fileURLToPath(new URL("./.tmp", import.meta.url)); + +describe("prerender output collision", () => { + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("keeps the first route's output and warns instead of overwriting it", async () => { + const outDir = join(tmpDir, "output"); + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + const nitro = await createNitro({ + rootDir: fixtureDir, + preset: "static", + output: { dir: outDir }, + prerender: { + crawlLinks: false, + // one at a time, so the route that claims the file is the first one listed + concurrency: 1, + // both resolve to `other/index.html` + routes: ["/other", "/other/index.html"], + }, + }); + + const warnings: string[] = []; + nitro.logger.warn = ((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }) as typeof nitro.logger.warn; + + try { + await prepare(nitro); + await copyPublicAssets(nitro); + await prerender(nitro); + await build(nitro); + } finally { + await nitro.close(); + } + + const collisionWarnings = warnings.filter((w) => w.includes("both prerender to")); + expect(collisionWarnings).toHaveLength(1); + expect(collisionWarnings[0]).toContain("/other"); + expect(collisionWarnings[0]).toContain("/other/index.html"); + + // only one of the two routes may claim the file, and it is the one rendered first + const written = nitro._prerenderedRoutes!.filter((r) => r.fileName === "/other/index.html"); + expect(written).toHaveLength(1); + expect(written[0].route).toBe("/other"); + + // and the file holds that route's render, whole + const contents = await readFile(join(outDir, "public/other/index.html"), "utf8"); + expect(contents).toBe(`rendered /other`); + }, 120_000); +}); diff --git a/test/prerender/fixture/server.ts b/test/prerender/fixture/server.ts new file mode 100644 index 0000000000..ed91620ef5 --- /dev/null +++ b/test/prerender/fixture/server.ts @@ -0,0 +1,8 @@ +export default { + fetch(req: Request) { + const { pathname } = new URL(req.url); + return new Response(`rendered ${pathname}`, { + headers: { "content-type": "text/html" }, + }); + }, +};