Goal
Migrate rclnodejs from CommonJS to ES Modules, enabling modern import { Node } from 'rclnodejs' syntax while maintaining backward compatibility for existing CJS consumers.
File Inventory
| Category |
Count |
Notes |
lib/*.js |
50 files |
Core library (all use module.exports) |
rosidl_gen/*.js |
13 files |
9 generators + 4 templates |
rosidl_parser/*.js |
2 files |
IDL parser utilities |
rostsd_gen/*.js |
1 file |
TypeScript declaration generator |
types/*.d.ts |
36 files |
TypeScript declarations |
index.js |
1 file |
Main entry point |
Dependencies ESM Status
| Package |
Status |
Strategy |
rxjs |
✅ ESM native |
Direct import |
debug |
✅ ESM native |
Direct import |
bindings |
⚠️ CJS only |
createRequire() |
walk |
⚠️ CJS only |
createRequire() |
json-bigint |
⚠️ CJS only |
createRequire() |
@rclnodejs/ref-struct-di |
⚠️ CJS only |
createRequire() |
@rclnodejs/ref-array-di |
⚠️ CJS only |
createRequire() |
third_party/ref-napi |
⚠️ CJS (bundled) |
Keep as CJS |
node-addon-api |
Build-time only |
No change needed |
Critical Issues Identified
1. Circular Dependencies
lib/rate.js → index.js: Creates Rate instances using rclnodejs.init() and rclnodejs.createNode()
index.js → lib/node.js → lib/lifecycle.js: Deferred imports at bottom of index.js
- Solution: Convert to dynamic
import() or refactor dependency injection
2. Dynamic Requires in Generated Code
rosidl_gen/templates/message-template.js generates CJS code with:
const ref = require('../../third_party/ref-napi');
const StructType = require('@rclnodejs/ref-struct-di')(ref);
- Decision: Keep generated code as CJS (files go to
generated/ directory)
3. Native Module Loading
lib/native_loader.js uses bindings('rclnodejs')
- Solution: Wrap with
createRequire()
Migration Phases
Phase 1: Preparation
Files: package.json, scripts/*.js, binding.gyp, rosidl_gen/*.js, rosidl_parser/*.js, rostsd_gen/*.js
Rename all files that must remain CJS to .cjs extension:
- Rename
scripts/*.js → scripts/*.cjs ✅ (Complete)
- Rename
rosidl_gen/*.js → rosidl_gen/*.cjs
- Rename
rosidl_parser/*.js → rosidl_parser/*.cjs
- Rename
rostsd_gen/*.js → rostsd_gen/*.cjs
- Update all references in
package.json, binding.gyp, and internal requires
- Ensure tests still pass before starting conversion
Phase 2: Core Library + Entry Point
Files: 50 JS files in lib/, index.js
⚠️ Atomic Change: All steps below must be done together to maintain testability
We split files into fewer PRs but pipelines won't work until then
- For each file:
- Remove
'use strict';
const X = require('./x.js') → import X from './x.js'
const { A, B } = require('./x.js') → import { A, B } from './x.js'
module.exports = X → export default X
module.exports = { A, B } → export { A, B }
- Special handling:
native_loader.js - Use createRequire() for CJS-only bindings package
rate.js - Dynamic import for circular dependency
- Add
"type": "module" to package.json
- Test:
npm test should pass
Phase 3: Package Configuration & Bundler
Files: package.json, tsup.config.js
After source conversion is complete, enable dual package exports:
{
"type": "module",
"engines": { "node": ">= 18.0.0" },
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"import": {
"types": "./types/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./types/index.d.ts",
"default": "./dist/index.cjs"
}
},
"./generated/*": "./generated/*"
}
}
Use tsup to bundle ESM source → CJS output for backward compatibility:
// tsup.config.js
export default {
entry: ['index.js'],
format: ['esm', 'cjs'],
outDir: 'dist',
external: [/generated\//, /third_party\//],
};
Phase 4: TypeScript Declarations
Files: 36 .d.ts files in types/
- Update
types/index.d.ts for ESM:
declare module 'rclnodejs' {
export const Node: typeof import('./node').Node;
// ... named exports
export default rcl;
}
Phase 5: Generator/Parser Modules (Optional)
Files: 16 .cjs files in rosidl_gen/, rosidl_parser/, rostsd_gen/
Optionally convert generator modules from CJS to ESM:
- Convert
.cjs files to ESM syntax
- Rename back to
.js
- Update
scripts/*.cjs to use dynamic import() for these modules
- Keep generated output as CJS (templates still produce
require() code)
💡 This phase is optional. The .cjs files work fine and can remain as CJS indefinitely.
Out of Scope (Deferred)
| Category |
Files |
Notes |
scripts/ |
8 JS files |
Already renamed to .cjs, remain CJS |
test/ |
All test files |
Can convert later or use --experimental-vm-modules |
example/ |
All examples |
Update as documentation |
third_party/ref-napi |
2 files |
Must stay CJS (native bindings) |
Backward Compatibility Strategy
| Consumer |
Syntax |
Works After Migration? |
| ESM |
import rclnodejs from 'rclnodejs' |
✅ Yes (native) |
| ESM |
import { Node } from 'rclnodejs' |
✅ Yes (native) |
| CJS |
const rclnodejs = require('rclnodejs') |
✅ Yes (via bundled CJS) |
| Generated |
require('./generated/...') |
✅ Yes (stays CJS) |
Risks & Mitigations
| Risk |
Likelihood |
Mitigation |
| FFI packages break |
Medium |
Wrap with createRequire(), test thoroughly |
| Circular deps cause runtime errors |
Medium |
Use dynamic import(), refactor if needed |
| Generated code compatibility |
Low |
Keep as CJS, test message serialization |
| Type definitions break |
Low |
Run tsd after each phase |
| Native addon loading fails |
Low |
Test across Node 18/20/22 |
| Bundler output issues |
Low |
Test require('rclnodejs') in CJS project |
Success Criteria
Summary of Changes from Original Plan
- Combined Phases - Convert lib/ and index.js together for testability
- Added temporary CJS isolation - Rename generator modules to
.cjs
- Added testability checkpoints - Each phase can be tested independently
- Reordered phases - Convert source FIRST, bundler LAST
Goal
Migrate
rclnodejsfrom CommonJS to ES Modules, enabling modernimport { Node } from 'rclnodejs'syntax while maintaining backward compatibility for existing CJS consumers.File Inventory
lib/*.jsmodule.exports)rosidl_gen/*.jsrosidl_parser/*.jsrostsd_gen/*.jstypes/*.d.tsindex.jsDependencies ESM Status
rxjsdebugbindingscreateRequire()walkcreateRequire()json-bigintcreateRequire()@rclnodejs/ref-struct-dicreateRequire()@rclnodejs/ref-array-dicreateRequire()third_party/ref-napinode-addon-apiCritical Issues Identified
1. Circular Dependencies
lib/rate.js→index.js: Creates Rate instances usingrclnodejs.init()andrclnodejs.createNode()index.js→lib/node.js→lib/lifecycle.js: Deferred imports at bottom ofindex.jsimport()or refactor dependency injection2. Dynamic Requires in Generated Code
rosidl_gen/templates/message-template.jsgenerates CJS code with:generated/directory)3. Native Module Loading
lib/native_loader.jsusesbindings('rclnodejs')createRequire()Migration Phases
Phase 1: Preparation
Files:
package.json,scripts/*.js,binding.gyp,rosidl_gen/*.js,rosidl_parser/*.js,rostsd_gen/*.jsRename all files that must remain CJS to
.cjsextension:scripts/*.js→scripts/*.cjs✅ (Complete)rosidl_gen/*.js→rosidl_gen/*.cjsrosidl_parser/*.js→rosidl_parser/*.cjsrostsd_gen/*.js→rostsd_gen/*.cjspackage.json,binding.gyp, and internal requiresPhase 2: Core Library + Entry Point
Files: 50 JS files in
lib/,index.js'use strict';const X = require('./x.js')→import X from './x.js'const { A, B } = require('./x.js')→import { A, B } from './x.js'module.exports = X→export default Xmodule.exports = { A, B }→export { A, B }native_loader.js- UsecreateRequire()for CJS-onlybindingspackagerate.js- Dynamic import for circular dependency"type": "module"topackage.jsonnpm testshould passPhase 3: Package Configuration & Bundler
Files:
package.json,tsup.config.jsAfter source conversion is complete, enable dual package exports:
{ "type": "module", "engines": { "node": ">= 18.0.0" }, "main": "./dist/index.cjs", "module": "./dist/index.js", "exports": { ".": { "import": { "types": "./types/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./types/index.d.ts", "default": "./dist/index.cjs" } }, "./generated/*": "./generated/*" } }Use
tsupto bundle ESM source → CJS output for backward compatibility:Phase 4: TypeScript Declarations
Files: 36
.d.tsfiles intypes/types/index.d.tsfor ESM:Phase 5: Generator/Parser Modules (Optional)
Files: 16
.cjsfiles inrosidl_gen/,rosidl_parser/,rostsd_gen/Optionally convert generator modules from CJS to ESM:
.cjsfiles to ESM syntax.jsscripts/*.cjsto use dynamicimport()for these modulesrequire()code)Out of Scope (Deferred)
scripts/.cjs, remain CJStest/--experimental-vm-modulesexample/third_party/ref-napiBackward Compatibility Strategy
import rclnodejs from 'rclnodejs'import { Node } from 'rclnodejs'const rclnodejs = require('rclnodejs')require('./generated/...')Risks & Mitigations
createRequire(), test thoroughlyimport(), refactor if neededtsdafter each phaserequire('rclnodejs')in CJS projectSuccess Criteria
import rclnodejs from 'rclnodejs'worksimport { Node, Clock } from 'rclnodejs'worksconst rclnodejs = require('rclnodejs')works (backward compat)Summary of Changes from Original Plan
.cjs