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
17 changes: 17 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1463,6 +1466,19 @@ export async function resolveNextConfig(
experimental?.serverComponentsExternalPackages,
);
const serverExternalPackages = topLevelServerExternalPackages ?? legacyServerComponentsExternal;
const transpilePackages = Array.isArray(config.transpilePackages)
? readStringArray(config.transpilePackages)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional parity nicety: Next.js throws when a package appears in both transpilePackages and serverExternalPackages ("The packages specified in the 'transpilePackages' conflict with the 'serverExternalPackages'"). Adding the same validation here would surface misconfiguration early and make the precedence question moot.

: [];
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.
Expand Down Expand Up @@ -1616,6 +1632,7 @@ export async function resolveNextConfig(
? config.reactMaxHeadersLength
: DEFAULT_REACT_MAX_HEADERS_LENGTH,
htmlLimitedBots,
transpilePackages,
serverExternalPackages,
cacheHandler,
cacheMaxMemorySize,
Expand Down
15 changes: 15 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
311 changes: 311 additions & 0 deletions packages/vinext/src/plugins/server-node-externals.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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<string, ModuleOwnership>,
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<string, ModuleOwnership>,
): 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text scan only inspects the resolved entry file's direct relative imports — a native-ESM entry that re-exports through a .js file which transitively imports CSS/assets would still be externalized and could throw under Node at runtime. Also, the regex matches import/require-like text anywhere in the source (including strings/comments), which can false-positive and force bundling. Both are acceptable conservative biases given the PR's stated goal, but worth a comment noting the entry-only scope so future readers don't assume it's a full transitive check.

// 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<string, boolean>();
const ownershipByModule = new Map<string, ModuleOwnership>();

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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serverExternalPackages is already merged into userSsrExternal in index.ts and propagated to both the RSC and SSR resolve.external configs (around L2111-2116 / L2195 / L2231). This explicit branch looks like it duplicates that existing externalization. If the existing wiring is insufficient (ordering / enforce: "pre" reasons), a short comment here would clarify why both paths are needed; otherwise this could be dropped, leaving the Pages-Router native-ESM detection below as the plugin's distinct value.

}

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;
},
};
}
Loading
Loading