Node ESM Migration skill for Codex
From require to import without breaking consumers. Codex picks ESM-only or a dual build based on the Node versions you support, converts files with a syntax mapping that covers __dirname, JSON and lazy requires, and writes an exports map with types first and default last. A bundled script verifies every export target exists and loads, then the packed tarball is tested from real ESM and CommonJS consumers before any publish.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- node-esm-migration Installs to ~/.agents/skills/node-esm-migration/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install node-esm-migration The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project node-esm-migration Installs to .agents/skills/node-esm-migration/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/node-esm-migration.zipunzip -o node-esm-migration.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) node-esm-migration PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $node-esm-migration, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Migrates Node.js packages and apps from CommonJS to ES modules - "type" module, file extensions, package.json exports maps with import/require/types conditions, __dirname and JSON import replacements, require(esm) on Node 20.19+ and 22.12+, TypeScript nodenext settings, Jest versus Vitest, and dual-package hazards - with a bundled exports-map checker. Use when a user hits ERR_REQUIRE_ESM or ERR_MODULE_NOT_FOUND, wants to go ESM-only, needs a dual CJS/ESM build, or an ESM-only dependency breaks a CommonJS project.
Use it when
- A Node package or app still uses require and module.exports
- An ESM-only dependency breaks a CommonJS project
- A published package needs a correct exports map or dual build
- TypeScript emits the wrong module format for Node
Use something else when
- Only the published type declarations are wrong: use typescript-declaration-files
- You are moving a frontend build from webpack to Vite: use webpack-to-vite
How to check the result
The bundled exports checker passes with --load after a build, and the packed tarball imports cleanly from a scratch ESM project and, if promised, a CommonJS one.
The mistake it prevents
Adding an exports map in a minor release, which silently blocks every deep import consumers relied on.
Done well means: every file’s module format is unambiguous (by "type" or extension), the
exports map is the only public surface and resolves correctly for both import and
require consumers you promise to support, and tests, tooling and the published tarball
all run in the new format.
Before changing anything
- Read
AGENTS.md,package.json(type,main,exports,engines,files, scripts), tsconfig (module,moduleResolution), and test runner config. - Decide the supported Node range from
enginesand CI. Node 18 and 20 are end-of-life; Node 20.19+ and 22.12+ canrequire()ES modules without flags, which changes the advice below. - Inventory CJS-only constructs:
rg -n "require\(|module\.exports|exports\.\w+\s*=|__dirname|__filename|require\.resolve|require\.cache" src
- For a library, record what consumers use today (
npm view <pkg> dependentsis not reliable; ask the user whether CJS consumers on older Node must keep working).
Pick the target
| Situation | Target |
|---|---|
| Application or CLI you run yourself | ESM-only: "type": "module" |
| Library, consumers on Node 20.19+/22.12+ and modern bundlers | ESM-only; CJS consumers can require() it unless it uses top-level await |
| Library that must support older CJS consumers | Dual build (ESM + CJS) from one source, built by tsup/tsdown/rollup |
| Large app, cannot convert at once | Keep "type": "commonjs", convert leaf files to .mjs or use await import() for ESM-only deps |
Prefer ESM-only. A dual package doubles the test matrix and invites the dual-package hazard
(two copies of module state, instanceof failing across them).
Conversion workflow
- [ ] 1. Add "type": "module" (or rename files to .mjs) on a branch
- [ ] 2. Convert require/module.exports to import/export, file by file
- [ ] 3. Add explicit file extensions to every relative import
- [ ] 4. Replace __dirname, require.resolve, JSON requires
- [ ] 5. Write the exports map; run scripts/check_exports.mjs
- [ ] 6. Update TypeScript, test runner, linters, scripts in package.json
- [ ] 7. Test the packed tarball from an ESM and (if promised) a CJS consumer
Syntax mapping
| CommonJS | ESM |
|---|---|
const x = require("x") | import x from "x" (default) or import { a } from "x" |
const { a } = require("./a") | import { a } from "./a.js" (extension required) |
require("./dir") | import "./dir/index.js" (no directory index resolution) |
module.exports = fn | export default fn |
exports.a = 1 | export const a = 1 |
__dirname, __filename | import.meta.dirname, import.meta.filename (Node 20.11+) |
require.resolve("x") | import.meta.resolve("x") (returns a URL string) |
require("./data.json") | import data from "./data.json" with { type: "json" } or JSON.parse(await readFile(...)) |
Conditional/lazy require | await import("x") |
require.main === module | import.meta.url === pathToFileURL(process.argv[1]).href |
Need require inside ESM | const require = createRequire(import.meta.url) from node:module |
Importing a CommonJS package from ESM: the default import is module.exports; named imports
work only when Node’s static analysis detects them. If import { a } from "cjs-pkg" fails,
use import pkg from "cjs-pkg"; const { a } = pkg;.
The exports map
{
"name": "acme-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"engines": { "node": ">=20.19" }
}
Dual build variant: nest import and require objects, each with its own types
(.d.ts and .d.cts) and default (.js and .cjs).
Rules:
- Adding
exportsis a breaking change: deep imports (acme-lib/dist/x.js) stop working. List every subpath consumers need, and release it as a major version. - Inside each condition object,
typesfirst,defaultlast; Node and TypeScript take the first match. - Export targets start with
./and must exist in the published files.
Check it:
node scripts/check_exports.mjs . # static checks: files exist, condition order, format mismatches
node scripts/check_exports.mjs . --load # also import()/require() each target in this Node version
The script exits 1 on errors (missing files, types not first, default not last) and warns
about a require target that is ESM, an import target that is CJS, and one .d.ts shared by
both branches. --load executes the package code, so run it only on the user’s own package.
For consumer-side type resolution, also run npx @arethetypeswrong/cli --pack . and
npx publint (both need network the first time).
TypeScript settings
{
"compilerOptions": {
"module": "nodenext", // Node apps and libraries; "bundler" only for bundled apps
"moduleResolution": "nodenext",
"verbatimModuleSyntax": true, // import type stays type-only; no format surprises
"target": "es2023",
"outDir": "dist"
}
}
- Source imports use
.jsextensions (import { x } from "./util.js"inutil.ts). From TS 5.7,rewriteRelativeImportExtensionsallows writing.tsand rewrites on emit. - Under
nodenext, TypeScript decides each file’s format exactly as Node does (from"type"and extensions); errors like TS1479 mean a CJS file imports ESM.
Tooling fallout
| Tool | Change |
|---|---|
| Jest | ESM needs node --experimental-vm-modules and jest.unstable_mockModule; migrating tests to Vitest is usually cheaper |
| Vitest, Node test runner | Work with ESM natively |
| ts-node | Replace with tsx or Node’s built-in type stripping (Node 22.18+/23.6+ for erasable TS syntax) |
| ESLint config | eslint.config.js is ESM under "type": "module"; CJS configs must be .cjs |
Config files (*.config.js) of other tools | Become ESM; tools that require() them may need .cjs names |
npm scripts with node -r | Use node --import for ESM loaders and preload modules |
Mocking require.cache | Not available; use the test runner’s module mocking |
Gotchas
- Missing
"type"in package.json: recent Node detects ESM syntax and runs the file as ESM anyway, with a performance warning and different behavior from your bundler. Always set"type"explicitly. require()of an ES module that uses top-levelawaitthrowsERR_REQUIRE_ASYNC_MODULE. Libraries meant to be required must avoid top-level await in their import graph.- ESM imports are hoisted and evaluated before the module body. Code that set
process.envbefore arequirenow runs after the import; move setup into a separate module imported first. - Circular imports surface as
ReferenceError: Cannot access 'x' before initializationrather than CJS’s partially filled{}. import()of a path needs a URL on Windows:import(pathToFileURL(p).href).- In a dual package, keep state (caches, singletons, class identity) out of modules or isolate it in a single CJS core that both entries wrap.
Verification
node scripts/check_exports.mjs . --loadexits 0 after a build.npm pack, then install the tarball into a scratch ESM project and import every public subpath; repeat withrequire()from a.cjsfile if CJS consumers are promised.- Full test suite, lint, and the build all pass in the new format; a CLI’s bin still runs via
npx. - Publishing the new major is outward-facing: confirm with the user before
npm publish, and include the breaking-change notes (exports map, minimum Node version) in the changelog.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Node ESM Migration"
short_description: "From require to import without breaking consumers"
default_prompt: "$node-esm-migration Convert this Node library from CommonJS to ESM-only" scripts/check_exports.mjs
#!/usr/bin/env node
// Checks a package's package.json "exports" map for common ESM/CJS mistakes.
//
// Usage: node check_exports.mjs [packageDir] [--load]
//
// Checks (errors exit 1, warnings only print):
// - every target file in "exports" (and "main"/"module"/"types") exists
// - "types" is the first key in any condition object that has it
// - "default" is the last key in any condition object that has it
// - a "require" target is not ESM by extension/type (.mjs, or .js in a "type":"module" package)
// - an "import" target is not a .cjs file
// - a ".d.ts" types file is not shared by both import and require branches (warn)
// With --load, each import target is loaded with import() and each require target with
// require(), proving the files parse and evaluate in this Node version. Only use --load on
// code you trust: it executes the package.
import { existsSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const args = process.argv.slice(2);
const load = args.includes('--load');
const dir = resolve(args.find((a) => !a.startsWith('--')) ?? '.');
const pkgPath = join(dir, 'package.json');
if (!existsSync(pkgPath)) {
console.error(`no package.json in ${dir}`);
process.exit(2);
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
const isModule = pkg.type === 'module';
const errors = [];
const warnings = [];
const targets = []; // { subpath, conditions: string[], file }
function walk(node, subpath, conds) {
if (node === null) return; // explicit block of a subpath
if (typeof node === 'string') {
targets.push({ subpath, conditions: conds, file: node });
return;
}
if (Array.isArray(node)) {
node.forEach((n) => walk(n, subpath, conds));
return;
}
const keys = Object.keys(node);
const isSubpathMap = keys.every((k) => k.startsWith('.'));
if (isSubpathMap) {
for (const k of keys) walk(node[k], k, []);
return;
}
if (keys.includes('types') && keys[0] !== 'types') {
errors.push(`${subpath} [${conds.join(' > ') || 'root'}]: "types" must be the first condition (found order: ${keys.join(', ')})`);
}
if (keys.includes('default') && keys.at(-1) !== 'default') {
errors.push(`${subpath} [${conds.join(' > ') || 'root'}]: "default" must be the last condition`);
}
for (const k of keys) walk(node[k], subpath, [...conds, k]);
}
if (pkg.exports === undefined) {
warnings.push('no "exports" field: every file in the package is importable and no conditions apply');
} else {
walk(pkg.exports, '.', []);
}
for (const field of ['main', 'module', 'types', 'typings']) {
if (typeof pkg[field] === 'string') targets.push({ subpath: `(${field})`, conditions: [field], file: pkg[field] });
}
const isTypes = (t) => t.conditions.includes('types') || t.conditions.includes('typings') || /\.d\.[cm]?ts$/.test(t.file);
const typesByFile = new Map();
for (const t of targets) {
const label = `${t.subpath} [${t.conditions.join(' > ') || 'default'}] -> ${t.file}`;
if (t.file.includes('*')) {
warnings.push(`${label}: pattern export, files not checked individually`);
continue;
}
if (!t.file.startsWith('./') && t.subpath.startsWith('.')) {
errors.push(`${label}: export targets must start with "./"`);
}
const abs = join(dir, t.file);
if (!existsSync(abs)) {
errors.push(`${label}: file does not exist (build first?)`);
continue;
}
if (isTypes(t)) {
const branch = t.conditions.includes('require') ? 'require' : t.conditions.includes('import') ? 'import' : 'other';
const set = typesByFile.get(t.file) ?? new Set();
set.add(branch);
typesByFile.set(t.file, set);
continue;
}
const esmFile = t.file.endsWith('.mjs') || (t.file.endsWith('.js') && isModule);
const cjsFile = t.file.endsWith('.cjs') || (t.file.endsWith('.js') && !isModule);
if (t.conditions.includes('require') && esmFile) {
warnings.push(`${label}: "require" points at an ES module; works only on Node 20.19+/22.12+ and fails if it uses top-level await`);
}
if (t.conditions.includes('import') && cjsFile) {
warnings.push(`${label}: "import" points at a CommonJS file; named imports depend on cjs-module-lexer detection`);
}
}
for (const [file, branches] of typesByFile) {
if (branches.has('import') && branches.has('require') && !/\.d\.[cm]ts$/.test(file)) {
warnings.push(`types file ${file} is shared by import and require branches; ship .d.cts for CJS (types will "masquerade")`);
}
}
if (load && errors.length === 0) {
const req = createRequire(pkgPath);
for (const t of targets) {
if (isTypes(t) || t.file.includes('*') || !/\.[cm]?js$/.test(t.file)) continue;
const abs = join(dir, t.file);
try {
if (t.conditions.includes('require')) req(abs);
else await import(pathToFileURL(abs).href);
console.log(`loaded ${t.subpath} [${t.conditions.join(' > ') || 'default'}]`);
} catch (err) {
errors.push(`${t.subpath} [${t.conditions.join(' > ')}]: failed to load: ${err.code ?? ''} ${err.message.split('\n')[0]}`);
}
}
}
console.log(`package ${pkg.name ?? '(unnamed)'} type=${pkg.type ?? 'commonjs'} targets=${targets.length}`);
for (const w of warnings) console.log(`warning ${w}`);
for (const e of errors) console.log(`error ${e}`);
console.log(`${errors.length} error(s), ${warnings.length} warning(s)`);
process.exit(errors.length ? 1 : 0);
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.