Skip to content

fix(build): externalize native ESM server packages#1993

Open
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/esm-externals-parity
Open

fix(build): externalize native ESM server packages#1993
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/esm-externals-parity

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Field Detail
Goal Match the Next.js esm-externals deploy contract for production builds.
Core change Add a server build resolver that externalizes explicit serverExternalPackages and Pages-side native ESM packages that Node can import directly.
Boundary Keep invalid ESM condition targets bundled, matching the upstream Turbopack expectation.
Primary files packages/vinext/src/plugins/server-node-externals.ts, packages/vinext/src/index.ts, tests/esm-externals.test.ts, tests/e2e/app-router/nextjs-compat/esm-externals.browser.spec.ts
Expected impact The upstream Next.js deploy fixture builds and passes instead of failing while resolving sentinel import("fail") entries.

Why

Next.js server builds do not treat every server node_modules import as something to bundle. The resolver distinguishes native ESM externals from invalid ESM condition targets, and serverExternalPackages explicitly opts app packages out of bundling. Vinext was force-bundling these package entries, so valid native ESM packages were analyzed by Rolldown and tripped the upstream fixture's sentinel import("fail") code.

Area Principle / invariant What this PR changes
Pages Router server build Valid native ESM packages can stay external when imported from Pages server code. Externalizes only package targets that Node can import directly, such as .mjs or .js under type: "module".
Invalid ESM condition targets A package whose import condition points at non-ESM-flagged .js should remain bundled in the Turbopack branch. Leaves those targets bundled, which preserves the upstream World expectation for invalid-esm-package.
App Router server packages serverExternalPackages is an explicit opt-out from server bundling. Honors the configured package names for server environments while leaving client bundles bundled.
Client compatibility Next defines process.browser differently in client and server bundles. Adds process.browser: true for client and overlays false for non-client environments.

What changed

Scenario Before After
Pages /static, /ssr, /ssg with valid ESM packages Build tried to bundle package entries and resolved fixture sentinel import("fail"). Valid native ESM package entries stay external and the upstream expected text renders.
Pages invalid ESM package Risk of treating it like a valid ESM external. The non-ESM-flagged .js target remains bundled and renders World, matching the Turbopack branch.
App /server, /client packages in serverExternalPackages Server build still bundled package entries. Server environments externalize them, while browser bundles still use browser condition files.
Browser condition files using process.browser Browser chunks could throw ReferenceError: process is not defined. Client builds inline process.browser as true, matching Next's define environment.
Maintainer review path
  1. packages/vinext/src/plugins/server-node-externals.ts
    Review the resolver gates: build-only, server-only, explicit package externalization, Pages-side native ESM check, and conservative non-JS import guard.

  2. packages/vinext/src/index.ts
    Review plugin registration placement and the process.browser define split.

  3. tests/helpers/esm-externals-fixture.ts
    Review the ported fixture package shapes against upstream package exports and route structure.

  4. tests/esm-externals.test.ts and tests/e2e/app-router/nextjs-compat/esm-externals.browser.spec.ts
    Review the SSR and browser assertions against the upstream Turbopack expected strings.

Validation

Added coverage:

  • tests/esm-externals.test.ts covers production build plus SSR output for the ported fixture.
  • tests/e2e/app-router/nextjs-compat/esm-externals.browser.spec.ts covers hydrated browser text for the same routes.

Commands run:

vp check packages/vinext/src/index.ts packages/vinext/src/plugins/server-node-externals.ts tests/helpers/esm-externals-fixture.ts tests/esm-externals.test.ts tests/e2e/app-router/nextjs-compat/esm-externals.browser.spec.ts
vp test run tests/esm-externals.test.ts tests/server-externals-manifest.test.ts tests/resolve-alias-build.test.ts
PLAYWRIGHT_PROJECT=app-router-chrome-browser-specific vp exec playwright test tests/e2e/app-router/nextjs-compat/esm-externals.browser.spec.ts --project=app-router-chrome-browser-specific
vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/esm-externals/esm-externals.test.ts

The upstream deploy run passed all 10 assertions for test/e2e/esm-externals/esm-externals.test.ts.

Risk / compatibility
  • Public API: no new public API.
  • Config impact: serverExternalPackages now affects the build resolver for Node production builds in the tested server environments.
  • Runtime impact: valid ESM externals are loaded by Node at runtime instead of being bundled. Invalid ESM condition targets remain bundled.
  • Cloudflare and Nitro builds: the new resolver is disabled when those plugins are present, preserving existing bundled behavior there.
  • Existing app risk: packages with direct non-JS relative imports are kept bundled to avoid handing CSS or asset imports to Node.
Non-goals
  • This does not attempt to fully clone Next.js Webpack external type handling across every bundler condition.
  • This does not change Cloudflare or Nitro externalization policy.
  • This does not make unrelated app-route hydration diagnostics part of the esm-externals parity contract.

References

Reference Why it matters
Next.js upstream esm-externals test Defines the exact route structure and expected SSR/browser strings.
Next.js resolveExternal Shows ESM-aware external resolution and native ESM detection.
Next.js external handler Shows Pages/App layer bundling decisions and opt-out handling.
Next.js Webpack config opt-out packages Shows serverExternalPackages merged into the bundling opt-out set.
Next.js define env process.browser Shows the client/server define expected by browser condition files.
Next.js serverExternalPackages docs Documents the public configuration surface.

@pkg-pr-new

pkg-pr-new Bot commented Jun 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1993
npm i https://pkg.pr.new/vinext@1993

