vite-legacy-pass-through - v1.3.0
    Preparing search index...

    vite-legacy-pass-through - v1.3.0

    vite-legacy-pass-through ⚔

    npm version npm downloads license CI

    A Vite plugin that marks legacy libraries as external, preventing Rolldown from bundling them and causing CommonJS interop errors at runtime.


    🧩 The story behind this plugin

    This plugin was born out of a real-world headache while juggling a legacy component library and a newer one built on top of it.

    The setup looked like this:

    • šŸ›ļø lib-legacy — an older component library with prop-types as a dependency. Used directly inside a Vite-powered web app, everything worked perfectly fine.
    • ✨ lib-awesome — a newer library built to override and extend UI and functionality from lib-legacy. Its components imported from lib-legacy, added behaviour, and re-exported them.

    The problem surfaced the moment lib-awesome was built with Vite 8. Because it imported components from lib-legacy and re-exported them, Rolldown pulled prop-types deep into the bundle. The output contained a file named something like prop-types-a1b2c3d4.js with a bare require(...) call — which blew up at runtime in ESM environments:

    ReferenceError: require is not defined
    at prop-types-a1b2c3d4.js:1:1

    After a lot of reading about how Vite 8 and Rolldown handle module bundling and CJS/ESM interop, the cleanest escape hatch turned out to be telling Rolldown: "don't touch lib-legacy — let it pass through as-is."

    That's exactly what this plugin does.

    flowchart TD
    subgraph without["āŒ Without the plugin"]
    A[lib-awesome] -->|imports & re-exports| B[lib-legacy]
    B -->|has dependency| C[prop-types CJS]
    A -->|build| D[Rolldown bundles everything]
    D --> E["prop-types-a1b2c3d4.js\nāš ļø require() call inside"]
    E --> F["šŸ’„ ReferenceError: require is not defined"]
    end

    subgraph with["āœ… With vite-legacy-pass-through"]
    G[lib-awesome] -->|imports & re-exports| H[lib-legacy]
    H -->|has dependency| I[prop-types CJS]
    G -->|build| J[Rolldown sees lib-legacy as external]
    J --> K["lib-legacy stays as import statement\nāœ… no bundling, no require()"]
    K --> L["šŸš€ Works at runtime"]
    end

    āš ļø Important: Rolldown does not recommend marking packages as external this way in library builds. Doing so shifts the module resolution responsibility entirely to the consumer — they must have the library available in their environment. Use this plugin only when you understand that trade-off and the legacy library is guaranteed to be present at runtime.


    npm install -D vite-legacy-pass-through
    

    // vite.config.ts
    import { defineConfig } from 'vite'
    import { legacyPassThrough } from 'vite-legacy-pass-through'

    export default defineConfig({
    plugins: [
    legacyPassThrough({
    libs: ['lib-legacy'],
    }),
    ],
    })

    Multiple libraries:

    legacyPassThrough({
    libs: ['lib-legacy', 'another-legacy-lib'],
    })

    To externalize a bare library import as well as its subpaths:

    legacyPassThrough({
    libs: ['lib-legacy'],
    matchBareImports: true,
    })

    With logging enabled (useful during development to confirm which imports are being bypassed):

    legacyPassThrough({
    libs: ['lib-legacy'],
    showLog: true,
    })

    Output when showLog: true:

    [vite-legacy-pass-through] Resolving: lib-legacy/components/Button
    [vite-legacy-pass-through] Resolving: lib-legacy/utils/format

    Running in both build and dev (e.g. if you need it in Storybook too):

    legacyPassThrough({
    libs: ['lib-legacy'],
    apply: 'serve', // or omit for the default 'build'
    })

    Overriding the excluded extensions (replaces the default list entirely):

    import { legacyPassThrough, DEFAULT_EXCLUDE_EXTENSIONS } from 'vite-legacy-pass-through'

    legacyPassThrough({
    libs: ['lib-legacy'],
    // extend the default list
    excludeExtensions: [...DEFAULT_EXCLUDE_EXTENSIONS, '.yaml'],
    })

    Option Type Required Default Description
    libs string[] Yes — List of library names to mark as external. Names are trimmed; empty entries are ignored. At least one valid entry is required.
    apply 'build' | 'serve' No 'build' When to apply the plugin. Defaults to 'build' to avoid interfering with dev tools like Storybook.
    excludeExtensions string[] No See below File extensions to skip — imports ending with these are left for Vite to handle normally. Replaces the default list when provided.
    matchBareImports boolean No false Also externalize an exact bare import such as import 'lib-legacy'.
    showLog boolean No false Logs each resolved import to the console.

    Imports from a matched lib that end with any of these extensions are not marked as external:

    .css .scss .sass .less .styl
    .png .jpg .jpeg .gif .svg .webp
    .woff .woff2 .ttf .eot
    .json .html

    To disable exclusions entirely, pass excludeExtensions: [].

    • A library is matched only when the import starts with its full name followed by /. This supports scoped packages such as @scope/library/components/Button and prevents matching library-extra when library is configured.
    • Bare imports such as import 'library' are intentionally left untouched unless matchBareImports: true is set.
    • The default extension exclusions are case-insensitive and still apply when an import includes a Vite query or fragment, such as library/styles.css?inline.
    • When an import is externalized, its original query or fragment is preserved.

    The plugin hooks into Vite's resolveId phase with enforce: 'pre' — meaning it runs before any other plugin — and marks any import whose path starts with <lib>/ as external. Rolldown then skips bundling it entirely and leaves the import statement untouched in the output.

    import Button from 'lib-legacy/components/Button'
    ↓ resolveId hook intercepts
    { id: 'lib-legacy/components/Button', external: true }
    ↓ Rolldown skips it, output keeps the import
    import Button from 'lib-legacy/components/Button'

    Note: bare imports (import 'lib-legacy' without a subpath) are not affected — only subpath imports (lib-legacy/...) are matched. This is intentional to avoid over-matching.


    • You are building a library that imports and re-exports from a legacy package.
    • That legacy package uses CommonJS internally (e.g. prop-types, older UI kits).
    • Vite 8 / Rolldown is wrapping those CJS modules into the bundle and generating require() calls that break in ESM environments.
    • The legacy package will be available at runtime in the consumer's environment (i.e. it is a peer or runtime dependency, not something you need to ship inside your bundle).

    • Vite: ^8.0.0
    • Node.js: >=20.19.0

    Install dependencies with npm ci, then use the following commands:

    Command Purpose
    npm run check Run ESLint, Biome, TypeScript, and the test suite.
    npm run build Build ESM, CommonJS, and declaration outputs with tsup.
    npm run test:coverage Run the test suite with V8 coverage.
    npm run pack:check Preview the files that would be published to npm.
    npm audit --audit-level=high Fail on high- or critical-severity dependency advisories.
    npm run docs Generate the public API reference in docs/api.

    The release workflow uses a clean, lockfile-based install, runs these quality and security checks, and validates the package contents before publishing. Dependency and GitHub Action updates are proposed weekly by Dependabot.

    See SECURITY.md for supported versions and private vulnerability reporting instructions.

    See CONTRIBUTING.md for the development workflow, test expectations, and release requirements.


    MIT