Skip to content

esmExternalRequirePlugin (builtin:esm-external-require) has no effect when building through vp run #1063

Open

Description

@shoya-kimura

Summary

esmExternalRequirePlugin from Vite 8 does not take effect when building through vp run, because vp uses its own bundled Vite CLI (@voidzero-dev/vite-plus-core/dist/vite/node/cli.js) instead of the project's vite/bin/vite.js. The plugin is registered and visible in the plugin list, but the Rolldown native-layer transformation that converts CJS require() calls to ESM import statements never executes. This results in require() calls remaining in the output, which breaks deployment to ESM-only runtimes like Cloudflare Workers.

Environment

  • vite-plus: v0.1.12
  • vite: v8.0.0
  • rolldown: v1.0.0-rc.9
  • Node.js: v24.14.0 (also reproduced on v22.16.0)
  • OS: Linux (Ubuntu 24.04, tested on both GitHub Actions and devcontainer)
  • Target: Cloudflare Workers (ESM-only runtime)

Reproduction

Minimal vite.config.ts

import { cloudflare } from '@cloudflare/vite-plugin';
import { defineConfig, esmExternalRequirePlugin } from 'vite';

export default defineConfig({
  plugins: [
    esmExternalRequirePlugin(),
    cloudflare({
      configPath: './wrangler.jsonc',
      persistState: true,
    })
  ],
});

The project has CJS dependencies (e.g., pg / node-postgres) that use require() for Node.js builtins (events, crypto, util, dns, fs).

Steps

# 1. Build with vite directly — works correctly
rm -rf dist && npx vite build
grep -c 'require(' dist/output/index.js
# → 0

# 2. Build with vp — require() calls remain
rm -rf dist && npx vp run --filter "@my/package" build
grep -c 'require(' dist/output/index.js
# → 24

Expected Behavior

vp run ... build should produce the same output as npx vite build. Specifically, esmExternalRequirePlugin should convert all CJS require() calls for external modules into ESM import statements.

Actual Behavior

vp run ... build produces output containing 24 require() calls — Rolldown's __require() shim wrapping Node.js builtins from CJS dependencies:

var __require = /* @__PURE__ */ createRequire(import.meta.url);
// ...
__require("util")
__require("crypto")
__require("fs")
__require("dns")
__require("events")

Deploying this to Cloudflare Workers fails with:

Uncaught Error: Calling `require` for "events" in an environment
that doesn't expose the `require` function.
[code: 10021]

Root Cause Analysis

The plugin is registered but the native transform does not execute

I instrumented the buildStart hook to inspect the plugin pipeline in both code paths:

npx vite build npx vp run ... build
Vite CLI binary vite@8.0.0/bin/vite.js vite-plus-core@0.1.12/dist/vite/node/cli.js
Total plugins 45 45
builtin:esm-external-require registered Yes Yes
Modules transformed 622 612
Output size 1,109.01 kB 1,106.65 kB
require() calls in output 0 24

The 10-module difference (622 vs 612) corresponds exactly to the synthetic import-wrapper modules that esmExternalRequirePlugin generates when working correctly.

What differs

The only observable differences between the two code paths:

  1. The Vite CLI entry point: vp uses its own bundled copy at @voidzero-dev/vite-plus-core/dist/vite/node/cli.js rather than the project's vite/bin/vite.js.
  2. One extra env var: vp sets VITE_PLUS_VERSION=0.1.12.

Everything else is identical: same cwd, same NODE_ENV, same config file, same plugin count, same plugin names.

Hypothesis

esmExternalRequirePlugin registers a Rolldown builtin plugin (builtin:esm-external-require) that operates in Rolldown's native (Rust) layer. When vp's bundled Vite CLI invokes Rolldown, the builtin plugin identifier is likely not being correctly resolved or passed through to the native layer. The plugin object exists in the JavaScript plugin array (hence it shows up in buildStart introspection), but the native-side transformation that actually rewrites the require() calls is never triggered.

Workaround

Use npx vite build directly instead of vp run ... build for affected packages.

Impact

This is a build-correctness issue that is silent at build time — no warnings or errors are emitted, and the build completes successfully. The problem only manifests at deploy or runtime. Any project using vp run + esmExternalRequirePlugin + an ESM-only runtime will produce broken output.

Minimal Reproduction Repository

https://github.com/shoya-kimura/vite-plus-repro-1063

git clone https://github.com/shoya-kimura/vite-plus-repro-1063
cd vite-plus-repro-1063
pnpm install

# Works — 0 require() in output
rm -rf dist && npx vite build
grep -c 'require(' dist/vp_repro/index.js  # → 0

# Broken — 24 require() in output
rm -rf dist && npx vp run build
grep -c 'require(' dist/vp_repro/index.js  # → 24

