Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/vinext/src/config/server-external-packages.ts
Original file line number Diff line number Diff line change
@@ -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;
93 changes: 53 additions & 40 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
}),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions tests/build-optimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <html><body>{children}</body></html>; }`,
);
await fsp.writeFile(
path.join(tmpDir, "app", "page.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default { serverExternalPackages: ["custom-native-package"] };`,
);

try {
const mockConfig = { root: tmpDir, build: {}, plugins: [] };
const result = await (mainPlugin as any).config(mockConfig, {
command: "serve",
});

expect(NEXT_SERVER_EXTERNAL_PACKAGES).toContain("sqlite3");

const rscExternal = result.environments.rsc.resolve?.external ?? [];
expect(rscExternal).toContain("sqlite3");
expect(rscExternal).toContain("custom-native-package");

const ssrExternal = result.environments.ssr.resolve?.external ?? [];
expect(ssrExternal).toContain("sqlite3");
expect(ssrExternal).toContain("custom-native-package");
expect(ssrExternal).toContain("ipaddr.js");
expect(result.environments.ssr.resolve?.noExternal).toBe(true);

const clientExclude = result.environments.client.optimizeDeps?.exclude ?? [];
expect(clientExclude).toContain("sqlite3");
expect(clientExclude).toContain("custom-native-package");
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15000);

it("externalizes Next.js default server packages in plain Pages Router Node builds", 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-pages-"),
);
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, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default { serverExternalPackages: ["custom-native-package"] };`,
);

try {
const mockConfig = { root: tmpDir, build: {}, plugins: [] };
const result = await (mainPlugin as any).config(mockConfig, {
command: "serve",
});

const topLevelExternal = result.ssr?.external ?? [];
expect(topLevelExternal).toContain("sqlite3");
expect(topLevelExternal).toContain("custom-native-package");
expect(topLevelExternal).toContain("react");
expect(result.ssr?.noExternal).toBe(true);

const pagesExternal = result.environments?.ssr?.resolve?.external ?? [];
expect(pagesExternal).toContain("sqlite3");
expect(pagesExternal).toContain("custom-native-package");
expect(pagesExternal).toContain("react");
expect(result.environments?.ssr?.resolve?.noExternal).toBe(true);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15000);

// Regression: `ipaddr.js` is imported by the next/image client shim for
// server-side private-IP validation. It's already in ssr.resolve.external,
// but the SSR dep optimizer would still pre-bundle it on first request,
Expand Down
Loading
Loading