-
Notifications
You must be signed in to change notification settings - Fork 83
Description
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 |
createRequire() |
|
walk |
createRequire() |
|
json-bigint |
createRequire() |
|
@rclnodejs/ref-struct-di |
createRequire() |
|
@rclnodejs/ref-array-di |
createRequire() |
|
third_party/ref-napi |
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 usingrclnodejs.init()andrclnodejs.createNode()index.js→lib/node.js→lib/lifecycle.js: Deferred imports at bottom ofindex.js- Solution: Convert to dynamic
import()or refactor dependency injection
2. Dynamic Requires in Generated Code
rosidl_gen/templates/message-template.jsgenerates 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.jsusesbindings('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 Xmodule.exports = { A, B }→export { A, B }
- Remove
- Special handling:
native_loader.js- UsecreateRequire()for CJS-onlybindingspackagerate.js- Dynamic import for circular dependency
- Add
"type": "module"topackage.json - Test:
npm testshould 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.tsfor 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
.cjsfiles to ESM syntax - Rename back to
.js - Update
scripts/*.cjsto use dynamicimport()for these modules - Keep generated output as CJS (templates still produce
require()code)
💡 This phase is optional. The
.cjsfiles 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
-
import rclnodejs from 'rclnodejs'works -
import { Node, Clock } from 'rclnodejs'works -
const rclnodejs = require('rclnodejs')works (backward compat) - All existing tests pass
- Generated message files load correctly
- TypeScript types resolve properly
- Examples run successfully
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