From 971104947bba8c2f60e6b8e8eb0ee9da7b8a43de Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Fri, 25 Sep 2026 20:41:23 +0200 Subject: [PATCH 1/3] fix(build): select Nitro server entry from multiple chunks --- src/build/info.ts | 28 +++++++++- test/fixture/build-info/exposed.ts | 1 + test/fixture/build-info/server.ts | 1 + test/unit/build-info.test.ts | 90 ++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 test/fixture/build-info/exposed.ts create mode 100644 test/fixture/build-info/server.ts create mode 100644 test/unit/build-info.test.ts diff --git a/src/build/info.ts b/src/build/info.ts index 86b9951702..17db391bf8 100644 --- a/src/build/info.ts +++ b/src/build/info.ts @@ -1,5 +1,5 @@ import type { Nitro, NitroBuildInfo, WorkerAddress } from "nitro/types"; -import { join, relative, resolve } from "pathe"; +import { extname, join, relative, resolve } from "pathe"; import { version as nitroVersion } from "nitro/meta"; import { presetsWithConfig } from "../presets/_types.gen.ts"; import { writeFile } from "../utils/fs.ts"; @@ -59,7 +59,7 @@ export async function writeBuildInfo( nitro: Nitro, output: RolldownOutput | RollupOutput | undefined ): Promise { - const serverEntryName = output?.output?.find((o) => o.type === "chunk" && o.isEntry)?.fileName; + const serverEntryName = resolveNitroServerEntry(output, { nitroEntry: nitro.options.entry }); const buildInfoPath = resolve(nitro.options.output.dir, "nitro.json"); const buildInfo: NitroBuildInfo = { @@ -117,3 +117,27 @@ export async function writeDevBuildInfo(nitro: Nitro, addr?: WorkerAddress): Pro }; await writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2)); } + +function resolveNitroServerEntry( + output: RolldownOutput | RollupOutput | undefined, + options: { nitroEntry: string } +): string | undefined { + return ( + output?.output.find( + (item) => + item.type === "chunk" && + item.isEntry && + isNitroEntry(item.facadeModuleId, options.nitroEntry) + ) ?? output?.output.find((item) => item.type === "chunk" && item.isEntry) + )?.fileName; +} + +function isNitroEntry(facadeModuleId: string | null, nitroEntry: string): boolean { + if (!facadeModuleId) { + return false; + } + const facade = resolve(facadeModuleId); + const entry = resolve(nitroEntry); + const extension = extname(facade); + return facade === entry || (!!extension && facade.slice(0, -extension.length) === entry); +} diff --git a/test/fixture/build-info/exposed.ts b/test/fixture/build-info/exposed.ts new file mode 100644 index 0000000000..4429550f5f --- /dev/null +++ b/test/fixture/build-info/exposed.ts @@ -0,0 +1 @@ +export const exposed = "module federation expose"; diff --git a/test/fixture/build-info/server.ts b/test/fixture/build-info/server.ts new file mode 100644 index 0000000000..433d7fe084 --- /dev/null +++ b/test/fixture/build-info/server.ts @@ -0,0 +1 @@ +export const server = "nitro server"; diff --git a/test/unit/build-info.test.ts b/test/unit/build-info.test.ts new file mode 100644 index 0000000000..ffe342ea44 --- /dev/null +++ b/test/unit/build-info.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { join } from "pathe"; +import { rolldown } from "rolldown"; +import type { RollupOutput } from "rollup"; +import type { Nitro } from "nitro/types"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { writeBuildInfo } from "../../src/build/info.ts"; + +describe("writeBuildInfo", () => { + let rootDir: string; + const fixtureDir = fileURLToPath(new URL("../fixture/build-info/", import.meta.url)); + + beforeEach(async () => { + rootDir = await mkdtemp(join(tmpdir(), "nitro-build-info-")); + }); + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }); + }); + + function createNitro(entry = join(rootDir, "server")) { + return { + options: { + rootDir, + entry, + output: { + dir: join(rootDir, ".output"), + serverDir: join(rootDir, ".output/server"), + publicDir: join(rootDir, ".output/public"), + }, + commands: {}, + }, + } as Nitro; + } + + async function buildInfoFor(entries: { fileName: string; facadeModuleId: string | null }[]) { + const nitro = createNitro(); + const output = { + output: entries.map((entry) => ({ type: "chunk", isEntry: true, ...entry })), + } as unknown as RollupOutput; + + const info = await writeBuildInfo(nitro, output); + const saved = JSON.parse(await readFile(join(rootDir, ".output/nitro.json"), "utf8")); + expect(saved.serverEntry).toBe(info.serverEntry); + return info; + } + + it("records Nitro's entry when another entry chunk appears first", async () => { + const info = await buildInfoFor([ + { fileName: "_chunks/app.mjs", facadeModuleId: join(rootDir, "app/app.ts") }, + { fileName: "index.mjs", facadeModuleId: join(rootDir, "server.ts") }, + ]); + expect(info.serverEntry).toBe("server/index.mjs"); + }); + + it("uses Nitro's entry even when the output name is customized", async () => { + const info = await buildInfoFor([ + { fileName: "_chunks/app.mjs", facadeModuleId: join(rootDir, "app/app.ts") }, + { fileName: "worker.mjs", facadeModuleId: join(rootDir, "server.ts") }, + ]); + expect(info.serverEntry).toBe("server/worker.mjs"); + }); + + it("keeps the original fallback when facade IDs are unavailable", async () => { + const info = await buildInfoFor([ + { fileName: "worker.mjs", facadeModuleId: null }, + { fileName: "index.mjs", facadeModuleId: null }, + ]); + expect(info.serverEntry).toBe("server/worker.mjs"); + }); + + it("selects Nitro's entry from real Rolldown output with another entry first", async () => { + const exposedEntry = join(fixtureDir, "exposed.ts"); + const nitroEntry = join(fixtureDir, "server.ts"); + const build = await rolldown({ input: [exposedEntry, nitroEntry] }); + const output = await build.write({ + dir: join(rootDir, ".output/server"), + entryFileNames: "[name].mjs", + }); + await build.close(); + + const entries = output.output.filter((item) => item.type === "chunk" && item.isEntry); + expect(entries.map((item) => item.fileName)).toEqual(["exposed.mjs", "server.mjs"]); + + const info = await writeBuildInfo(createNitro(join(fixtureDir, "server")), output); + expect(info.serverEntry).toBe("server/server.mjs"); + }); +}); From 91100e98d330f406024a1bb42da37259427331ef Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Fri, 25 Sep 2026 21:29:08 +0200 Subject: [PATCH 2/3] fix(build): disambiguate server entry extensions --- src/build/config.ts | 6 ++++-- src/build/info.ts | 7 ++++++- test/fixture/build-info/server.js | 1 + test/unit/build-info.test.ts | 24 +++++++++++++++++++++++- 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 test/fixture/build-info/server.js diff --git a/src/build/config.ts b/src/build/config.ts index ca7d2c6457..ab655b954f 100644 --- a/src/build/config.ts +++ b/src/build/config.ts @@ -7,9 +7,11 @@ export type BaseBuildConfig = ReturnType; const ROOT_ALIAS = "@"; +// prettier-ignore +export const BUILD_EXTENSIONS = [".ts", ".mjs", ".js", ".json", ".node", ".tsx", ".jsx"]; + export function baseBuildConfig(nitro: Nitro) { - // prettier-ignore - const extensions: string[] = [".ts", ".mjs", ".js", ".json", ".node", ".tsx", ".jsx" ]; + const extensions: string[] = [...BUILD_EXTENSIONS]; const isNodeless = nitro.options.node === false; diff --git a/src/build/info.ts b/src/build/info.ts index 17db391bf8..3209b0a3af 100644 --- a/src/build/info.ts +++ b/src/build/info.ts @@ -1,4 +1,5 @@ import type { Nitro, NitroBuildInfo, WorkerAddress } from "nitro/types"; +import { resolveModulePath } from "exsolve"; import { extname, join, relative, resolve } from "pathe"; import { version as nitroVersion } from "nitro/meta"; import { presetsWithConfig } from "../presets/_types.gen.ts"; @@ -7,6 +8,7 @@ import { mkdir, readFile, stat } from "node:fs/promises"; import { dirname } from "node:path"; import type { RolldownOutput } from "rolldown"; import type { RollupOutput } from "rollup"; +import { BUILD_EXTENSIONS } from "./config.ts"; const NITRO_WELLKNOWN_DIR = "node_modules/.nitro"; @@ -122,12 +124,15 @@ function resolveNitroServerEntry( output: RolldownOutput | RollupOutput | undefined, options: { nitroEntry: string } ): string | undefined { + const resolvedEntry = options.nitroEntry.startsWith("#") + ? undefined + : resolveModulePath(options.nitroEntry, { try: true, extensions: BUILD_EXTENSIONS }); return ( output?.output.find( (item) => item.type === "chunk" && item.isEntry && - isNitroEntry(item.facadeModuleId, options.nitroEntry) + isNitroEntry(item.facadeModuleId, resolvedEntry || options.nitroEntry) ) ?? output?.output.find((item) => item.type === "chunk" && item.isEntry) )?.fileName; } diff --git a/test/fixture/build-info/server.js b/test/fixture/build-info/server.js new file mode 100644 index 0000000000..a18fda228d --- /dev/null +++ b/test/fixture/build-info/server.js @@ -0,0 +1 @@ +export const server = "unrelated entry"; diff --git a/test/unit/build-info.test.ts b/test/unit/build-info.test.ts index ffe342ea44..5c66dd48f3 100644 --- a/test/unit/build-info.test.ts +++ b/test/unit/build-info.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; -import { join } from "pathe"; +import { join, resolve } from "pathe"; import { rolldown } from "rolldown"; import type { RollupOutput } from "rollup"; import type { Nitro } from "nitro/types"; @@ -87,4 +87,26 @@ describe("writeBuildInfo", () => { const info = await writeBuildInfo(createNitro(join(fixtureDir, "server")), output); expect(info.serverEntry).toBe("server/server.mjs"); }); + + it("distinguishes entry files with the same path stem", async () => { + const serverJs = join(fixtureDir, "server.js"); + const serverTs = join(fixtureDir, "server.ts"); + const build = await rolldown({ + input: [serverJs, join(fixtureDir, "server")], + resolve: { extensions: [".ts", ".js"] }, + }); + const output = await build.write({ + dir: join(rootDir, ".output/server"), + entryFileNames: "[name]-[hash].mjs", + }); + await build.close(); + + const entries = output.output.flatMap((item) => + item.type === "chunk" && item.isEntry ? [item] : [] + ); + expect(entries.map((item) => resolve(item.facadeModuleId!))).toEqual([serverJs, serverTs]); + + const info = await writeBuildInfo(createNitro(join(fixtureDir, "server")), output); + expect(info.serverEntry).toBe(`server/${entries[1].fileName}`); + }); }); From 128cc54fe45950bf624f0194a1e07213c4adaedb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 3 Oct 2026 16:57:59 +0000 Subject: [PATCH 3/3] refactor(build): simplify server entry matching - match facade ids by resolved path only, drop stem fallback - warn when no output chunk matches the nitro entry - move BUILD_EXTENSIONS to a standalone module to keep info.ts light - generate unit test fixtures in a temp dir --- src/build/_extensions.ts | 2 ++ src/build/config.ts | 4 +-- src/build/info.ts | 48 ++++++++++++++-------------- test/fixture/build-info/exposed.ts | 1 - test/fixture/build-info/server.js | 1 - test/fixture/build-info/server.ts | 1 - test/unit/build-info.test.ts | 50 +++++++++++++++++------------- 7 files changed, 54 insertions(+), 53 deletions(-) create mode 100644 src/build/_extensions.ts delete mode 100644 test/fixture/build-info/exposed.ts delete mode 100644 test/fixture/build-info/server.js delete mode 100644 test/fixture/build-info/server.ts diff --git a/src/build/_extensions.ts b/src/build/_extensions.ts new file mode 100644 index 0000000000..51ca3772d8 --- /dev/null +++ b/src/build/_extensions.ts @@ -0,0 +1,2 @@ +// prettier-ignore +export const BUILD_EXTENSIONS = [".ts", ".mjs", ".js", ".json", ".node", ".tsx", ".jsx"]; diff --git a/src/build/config.ts b/src/build/config.ts index ab655b954f..e1684cd01f 100644 --- a/src/build/config.ts +++ b/src/build/config.ts @@ -2,14 +2,12 @@ import type { Nitro, NitroImportMeta } from "nitro/types"; import { defineEnv } from "unenv"; import { pkgDir } from "nitro/meta"; import { pathRegExp, toPathRegExp } from "../utils/regex.ts"; +import { BUILD_EXTENSIONS } from "./_extensions.ts"; export type BaseBuildConfig = ReturnType; const ROOT_ALIAS = "@"; -// prettier-ignore -export const BUILD_EXTENSIONS = [".ts", ".mjs", ".js", ".json", ".node", ".tsx", ".jsx"]; - export function baseBuildConfig(nitro: Nitro) { const extensions: string[] = [...BUILD_EXTENSIONS]; diff --git a/src/build/info.ts b/src/build/info.ts index 3209b0a3af..e5f00feeec 100644 --- a/src/build/info.ts +++ b/src/build/info.ts @@ -1,6 +1,6 @@ import type { Nitro, NitroBuildInfo, WorkerAddress } from "nitro/types"; import { resolveModulePath } from "exsolve"; -import { extname, join, relative, resolve } from "pathe"; +import { join, relative, resolve } from "pathe"; import { version as nitroVersion } from "nitro/meta"; import { presetsWithConfig } from "../presets/_types.gen.ts"; import { writeFile } from "../utils/fs.ts"; @@ -8,7 +8,7 @@ import { mkdir, readFile, stat } from "node:fs/promises"; import { dirname } from "node:path"; import type { RolldownOutput } from "rolldown"; import type { RollupOutput } from "rollup"; -import { BUILD_EXTENSIONS } from "./config.ts"; +import { BUILD_EXTENSIONS } from "./_extensions.ts"; const NITRO_WELLKNOWN_DIR = "node_modules/.nitro"; @@ -61,7 +61,7 @@ export async function writeBuildInfo( nitro: Nitro, output: RolldownOutput | RollupOutput | undefined ): Promise { - const serverEntryName = resolveNitroServerEntry(output, { nitroEntry: nitro.options.entry }); + const serverEntryName = resolveNitroServerEntry(nitro, output); const buildInfoPath = resolve(nitro.options.output.dir, "nitro.json"); const buildInfo: NitroBuildInfo = { @@ -121,28 +121,26 @@ export async function writeDevBuildInfo(nitro: Nitro, addr?: WorkerAddress): Pro } function resolveNitroServerEntry( - output: RolldownOutput | RollupOutput | undefined, - options: { nitroEntry: string } + nitro: Nitro, + output: RolldownOutput | RollupOutput | undefined ): string | undefined { - const resolvedEntry = options.nitroEntry.startsWith("#") - ? undefined - : resolveModulePath(options.nitroEntry, { try: true, extensions: BUILD_EXTENSIONS }); - return ( - output?.output.find( - (item) => - item.type === "chunk" && - item.isEntry && - isNitroEntry(item.facadeModuleId, resolvedEntry || options.nitroEntry) - ) ?? output?.output.find((item) => item.type === "chunk" && item.isEntry) - )?.fileName; -} - -function isNitroEntry(facadeModuleId: string | null, nitroEntry: string): boolean { - if (!facadeModuleId) { - return false; + const entries = output?.output.filter((item) => item.type === "chunk" && item.isEntry) ?? []; + if (entries.length < 2) { + return entries[0]?.fileName; + } + const nitroEntry = resolve( + (!nitro.options.entry.startsWith("#") && + resolveModulePath(nitro.options.entry, { try: true, extensions: BUILD_EXTENSIONS })) || + nitro.options.entry + ); + const match = entries.find( + (item) => + item.type === "chunk" && !!item.facadeModuleId && resolve(item.facadeModuleId) === nitroEntry + ); + if (!match) { + nitro.logger.warn( + `Could not find the output chunk for Nitro entry \`${nitro.options.entry}\`. Using \`${entries[0].fileName}\` as server entry.` + ); } - const facade = resolve(facadeModuleId); - const entry = resolve(nitroEntry); - const extension = extname(facade); - return facade === entry || (!!extension && facade.slice(0, -extension.length) === entry); + return (match || entries[0]).fileName; } diff --git a/test/fixture/build-info/exposed.ts b/test/fixture/build-info/exposed.ts deleted file mode 100644 index 4429550f5f..0000000000 --- a/test/fixture/build-info/exposed.ts +++ /dev/null @@ -1 +0,0 @@ -export const exposed = "module federation expose"; diff --git a/test/fixture/build-info/server.js b/test/fixture/build-info/server.js deleted file mode 100644 index a18fda228d..0000000000 --- a/test/fixture/build-info/server.js +++ /dev/null @@ -1 +0,0 @@ -export const server = "unrelated entry"; diff --git a/test/fixture/build-info/server.ts b/test/fixture/build-info/server.ts deleted file mode 100644 index 433d7fe084..0000000000 --- a/test/fixture/build-info/server.ts +++ /dev/null @@ -1 +0,0 @@ -export const server = "nitro server"; diff --git a/test/unit/build-info.test.ts b/test/unit/build-info.test.ts index 5c66dd48f3..55e5613231 100644 --- a/test/unit/build-info.test.ts +++ b/test/unit/build-info.test.ts @@ -1,30 +1,34 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; import { join, resolve } from "pathe"; import { rolldown } from "rolldown"; import type { RollupOutput } from "rollup"; import type { Nitro } from "nitro/types"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { writeBuildInfo } from "../../src/build/info.ts"; describe("writeBuildInfo", () => { let rootDir: string; - const fixtureDir = fileURLToPath(new URL("../fixture/build-info/", import.meta.url)); + let warn: ReturnType; beforeEach(async () => { - rootDir = await mkdtemp(join(tmpdir(), "nitro-build-info-")); + rootDir = await realpath(await mkdtemp(join(tmpdir(), "nitro-build-info-"))); + warn = vi.fn(); + await writeFile(join(rootDir, "server.ts"), `export const server = "nitro server";\n`); + await writeFile(join(rootDir, "server.js"), `export const server = "unrelated entry";\n`); + await writeFile(join(rootDir, "exposed.ts"), `export const exposed = "exposed";\n`); }); afterEach(async () => { await rm(rootDir, { recursive: true, force: true }); }); - function createNitro(entry = join(rootDir, "server")) { + function createNitro() { return { + logger: { warn }, options: { rootDir, - entry, + entry: join(rootDir, "server"), output: { dir: join(rootDir, ".output"), serverDir: join(rootDir, ".output/server"), @@ -32,16 +36,15 @@ describe("writeBuildInfo", () => { }, commands: {}, }, - } as Nitro; + } as unknown as Nitro; } async function buildInfoFor(entries: { fileName: string; facadeModuleId: string | null }[]) { - const nitro = createNitro(); const output = { output: entries.map((entry) => ({ type: "chunk", isEntry: true, ...entry })), } as unknown as RollupOutput; - const info = await writeBuildInfo(nitro, output); + const info = await writeBuildInfo(createNitro(), output); const saved = JSON.parse(await readFile(join(rootDir, ".output/nitro.json"), "utf8")); expect(saved.serverEntry).toBe(info.serverEntry); return info; @@ -53,6 +56,7 @@ describe("writeBuildInfo", () => { { fileName: "index.mjs", facadeModuleId: join(rootDir, "server.ts") }, ]); expect(info.serverEntry).toBe("server/index.mjs"); + expect(warn).not.toHaveBeenCalled(); }); it("uses Nitro's entry even when the output name is customized", async () => { @@ -63,18 +67,19 @@ describe("writeBuildInfo", () => { expect(info.serverEntry).toBe("server/worker.mjs"); }); - it("keeps the original fallback when facade IDs are unavailable", async () => { + it("falls back to the first entry with a warning when facade IDs are unavailable", async () => { const info = await buildInfoFor([ { fileName: "worker.mjs", facadeModuleId: null }, { fileName: "index.mjs", facadeModuleId: null }, ]); expect(info.serverEntry).toBe("server/worker.mjs"); + expect(warn).toHaveBeenCalledOnce(); }); it("selects Nitro's entry from real Rolldown output with another entry first", async () => { - const exposedEntry = join(fixtureDir, "exposed.ts"); - const nitroEntry = join(fixtureDir, "server.ts"); - const build = await rolldown({ input: [exposedEntry, nitroEntry] }); + const build = await rolldown({ + input: [join(rootDir, "exposed.ts"), join(rootDir, "server.ts")], + }); const output = await build.write({ dir: join(rootDir, ".output/server"), entryFileNames: "[name].mjs", @@ -84,29 +89,30 @@ describe("writeBuildInfo", () => { const entries = output.output.filter((item) => item.type === "chunk" && item.isEntry); expect(entries.map((item) => item.fileName)).toEqual(["exposed.mjs", "server.mjs"]); - const info = await writeBuildInfo(createNitro(join(fixtureDir, "server")), output); + const info = await writeBuildInfo(createNitro(), output); expect(info.serverEntry).toBe("server/server.mjs"); }); it("distinguishes entry files with the same path stem", async () => { - const serverJs = join(fixtureDir, "server.js"); - const serverTs = join(fixtureDir, "server.ts"); const build = await rolldown({ - input: [serverJs, join(fixtureDir, "server")], + input: { a: join(rootDir, "server.js"), b: join(rootDir, "server") }, resolve: { extensions: [".ts", ".js"] }, }); const output = await build.write({ dir: join(rootDir, ".output/server"), - entryFileNames: "[name]-[hash].mjs", + entryFileNames: "[name].mjs", }); await build.close(); const entries = output.output.flatMap((item) => item.type === "chunk" && item.isEntry ? [item] : [] ); - expect(entries.map((item) => resolve(item.facadeModuleId!))).toEqual([serverJs, serverTs]); + expect(entries.map((item) => resolve(item.facadeModuleId!))).toEqual([ + join(rootDir, "server.js"), + join(rootDir, "server.ts"), + ]); - const info = await writeBuildInfo(createNitro(join(fixtureDir, "server")), output); - expect(info.serverEntry).toBe(`server/${entries[1].fileName}`); + const info = await writeBuildInfo(createNitro(), output); + expect(info.serverEntry).toBe("server/b.mjs"); }); });