Activity

  1. claude commented on Mar 20, 2026

    @claude
  2. smorimoto commented on Apr 29, 2026

    @smorimoto

    We are hitting the exact same bug from a different angle, which I think strengthens the version-mismatch hypothesis. Sharing the data in case it helps narrow down the fix.

    Environment

    • vite-plus: 0.1.20 (latest)
    • @cloudflare/vite-plugin: 1.34.0 (the version that ships esmExternalRequirePlugin registration via configEnvironment)
    • rolldown: 1.0.0-rc.17 (bundled by vite-plus-core 0.1.20)
    • Bun: 1.3.13
    • macOS (and Linux on Cloudflare Workers Builds)
    • Target: Cloudflare Workers (TanStack Start, deployed via wrangler deploy)

    Reproduction differs from #1063 in two ways

    1. No vp run involved. We invoke the build through plain bun run build, which calls prisma generate && CLOUDFLARE_ENV=production vp build. The vp binary here is vite-plus's direct CLI (not the workspace runner), so the bundled-vs-installed Vite CLI distinction from your reproduction doesn't apply.
    2. The plugin we never register ourselves. esmExternalRequirePlugin is registered solely by @cloudflare/vite-plugin@1.34.0's nodejs-compat plugin via its configEnvironment hook. There is no user-side import of the plugin.

    Yet the symptom is identical: pg and @prisma/adapter-pg ship CJS that calls require("events"), require("crypto"), require("net"), etc., and these survive bundling as raw __require() stubs, so Cloudflare's deploy-time validation rejects the worker with [code: 10021].

    Plugin reaches rolldown with the right config

    I patched node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/chunks/node.js to log the plugin state at three points in the pipeline. In all three the plugin is present and configured:

    [vite:rollup-options-plugins] env=ssr count=1 names=builtin:esm-external-require
    [esm-external] env=ssr _options.external.length=110 skip=true enforce=pre keys=enforce,name,_options
    [resolveRolldownOptions] env=ssr final plugins=...,vite:prepare-out-dir,builtin:esm-external-require,builtin:vite-dynamic-import-vars,...
    

    110 externals (including events and node:events), skipDuplicateCheck: true, enforce: pre, all preserved through injectEnvironmentToHooks. The plugin object is in the array passed to rolldown(rollupOptions), but the native transform never fires.

    Surprising trigger: bun's linker mode

    The behaviour is gated by Bun's package layout. With our bunfig.toml set to:

    [install]
    linker = "isolated"

    the cloudflare-side automatic registration works correctly — output has zero __require() calls, require_builtin_esm_external_* shims are generated. With:

    [install]
    # linker = "isolated"   <-- removed; default flat layout

    the same vite.config.ts, same @cloudflare/vite-plugin version, same vite-plus version produces broken output (24+ raw __require("events") calls in the pg chunk). Reverting linker = "isolated" restores the working behaviour without any other code change.

    This fits @claude's version-mismatch hypothesis at the bot comment, but from a slightly different angle: under Bun's isolated linker, top-level node_modules/<pkg> entries are symlinks into node_modules/.bun/<pkg>@<ver>+<hash>/.... With the flat linker, packages are physically duplicated. Either way Node's resolver should land on the same realpath, but rolldown's native binding may key off something layout-sensitive (e.g. the path used for the BuiltinPlugin identity check, or which @rolldown/binding-* instance is loaded by which call site).

    Workaround

    Re-registering esmExternalRequirePlugin from user-side vite.config.ts works regardless of linker mode:

    import { nonPrefixedNodeModules } from "@cloudflare/unenv-preset";
    import { defineConfig, esmExternalRequirePlugin } from "vite-plus";
    
    export default defineConfig({
      build: {
        rolldownOptions: {
          plugins: [
            esmExternalRequirePlugin({
              external: [
                ...nonPrefixedNodeModules,
                ...nonPrefixedNodeModules.map((m) => `node:${m}`),
              ],
              skipDuplicateCheck: true,
            }),
          ],
        },
      },
      plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }) /* ... */],
    });

    Adding the same plugin again (with the same externals) makes the transformation succeed, even though @cloudflare/vite-plugin's instance is still registered. So the user-side registration path is somehow privileged relative to the configEnvironment path, even when both end up at the same logical position in environment.plugins.

    Happy to put together a minimal repro repo (Bun + @cloudflare/vite-plugin + pg) if that would help. We have the failure reproduced reliably on Workers Builds CI.

  3. added theissue type on May 18, 2026
  4. added this to the Beta milestone on May 18, 2026
  5. modified the milestones: Beta, RC on Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

Priority

None yet

Effort

None yet

Target date

None yet

Start date

None yet

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions