This project showcases how different bundlers and runtimes resolve imports from a package that uses exports maps in its package.json. The goal is to understand the inconsistencies and document them.
The test suite in packages/import-test/bundler.test.ts runs a series of tests against different bundlers (ESBuild, Vite, Webpack, RsBuild) and runtimes (Node.js, Bun, Deno, Cloudflare Workerd). It checks which file is resolved for different subpaths of an example package (@jkomyno/exported-pkg).
The exports map in @jkomyno/exported-pkg is structured as follows:
package.json#/exports/.: Simplest entry, with no platform-specific condition."exports": { ".": { "types": "./src/index.d.ts", "require": "./src/index.cjs", "import": "./src/index.mjs", "default": "./src/index.js" } }
package.json#/exports/client: Uses conditional runtime names according to the WinterCG Runtime Keys specification, but it doesn't includenode."exports": { ".": { "./client": { "types": "./src/client/client.d.ts", "workerd": { "import": "./src/client/client-workerd.ts" }, "bun": { "import": "./src/client/client-bun.ts" }, "deno": { "import": "./src/client/client-deno.ts", "default": "./src/client/client-deno.js" }, "require": "./src/client/client.cjs", "import": "./src/client/client.mjs", "default": "./src/client/client.js" } } }
package.json#/exports/runtime: Uses conditional runtime names, includingnode."exports": { "./runtime": { "types": "./src/runtime/runtime.d.ts", "worker": { "import": "./src/runtime/runtime-worker.ts" }, "workerd": { "import": "./src/runtime/runtime-workerd.ts" }, "bun": { "import": "./src/runtime/runtime-bun.ts" }, "deno": { "import": "./src/runtime/runtime-deno.ts", "default": "./src/runtime/runtime-deno.js" }, "node": { "types": "./src/runtime/runtime-node.d.ts", "require": "./src/runtime/runtime-node.cjs", "import": "./src/runtime/runtime-node.mjs", "default": "./src/runtime/runtime-node.js" }, "require": "./src/runtime/runtime.cjs", "import": "./src/runtime/runtime.mjs", "default": "./src/runtime/runtime.js" } }
The main takeaway is that package resolution behavior is not consistent among bundlers. It's also influenced by bundler-specific settings, such as platform on ESBuild, or by plugins in Vite.
| Runtime | Behavior |
|---|---|
| Bun | Prefers bun.import, falling back to import. It can resolve TypeScript files without requiring any transpilation. |
| Deno | Prefers deno.import, falling back to import. It can resolve TypeScript files without requiring any transpilation. |
| Cloudflare Workerd | Prefers workerd.import. when resolving TypeScript files. |
Here's a summary of how different bundlers resolve package exports. The tests are run for both CommonJS (CJS) and ECMAScript Modules (ESM) outputs.
ESBuild's resolution is heavily influenced by the platform setting. Its behavior can be further customized by specifying custom conditions in the build options.
With default conditions:
| Platform | Format | index resolution |
client resolution |
runtime resolution |
|---|---|---|---|---|
node |
CJS | exports['.'].import |
exports['.'].import |
exports['.'].node.import |
node |
ESM | exports['.'].import |
exports['.'].import |
exports['.'].node.import |
neutral |
CJS | exports['.'].import |
exports['.'].import |
exports['.'].import |
neutral |
ESM | exports['.'].import |
exports['.'].import |
exports['.'].import |
With custom conditions:
- When
format: 'cjs', setconditions: ['require']
| Platform | Format | index resolution |
client resolution |
runtime resolution |
|---|---|---|---|---|
node |
CJS | exports['.'].require |
exports['.'].require |
exports['.'].node.require |
neutral |
CJS | exports['.'].require |
exports['.'].require |
exports['.'].require |
- With
platform: 'node': ESBuild correctly prefers thenodeconditional export forpackage.json#/exports/runtime, but it defaults toimportsubpaths even whenformat: 'cjs'. While this is documented behavior, it can lead to errors when library authors expectrequireto be used for CJS outputs. The workaround is to setconditions: ['require']in the build options. - With
platform: 'neutral': ESBuild falls back to the standardrequireandimportfields, ignoring thenodeconditional export as intended.
Rolldown's resolution behavior is very similar to ESBuild, as it's designed to be compatible with ESBuild's API and behavior.
With default conditionNames:
| Platform | Format | index resolution |
client resolution |
runtime resolution |
|---|---|---|---|---|
node |
CJS | exports['.'].import |
exports['.'].import |
exports['.'].node.import |
node |
ESM | exports['.'].import |
exports['.'].import |
exports['.'].node.import |
neutral |
CJS | exports['.'].import |
exports['.'].import |
exports['.'].import |
neutral |
ESM | exports['.'].import |
exports['.'].import |
exports['.'].import |
With custom conditionNames:
- When
format: 'cjs', setconditionNames: ['require']
| Platform | Format | index resolution |
client resolution |
runtime resolution |
|---|---|---|---|---|
node |
CJS | exports['.'].require |
exports['.'].require |
exports['.'].node.require |
neutral |
CJS | exports['.'].require |
exports['.'].require |
exports['.'].require |
- With
platform: 'node': Rolldown correctly prefers thenodeconditional export forpackage.json#/exports/runtime, but like ESBuild, it defaults toimportsubpaths even whenformat: 'cjs'. The workaround is to setconditionNames: ['require']in the resolve options. - With
platform: 'neutral': Rolldown falls back to the standardrequireandimportfields, ignoring thenodeconditional export as intended.
Vite's behavior is consistent and predictable.
| Format | index resolution |
client resolution |
runtime resolution |
|---|---|---|---|
| CJS | exports['.'].require |
exports['.'].require |
exports['.'].require |
| ESM | exports['.'].import |
exports['.'].import |
exports['.'].import |
Vite does not follow the WinterCG Runtime Keys specification, so it never picks the node conditional export.
When using the @cloudflare/vite-plugin, Vite's behavior changes to prefer exports['.'].workerd.import over exports['.'].import. Only the ESM format is supported by the Cloudflare Workerd runtime.
| Format | index resolution |
client resolution |
runtime resolution |
|---|---|---|---|
| ESM | exports['.'].import |
exports['.'].workerd.import |
exports['.'].workerd.import |
Webpack shows an interesting behavior, especially with ESM modules.
| Format | index resolution |
client resolution |
runtime resolution |
|---|---|---|---|
| CJS | exports['.'].require |
exports['.'].require |
exports['.'].node.require |
| ESM | exports['.'].require |
exports['.'].require |
exports['.'].node.require |
- For CJS: Webpack correctly uses
node.requirefor theruntimesubpath. - For ESM: Webpack still resolves to
requireandnode.requirepaths, which can be unexpected when bundling for an ESM target. This is likely due to how Webpack'starget: 'node'interacts with module resolution.
RsBuild's behavior is similar to ESBuild with platform: 'node'.
| Format | index resolution |
client resolution |
runtime resolution |
|---|---|---|---|
| ESM | exports['.'].import |
exports['.'].import |
exports['.'].node.import |
RsBuild correctly uses the node conditional export for the package.json#/exports/runtime when bundling for ESM.
First, install the dependencies:
bun installTo run the test suite, which validates the behavior of different bundlers and runtimes, run:
bun testThe way bundlers resolve package exports can be complex and depends on their configuration. For library authors, it's crucial to provide a comprehensive exports map to support various environments. For application developers, it's important to understand how their chosen bundler is configured, as it can affect which version of a library's code is included in the final bundle.
This investigation highlights the importance of testing against multiple bundlers to ensure that a package behaves as expected across the JavaScript ecosystem.
-
Why did you create this project?
I was going crazy trying to debug an issue involving
@prisma/client, ESBuild, andformat: 'cjs'. That lead me to investigate how different bundlers resolve package exports, especially when using theexportsfield inpackage.json.
Hi, I'm Alberto Schiabel, you can follow me on:
Give a βοΈ if this project helped or inspired you!
Built with β€οΈ by Alberto Schiabel.
This project is MIT licensed.