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 0dcc69d1b8..b8a1909d01 100644 --- a/src/build/config.ts +++ b/src/build/config.ts @@ -1,6 +1,7 @@ import type { Nitro, NitroImportMeta } from "nitro/types"; import { pkgDir } from "nitro/meta"; import { pathRegExp, toPathRegExp } from "../utils/regex.ts"; +import { BUILD_EXTENSIONS } from "./_extensions.ts"; import { resolveBuildEnv } from "./env.ts"; import { isUnenvId } from "./plugins/unenv.ts"; @@ -9,8 +10,7 @@ export type BaseBuildConfig = Awaited>; const ROOT_ALIAS = "@"; export async 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 86b9951702..e5f00feeec 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 { 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 "./_extensions.ts"; const NITRO_WELLKNOWN_DIR = "node_modules/.nitro"; @@ -59,7 +61,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(nitro, output); const buildInfoPath = resolve(nitro.options.output.dir, "nitro.json"); const buildInfo: NitroBuildInfo = { @@ -117,3 +119,28 @@ export async function writeDevBuildInfo(nitro: Nitro, addr?: WorkerAddress): Pro }; await writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2)); } + +function resolveNitroServerEntry( + nitro: Nitro, + output: RolldownOutput | RollupOutput | undefined +): string | undefined { + 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.` + ); + } + return (match || entries[0]).fileName; +} diff --git a/test/unit/build-info.test.ts b/test/unit/build-info.test.ts new file mode 100644 index 0000000000..55e5613231 --- /dev/null +++ b/test/unit/build-info.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +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, vi } from "vitest"; +import { writeBuildInfo } from "../../src/build/info.ts"; + +describe("writeBuildInfo", () => { + let rootDir: string; + let warn: ReturnType; + + beforeEach(async () => { + 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() { + return { + logger: { warn }, + options: { + rootDir, + entry: join(rootDir, "server"), + output: { + dir: join(rootDir, ".output"), + serverDir: join(rootDir, ".output/server"), + publicDir: join(rootDir, ".output/public"), + }, + commands: {}, + }, + } as unknown as Nitro; + } + + async function buildInfoFor(entries: { fileName: string; facadeModuleId: string | null }[]) { + const output = { + output: entries.map((entry) => ({ type: "chunk", isEntry: true, ...entry })), + } as unknown as RollupOutput; + + 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; + } + + 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"); + expect(warn).not.toHaveBeenCalled(); + }); + + 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("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 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", + }); + 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(), output); + expect(info.serverEntry).toBe("server/server.mjs"); + }); + + it("distinguishes entry files with the same path stem", async () => { + const build = await rolldown({ + 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].mjs", + }); + await build.close(); + + const entries = output.output.flatMap((item) => + item.type === "chunk" && item.isEntry ? [item] : [] + ); + expect(entries.map((item) => resolve(item.facadeModuleId!))).toEqual([ + join(rootDir, "server.js"), + join(rootDir, "server.ts"), + ]); + + const info = await writeBuildInfo(createNitro(), output); + expect(info.serverEntry).toBe("server/b.mjs"); + }); +});