Node.js utility for transforming a JavaScript or TypeScript file from CommonJS to an ES module, or vice versa.
- CommonJS ➡️ ES module
- ES module ➡️ CommonJS
Highlights
- CJS ➡️ ESM and ESM ➡️ CJS with one function call.
- Defaults to safe CommonJS output: strict live bindings, import.meta shims, and specifier preservation.
- Configurable lowering modes: full syntax transforms or globals-only.
- Specifier tools: add extensions, add directory indexes, or map with a custom callback.
- Output control: write to disk (
out/inPlace) or return the transformed string. - CLI:
dubfor batch transforms, dry-run/list/summary, stdin/stdout, and colorized diagnostics. See docs/cli.md.
Important
All parsing logic is applied under the assumption the code is in strict mode which modules run under by default.
By default @knighted/module transforms the one-to-one differences between ES modules and CommonJS. Options let you control syntax rewriting (full vs globals-only), specifier updates, and output.
- Node >= 22.21.1 (<23), >= 24 (<25), or >= 26 (<27)
npm install @knighted/moduleESM ➡️ CJS:
file.js
import{argv}from'node:process'import{pathToFileURL}from'node:url'import{realpath}from'node:fs/promises'constdetectCalledFromCli=asyncpath=>{constrealPath=awaitrealpath(path)if(import.meta.url===pathToFileURL(realPath).href){console.log('invoked directly by node')}}detectCalledFromCli(argv[1])Transform it to CommonJS:
import{transform}from'@knighted/module'awaittransform('./file.js',{target: 'commonjs',out: './file.cjs',})Which produces:
file.cjs
const{ argv }=require('node:process')const{ pathToFileURL }=require('node:url')const{ realpath }=require('node:fs/promises')constdetectCalledFromCli=asyncpath=>{constrealPath=awaitrealpath(path)if(require('node:url').pathToFileURL(__filename).toString()===pathToFileURL(realPath).href){console.log('invoked directly by node')}}detectCalledFromCli(argv[1])When executed from the CLI
use@computer: $ node file.cjsinvoked directly by nodeCJS ➡️ ESM:
import{transform}from'@knighted/module'awaittransform('./file.cjs',{target: 'module',out: './file.mjs',})typeModuleOptions={target: 'module'|'commonjs'sourceType?: 'auto'|'module'|'commonjs'transformSyntax?: boolean|'globals-only'sourceMap?: booleanliveBindings?: 'strict'|'loose'|'off'appendJsExtension?: 'off'|'relative-only'|'all'appendDirectoryIndex?: string|falserewriteSpecifier?:
|'.js'|'.mjs'|'.cjs'|'.ts'|'.mts'|'.cts'|((value: string)=>string|null|undefined)rewriteTemplateLiterals?: 'allow'|'static-only'dirFilename?: 'inject'|'preserve'|'error'importMeta?: 'preserve'|'shim'|'error'importMetaMain?: 'shim'|'warn'|'error'requireMainStrategy?: 'import-meta-main'|'realpath'detectCircularRequires?: 'off'|'warn'|'error'detectDualPackageHazard?: 'off'|'warn'|'error'dualPackageHazardScope?: 'file'|'project'dualPackageHazardAllowlist?: string[]requireSource?: 'builtin'|'create-require'importMetaPrelude?: 'off'|'auto'|'on'cjsDefault?: 'module-exports'|'auto'|'none'idiomaticExports?: 'off'|'safe'|'aggressive'topLevelAwait?: 'error'|'wrap'|'preserve'out?: stringcwd?: stringinPlace?: boolean}target(commonjs): output module system.transformSyntax(true): enable/disable the ESM↔CJS lowering pass; set to'globals-only'to rewrite module globals (import.meta.*,__dirname,__filename,require.mainshims) while leaving import/export syntax untouched. In'globals-only', no helpers are injected (e.g.,__requireResolve),require.resolverewrites toimport.meta.resolve, andidiomaticExportsis skipped. See globals-only.liveBindings(strict): getter-based live bindings, or snapshot (loose/off).appendJsExtension(relative-onlywhen targeting ESM): append.jsto relative specifiers; never touches bare specifiers.appendDirectoryIndex(index.js): when a relative specifier ends with a slash, append this index filename (setfalseto disable).appendersprecedence:rewriteSpecifierruns first; if it returns a string, that result is used. If it returnsundefinedornull,appendJsExtensionandappendDirectoryIndexstill run. Bare specifiers are never modified by appenders.rewriteTemplateLiterals(allow): whenstatic-only, interpolated template literals are left untouched by specifier rewriting; string literals and non-interpolated templates still rewrite.dirFilename(inject): inject__dirname/__filename, preserve existing, or throw.importMeta(shim): rewriteimport.meta.*to CommonJS equivalents.importMetaMain(shim): gateimport.meta.mainwith shimming/warning/error when Node support is too old.requireMainStrategy(import-meta-main): useimport.meta.mainor the realpath-basedpathToFileURL(realpathSync(process.argv[1])).hrefcheck.importMetaPrelude(auto): emit a no-opvoid import.meta.filename;touch.onalways emits;offnever emits;autoemits only when helpers that referenceimport.meta.*are synthesized (e.g.,__dirname/__filenamein CJS→ESM, require-main shims, createRequire helpers). Useful for bundlers/transpilers that do usage-basedimport.metapolyfilling.detectCircularRequires(off): optionally detect relative static require cycles across.js/.mjs/.cjs/.ts/.mts/.cts(realpath-normalized) and warn/throw.detectDualPackageHazard(warn): flag whenimportandrequiremix for the same package or root/subpath are combined in ways that can resolve to separate module instances (dual packages). Set toerrorto fail the transform.dualPackageHazardScope(file):filepreserves the legacy per-file detector;projectaggregates package usage across all CLI inputs (useful in monorepos/hoisted installs) and emits one diagnostic per package.dualPackageHazardAllowlist([]): suppress dual-package hazard diagnostics for the listed packages. Accepts an array in the API; entries are trimmed and empty values dropped. The CLI flag--dual-package-hazard-allowlist pkg1,pkg2parses a comma- or space-separated string into this array. Applies to bothfileandprojectscopes.topLevelAwait(error): throw, wrap, or preserve when TLA appears in CommonJS output.wrapruns the file body inside an async IIFE (exports may resolve after the initial tick);preserveleavesawaitat top level, which Node will reject for CJS.rewriteSpecifier(off): rewrite relative specifiers to a chosen extension or via a callback. Precedence: the callback (if provided) runs first; if it returns a string, that wins. If it returnsundefinedornull, the appenders still apply.requireSource(builtin): whetherrequirecomes from Node orcreateRequire.cjsDefault(auto): bundler-style default interop vs directmodule.exports.idiomaticExports(safe): when raising CJS to ESM, attempt to synthesizeexportstatements directly when it is safe.offalways uses the helper bag;aggressivecurrently matchessafeheuristics.out/inPlace: choose output location. Default returns the transformed string (CLI emits to stdout).outwrites to the provided path.inPlaceoverwrites the input files on disk and does not return/emit the code.sourceMap(false): when true, returns{ code, map }fromtransformand writes the map if you also setout/inPlace. Maps are generated from the same MagicString pipeline used for the code.cwd(process.cwd()): Base directory used to resolve relativeoutpaths.
Note
Package-level metadata (package.json updates such as setting "type": "module" or authoring exports) is not edited by this tool today; plan that change outside the per-file transform.
See docs/esm-to-cjs.md for deeper notes on live bindings, interop helpers, top-level await behavior, and import.meta.main handling. For CommonJS to ESM lowering details, read docs/cjs-to-esm.md.
Note
Known limitations: with and unshadowed eval are rejected when raising CJS to ESM because the rewrite would be unsound; bare specifiers are not rewritten—only relative specifiers participate in rewriteSpecifier.
Pass a diagnostics callback to surface CJS→ESM edge cases (mixed module.exports/exports, top-level return, legacy require.cache/require.extensions, live-binding reassignments, string-literal export names):
import{transform}from'@knighted/module'constdiagnostics: any[]=[]awaittransform('./file.cjs',{target: 'module',diagnostics: diag=>diagnostics.push(diag),})console.log(diagnostics)// [// {// level: 'warning',// code: 'cjs-mixed-exports',// message: 'Both module.exports and exports are assigned in this module; CommonJS shadowing may not match synthesized ESM exports.',// filePath: './file.cjs',// loc: { start: 12, end: 48 }// },// ...// ]Warning
When raising CommonJS to ESM, synthesized named exports rely on literal keys and const literal aliases (e.g., const key = 'foo'; exports[key] = value). var/let bindings used as export keys are not tracked, so prefer direct property names or const literals when exporting.
TypeScript reports asymmetric module-global errors (e.g., import.meta in CJS, __dirname in ESM) as tracked in microsoft/TypeScript#58658. You can mitigate this by running @knighted/modulebeforetsc so the checker sees already-rewritten sources. For a specifier + globals-only pass that leaves import/export syntax for tsc, set transformSyntax: 'globals-only'.
Minimal flow:
dub -t commonjs "src/**/*.{ts,js,mts,cts}" --ignore node_modules/** --transform-syntax globals-only --in-place
tscNote
With TypeScript 6+, when a tsconfig.json is present, rootDir defaults to .. If your config uses outDir and includes sources under src, set "rootDir": "./src" explicitly to avoid TS5011.
This pre-tsc step rewrites globals-only (keeps import/export syntax) so the TypeScript checker sees already-rewritten sources; runtime semantics still match the target build.