Nitro appears to be downgrading a dependency to CJS rather than using ESM
Environment
nitro v2.11.12 node v22.14.0
minimal nitro config
//https://nitro.unjs.io/config
export default defineNitroConfig({
srcDir: 'server',
preset: 'aws-lambda',
})
Reproduction
https://github.com/mckamyk/nitro-cjs-degrade
I wrote an analyze.ts script that uses depcruise to go through all of the dependencies of .output/server/index.mjs, and ranks them by the number of second-level imports on each top-level import. This helped me track down the cause of the EMFILE error in my lambda. An example of the output at the time of writing below.
../../node_modules/viem/_cjs/index.js depends on 148 files
../../node_modules/viem/_cjs/utils/index.js depends on 102 files
../../node_modules/viem/_cjs/clients/decorators/public.js depends on 52 files
../../node_modules/viem/_cjs/clients/decorators/test.js depends on 30 files
../../node_modules/viem/_cjs/clients/decorators/wallet.js depends on 23 files
../../node_modules/viem/_cjs/actions/public/call.js depends on 21 files
../../node_modules/ws/lib/websocket.js depends on 16 files
../../node_modules/abitype/dist/cjs/exports/index.js depends on 15 files
../../node_modules/viem/_cjs/actions/public/simulateBlocks.js depends on 15 files
../../node_modules/viem/_cjs/actions/public/verifyHash.js depends on 15 files
The code that was bundled to produce this is simply
import { createPublicClient, http } from 'viem'
const client = createPublicClient({
transport: http('http://localhost:8545'),
})
export default defineEventHandler(async () => {
return client.getBlockNumber().then((r) => r.toString())
})
Describe the bug
I have a dependency (viem) that has both ESM and CJS exports defined in its package.json when installed. However, when I build with nitro, it always seems to include the CJS bundle, which breaks all tree-shaking.
The problem is worsened as when I build for a aws-lambda target, and my lambda fails with EMFILE (too many open file handles), largely contributed by viem not being tree-shaken.
Additional context
I discovered this building with TanStack Start, which uses nitro via vinxi. I tested it with TanStack Start's alpha release, which removes vinxi, and the issue persisted. I narrowed it down to nitrojs in the reproduction repo.
I've tested this same code in a small React app using Vite, bun build index.ts as well as bun rollup -c with the following config
import { defineConfig } from 'rollup'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
export default defineConfig({
input: 'index.js',
output: {
dir: 'dist',
},
plugins: [nodeResolve(), commonjs()],
})
All of these alternatives correctly use ESM, and nitro seems to be the odd man out using CJS for this dependency.
Logs
❯ bun run build
$ nitro build
WARN Please add compatibilityDate: '2025-06-04' to the config file. Using 2024-04-03 as fallback. nitro 11:34:12 AM
More info: https://nitro.build/deploy#compatibility-date
✔ Generated public .output/public nitro 11:34:12 AM
ℹ Building Nitro Server (preset: aws-lambda, compatibility date: ``) nitro 11:34:12 AM
✔ Nitro Server built nitro 11:34:13 AM
├─ .output/server/chunks/nitro/nitro.mjs (135 kB) (33.2 kB gzip)
├─ .output/server/chunks/nitro/nitro.mjs.map (2.45 kB) (635 B gzip)
├─ .output/server/chunks/routes/index.mjs (516 B) (297 B gzip)
├─ .output/server/chunks/routes/index.mjs.map (330 B) (197 B gzip)
├─ .output/server/index.mjs (334 B) (199 B gzip)
└─ .output/server/package.json (279 B) (180 B gzip)
Σ Total size: 1.41 MB (401 kB gzip
If it helps, here's the package.json of the viem package in my node_modules
{
"name": "viem",
"description": "TypeScript Interface for Ethereum",
"version": "2.30.6",
"main": "./_cjs/index.js",
"module": "./_esm/index.js",
"types": "./_types/index.d.ts",
"typings": "./_types/index.d.ts",
"sideEffects": false,
"files": [
"*",
"!**/*.bench.ts",
"!**/*.bench-d.ts",
"!**/*.test.ts",
"!**/*.test.ts.snap",
"!**/*.test-d.ts",
"!**/*.tsbuildinfo",
"!tsconfig.build.json",
"!jsr.json"
],
"exports": {
".": {
"types": "./_types/index.d.ts",
"import": "./_esm/index.js",
"default": "./_cjs/index.js"
},
"./account-abstraction": {
"types": "./_types/account-abstraction/index.d.ts",
"import": "./_esm/account-abstraction/index.js",
"default": "./_cjs/account-abstraction/index.js"
},
"./accounts": {
"types": "./_types/accounts/index.d.ts",
"import": "./_esm/accounts/index.js",
"default": "./_cjs/accounts/index.js"
},
"./actions": {
"types": "./_types/actions/index.d.ts",
"import": "./_esm/actions/index.js",
"default": "./_cjs/actions/index.js"
},
"./celo": {
"types": "./_types/celo/index.d.ts",
"import": "./_esm/celo/index.js",
"default": "./_cjs/celo/index.js"
},
"./chains": {
"types": "./_types/chains/index.d.ts",
"import": "./_esm/chains/index.js",
"default": "./_cjs/chains/index.js"
},
"./chains/utils": {
"types": "./_types/chains/utils.d.ts",
"import": "./_esm/chains/utils.js",
"default": "./_cjs/chains/utils.js"
},
"./ens": {
"types": "./_types/ens/index.d.ts",
"import": "./_esm/ens/index.js",
"default": "./_cjs/ens/index.js"
},
"./experimental": {
"types": "./_types/experimental/index.d.ts",
"import": "./_esm/experimental/index.js",
"default": "./_cjs/experimental/index.js"
},
"./experimental/erc7739": {
"types": "./_types/experimental/erc7739/index.d.ts",
"import": "./_esm/experimental/erc7739/index.js",
"default": "./_cjs/experimental/erc7739/index.js"
},
"./experimental/erc7821": {
"types": "./_types/experimental/erc7821/index.d.ts",
"import": "./_esm/experimental/erc7821/index.js",
"default": "./_cjs/experimental/erc7821/index.js"
},
"./experimental/erc7846": {
"types": "./_types/experimental/erc7846/index.d.ts",
"import": "./_esm/experimental/erc7846/index.js",
"default": "./_cjs/experimental/erc7846/index.js"
},
"./experimental/erc7895": {
"types": "./_types/experimental/erc7895/index.d.ts",
"import": "./_esm/experimental/erc7895/index.js",
"default": "./_cjs/experimental/erc7895/index.js"
},
"./linea": {
"types": "./_types/linea/index.d.ts",
"import": "./_esm/linea/index.js",
"default": "./_cjs/linea/index.js"
},
"./node": {
"types": "./_types/node/index.d.ts",
"import": "./_esm/node/index.js",
"default": "./_cjs/node/index.js"
},
"./nonce": {
"types": "./_types/nonce/index.d.ts",
"import": "./_esm/nonce/index.js",
"default": "./_cjs/nonce/index.js"
},
"./op-stack": {
"types": "./_types/op-stack/index.d.ts",
"import": "./_esm/op-stack/index.js",
"default": "./_cjs/op-stack/index.js"
},
"./siwe": {
"types": "./_types/siwe/index.d.ts",
"import": "./_esm/siwe/index.js",
"default": "./_cjs/siwe/index.js"
},
"./utils": {
"types": "./_types/utils/index.d.ts",
"import": "./_esm/utils/index.js",
"default": "./_cjs/utils/index.js"
},
"./window": {
"types": "./_types/window/index.d.ts",
"import": "./_esm/window/index.js",
"default": "./_cjs/window/index.js"
},
"./zksync": {
"types": "./_types/zksync/index.d.ts",
"import": "./_esm/zksync/index.js",
"default": "./_cjs/zksync/index.js"
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"accounts": [
"./_types/accounts/index.d.ts"
],
"actions": [
"./_types/actions/index.d.ts"
],
"celo": [
"./_types/celo/index.d.ts"
],
"chains": [
"./_types/chains/index.d.ts"
],
"chains/utils": [
"./_types/chains/utils.d.ts"
],
"ens": [
"./_types/ens/index.d.ts"
],
"experimental": [
"./_types/experimental/index.d.ts"
],
"experimental/erc7739": [
"./_types/experimental/erc7739/index.d.ts"
],
"experimental/erc7821": [
"./_types/experimental/erc7821/index.d.ts"
],
"experimental/erc7846": [
"./_types/experimental/erc7846/index.d.ts"
],
"experimental/erc7895": [
"./_types/experimental/erc7895/index.d.ts"
],
"node": [
"./_types/node/index.d.ts"
],
"op-stack": [
"./_types/op-stack/index.d.ts"
],
"siwe": [
"./_types/siwe/index.d.ts"
],
"utils": [
"./_types/utils/index.d.ts"
],
"window": [
"./_types/window/index.d.ts"
],
"zksync": [
"./_types/zksync/index.d.ts"
]
}
},
"peerDependencies": {
"typescript": ">=5.0.4"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"dependencies": {
"@noble/curves": "1.9.1",
"@noble/hashes": "1.8.0",
"@scure/bip32": "1.7.0",
"@scure/bip39": "1.6.0",
"abitype": "1.0.8",
"isows": "1.0.7",
"ox": "0.7.1",
"ws": "8.18.2"
},
"license": "MIT",
"homepage": "https://viem.sh",
"repository": "wevm/viem",
"authors": [
"awkweb.eth",
"jxom.eth"
],
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"keywords": [
"eth",
"ethereum",
"dapps",
"wallet",
"web3",
"typescript"
]
}
Thanks for your time making a minimal reproduction.
I think your analysis script might be wrong. Check .output/server/node_modules/viem/, only _esm deps are traced. (top level node_modules is part of your development project, .output is to be deployed)
You're right, _esm is the only thing inside .output/server/node_modules.
I guess I also missed that the output of the script saying ../../node_modules/viem... also indicates depcruise is pulling from node modules outside of the folder, which I guess is just dep cruise configured incorrectly. This doesn't happen when I move .output and analyze.ts out to a /tmp/test directory
Gah the annoying rat race I've been on continues.
This might kick back my EMFILE issue to TanStack Start or sst in some way. I'm definitely getting an EMFILE issue, and I recall it being associated with viem trying to import way more than it needed. I was sorta able to get it worked around by adding vite.ssr.noExternals: ['viem'] setting in TSS's app.config.ts which in a vinxi thing.
I'll keep digging. I guess I can close this for now.
@pi0 Okay so I've run into this again. This time @walletconnect/utils is an example that is using the cjs bundle rather than the esm import. Which is causing cjs versions of viem to get imported.
New Repo: https://github.com/mckamyk/bundle-hell-tss
$ bun run build
ls .output/server/node_modules/@walletconnect/utils/dist
index.cjs.js
$ tree -L1 .output/server/node_modules/.nitro/viem*
.output/server/node_modules/.nitro/[email protected]
├── _cjs
├── node_modules
└── package.json
.output/server/node_modules/.nitro/[email protected]
├── _cjs
├── node_modules
└── package.json
.output/server/node_modules/.nitro/[email protected]
├── _cjs
├── _esm
├── node_modules
└── package.json
10 directories, 3 files
[email protected] is the version that is installed directly by my package.json. The other version are from dependencies.
bun pm ls -a > deps.local
bat deps.local
# /viem
to find who's using what.
@mckamyk can you please help on making a "nitro-only" reproduction? Having layers on top it is very hard to investigate root causes.
Yes, here's one. (actually its the original example, updated)
https://github.com/mckamyk/nitro-cjs-degrade
When I copy out .output to /tmp/output using just cp -r, which collapses all symlinks and removes a parent node_modules from contention, then run my analyze script I get a bunch of really long and nested import paths, such as
node_modules/@reown/appkit-adapter-wagmi/node_modules/@reown/appkit-controllers/node_modules/viem/_cjs/index.js depends on 148 files
This simply helps me just track down the import path and see which packages along that path are getting bundled as cjs or esm.
in this particular case, the leaf cjs package of viem is reached by way of
-
@reown/appkit-adapter-wagmi- esm
-
@reown/appkit-controllers- esm
-
viem- cjs
Another interesting example I found,
node_modules/@reown/appkit-adapter-wagmi/node_modules/@reown/appkit-controllers/node_modules/@walletconnect/universal-provider/node_modules/@walletconnect/sign-client/node_modules/@walletconnect/core/node_modules/@walletconnect/utils/node_modules/viem/_cjs/index.js depends on 148 files
-
@reown/appkit-adapter-wagmi- esm
-
@reown/appkit-controllers- esm
-
@walletconnect/universal-provider- cjs
-
@walletconnect/sign-client- cjs
-
@walletconnect/core- cjs
-
@walletconnect/utils- cjs
-
viem- cjs
Starting to see a bit of a pattern. All of @walletconnects dependencies seem to be cjs, despite their package.jsons appearing to be esm compatible,
{
"name": "@walletconnect/utils",
"description": "Utilities for WalletConnect Protocol",
"version": "2.21.3",
"author": "WalletConnect, Inc. <walletconnect.com>",
"homepage": "https://github.com/walletconnect/walletconnect-monorepo/",
"license": "Apache-2.0",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
"unpkg": "dist/index.umd.js",
"types": "dist/types/index.d.ts",
"sideEffects": false,
"files": [
"dist"
],
// ...
}
Since we're looking at package.jsons lets look at viem's
{
"name": "viem",
"description": "TypeScript Interface for Ethereum",
"version": "2.31.0",
"main": "./_cjs/index.js",
"module": "./_esm/index.js",
"types": "./_types/index.d.ts",
"typings": "./_types/index.d.ts",
"sideEffects": false,
"files": [
"*",
"!**/*.bench.ts",
"!**/*.bench-d.ts",
"!**/*.test.ts",
"!**/*.test.ts.snap",
"!**/*.test-d.ts",
"!**/*.tsbuildinfo",
"!tsconfig.build.json",
"!jsr.json"
],
"exports": {
".": {
"types": "./_types/index.d.ts",
"import": "./_esm/index.js",
"default": "./_cjs/index.js"
},
"./account-abstraction": {
"types": "./_types/account-abstraction/index.d.ts",
"import": "./_esm/account-abstraction/index.js",
"default": "./_cjs/account-abstraction/index.js"
},
//...
}
Both of them seem to have valid definitions for cjs and esm bundles.
Kinda a dead end here, I feel. I did find it peculiar that as soon as the import path leaves @reown it drops down the cjs.
to test this, I modified the source code to
import { hashKey } from '@walletconnect/utils'
export default defineEventHandler(async () => {
return hashKey('fofofo')
})
and looked at the bundle.
Sure enough
❯ ls .output/server/node_modules/@walletconnect/utils/dist
index.cjs.js
This dependency, even directly imported, was outputting cjs. A peek into my actual node_modules shows that it does have esm builds available.
❯ ls node_modules/@walletconnect/utils/dist
index.cjs.js index.es.js index.umd.js tsconfig.tsbuildinfo
index.cjs.js.map index.es.js.map index.umd.js.map types
In addition, we know @walletconnect/utils has a viem dependency,
❯ ls .output/server/node_modules/viem/
_cjs node_modules package.json
viem also is getting cjs packed.
When I change the source to only directly import viem,
import { isHex } from 'viem'
export default defineEventHandler(async () => {
return isHex('jkfdjkd')
})
I get viem in a proper esm import again
❯ ls .output/server/node_modules/viem
_esm package.json
04-mini seems to imply that without the exports field in the package.json, tools like nitro and rollup will ignore the module field and instead use the main field.
No idea if that's correct, but it seems worth mentioning.
Yes, nitro does not uses module field for external dependencies because it is not standard and runtimes (Node.js, Deno, Bun) also do not use or recognize module field`, if we trace dependencies with this field, in runtime (output dir) won't work!
ESM packages should have exports or main + type: "module"
So that means the @walletconnect dependencies aren't packaging for ESM correctly. Is there a nitro config way to force ESM in these cases? Is the only option to take it up with those maintainers?
This doesn't factor in the first case of reown, reown, viem import chain also using cjs. Viem used exports properly as you describe.
We can configure nitro (rollup) to force bundle all dependencies and without externals and also use module field (which is valid only for ESM-bundled deps, therefore externals won't work because native node.js ESM does not recognize module field.).
I suggest moving it to usptream to fix @walletconnect/ pkgs it is much reliable fix to add exports field to those packags.
Just curious, if there's a import chain that starts out esm, downgrades to cjs, and then hits a dependency that is correctly configured to provide both cjs and esm dependencies, will that last one upgrade back to esm, or will it have to stay cjs to remain compatible with the cjs importer?
Even though recent versions of Node.js allow (non async) require(esm), technically any require statement is resolved with "require" condition unlees using dynamic import() to force using "module" condition (exports field not module field).
so assuming they bundled their cjs normally, it would be using standard requires and would target cjs downstream. Only if they had some kind of manual bundling step to use async import would it jump back up to esm.
got it, working with @walletconnect team to hopefully add the exports key to package.json.
Still curious as to why the @reown packages, using their esm bundles, is getting cjs viem.