diff --git a/packages/vinext/src/config/server-external-packages.ts b/packages/vinext/src/config/server-external-packages.ts new file mode 100644 index 000000000..4dde1dbad --- /dev/null +++ b/packages/vinext/src/config/server-external-packages.ts @@ -0,0 +1,83 @@ +// Mirrors Next.js v16.2.6's default server-external package list. +// Source: packages/next/src/lib/server-external-packages.jsonc +export const NEXT_SERVER_EXTERNAL_PACKAGES = [ + "@alinea/generated", + "@appsignal/nodejs", + "@aws-sdk/client-s3", + "@aws-sdk/s3-presigned-post", + "@blockfrost/blockfrost-js", + "@highlight-run/node", + "@huggingface/transformers", + "@jpg-store/lucid-cardano", + "@libsql/client", + "@mikro-orm/core", + "@mikro-orm/knex", + "@node-rs/argon2", + "@node-rs/bcrypt", + "@prisma/client", + "@react-pdf/renderer", + "@sentry/profiling-node", + "@sparticuz/chromium", + "@sparticuz/chromium-min", + "@statsig/statsig-node-core", + "@swc/core", + "@xenova/transformers", + "@zenstackhq/runtime", + "argon2", + "autoprefixer", + "aws-crt", + "bcrypt", + "better-sqlite3", + "canvas", + "chromadb-default-embed", + "config", + "cpu-features", + "cypress", + "dd-trace", + "eslint", + "express", + "firebase-admin", + "htmlrewriter", + "import-in-the-middle", + "isolated-vm", + "jest", + "jsdom", + "keyv", + "libsql", + "mdx-bundler", + "mongodb", + "mongoose", + "newrelic", + "next-mdx-remote", + "next-seo", + "node-cron", + "node-pty", + "node-web-audio-api", + "onnxruntime-node", + "oslo", + "pg", + "pino", + "pino-pretty", + "pino-roll", + "playwright", + "playwright-core", + "postcss", + "prettier", + "prisma", + "puppeteer", + "puppeteer-core", + "ravendb", + "require-in-the-middle", + "rimraf", + "sharp", + "shiki", + "sqlite3", + "thread-stream", + "ts-morph", + "ts-node", + "typescript", + "vscode-oniguruma", + "webpack", + "websocket", + "zeromq", +] as const; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 916ca20db..8e4bfdb02 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -69,6 +69,7 @@ import { type NextConfigInput, type ResolvedNextConfig, } from "./config/next-config.js"; +import { NEXT_SERVER_EXTERNAL_PACKAGES } from "./config/server-external-packages.js"; import { findMiddlewareFile, runMiddleware } from "./server/middleware.js"; import { isNextDataPathname, parseNextDataPathname } from "./server/pages-data-route.js"; @@ -226,6 +227,10 @@ function isInsideDirectory(dir: string, filePath: string): boolean { return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); } +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} + function hasServerOnlyMarkerImport(code: string): boolean { if (!code.includes("server-only")) return false; @@ -1747,6 +1752,36 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Next emits CSS url() deps as files, not inlined data URLs. A user's // explicit `build.assetsInlineLimit` always wins. clientAssetsInlineLimit = config.build?.assetsInlineLimit ?? 0; + // Next.js skips bundling a default native/heavy package list plus + // `serverExternalPackages`. Apply that single policy to every Node + // server build boundary; Vite's top-level `ssr.*` does not cover + // custom RSC/SSR environments. + const configuredServerExternalPackages = nextConfig.serverExternalPackages; + const effectiveServerExternalPackages = uniqueStrings([ + ...NEXT_SERVER_EXTERNAL_PACKAGES, + ...configuredServerExternalPackages, + ]); + const userSsrExternal: string[] | true = Array.isArray(config.ssr?.external) + ? uniqueStrings([...config.ssr.external, ...effectiveServerExternalPackages]) + : config.ssr?.external === true + ? true + : effectiveServerExternalPackages; + const pagesNodeServerExternal: string[] | true = + userSsrExternal === true + ? true + : uniqueStrings([ + "react", + "react-dom", + "react-dom/server", + "ipaddr.js", + ...userSsrExternal, + ]); + const appRscServerExternal: string[] | true = + userSsrExternal === true + ? true + : uniqueStrings(["satori", "@resvg/resvg-js", "yoga-wasm-web", ...userSsrExternal]); + const appSsrServerExternal: string[] | true = + userSsrExternal === true ? true : uniqueStrings([...userSsrExternal, "ipaddr.js"]); const devHmrConfig = config.server?.hmr === false ? false @@ -1975,7 +2010,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? { ssr: { external: true as const } } : { ssr: { - external: ["react", "react-dom", "react-dom/server", "ipaddr.js"], + external: pagesNodeServerExternal, noExternal: true, }, }), @@ -2129,28 +2164,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }; - // Collect user-provided ssr.external so we can propagate it into - // both the RSC and SSR environment configs. Vite's `ssr.*` config - // only applies to the default `ssr` environment, not custom ones - // like `rsc`. Native addon packages (e.g. better-sqlite3) listed - // in ssr.external must be externalized from ALL server environments. - // Vite's SSROptions.external is `string[] | true`; handle both forms. - // - // Also merge in `serverExternalPackages` from next.config (and the - // legacy `experimental.serverComponentsExternalPackages` alias). These - // are packages that Next.js intentionally skips bundling and loads - // natively — e.g. packages that import Node-specific entry points via - // conditional exports (like `file-type` which exports `fileTypeFromFile` - // only from its `node` condition, not from the universal `default` one). - // Without externalizing them, Vite's optimizer picks the wrong export - // condition and the build fails with MISSING_EXPORT errors. - const nextServerExternal: string[] = nextConfig?.serverExternalPackages ?? []; - const userSsrExternal: string[] | true = Array.isArray(config.ssr?.external) - ? [...config.ssr.external, ...nextServerExternal] - : config.ssr?.external === true - ? true - : nextServerExternal; - // Capture top-level optimizeDeps populated by earlier plugins // (e.g. @lingui/vite-plugin) so we merge rather than overwrite. // Moved above the hasAppDir branch so both Pages Router and App @@ -2237,10 +2250,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Note: Do NOT externalize react/react-dom here — they must // be bundled with the "react-server" condition for RSC. // Skip when targeting bundled runtimes (Cloudflare/Nitro). - external: - userSsrExternal === true - ? true - : ["satori", "@resvg/resvg-js", "yoga-wasm-web", ...userSsrExternal], + external: appRscServerExternal, // Force all node_modules through Vite's transform pipeline // so non-JS imports (CSS, images) don't hit Node's native // ESM loader. Matches Next.js behavior of bundling everything. @@ -2280,7 +2290,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? {} : { resolve: { - external: userSsrExternal === true ? true : [...userSsrExternal, "ipaddr.js"], + external: appSsrServerExternal, // Force all node_modules through Vite's transform pipeline // so non-JS imports (CSS, images) don't hit Node's native // ESM loader. Matches Next.js behavior of bundling everything. @@ -2332,17 +2342,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { consumer: "client", optimizeDeps: { // Exclude server-external packages from the client dep optimizer. - // These packages are server-only by design (listed in next.config's - // `serverExternalPackages`). If the client optimizer crawls into - // them through app/ entries, it will use browser export conditions - // and pick the wrong conditional export (e.g. `file-type` exports - // `fileTypeFromFile` only from its `node` condition via `index.js`, - // but the browser optimizer resolves to `core.js` which lacks it, - // causing MISSING_EXPORT build failures). + // These packages are server-only by design (Next.js defaults plus + // next.config's `serverExternalPackages`). If the client optimizer + // crawls into them through app/ entries, it will use browser export + // conditions and pick the wrong conditional export (e.g. `file-type` + // exports `fileTypeFromFile` only from its `node` condition via + // `index.js`, but the browser optimizer resolves to `core.js` which + // lacks it, causing MISSING_EXPORT build failures). exclude: mergeOptimizeDepsExclude( incomingExclude, VINEXT_OPTIMIZE_DEPS_EXCLUDE, - nextServerExternal, + effectiveServerExternalPackages, ), // Crawl app/ source files up front so client-only deps imported // by user components are discovered during startup instead of @@ -2429,10 +2439,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, ssr: { - resolve: { - external: ["react", "react-dom", "react-dom/server", "ipaddr.js"], - noExternal: true as const, - }, + resolve: + pagesNodeServerExternal === true + ? { external: true as const } + : { + external: pagesNodeServerExternal, + noExternal: true as const, + }, optimizeDeps: { // `ipaddr.js` is imported by the next/image shim for // private-IP validation and is externalized via diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 4470fd2ce..b43c37ebd 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -34,6 +34,7 @@ import { collectAssetTags } from "../packages/vinext/src/server/pages-asset-tags import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js"; import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js"; +import { NEXT_SERVER_EXTERNAL_PACKAGES } from "../packages/vinext/src/config/server-external-packages.js"; // Create a clientManualChunks instance with a test shims directory. // The exact path doesn't matter for the node_modules-focused tests; @@ -474,6 +475,112 @@ describe("optimizeDeps.exclude for vinext", () => { } }, 15000); + it("externalizes Next.js default server packages in App Router Node environments", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const plugins = vinext(); + const mainPlugin = plugins.find( + (p: any) => p.name === "vinext:config" && typeof p.config === "function", + ); + expect(mainPlugin).toBeDefined(); + + const os = await import("node:os"); + const fsp = await import("node:fs/promises"); + const path = await import("node:path"); + + const tmpDir = await fsp.mkdtemp( + path.join(os.tmpdir(), "vinext-ts-test-default-server-externals-app-"), + ); + const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules"); + await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction"); + await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "app", "layout.tsx"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { return
{children}; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "app", "page.tsx"), + `export default function Home() { returnindex page
+{JSON.stringify(props)}
+ > + ); +} +`, + ); + + await fsp.writeFile( + path.join(root, "pages", "blog", "[slug].tsx"), + `import path from "node:path"; +import { open } from "sqlite"; +import sqlite3 from "sqlite3"; +import { useRouter } from "next/router"; + +export const getStaticProps = async ({ params }) => { + const dbPath = path.join(process.cwd(), "data.sqlite"); + + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, + }); + + const users = await db.all("SELECT * FROM users"); + + return { + props: { + users, + blog: true, + params: params || null, + }, + }; +}; + +export const getStaticPaths = () => { + return { + paths: ["/blog/first"], + fallback: true, + }; +}; + +export default function Page(props) { + const router = useRouter(); + + if (router.isFallback) { + return "Loading..."; + } + + return ( + <> +blog page
+{JSON.stringify(props)}
+ > + ); +} +`, + ); +} + +async function buildPagesFixtureToOutDir(root: string, outDir: string): Promise([^<]*)
`)); + expect(match).not.toBeNull(); + return decodeHtmlText(match![1]); +} + +async function fetchHtml(baseUrl: string, pathname: string): Promise