Making More npm Packages Work with jsDelivr ESM
After modernizing our backend, we reviewed every reported /+esm issue and used production APM data to analyze the remaining failures. The result is broader support for modern JavaScript, complex CommonJS packages, source-relative assets, and additional Node.js compatibility APIs.
When we introduced jsDelivr’s ESM bundling service, the idea was simple: take a package published to npm and return a browser-ready ES module.
A /+esm request does much more than change the module syntax. jsDelivr resolves package exports and browser entry points, converts CommonJS where necessary, provides browser-compatible implementations of supported Node.js APIs, bundles dependencies, removes unused code, minifies the result, and generates a source map. We described many of those capabilities when we announced the service in 2023.
That initial implementation covered a large part of the npm ecosystem. Published packages, however, often combine export maps, generated CommonJS helpers, browser aliases, dynamic imports, source maps, WebAssembly files, Node.js APIs, and dependencies built by different generations of tooling.
Over the past few months, we focused on those remaining cases.
The work began with the backend itself. Several dependencies were multiple major versions behind, and some current releases had moved to ESM-only distribution. After converting the backend to native ESM and updating the dependency stack – including Rollup 2 to Rollup 4 and major upgrades to its CommonJS, JSON, and replacement plugins – we worked through the outstanding compatibility reports one by one.
Once the known reports had been reviewed, we used production APM data to find and categorize the remaining failed /+esm requests. Wherever the failure could be handled safely in jsDelivr, we updated the bundling pipeline and added a regression test.
The result is a set of targeted compatibility improvements, each prompted by a real package and implemented for the wider class of packages using the same pattern.
Updating the bundling stack first
Before starting another round of backend and bundling changes, we wanted to bring the dependency stack up to date.
The Rollup 4 upgrade was one part of that work. Rollup itself still supports use from CommonJS; the broader issue was that several other dependencies had moved to ESM-only releases, while many packages in the application were several major versions behind.
We converted the backend, its scripts, configuration, tests, and supporting tools to native ESM, then upgraded the dependencies together. This avoided adding new fixes and refactorings around versions we already intended to replace.
Some reported package failures disappeared once the current toolchain was in place. Modern versions of Rollup and its plugins already understand syntax and package metadata that the older versions did not.
JSON import attributes are one example. Packages increasingly use the standardized syntax:
import metadata from './package.json' with { type: 'json' };
A reported case involving @uppy/core failed under the previous Rollup 2 based pipeline. The updated toolchain handles the syntax correctly, and we added a regression test to keep it covered.
Other failures required changes specific to the way jsDelivr resolves, converts, and serves packages. Those became the main focus of the compatibility work.
Starting with reports, then measuring the rest
We started with the open issue reports because each one provided a concrete package, URL, and reproducible failure.
QuickJS could not locate a WebAssembly file shipped next to one of its modules. An AWS SDK bundle lost named exports while crossing several CommonJS and ESM boundaries. Other packages used generated TypeScript re-export helpers, top-level await, unusual CommonJS property names, or Node.js modules that appeared only inside unreachable server-side code.
Each case exposed a pattern that could affect other packages as well.
After reviewing the reported backlog, we moved to production data. We grouped the remaining failed /+esm transactions into three broad categories:
- Gaps in the jsDelivr bundling pipeline.
- Malformed or unusual package metadata that could be normalized or safely ignored.
- Packages that genuinely require Node.js, React Native tooling, a server environment, or another runtime that a browser ESM bundle cannot provide.
We did not try to force every package through the bundler. A successful response is only useful if the generated module behaves correctly. We fixed transformations that could be handled safely and kept clear compatibility errors for packages that target another runtime.
Supporting modern JavaScript as packages actually publish it
Supporting current JavaScript syntax depends both on the parser and on classifying each file correctly from its package metadata.
Top-level await in module packages
Rollup already supports top-level await in ESM. The failure came from passing some .js entry points through CommonJS conversion even when their package declared:
{
"type": "module"
}
That CommonJS pass could reject or incorrectly transform an otherwise valid ESM entry point.
For packages with "type": "module", we now run CommonJS conversion only for .cjs files and .js files inside nested dependencies. The package’s own .js entry point remains ESM, allowing Rollup to handle its top-level await normally.
More forms of NODE_ENV
Many packages use process.env.NODE_ENV to remove development-only code. Published output uses several variations of that expression.
The replacement logic now covers guarded access such as:
typeof process !== 'undefined' && process.env.NODE_ENV
It also recognizes global.process, globalThis.process, and bracket notation:
process.env['NODE_ENV'];
process.env["NODE_ENV"];
global.process.env.NODE_ENV;
globalThis.process.env.NODE_ENV;
The matching was also narrowed to avoid replacing unrelated values, including quoted object keys and longer member chains such as:
host.process.env.NODE_ENV
Once the intended expressions are replaced with the production value, Rollup can remove development-only branches from the final bundle.
Shebangs without broken source maps
Some package entry points also serve as command-line programs and begin with a shebang:
#!/usr/bin/env node
The shebang is useful when the file is executed directly, but it can interfere with later stages of JavaScript processing.
We now replace only the initial #! characters with a JavaScript comment marker. The line count and source length remain unchanged, so existing source-map positions continue to align with the file.
Browser-field self-aliases
The ESM resolver uses the package’s browser field when selecting browser-specific files.
Some packages include explicit self-mappings:
{
"browser": {
"./dist/iife/index.js": "./dist/iife/index.js"
}
}
By the time the resolver reaches this mapping, the file is already the intended browser target. Following the same alias again only restarts the lookup and can create a resolution loop.
We now treat a self-mapping as already resolved and continue with the current file.
CommonJS is not one format
CommonJS packages expose their APIs in many different forms, especially after TypeScript, Babel, or another compiler has generated the published output.
jsDelivr has to detect those exports statically, convert them into ESM bindings, and preserve them through bundling. Several recent fixes cover different stages of that process.
Exports inside conditional branches
A CommonJS package may define an export only inside a branch:
if (condition) {
exports.fallback = fallback;
}
Our previous export detection could miss assignments in structures like this.
The current implementation combines two CommonJS lexers because they cover different patterns. Node’s cjs-module-lexer detects conditional exports.name assignments, while @esm.sh/cjs-module-lexer retains support for object exports and re-export patterns already used by the pipeline.
The results are merged before the ESM interface is generated. If one lexer cannot parse a particular file, the result from the other can still be used.
Export names that are not JavaScript identifiers
CommonJS exports are object properties, so their names do not need to be valid JavaScript identifiers:
exports['foo-bar'] = value;
Those names could previously be omitted when generating the ESM interface.
We now import the CommonJS namespace, read the property through bracket access, and re-export it using the original string-literal name. Packages therefore retain unusual but valid CommonJS export names after conversion.
TypeScript’s __exportStar helper
TypeScript frequently compiles re-exports into code resembling:
tslib.__exportStar(require('./implementation'), exports);
The visible file contains no direct exports.name = ... assignments. Its public API comes from the required module through the generated helper.
Our named-export detector now recognizes __exportStar calls whose target is exports or module.exports, follows the referenced module, and includes its exports in the generated ESM interface.
The reported @aws-sdk/client-s3 failure included this pattern. Browser-oriented crypto dependencies exposed Sha1 and Sha256 through generated re-export helpers, but those names were missing from the static export list.
CommonJS importing external ESM
Once those exports were detected, the same AWS bundle exposed another interop problem.
When converted CommonJS code imported an external dependency, the CommonJS plugin could still apply CommonJS default-export assumptions. jsDelivr had already normalized that dependency to an ESM URL such as:
/npm/package@version/+esm
We now configure the CommonJS conversion stage to treat those external dependencies as ESM namespaces. Named exports remain available instead of being reduced to an assumed CommonJS default.
Keeping normalized CDN imports absolute
The CommonJS plugin creates virtual proxy modules for external imports.
When one of those proxies re-imported an already-normalized /npm/.../+esm URL, Rollup could interpret it relative to the virtual module and turn it into an invalid path beginning with ./npm/.
These CDN imports are now explicitly marked as absolute when they pass through a CommonJS external proxy.
Removing synthetic null defaults
While verifying this interop path, we also removed an older jsDelivr behavior that added:
export default null;
to bundles containing only named exports.
That synthetic default did not exist in the original package. In some CommonJS interop paths, it could be selected instead of the module namespace, causing consumers to receive null even though the bundle had valid named exports.
Named-only packages now remain named-only.
Together, these changes preserve named exports across TypeScript-generated CommonJS re-exports, external ESM dependencies, and the virtual proxy modules created during conversion.
Preserving where a module came from
Some packages load assets relative to the module that imports them. Bundling moves the JavaScript to a new /+esm URL, so the original module location has to be preserved for those operations.
Source-relative import.meta.url
Packages commonly locate adjacent files using:
const wasmUrl = new URL('./module.wasm', import.meta.url);
Without special handling, Rollup would evaluate this relative to the final /+esm resource. The .wasm file, however, still lives next to the original source module inside the npm package.
This caused QuickJS to request its WebAssembly file from the wrong path and receive a 404. The same pattern can be used for workers, dictionaries, model files, and other assets shipped alongside a module.
jsDelivr now uses Rollup’s resolveImportMeta hook to replace import.meta.url with a URL derived from the original npm path of each source module. Relative assets continue to resolve from their published location even after the JavaScript has been bundled.
Tolerating imperfect source maps
Third-party source maps sometimes contain data that Rollup cannot combine safely.
Some maps use null for an unknown source name. Others contain unsupported source values or a non-string sourceRoot. A further case occurs when two source names normalize to the same path but provide different sourcesContent.
We now normalize recoverable values, such as replacing a null source name with unknown. When the map contains contradictory or unsupported metadata, we discard that map and continue bundling the JavaScript.
An unusable source map no longer makes an otherwise valid package unavailable.
Generated source-map identifiers are also derived from the final serialized map rather than only from the generated JavaScript. Two transformations that produce the same code but different mappings no longer risk sharing the wrong source-map URL.
Letting tree-shaking make the final decision
Previously, jsDelivr stopped a build as soon as it encountered an unsupported Node.js built-in module such as dgram.
That is correct when the browser bundle genuinely uses the API. It is too early when the import exists only inside a server-specific branch:
if (isNode) {
const dgram = require('dgram');
}
Rollup may be able to prove that the branch is unreachable and remove both the branch and the import.
Unsupported built-ins are now represented temporarily as side-effect-free virtual modules. Rollup performs tree-shaking first, and jsDelivr checks the generated bundle afterward.
If the unsupported import was removed, the bundle succeeds. If it remains in the output, the transformation still fails and identifies the unsupported module.
Compatibility is therefore determined from the generated browser bundle rather than from every import present anywhere in the source package.
Expanding the Node.js compatibility layer
Some packages use Node.js APIs along code paths that are still meaningful in a browser when a suitable implementation is available.
jsDelivr provides those implementations through a fork of rollup-plugin-polyfill-node. During the production failure analysis, we made sixteen focused updates to the fork.
The additions and fixes include:
util APIs: stripVTControlCharacters, util.types, and TextEncoder.
URL and path APIs: urlToHttpOptions, pathToFileURL, the path.posix exports, and implementations of path.parse() and path.format().
Runtime and system APIs: process[Symbol.toStringTag], coverage for os.homedir(), and support for timers/promises.
Additional built-in modules: broader crypto support and new handling for fs and fs/promises.
Resolution and behavior fixes: explicit resolution of the inherits package, corrected internal polyfill resolution, removal of unnecessary circular-dependency warnings from the stream implementation, and regression coverage for the correct zlib error codes.
The backend now allows fs and fs/promises imports and uses the expanded implementations from the fork.
The fs polyfill does not expose the user’s local filesystem. It provides the browser-side API behavior expected by packages that already have a meaningful browser execution path.
Faster and more predictable transformations
The /+esm transform runs within a limited request budget, and Rollup already uses part of that time for module resolution, conversion, and tree-shaking.
The final ESM minification step now uses esbuild instead of Terser. This reduces the time spent after Rollup has completed the main bundling work.
We made a similar change to jsDelivr’s general JavaScript minifier. Files larger than 4 MiB now use esbuild, while smaller files continue through Terser.
Packages with many imports from the same external dependency also caused repeated metadata lookups. A package such as antd may import several files from one dependency, but the dependency version only needs to be resolved once. We now fetch each external package manifest once and reuse the resolved version for all of its files.
Large Rollup bundles are explicitly closed immediately after generation, and large intermediate objects are released sooner. Serialized source-map data is also reused instead of creating equivalent copies during the final write steps.
Transform failures now retain structured information including the transform name, failure type, process signal, exit status, output file, and captured output. We also fixed a proxy error path that could replace the original transformation exception with a secondary error.
This telemetry made the production analysis more reliable by separating package compatibility failures from timeouts, memory pressure, subprocess exits, and unexpected backend errors.
Recognizing packages that need another runtime
The production data also contained many packages that publish JSX directly in .js files, particularly packages intended for React Native.
Those files are normally processed by Metro, Babel, or another project-specific toolchain. jsDelivr does not run arbitrary package-specific compiler configurations, and applying a generic JSX transform could produce output that does not match the package author’s intended environment.
We now recognize this parse-error pattern and return a clear unsupported JSX compatibility error instead of recording it as an unexplained transform crash. The regression coverage includes @expo/vector-icons.
Packages that still use unsupported Node.js built-ins after tree-shaking continue to fail for the same reason: the generated browser bundle still depends on an API that jsDelivr cannot provide safely.
Most remaining failures now fall into a few expected groups: server-only packages, React Native packages, and packages whose published files require an application-specific compilation step.
From package reports to regression tests
Getting QuickJS, the AWS SDK, Uppy, and the other reported packages working was the immediate goal. Each fix was also implemented at the pattern level and covered by a regression test.
QuickJS exposed a general problem with source-relative assets. The AWS SDK exposed several CommonJS and ESM interop problems. Uppy provided coverage for current JSON import syntax. Other packages revealed gaps in source-map handling, browser-field resolution, Node.js polyfills, environment replacement, and the timing of built-in-module validation.
Packages using the same patterns now benefit from those fixes automatically, while the original reports remain covered by the test suite.
Most npm packages already worked through jsDelivr ESM. The recent changes reduce the remaining long tail and give us a more systematic way to identify and fix the next compatibility issue.