Skip to content

ES Module Migration Plan for rclnodejs #1358

@mahmoud-ghalayini

Description

@mahmoud-ghalayini

Goal

Migrate rclnodejs from CommonJS to ES Modules, enabling modern import { Node } from 'rclnodejs' syntax while maintaining backward compatibility for generated message files.

File Inventory

Category Count Notes
lib/*.js 48 files Core library (39 use module.exports)
rosidl_gen/*.js 10 files Message 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.jsindex.js: Creates Rate instances using rclnodejs.init() and rclnodejs.createNode()
  • index.jslib/node.jslib/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()

Essential Migration Steps (5 Phases)

Phase 1: Package Configuration

Files: package.json

{
  "type": "module",
  "engines": { "node": ">= 18.0.0" },
  "exports": {
    ".": {
      "import": "./index.js",
      "types": "./types/index.d.ts"
    },
    "./generated/*": "./generated/*"
  }
}
  • Bump Node.js to 18+ (stable ESM support)
  • Add exports map with dual support for generated CJS files

Phase 2: Core Library (lib/)

Files: 48 JS files

Changes per file:

  1. Remove 'use strict';
  2. const X = require('./x.js')import X from './x.js'
  3. const { A, B } = require('./x.js')import { A, B } from './x.js'
  4. module.exports = Xexport default X
  5. module.exports = { A, B }export { A, B }

Special handling (Requires verification):

  • native_loader.js: Use createRequire() for bindings
    import { createRequire } from 'node:module';
    const require = createRequire(import.meta.url);
    const bindings = require('bindings');
  • rate.js: Dynamic import for circular dependency
    const rclnodejs = await import('../index.js');

Phase 3: Entry Point (index.js)

Files: 1 file

  • Convert all requires to imports
  • Resolve circular deps with dynamic imports or reorganization
  • Export named + default:
    export { Node, Clock, Duration, ... };
    export default rcl;

Phase 4: Generator/Parser Modules

Files: 13 JS files in rosidl_gen/, rosidl_parser/, rostsd_gen/

  • Convert to ESM syntax
  • Keep generated output as CJS (templates produce require() code)
  • Generated files in generated/ load FFI dependencies that are CJS-only

Phase 5: 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;
    }
  • Ensure ambient declarations remain compatible

Out of Scope (Deferred)

Category Files Notes
scripts/ 11 JS files Build/test scripts, can remain CJS
test/ All test files Can use --experimental-vm-modules
example/ All examples Update as documentation
third_party/ref-napi 2 files Must stay CJS (native bindings)

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

Success Criteria

  • import rclnodejs from 'rclnodejs' works
  • import { Node, Clock } from 'rclnodejs' works
  • All existing tests pass
  • Generated message files load correctly
  • TypeScript types resolve properly
  • Examples run successfully

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions