diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 4c69861d8..83bad753e 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -393,6 +393,8 @@ export type ResolvedNextConfig = { reactMaxHeadersLength: number; /** Serialized htmlLimitedBots regexp source from next.config. */ htmlLimitedBots: string | undefined; + /** Packages that should stay bundled/transpiled instead of being loaded natively. */ + transpilePackages: string[]; /** * Packages that should be treated as server-external (not bundled by Vite). * Sourced from `serverExternalPackages` or the legacy @@ -1296,6 +1298,7 @@ export async function resolveNextConfig( expireTime: DEFAULT_EXPIRE_TIME, reactMaxHeadersLength: DEFAULT_REACT_MAX_HEADERS_LENGTH, htmlLimitedBots: undefined, + transpilePackages: [], serverExternalPackages: [], cacheHandler: undefined, cacheMaxMemorySize: undefined, @@ -1463,6 +1466,19 @@ export async function resolveNextConfig( experimental?.serverComponentsExternalPackages, ); const serverExternalPackages = topLevelServerExternalPackages ?? legacyServerComponentsExternal; + const transpilePackages = Array.isArray(config.transpilePackages) + ? readStringArray(config.transpilePackages) + : []; + const externalPackageConflicts = transpilePackages.filter((pkg) => + serverExternalPackages.includes(pkg), + ); + if (externalPackageConflicts.length > 0) { + throw new Error( + `The packages specified in the 'transpilePackages' conflict with the 'serverExternalPackages': ${externalPackageConflicts.join( + ", ", + )}`, + ); + } // Warn about unsupported experimental.swcEnvOptions. vinext uses Vite for // transforms, not SWC, so automatic polyfill injection is not applicable. @@ -1616,6 +1632,7 @@ export async function resolveNextConfig( ? config.reactMaxHeadersLength : DEFAULT_REACT_MAX_HEADERS_LENGTH, htmlLimitedBots, + transpilePackages, serverExternalPackages, cacheHandler, cacheMaxMemorySize, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 1abcf6112..3096be414 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -118,6 +118,7 @@ import { INSTRUMENTATION_CLIENT_EMPTY_MODULE, } from "./client/instrumentation-client-inject.js"; import { createMiddlewareServerOnlyPlugin } from "./plugins/middleware-server-only.js"; +import { createServerNodeExternalsPlugin } from "./plugins/server-node-externals.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; import { createDynamicPreloadMetadataPlugin } from "./plugins/dynamic-preload-metadata.js"; import { createOgInlineFetchAssetsPlugin, createOgAssetsPlugin } from "./plugins/og-assets.js"; @@ -1137,6 +1138,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { middlewarePath ? (tryRealpathSync(middlewarePath) ?? middlewarePath) : null, serverOnlyShimPath: resolveShimModulePath(shimsDir, "server-only"), }), + createServerNodeExternalsPlugin({ + getAppDir: () => (hasAppDir ? appDir : null), + getPagesDir: () => (hasPagesDir ? pagesDir : null), + getServerExternalPackages: () => nextConfig?.serverExternalPackages ?? [], + getTranspilePackages: () => nextConfig?.transpilePackages ?? [], + isEnabled: () => !hasCloudflarePlugin && !hasNitroPlugin, + }), // Resolve `data:text/css[+module],...` imports into virtual CSS files so // Vite's CSS pipeline (LightningCSS, CSS modules) processes them instead // of leaving the data URL as a runtime import that Node/workerd cannot @@ -1427,6 +1435,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // `vinext:compiler-define-server` configEnvironment hook below, which // Vite merges over this base value for server environments only. defines["process.env.NEXT_RUNTIME"] = '""'; + // Legacy Next.js browser sentinel. Client bundles receive `true`, and + // server environments override it to `false` below. Some packages still + // branch on this instead of `typeof window`. + // + // Mirrors Next.js: packages/next/src/build/define-env.ts. + defines["process.browser"] = JSON.stringify(true); // Next.js version compat — mirrors Next.js' `process.env.__NEXT_VERSION`, // which is substituted by their webpack DefinePlugin at build time // (see `packages/next/src/client/next.ts` line 5 and @@ -3967,6 +3981,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // `process.env.NEXT_RUNTIME` to construct revalidation paths would then // compute `'/nodejs'` correctly instead of `''` (issue #1365). serverDefines["process.env.NEXT_RUNTIME"] = JSON.stringify("nodejs"); + serverDefines["process.browser"] = JSON.stringify(false); // On-demand ISR revalidation secret — baked SERVER-ONLY (the `client` // early-return above guarantees it never reaches the browser bundle) so diff --git a/packages/vinext/src/plugins/server-node-externals.ts b/packages/vinext/src/plugins/server-node-externals.ts new file mode 100644 index 000000000..79a7cbd78 --- /dev/null +++ b/packages/vinext/src/plugins/server-node-externals.ts @@ -0,0 +1,311 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Plugin } from "vite"; +import { stripViteModuleQuery } from "../utils/path.js"; + +type ServerNodeExternalsOptions = { + getAppDir: () => string | null; + getPagesDir: () => string | null; + getServerExternalPackages: () => readonly string[]; + getTranspilePackages: () => readonly string[]; + isEnabled: () => boolean; +}; + +type PackageMetadata = { + type: string | null; +}; + +type ModuleOwnership = { + app: boolean; + pages: boolean; +}; + +const NODE_ESM_RELATIVE_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]); + +const FRAMEWORK_PACKAGE_NAMES = new Set([ + "@vitejs/plugin-react", + "@vitejs/plugin-rsc", + "react", + "react-dom", + "react-server-dom-webpack", + "scheduler", + "vite", + "vinext", +]); + +const BUNDLED_SERVER_PACKAGE_NAMES = new Set([ + // `next/og` delegates to @vercel/og through a Vinext shim. The package must + // stay in Vite's graph so vinext:og-font-patch and vinext:og-assets can + // rewrite/copy its WASM assets for Node and Workers. + "@vercel/og", +]); + +const MODULE_SPECIFIER_RE = + /\b(?:import|export)\s+(?:type\s+)?(?:[^'"]*?\s+from\s*)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g; + +const realpathCache = new Map(); + +function realpathIfExists(filePath: string): string { + const cached = realpathCache.get(filePath); + if (cached !== undefined) return cached; + + let resolved = filePath; + try { + resolved = fs.realpathSync.native(filePath); + } catch { + // Virtual and not-yet-materialized paths should keep their original shape. + } + realpathCache.set(filePath, resolved); + return resolved; +} + +function isBarePackageRequest(id: string): boolean { + return ( + id !== "" && + id[0] !== "." && + id[0] !== "/" && + id[0] !== "\0" && + !id.includes(":") && + !path.isAbsolute(id) + ); +} + +function getPackageName(id: string): string | null { + const [first, second] = id.split("/"); + if (!first) return null; + if (first.startsWith("@")) { + return second ? `${first}/${second}` : null; + } + return first; +} + +function isFrameworkOrVinextRequest(id: string, packageName: string): boolean { + return ( + FRAMEWORK_PACKAGE_NAMES.has(packageName) || + id === "next" || + id.startsWith("next/") || + id.startsWith("vinext/") || + id.startsWith("@vinext/") + ); +} + +function isInsideDirectory(dir: string, filePath: string): boolean { + const relative = path.relative(dir, filePath); + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +function moduleOwnershipKey(environmentName: string, filePath: string): string { + return `${environmentName}\0${filePath}`; +} + +function directOwnershipForFile( + filePath: string, + appDir: string | null, + pagesDir: string | null, +): ModuleOwnership | null { + const realFilePath = realpathIfExists(filePath); + const realAppDir = appDir ? realpathIfExists(appDir) : null; + const realPagesDir = pagesDir ? realpathIfExists(pagesDir) : null; + + if (realAppDir && isInsideDirectory(realAppDir, realFilePath)) { + return { app: true, pages: false }; + } + if (realPagesDir && isInsideDirectory(realPagesDir, realFilePath)) { + return { app: false, pages: true }; + } + return null; +} + +function mergeOwnership( + ownershipByModule: Map, + environmentName: string, + filePath: string, + ownership: ModuleOwnership | null, +): void { + if (!ownership) return; + + const key = moduleOwnershipKey(environmentName, realpathIfExists(filePath)); + const current = ownershipByModule.get(key); + ownershipByModule.set(key, { + app: Boolean(current?.app || ownership.app), + pages: Boolean(current?.pages || ownership.pages), + }); +} + +function ownershipForImporter( + importer: string | undefined, + environmentName: string, + appDir: string | null, + pagesDir: string | null, + ownershipByModule: Map, +): ModuleOwnership | null { + if (!importer) return null; + + const cleanImporter = stripViteModuleQuery(importer); + if (!path.isAbsolute(cleanImporter)) return null; + + const realImporter = realpathIfExists(cleanImporter); + return ( + directOwnershipForFile(realImporter, appDir, pagesDir) ?? + ownershipByModule.get(moduleOwnershipKey(environmentName, realImporter)) ?? + null + ); +} + +function isInNodeModules(filePath: string): boolean { + return filePath.split(path.sep).includes("node_modules"); +} + +function findPackageJson(filePath: string): string | null { + const parts = filePath.split(path.sep); + const nodeModulesIndex = parts.lastIndexOf("node_modules"); + if (nodeModulesIndex === -1) return null; + + const packageNameStart = nodeModulesIndex + 1; + const firstPackageSegment = parts[packageNameStart]; + if (!firstPackageSegment) return null; + + const packageRootEndExclusive = firstPackageSegment.startsWith("@") + ? packageNameStart + 2 + : packageNameStart + 1; + if (parts.length <= packageRootEndExclusive) return null; + + const packageRoot = parts.slice(0, packageRootEndExclusive).join(path.sep); + const candidate = path.join(packageRoot, "package.json"); + if (fs.existsSync(candidate)) { + return candidate; + } + return null; +} + +function readPackageMetadata(filePath: string): PackageMetadata | null { + const packageJsonPath = findPackageJson(filePath); + if (!packageJsonPath) return null; + + try { + const parsed: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const type = + parsed && typeof parsed === "object" && "type" in parsed + ? (parsed as { type?: unknown }).type + : null; + return { + type: typeof type === "string" ? type : null, + }; + } catch { + return { type: null }; + } +} + +function canNodeImportResolvedFile(filePath: string, metadata: PackageMetadata | null): boolean { + const ext = path.extname(filePath); + return ext === ".mjs" || (ext === ".js" && metadata?.type === "module"); +} + +function hasNodeUnsupportedRelativeImport(source: string): boolean { + // This is intentionally an entry-file guard, not a complete graph analysis. + // False positives keep packages bundled; false negatives are still bounded by + // the Pages-only ownership check below. + MODULE_SPECIFIER_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = MODULE_SPECIFIER_RE.exec(source)) !== null) { + const specifier = match[1] ?? match[2] ?? match[3]; + if (!specifier?.startsWith(".")) continue; + + const cleanSpecifier = specifier.split(/[?#]/)[0] ?? specifier; + if (!NODE_ESM_RELATIVE_EXTENSIONS.has(path.extname(cleanSpecifier))) { + return true; + } + } + + return false; +} + +function shouldKeepBundledForNodeUnsupportedImports(filePath: string): boolean { + try { + return hasNodeUnsupportedRelativeImport(fs.readFileSync(filePath, "utf8")); + } catch { + return true; + } +} + +export function createServerNodeExternalsPlugin(options: ServerNodeExternalsOptions): Plugin { + let command: "build" | "serve" = "serve"; + const nativeEsmCache = new Map(); + const ownershipByModule = new Map(); + + return { + name: "vinext:server-node-externals", + enforce: "pre", + + configResolved(config) { + command = config.command; + }, + + async resolveId(id, importer) { + if (command !== "build") return null; + if (!options.isEnabled()) return null; + if (this.environment?.name === "client") return null; + + const environmentName = this.environment?.name ?? "unknown"; + const appDir = options.getAppDir(); + const pagesDir = options.getPagesDir(); + const importerOwnership = ownershipForImporter( + importer, + environmentName, + appDir, + pagesDir, + ownershipByModule, + ); + + if (!isBarePackageRequest(id)) { + if (!importerOwnership) return null; + + const resolved = await this.resolve(id, importer, { skipSelf: true }); + if (!resolved || resolved.external) return null; + + const resolvedFile = realpathIfExists(stripViteModuleQuery(resolved.id)); + if (path.isAbsolute(resolvedFile)) { + mergeOwnership(ownershipByModule, environmentName, resolvedFile, importerOwnership); + } + return null; + } + + const packageName = getPackageName(id); + if (!packageName) return null; + if (isFrameworkOrVinextRequest(id, packageName)) return null; + if (BUNDLED_SERVER_PACKAGE_NAMES.has(packageName)) return null; + + if (options.getTranspilePackages().includes(packageName)) return null; + + // Pages-only builds do not propagate next.config serverExternalPackages + // through userSsrExternal, so keep this explicit resolver branch. + if (options.getServerExternalPackages().includes(packageName)) { + return { id, external: true }; + } + + const resolved = await this.resolve(id, importer, { skipSelf: true }); + if (!resolved || resolved.external) return null; + + const resolvedFile = realpathIfExists(stripViteModuleQuery(resolved.id)); + if (!path.isAbsolute(resolvedFile)) return null; + + mergeOwnership(ownershipByModule, environmentName, resolvedFile, importerOwnership); + + if (!pagesDir || !importerOwnership?.pages || importerOwnership.app) return null; + if (!isInNodeModules(resolvedFile)) return null; + + const cached = nativeEsmCache.get(resolvedFile); + if (cached !== undefined) { + return cached ? { id, external: true } : null; + } + + const metadata = readPackageMetadata(resolvedFile); + const shouldExternalize = + canNodeImportResolvedFile(resolvedFile, metadata) && + !shouldKeepBundledForNodeUnsupportedImports(resolvedFile); + nativeEsmCache.set(resolvedFile, shouldExternalize); + + return shouldExternalize ? { id, external: true } : null; + }, + }; +} diff --git a/tests/compiler-define.test.ts b/tests/compiler-define.test.ts index 528367cbb..bc373acd3 100644 --- a/tests/compiler-define.test.ts +++ b/tests/compiler-define.test.ts @@ -145,17 +145,19 @@ describe("compiler.define forwarding to Vite", () => { { command: "build" }, ); - // NEXT_RUNTIME is always injected for server environments in addition to + // Next.js compile-time server built-ins are injected in addition to // user-configured defineServer entries. expect(rscResult?.define).toEqual({ MY_SERVER_VARIABLE: '"server"', "process.env.MY_MAGIC_SERVER_EXPR": '"serverbarbaz"', "process.env.NEXT_RUNTIME": '"nodejs"', + "process.browser": "false", }); expect(ssrResult?.define).toEqual({ MY_SERVER_VARIABLE: '"server"', "process.env.MY_MAGIC_SERVER_EXPR": '"serverbarbaz"', "process.env.NEXT_RUNTIME": '"nodejs"', + "process.browser": "false", }); // Client environment must never receive server-only defines. expect(clientResult).toBeNull(); @@ -187,13 +189,15 @@ describe("compiler.define forwarding to Vite", () => { )) as { define?: Record }; // Top-level define (applies to all environments including client) must - // set NEXT_RUNTIME to '' — matching Next.js's client-bundle value. + // set client-bundle values that match Next.js. expect(configResult.define?.["process.env.NEXT_RUNTIME"]).toBe('""'); + expect(configResult.define?.["process.browser"]).toBe("true"); - // Server environments must override NEXT_RUNTIME to 'nodejs'. + // Server environments must override client defaults with server values. for (const env of ["rsc", "ssr"]) { const result = serverDefinePlugin!.configEnvironment!(env, {}, { command: "build" }); expect(result?.define?.["process.env.NEXT_RUNTIME"]).toBe('"nodejs"'); + expect(result?.define?.["process.browser"]).toBe("false"); } // Client environment returns null — it receives the top-level '' value @@ -281,7 +285,7 @@ describe("compiler.define forwarding to Vite", () => { // Explicitly clear the build-time revalidate secret env var so the hook has // no user `defineServer` entries AND no baked revalidate-secret define — - // only the built-in NEXT_RUNTIME define is present. + // only the Next-compatible built-in defines are present. const prev = process.env.__VINEXT_SHARED_REVALIDATE_SECRET; delete process.env.__VINEXT_SHARED_REVALIDATE_SECRET; @@ -292,13 +296,17 @@ describe("compiler.define forwarding to Vite", () => { { command: "build" }, ); const rscResult = serverDefinePlugin!.configEnvironment!("rsc", {}, { command: "build" }); - // NEXT_RUNTIME is always injected for server environments, so the hook + // Built-ins are always injected for server environments, so the hook // always returns a define object (never null) even without user defineServer. expect(rscResult).not.toBeNull(); expect(rscResult?.define?.["process.env.NEXT_RUNTIME"]).toBe('"nodejs"'); + expect(rscResult?.define?.["process.browser"]).toBe("false"); // No other keys should be present when neither defineServer nor revalidate // secret are configured. - expect(Object.keys(rscResult!.define!)).toEqual(["process.env.NEXT_RUNTIME"]); + expect(Object.keys(rscResult!.define!)).toEqual([ + "process.env.NEXT_RUNTIME", + "process.browser", + ]); } finally { if (prev === undefined) delete process.env.__VINEXT_SHARED_REVALIDATE_SECRET; else process.env.__VINEXT_SHARED_REVALIDATE_SECRET = prev; diff --git a/tests/e2e/app-router/nextjs-compat/esm-externals.browser.spec.ts b/tests/e2e/app-router/nextjs-compat/esm-externals.browser.spec.ts new file mode 100644 index 000000000..27b9db5f3 --- /dev/null +++ b/tests/e2e/app-router/nextjs-compat/esm-externals.browser.spec.ts @@ -0,0 +1,83 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, type Page, test } from "@playwright/test"; +import { createBuilder } from "vite"; +import { + closeServer, + createEsmExternalsFixture, + ESM_EXTERNALS_ROUTE_EXPECTATIONS, + firstParagraphText, +} from "../../../helpers/esm-externals-fixture.js"; + +// Match the upstream contract: this test asserts rendered text, not console cleanliness. +test.setTimeout(180_000); + +async function writeVinextConfig(fixtureRoot: string): Promise { + const vinextSource = path.resolve(process.cwd(), "packages/vinext/src/index.ts"); + await fs.writeFile( + path.join(fixtureRoot, "vite.config.ts"), + `import { defineConfig } from "vite"; +import vinext from ${JSON.stringify(pathToFileURL(vinextSource).href)}; + +export default defineConfig({ + plugins: [vinext({ appDir: import.meta.dirname })], +}); +`, + ); +} + +async function assertRenderedRoute( + page: Page, + baseUrl: string, + { kind, route, text }: (typeof ESM_EXTERNALS_ROUTE_EXPECTATIONS)[number], +): Promise { + const res = await page.request.get(`${baseUrl}${route}`); + expect(res.status()).toBe(200); + expect(firstParagraphText(await res.text())).toBe(text); + + await page.goto(`${baseUrl}${route}`, { waitUntil: "load" }); + await page.waitForFunction(() => "__VINEXT_HYDRATED_AT" in window); + await expect(page.locator(kind === "pages" ? "body p" : "body > p")).toHaveText(text); +} + +// Ported from Next.js: test/e2e/esm-externals/esm-externals.test.ts +// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/esm-externals/esm-externals.test.ts +test.describe("esm externals production browser parity", () => { + test("renders the same SSR HTML and hydrated browser text as the upstream Turbopack fixture", async ({ + page, + }) => { + const fixture = await createEsmExternalsFixture(); + + try { + await writeVinextConfig(fixture.root); + const builder = await createBuilder({ + root: fixture.root, + configFile: path.join(fixture.root, "vite.config.ts"), + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = + await import("../../../../packages/vinext/src/server/prod-server.js"); + const started = await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir: path.join(fixture.root, "dist"), + noCompression: true, + }); + + try { + const baseUrl = `http://127.0.0.1:${started.port}`; + + for (const expectation of ESM_EXTERNALS_ROUTE_EXPECTATIONS) { + await assertRenderedRoute(page, baseUrl, expectation); + } + } finally { + await closeServer(started.server); + } + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/tests/esm-externals.test.ts b/tests/esm-externals.test.ts new file mode 100644 index 000000000..55f29c287 --- /dev/null +++ b/tests/esm-externals.test.ts @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { describe, expect, it } from "vite-plus/test"; +import { createBuilder } from "vite"; +import vinext from "../packages/vinext/src/index.js"; +import { emitStandaloneOutput } from "../packages/vinext/src/build/standalone.js"; +import { + closeServer, + createEsmExternalsFixture, + ESM_EXTERNALS_APP_TRANSITIVE_PACKAGE, + ESM_EXTERNALS_BUNDLED_PAGE_PACKAGES, + ESM_EXTERNALS_EXPLICIT_APP_PACKAGES, + ESM_EXTERNALS_IMPLICIT_PAGE_PACKAGES, + ESM_EXTERNALS_ROUTE_EXPECTATIONS, + firstParagraphText, +} from "./helpers/esm-externals-fixture.js"; + +type StartedStandaloneServer = { + baseUrl: string; + stop: () => Promise; +}; + +function stopChild(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + + return new Promise((resolve) => { + const forceKill = setTimeout(() => { + child.kill("SIGKILL"); + }, 2_000); + child.once("exit", () => { + clearTimeout(forceKill); + resolve(); + }); + child.kill("SIGTERM"); + }); +} + +function startStandaloneServer(serverPath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [serverPath], { + env: { ...process.env, HOST: "127.0.0.1", PORT: "0" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + let settled = false; + + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + callback(); + }; + const fail = (message: string): void => { + finish(() => { + void stopChild(child); + reject(new Error(`${message}\n${output}`)); + }); + }; + const timeout = setTimeout(() => { + fail("Timed out waiting for standalone server to start"); + }, 15_000); + + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + const match = /Production server running at (http:\/\/127\.0\.0\.1:\d+)/.exec(output); + if (!match) return; + + finish(() => { + resolve({ + baseUrl: match[1]!, + stop: () => stopChild(child), + }); + }); + }); + child.stderr.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + }); + child.once("error", (error) => { + fail(`Standalone server failed to start: ${error.message}`); + }); + child.once("exit", (code, signal) => { + if (!settled) { + fail( + `Standalone server exited before startup: code=${code ?? "null"} signal=${signal ?? "null"}`, + ); + } + }); + }); +} + +// Ported from Next.js: test/e2e/esm-externals/esm-externals.test.ts +// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/esm-externals/esm-externals.test.ts +describe("esm externals production parity", () => { + it("builds and renders the same SSR output as the upstream Turbopack deploy fixture", async () => { + const fixture = await createEsmExternalsFixture(); + + try { + const builder = await createBuilder({ + root: fixture.root, + configFile: false, + plugins: [vinext({ appDir: fixture.root })], + logLevel: "silent", + }); + await builder.buildApp(); + emitStandaloneOutput({ + root: fixture.root, + outDir: path.join(fixture.root, "dist"), + vinextPackageRoot: path.resolve(process.cwd(), "packages/vinext"), + }); + const serverExternals = JSON.parse( + fs.readFileSync(path.join(fixture.root, "dist/server/vinext-externals.json"), "utf8"), + ) as string[]; + for (const pagePackage of ESM_EXTERNALS_IMPLICIT_PAGE_PACKAGES) { + expect(serverExternals).toContain(pagePackage); + } + for (const appPackage of ESM_EXTERNALS_EXPLICIT_APP_PACKAGES) { + expect(serverExternals).toContain(appPackage); + } + for (const bundledPackage of ESM_EXTERNALS_BUNDLED_PAGE_PACKAGES) { + expect(serverExternals).not.toContain(bundledPackage); + } + expect(serverExternals).not.toContain(ESM_EXTERNALS_APP_TRANSITIVE_PACKAGE); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + const started = await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir: path.join(fixture.root, "dist"), + noCompression: true, + }); + + try { + const baseUrl = `http://127.0.0.1:${started.port}`; + + for (const { route, text } of ESM_EXTERNALS_ROUTE_EXPECTATIONS) { + const res = await fetch(`${baseUrl}${route}`); + expect(res.status).toBe(200); + expect(firstParagraphText(await res.text())).toBe(text); + } + } finally { + await closeServer(started.server); + } + + const standalone = await startStandaloneServer( + path.join(fixture.root, "dist/standalone/server.js"), + ); + try { + const res = await fetch(`${standalone.baseUrl}/static`); + expect(res.status).toBe(200); + expect(firstParagraphText(await res.text())).toBe(ESM_EXTERNALS_ROUTE_EXPECTATIONS[0].text); + } finally { + await standalone.stop(); + } + } finally { + fixture.cleanup(); + } + }, 180_000); +}); diff --git a/tests/helpers/esm-externals-fixture.ts b/tests/helpers/esm-externals-fixture.ts new file mode 100644 index 000000000..3eb9e2a5d --- /dev/null +++ b/tests/helpers/esm-externals-fixture.ts @@ -0,0 +1,373 @@ +import fs from "node:fs"; +import type { Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const ROOT_NODE_MODULES = path.resolve(process.cwd(), "node_modules"); + +const ESM_EXTERNALS_PAGE_TEXT = "Hello World+World+World+World+World+World+World+World+World+World"; +const ESM_EXTERNALS_APP_SERVER_TEXT = "Hello World+World+World+World"; +const ESM_EXTERNALS_APP_CLIENT_TEXT = "Hello World+World+World"; +export const ESM_EXTERNALS_ROUTE_EXPECTATIONS = [ + { kind: "pages", route: "/static", text: ESM_EXTERNALS_PAGE_TEXT }, + { kind: "pages", route: "/ssr", text: ESM_EXTERNALS_PAGE_TEXT }, + { kind: "pages", route: "/ssg", text: ESM_EXTERNALS_PAGE_TEXT }, + { kind: "app", route: "/server", text: ESM_EXTERNALS_APP_SERVER_TEXT }, + { kind: "app", route: "/client", text: ESM_EXTERNALS_APP_CLIENT_TEXT }, +] as const; + +export const ESM_EXTERNALS_APP_TRANSITIVE_PACKAGE = "app-transitive-esm-package"; +export const ESM_EXTERNALS_IMPLICIT_PAGE_PACKAGES = ["esm-package1", "esm-package2"] as const; +export const ESM_EXTERNALS_EXPLICIT_APP_PACKAGES = [ + "app-esm-package1", + "app-esm-package2", + "app-cjs-esm-package", +] as const; +export const ESM_EXTERNALS_BUNDLED_PAGE_PACKAGES = [ + "extensionless-esm-package", + "transpiled-esm-package", +] as const; + +const BROWSER_WORLD_SOURCE = `export default 'World'\n\nif (!process.browser) throw new Error('Browser only code in server build')\n`; +const ESM_WORLD_SOURCE = `export default 'World'\n\nif (Math.random() < 0) import('fail')\n`; +const ESM_TLA_WORLD_SOURCE = `export default 'World'\n\nawait 1\n\nif (Math.random() < 0) import('fail')\n`; +const CJS_WORLD_SOURCE = `module.exports = 'World'\n\nif (Math.random() < 0) require('fail')\n`; +const WRONG_CJS_SOURCE = `module.exports = 'Wrong'\n`; +const TRANSPILED_WORLD_SOURCE = `const loadedFromNodeModules = import.meta.url.includes("/node_modules/transpiled-esm-package/");\nexport default loadedFromNodeModules ? 'Externalized' : 'World';\n\nif (Math.random() < 0) import(/* @vite-ignore */ 'fail')\n`; +// Rolldown resolves dead dynamic imports when bundling. Keep the same sentinel +// shape, but ignore resolution here so the route can prove bundled-vs-external. +const APP_TRANSITIVE_WORLD_SOURCE = `const loadedFromNodeModules = import.meta.url.includes("/node_modules/${ESM_EXTERNALS_APP_TRANSITIVE_PACKAGE}/");\nexport default loadedFromNodeModules ? 'Externalized' : 'World';\n\nif (Math.random() < 0) import(/* @vite-ignore */ 'fail')\n`; + +type ConditionalExportPackageOptions = { + directory: string; + packageName?: string; + packageType?: "module"; + browserFile: string; + importFile: string; + requireFile: string; + importSource: string; + requireSource: string; +}; + +export type EsmExternalsFixture = { + root: string; + cleanup: () => void; +}; + +export async function closeServer(server: Server): Promise { + const closed = new Promise((resolve) => server.close(() => resolve())); + server.closeIdleConnections(); + server.closeAllConnections(); + await closed; +} + +function normalizeHtmlText(value: string): string { + return value.replace(//g, ""); +} + +export function firstParagraphText(html: string): string { + const match = /]*>(.*?)<\/p>/s.exec(html); + if (!match) throw new Error(`Expected HTML to contain a paragraph: ${html}`); + return normalizeHtmlText(match[1]!.replace(/<[^>]*>/g, "")); +} + +function linkWorkspacePackage(tmpDir: string, specifier: string): void { + const directSource = path.join(ROOT_NODE_MODULES, specifier); + const source = fs.existsSync(directSource) + ? fs.realpathSync(directSource) + : findPnpmPackageSource(specifier); + const destination = path.join(tmpDir, "node_modules", specifier); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.symlinkSync(source, destination, "junction"); +} + +function findPnpmPackageSource(specifier: string): string { + const packageDirName = specifier.replace("/", "+"); + const pnpmDir = path.join(ROOT_NODE_MODULES, ".pnpm"); + const entry = fs + .readdirSync(pnpmDir) + .find( + (candidate) => candidate === packageDirName || candidate.startsWith(`${packageDirName}@`), + ); + if (!entry) { + throw new Error(`Unable to locate ${specifier} in ${ROOT_NODE_MODULES}`); + } + + return fs.realpathSync(path.join(pnpmDir, entry, "node_modules", specifier)); +} + +function writeFile(root: string, relativePath: string, source: string): void { + const filePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); +} + +function writePackage( + root: string, + name: string, + packageJson: object, + files: Record, +): void { + writeFile(root, `node_modules/${name}/package.json`, JSON.stringify(packageJson, null, 2)); + for (const [fileName, source] of Object.entries(files)) { + writeFile(root, `node_modules/${name}/${fileName}`, source); + } +} + +function writeConditionalExportPackage( + root: string, + { + directory, + packageName = directory, + packageType, + browserFile, + importFile, + requireFile, + importSource, + requireSource, + }: ConditionalExportPackageOptions, +): void { + writePackage( + root, + directory, + { + name: packageName, + ...(packageType ? { type: packageType } : {}), + exports: { + "./package.json": "./package.json", + "./entry": { + browser: `./${browserFile}`, + import: `./${importFile}`, + require: `./${requireFile}`, + }, + }, + }, + { + [browserFile]: BROWSER_WORLD_SOURCE, + [importFile]: importSource, + [requireFile]: requireSource, + }, + ); +} + +export async function createEsmExternalsFixture(): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-esm-externals-")); + + fs.mkdirSync(path.join(tmpDir, "node_modules"), { recursive: true }); + for (const specifier of [ + "react", + "react-dom", + "react-server-dom-webpack", + "vite", + "@vitejs/plugin-react", + "@vitejs/plugin-rsc", + "@mdx-js/react", + "@mdx-js/rollup", + "ipaddr.js", + ]) { + linkWorkspacePackage(tmpDir, specifier); + } + + writeFile(tmpDir, "package.json", JSON.stringify({ type: "module" })); + writeFile( + tmpDir, + "next.config.mjs", + `export default { + output: "standalone", + turbopack: { + resolveAlias: { + "preact/compat": "react", + }, + }, + transpilePackages: ["transpiled-esm-package"], + serverExternalPackages: ["app-esm-package1", "app-esm-package2", "app-cjs-esm-package"], + webpack(config) { + config.resolve.alias = { + ...config.resolve.alias, + "preact/compat": "react", + }; + return config; + }, +}; +`, + ); + + writeConditionalExportPackage(tmpDir, { + directory: "esm-package1", + packageName: "esm-package", + browserFile: "browser.mjs", + importFile: "correct.mjs", + requireFile: "wrong.js", + importSource: ESM_WORLD_SOURCE, + requireSource: WRONG_CJS_SOURCE, + }); + writeConditionalExportPackage(tmpDir, { + directory: "esm-package2", + packageName: "esm-package", + packageType: "module", + browserFile: "browser.mjs", + importFile: "correct.js", + requireFile: "wrong.cjs", + importSource: ESM_TLA_WORLD_SOURCE, + requireSource: WRONG_CJS_SOURCE, + }); + writeConditionalExportPackage(tmpDir, { + directory: "invalid-esm-package", + browserFile: "browser.js", + importFile: "correct.js", + requireFile: "alternative.js", + importSource: `export default 'World'\n`, + requireSource: `module.exports = 'Alternative'\n\nif (Math.random() < 0) require('fail')\n`, + }); + writeConditionalExportPackage(tmpDir, { + directory: "app-esm-package1", + packageName: "app-esm-package", + browserFile: "browser.mjs", + importFile: "correct.mjs", + requireFile: "wrong.js", + importSource: ESM_WORLD_SOURCE, + requireSource: WRONG_CJS_SOURCE, + }); + writeConditionalExportPackage(tmpDir, { + directory: "app-esm-package2", + packageName: "app-esm-package", + packageType: "module", + browserFile: "browser.mjs", + importFile: "correct.js", + requireFile: "wrong.cjs", + importSource: ESM_TLA_WORLD_SOURCE, + requireSource: WRONG_CJS_SOURCE, + }); + writeConditionalExportPackage(tmpDir, { + directory: "app-cjs-esm-package", + browserFile: "browser.js", + importFile: "correct.js", + requireFile: "alternative.js", + importSource: CJS_WORLD_SOURCE, + requireSource: `module.exports = 'Alternative'\n`, + }); + writeConditionalExportPackage(tmpDir, { + directory: "extensionless-esm-package", + packageType: "module", + browserFile: "browser.mjs", + importFile: "correct.js", + requireFile: "wrong.cjs", + importSource: `import World from './dep';\n\nexport default World;\n\nif (Math.random() < 0) import('fail')\n`, + requireSource: WRONG_CJS_SOURCE, + }); + writeFile(tmpDir, "node_modules/extensionless-esm-package/dep.js", `export default 'World'\n`); + writeConditionalExportPackage(tmpDir, { + directory: "transpiled-esm-package", + packageType: "module", + browserFile: "browser.mjs", + importFile: "correct.js", + requireFile: "wrong.cjs", + importSource: TRANSPILED_WORLD_SOURCE, + requireSource: WRONG_CJS_SOURCE, + }); + writeConditionalExportPackage(tmpDir, { + directory: ESM_EXTERNALS_APP_TRANSITIVE_PACKAGE, + packageType: "module", + browserFile: "browser.mjs", + importFile: "correct.js", + requireFile: "wrong.cjs", + importSource: APP_TRANSITIVE_WORLD_SOURCE, + requireSource: WRONG_CJS_SOURCE, + }); + writePackage( + tmpDir, + "app-wrapper-package", + { name: "app-wrapper-package", type: "module", exports: { ".": "./index.js" } }, + { + "index.js": `import World from "${ESM_EXTERNALS_APP_TRANSITIVE_PACKAGE}/entry";\n\nexport default World;\n`, + }, + ); + writePackage( + tmpDir, + "fail", + { name: "fail", type: "module", exports: "./index.js" }, + { "index.js": `throw new Error('Dead dynamic import should not execute')\n` }, + ); + writePackage( + tmpDir, + "preact", + { name: "preact", exports: { "./compat": "./compat.js" } }, + { "compat.js": `throw new Error('Should not be executed')\n` }, + ); + + const pagesSource = `import React from "preact/compat"; +import World1 from "esm-package1/entry"; +import World2 from "esm-package2/entry"; +import World3 from "invalid-esm-package/entry"; +import World4 from "extensionless-esm-package/entry"; +import World5 from "transpiled-esm-package/entry"; + +const worlds = "World+World+World+World+World"; + +export default function Index({ worlds: propWorlds = worlds }) { + return

Hello {World1}+{World2}+{World3}+{World4}+{World5}+{propWorlds}

; +} +`; + writeFile(tmpDir, "pages/static.js", pagesSource); + writeFile( + tmpDir, + "pages/ssr.js", + pagesSource.replace( + 'const worlds = "World+World+World+World+World";', + `export function getServerSideProps() { + return { props: { worlds: \`\${World1}+\${World2}+\${World3}+\${World4}+\${World5}\` } }; +}`, + ), + ); + writeFile( + tmpDir, + "pages/ssg.js", + pagesSource.replace( + 'const worlds = "World+World+World+World+World";', + `export async function getStaticProps() { + return { props: { worlds: \`\${World1}+\${World2}+\${World3}+\${World4}+\${World5}\` } }; +}`, + ), + ); + + writeFile( + tmpDir, + "app/layout.js", + `export default function Layout({ children }) { + return {children}; +} +`, + ); + writeFile( + tmpDir, + "app/server/page.js", + `import World1 from "app-esm-package1/entry"; +import World2 from "app-esm-package2/entry"; +import World3 from "app-cjs-esm-package/entry"; +import WrappedWorld from "app-wrapper-package"; + +export default function Page() { + return

Hello {World1}+{World2}+{World3}+{WrappedWorld}

; +} +`, + ); + writeFile( + tmpDir, + "app/client/page.js", + `"use client"; + +import World1 from "app-esm-package1/entry"; +import World2 from "app-esm-package2/entry"; +import World3 from "app-cjs-esm-package/entry"; + +export default function Page() { + return

Hello {World1}+{World2}+{World3}

; +} +`, + ); + + return { + root: tmpDir, + cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }), + }; +} diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index a94b60ae7..3a3db11f6 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -1369,6 +1369,17 @@ describe("resolveNextConfig serverExternalPackages", () => { }); expect(resolved.serverExternalPackages).toEqual(["payload"]); }); + + it("throws when serverExternalPackages conflicts with transpilePackages", async () => { + await expect( + resolveNextConfig({ + serverExternalPackages: ["payload", "sharp"], + transpilePackages: ["payload"], + }), + ).rejects.toThrow( + "The packages specified in the 'transpilePackages' conflict with the 'serverExternalPackages': payload", + ); + }); }); describe("resolveNextConfig serverActionsBodySizeLimit", () => { @@ -1846,6 +1857,7 @@ describe("detectNextIntlConfig", () => { serverActionsBodySizeLimit: 1 * 1024 * 1024, serverActionsBodySizeLimitLabel: "1 MB", htmlLimitedBots: undefined, + transpilePackages: [], serverExternalPackages: [], cacheHandler: undefined, cacheMaxMemorySize: undefined,