commit: b332e28

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/esm-externals-parity branch 3 times, most recently from a828411 to fe42289 Compare June 13, 2026 06:20
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 13, 2026 06:23
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/esm-externals-parity branch 2 times, most recently from 117b914 to 2e5eeeb Compare June 14, 2026 06:48
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for correctness and parity issues

@ask-bonk ask-bonk Bot left a comment

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.

Reviewed for correctness and Next.js parity. The core approach is sound and the fixture is a faithful port of the upstream esm-externals test. The Pages-Router native-ESM detection and the process.browser define split both look correct. A few parity gaps and a redundancy worth discussing:

1. serverExternalPackages vs transpilePackages precedence diverges from Next.js (parity). Next.js (webpack-config.ts) does two things this PR doesn't: (a) it throws if a package appears in both lists, and (b) when computing optOutBundlingPackages it filters out anything in transpilePackages — so transpile always wins. This PR checks serverExternalPackages first and returns external: true before the transpilePackages check, so if a package is in both, vinext externalizes it (the opposite of Next.js). The fixture keeps the two sets disjoint so this isn't exercised by tests. Consider checking transpilePackages first, and/or adding the conflict validation in next-config.ts.

2. serverExternalPackages externalization appears redundant with existing ssr.external wiring. index.ts already merges serverExternalPackages into userSsrExternal and propagates it to both the RSC (~L2195) and SSR (~L2231) resolve.external configs. The new plugin's explicit branch (server-node-externals.ts L275-278) seems to duplicate that. If the existing path is insufficient (e.g. timing/enforce ordering), a comment explaining why both are needed would help; otherwise this branch may be removable, leaving the genuinely new Pages-Router native-ESM detection as the plugin's purpose.

3. Native-ESM detection is heuristic and entry-only. hasNodeUnsupportedRelativeImport text-scans only the resolved entry file's direct relative imports. A native-ESM entry that re-exports through a .js chain importing CSS/assets transitively would still be externalized and could fail at runtime under Node. This matches the PR's documented "conservative" intent, but the regex also scans strings/comments and could false-positive (forcing bundling) on import-like text inside template literals. Acceptable as a safety bias, just noting the limitation.

No blocking issues; the divergences in (1) are the most important for parity.

if (explicitServerExternal) {
return { id, external: true };
}
if (options.getTranspilePackages().includes(packageName)) return null;

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.

Parity: Next.js gives transpilePackages precedence over serverExternalPackages (and actually throws if a package is in both). Here serverExternalPackages is checked first and short-circuits with external: true, so a package present in both lists gets externalized — the opposite of Next.js. Consider checking transpile first:

Suggested change
if (options.getTranspilePackages().includes(packageName)) return null;
if (options.getTranspilePackages().includes(packageName)) return null;
const explicitServerExternal = options.getServerExternalPackages().includes(packageName);
if (explicitServerExternal) {
return { id, external: true };
}

The fixture keeps the two sets disjoint, so the current ordering isn't caught by tests. See webpack-config.ts: optOutBundlingPackages = EXTERNAL_PACKAGES.concat(serverExternalPackages).filter((pkg) => !finalTranspilePackages?.includes(pkg)).


const explicitServerExternal = options.getServerExternalPackages().includes(packageName);
if (explicitServerExternal) {
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.

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.

);
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.

@ask-bonk

ask-bonk Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #1993. Here's a summary of what I found:

Review Summary

The PR's core approach is sound — the resolver correctly distinguishes native ESM externals from invalid ESM condition targets, the test fixture is a faithful port of upstream Next.js's esm-externals test, and the process.browser define split (top-level true for client, overridden to false for server environments) matches Next.js's define-env.ts.

I posted one COMMENT-level review with 4 inline comments. Key findings:

1. Parity divergence — serverExternalPackages vs transpilePackages precedence (most important). Next.js's webpack-config.ts (a) throws if a package is in both lists, and (b) filters transpilePackages out of the opt-out set so transpile always wins. This PR checks serverExternalPackages first and returns external: true before checking transpilePackages (server-node-externals.ts:275-279), so a package in both gets externalized — the opposite of Next.js. The fixture keeps the sets disjoint, so tests don't catch it. Suggested reordering and/or adding conflict validation.

2. Redundancy. serverExternalPackages is already merged into userSsrExternal in index.ts and propagated to both RSC and SSR resolve.external configs. The plugin's explicit serverExternalPackages branch appears to duplicate that — worth a clarifying comment or removal.

3. Heuristic limitation. hasNodeUnsupportedRelativeImport only scans the entry file's direct relative imports (not transitive), and the regex matches import-like text in strings/comments. Acceptable conservative bias, but worth documenting.

No blocking issues were found — the parity divergence in (1) is the most significant.

github run

Vinext bundled almost every server node_modules dependency during production builds. That diverged from Next.js Pages Router externalization and made valid ESM externals resolve sentinel dynamic imports during build.

The resolver treated app serverExternalPackages and valid Pages ESM packages the same as ordinary bundled dependencies. This broke the Next.js esm-externals deploy fixture before runtime.

Add a server build resolver that honors serverExternalPackages and externalizes only Pages-side native ESM packages that Node can import directly, while leaving invalid ESM condition targets bundled. Mirror Next's process.browser define split for client and server bundles.

Port the upstream fixture coverage into Vitest and Playwright, and verify the upstream deploy test passes.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/esm-externals-parity branch from 2e5eeeb to b332e28 Compare June 14, 2026 15:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants