diff --git a/src/build/plugins.ts b/src/build/plugins.ts index ace6f18f4d..3140b83c0e 100644 --- a/src/build/plugins.ts +++ b/src/build/plugins.ts @@ -11,6 +11,7 @@ import { virtual, virtualDeps } from "./plugins/virtual.ts"; import { sourcemapMinify } from "./plugins/sourcemap-min.ts"; import { raw } from "./plugins/raw.ts"; import { externals } from "./plugins/externals.ts"; +import { userCodeExternal } from "./plugins/user-code-external.ts"; export async function baseBuildPlugins(nitro: Nitro, base: BaseBuildConfig) { const plugins: Plugin[] = []; @@ -20,6 +21,10 @@ export async function baseBuildPlugins(nitro: Nitro, base: BaseBuildConfig) { nitro.vfs = virtualPlugin.api.modules; plugins.push(virtualPlugin, virtualDeps()); + if (nitro.options.builderless) { + plugins.push(userCodeExternal(nitro)); + } + // Auto imports if (nitro.options.imports) { const unimportPlugin = await import("unimport/unplugin"); diff --git a/src/build/plugins/user-code-external.ts b/src/build/plugins/user-code-external.ts new file mode 100644 index 0000000000..717ed958f7 --- /dev/null +++ b/src/build/plugins/user-code-external.ts @@ -0,0 +1,25 @@ +import type { Plugin } from "rollup"; +import type { Nitro } from "nitro/types"; +import { isBuilderlessUserCodePath, splitSpecifier } from "../utils/builderless-path.ts"; + +export const BUILDERLESS_EXTERNAL_MARKER = "nitro:user-code-external"; + +export function userCodeExternal(nitro: Nitro): Plugin { + return { + name: BUILDERLESS_EXTERNAL_MARKER, + resolveId: { + order: "pre", + handler(id) { + const [path] = splitSpecifier(id); + if (!path || !isBuilderlessUserCodePath(path, nitro)) { + return null; + } + return { + id, + external: true, + moduleSideEffects: true, + }; + }, + }, + }; +} diff --git a/src/build/rewrite.ts b/src/build/rewrite.ts new file mode 100644 index 0000000000..2892bb57c6 --- /dev/null +++ b/src/build/rewrite.ts @@ -0,0 +1,57 @@ +import type { Nitro } from "nitro/types"; +import { readFile } from "node:fs/promises"; +import { glob } from "tinyglobby"; +import { dirname, relative } from "pathe"; +import { writeFile } from "../utils/fs.ts"; +import { isBuilderlessUserCodePath, splitSpecifier } from "./utils/builderless-path.ts"; + +export async function rewriteBuilderlessImports(nitro: Nitro) { + if (!nitro.options.builderless) { + return; + } + + const files = await glob("**/*.{mjs,js}", { + cwd: nitro.options.output.serverDir, + absolute: true, + }); + + await Promise.all( + files.map(async (file) => { + const source = await readFile(file, "utf8"); + const rewritten = rewriteModuleImports(source, file, nitro); + if (rewritten !== source) { + await writeFile(file, rewritten); + } + }) + ); +} + +function rewriteModuleImports(source: string, fromFile: string, nitro: Nitro) { + const rewrite = (specifier: string) => rewriteSpecifier(specifier, fromFile, nitro); + + return source + .replace(/from\s+(['"])([^'"]+)\1/g, (_full, quote: string, specifier: string) => { + return `from ${quote}${rewrite(specifier)}${quote}`; + }) + .replace(/\bimport\s+(['"])([^'"]+)\1/g, (_full, quote: string, specifier: string) => { + return `import ${quote}${rewrite(specifier)}${quote}`; + }) + .replace(/import\(\s*(['"])([^'"]+)\1\s*\)/g, (_full, quote: string, specifier: string) => { + return `import(${quote}${rewrite(specifier)}${quote})`; + }); +} + +function rewriteSpecifier(specifier: string, fromFile: string, nitro: Nitro) { + const [path, query] = splitSpecifier(specifier); + if (!path || !isBuilderlessUserCodePath(path, nitro)) { + return specifier; + } + + const relativePath = toPosixPath(relative(dirname(fromFile), path)); + const normalized = relativePath.startsWith(".") ? relativePath : `./${relativePath}`; + return normalized + query; +} + +function toPosixPath(path: string) { + return path.replace(/\\/g, "/"); +} diff --git a/src/build/rolldown/prod.ts b/src/build/rolldown/prod.ts index baa42092e1..e9442e933d 100644 --- a/src/build/rolldown/prod.ts +++ b/src/build/rolldown/prod.ts @@ -7,6 +7,7 @@ import { scanHandlers } from "../../scan.ts"; import { generateFSTree } from "../../utils/fs-tree.ts"; import { writeTypes } from "../types.ts"; import { writeBuildInfo } from "../info.ts"; +import { rewriteBuilderlessImports } from "../rewrite.ts"; import type { RolldownOutput } from "rolldown"; export async function buildProduction(nitro: Nitro, config: RolldownOptions) { @@ -24,6 +25,7 @@ export async function buildProduction(nitro: Nitro, config: RolldownOptions) { ); const build = await rolldown.rolldown(config); output = (await build.write(config.output as OutputOptions)) as RolldownOutput; + await rewriteBuilderlessImports(nitro); } const buildInfo = await writeBuildInfo(nitro, output); diff --git a/src/build/rollup/prod.ts b/src/build/rollup/prod.ts index e67678f635..96e2593d1c 100644 --- a/src/build/rollup/prod.ts +++ b/src/build/rollup/prod.ts @@ -5,6 +5,7 @@ import { scanHandlers } from "../../scan.ts"; import { generateFSTree } from "../../utils/fs-tree.ts"; import { writeTypes } from "../types.ts"; import { writeBuildInfo } from "../info.ts"; +import { rewriteBuilderlessImports } from "../rewrite.ts"; import { formatRollupError } from "./error.ts"; import type { RollupOutput } from "rollup"; @@ -27,6 +28,7 @@ export async function buildProduction(nitro: Nitro, rollupConfig: RollupConfig) }); output = await build.write(rollupConfig.output); + await rewriteBuilderlessImports(nitro); } const buildInfo = await writeBuildInfo(nitro, output); diff --git a/src/build/utils/builderless-path.ts b/src/build/utils/builderless-path.ts new file mode 100644 index 0000000000..a62971f105 --- /dev/null +++ b/src/build/utils/builderless-path.ts @@ -0,0 +1,42 @@ +import type { Nitro } from "nitro/types"; +import { presetsDir, runtimeDir } from "nitro/meta"; +import { isAbsolute, relative } from "pathe"; + +export function splitSpecifier(specifier: string) { + const queryIndex = specifier.indexOf("?"); + if (queryIndex < 0) { + return [specifier, ""] as const; + } + return [specifier.slice(0, queryIndex), specifier.slice(queryIndex)] as const; +} + +export function isNodeModulesPath(path: string) { + return /[/\\]node_modules[/\\]/.test(path); +} + +export function isSubpath(path: string, parent: string) { + const rel = relative(parent, path); + return !rel || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +export function isBuilderlessUserCodePath(path: string, nitro: Nitro) { + if (!isAbsolute(path) || isNodeModulesPath(path)) { + return false; + } + if (isSubpath(path, runtimeDir) || isSubpath(path, presetsDir)) { + return false; + } + + const includeRoots = [...new Set([nitro.options.rootDir, ...nitro.options.scanDirs])]; + const excludeRoots = [ + nitro.options.buildDir, + nitro.options.output.dir, + nitro.options.output.serverDir, + nitro.options.output.publicDir, + ]; + + if (!includeRoots.some((root) => isSubpath(path, root))) { + return false; + } + return !excludeRoots.some((root) => isSubpath(path, root)); +} diff --git a/src/build/vite/prod.ts b/src/build/vite/prod.ts index 127c2fe50b..ae652a95c3 100644 --- a/src/build/vite/prod.ts +++ b/src/build/vite/prod.ts @@ -7,6 +7,7 @@ import { colors as C } from "consola/utils"; import { copyPublicAssets } from "../../builder.ts"; import { existsSync } from "node:fs"; import { writeBuildInfo } from "../info.ts"; +import { rewriteBuilderlessImports } from "../rewrite.ts"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { isTest, isCI } from "std-env"; import type { RolldownOutput } from "rolldown"; @@ -109,6 +110,7 @@ export async function buildEnvironments(ctx: NitroPluginContext, builder: ViteBu // Build the Nitro server bundle const output = (await builder.build(builder.environments.nitro)) as RolldownOutput; + await rewriteBuilderlessImports(nitro); // Close the Nitro instance await nitro.close(); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index f032f77b44..acfe87616d 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -68,6 +68,7 @@ export const NitroDefaults: NitroConfig = { // Builder builder: undefined, + builderless: false, moduleSideEffects: ["unenv/polyfill/"], replace: {}, node: true, diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index a6447b92c3..627739c8a8 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -275,7 +275,7 @@ export async function writeWranglerConfig(nitro: Nitro, cfTarget: "pages" | "mod if (cfTarget === "module") { // Avoid double bundling if (wranglerConfig.no_bundle === undefined) { - wranglerConfig.no_bundle = true; + wranglerConfig.no_bundle = !nitro.options.builderless; } // Scan all server/ chunks diff --git a/src/types/config.ts b/src/types/config.ts index 8e733da763..db5bb27110 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -216,8 +216,13 @@ export interface NitroOptions extends PresetOptions { retryDelay?: number; }; - // Rollup + // Builder builder?: "rollup" | "rolldown" | "vite"; + /** + * Keep Nitro runtime bundling while leaving user handlers/modules unbundled. + * Useful when a downstream platform bundler (e.g. Wrangler) should bundle app code. + */ + builderless?: boolean; rollupConfig?: RollupConfig; rolldownConfig?: RolldownConfig; entry: string; diff --git a/test/minimal/minimal.test.ts b/test/minimal/minimal.test.ts index 80fe1e9f2a..0917c7f24f 100644 --- a/test/minimal/minimal.test.ts +++ b/test/minimal/minimal.test.ts @@ -2,8 +2,9 @@ import { afterAll, describe, expect, it } from "vitest"; import { createNitro, build, prepare } from "nitro/builder"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { mkdir, rm, stat } from "node:fs/promises"; +import { mkdir, readFile, rm, stat } from "node:fs/promises"; import { glob } from "tinyglobby"; +import escapeRE from "escape-string-regexp"; const fixtureDir = fileURLToPath(new URL("./", import.meta.url)); const tmpDir = fileURLToPath(new URL(".tmp", import.meta.url)); @@ -65,6 +66,46 @@ describe("minimal fixture", () => { } } + describe("builderless", () => { + const builders = ["rolldown", "rollup", "vite", "vite7"] as const; + + for (const builder of builders) { + describe(builder, () => { + let outDir: string; + + it("externalizes app code", async () => { + outDir = join(tmpDir, "output", `builderless-${builder}`); + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + const nitro = await createNitro({ + rootDir: fixtureDir, + output: { dir: outDir }, + builder: builder.includes("vite") ? "vite" : (builder as "rolldown" | "rollup"), + // @ts-expect-error for testing + __vitePkg__: builder.includes("vite") ? builder : undefined, + builderless: true, + }); + await prepare(nitro); + await build(nitro); + await nitro.close(); + }); + + it("rewrites absolute imports to relative paths", async () => { + const files = await glob("server/**/*.{mjs,js}", { cwd: outDir, absolute: true }); + const contents = (await Promise.all(files.map((file) => readFile(file, "utf8")))).join( + "\n" + ); + const rootDirPattern = new RegExp( + escapeRE(fileURLToPath(new URL("./", import.meta.url))) + ); + + expect(contents).toMatch(/from ['"](?:\.\.\/)+server\.ts['"]/); + expect(contents).not.toMatch(rootDirPattern); + }); + }); + } + }); + if (process.env.TEST_DEBUG) { afterAll(() => { console.table(results);