Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ Node.js utility for transforming a JavaScript or TypeScript file from an ES modu
- ES module ➡️ CommonJS
- CommonJS ➡️ ES module

Highlights

- Defaults to safe CommonJS output: strict live bindings, import.meta shims, and specifier preservation.
- Opt into stricter/looser behaviors: live binding enforcement, import.meta.main gating, and top-level await strategies.
- Can optionally rewrite relative specifiers and write transformed output to disk.

> [!IMPORTANT]
> All parsing logic is applied under the assumption the code is in [strict mode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode) which [modules run under by default](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#other_differences_between_modules_and_classic_scripts).

Expand All @@ -18,9 +24,15 @@ By default `@knighted/module` transforms the one-to-one [differences between ES

- Node >= 20.11.0

## Install

```bash
npm install @knighted/module
```

## Example

Given an ES module
Given an ES module:

**file.js**

Expand All @@ -40,7 +52,7 @@ const detectCalledFromCli = async path => {
detectCalledFromCli(argv[1])
```

You can transform it to the equivalent CommonJS module
Transform it to CommonJS:

```js
import { transform } from '@knighted/module'
Expand All @@ -51,7 +63,7 @@ await transform('./file.js', {
})
```

Which produces
Which produces:

**file.cjs**

Expand Down Expand Up @@ -99,6 +111,7 @@ type ModuleOptions = {
| ((value: string) => string | null | undefined)
dirFilename?: 'inject' | 'preserve' | 'error'
importMeta?: 'preserve' | 'shim' | 'error'
importMetaMain?: 'shim' | 'warn' | 'error'
requireSource?: 'builtin' | 'create-require'
cjsDefault?: 'module-exports' | 'auto' | 'none'
topLevelAwait?: 'error' | 'wrap' | 'preserve'
Expand All @@ -107,7 +120,24 @@ type ModuleOptions = {
}
```

Behavior notes (defaults in parentheses)

- `target` (`commonjs`): output module system.
- `transformSyntax` (true): enable/disable the ESM↔CJS lowering pass.
- `liveBindings` (`strict`): getter-based live bindings, or snapshot (`loose`/`off`).
- `dirFilename` (`inject`): inject `__dirname`/`__filename`, preserve existing, or throw.
- `importMeta` (`shim`): rewrite `import.meta.*` to CommonJS equivalents.
- `importMetaMain` (`shim`): gate `import.meta.main` with shimming/warning/error when Node support is too old.
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
- `rewriteSpecifier` (off): rewrite relative specifiers to a chosen extension or via a callback.
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
- `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only.

See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings, interop helpers, top-level await behavior, and `import.meta.main` handling.

## Roadmap

- Remove `@knighted/specifier` and avoid double parsing.
- Flesh out live-binding and top-level await handling.
- Emit source maps and clearer diagnostics for transform choices.
- Broaden fixtures covering live-binding and top-level await edge cases across Node versions.
50 changes: 50 additions & 0 deletions docs/esm-to-cjs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ESM to CommonJS lowering

## Scope

- Translates ESM syntax to CommonJS when `target: 'commonjs'` with `transformSyntax` enabled.
- Assumes Node 20.11+ runtime with `__filename`/`__dirname` shims supplied by the host.
- Keeps optional live-binding behavior via `liveBindings` (strict/loose/off).
- Top-level await (TLA) handling is controlled by `topLevelAwait: 'error' | 'wrap' | 'preserve'` when lowering to CommonJS.
- Optional import.meta.main gating via `importMetaMain: 'shim' | 'warn' | 'error'`.

## Imports and interop

- `import` statements become `require` calls; side-effect imports become bare `require()` calls.
- Default imports use the bundler-style helper `__interopDefault = mod => (mod && mod.__esModule ? mod.default : mod)` when `cjsDefault: 'auto'`; otherwise they can target `module.exports` or `.default` directly per option.
- Namespace imports bind to the full `require` result; named imports destructure from the same binding.
- Re-exporting a default (`export { default as x } from`) routes through the interop helper to match bundler behavior for CJS sources.
- When the interop helper is emitted, `exports.__esModule = true` is also seeded for downstream consumers.

## Exports and live bindings

- Local named exports emit to `exports.<name>` (or bracket access) with `Object.defineProperty` getters when `liveBindings: 'strict'`; snapshot assignment is used for `loose`/`off`.
- `export default` writes to `module.exports = <expr>` in the common case; when TLA is present and allowed (`wrap`/`preserve`), default exports write to `exports.default = <expr>` so the exports object stays stable for async wrapping.
- `export * from` lowers to a `for...in` over the required module, skipping `default`, guarding on `hasOwnProperty`, and defining getters to mirror live bindings.
- Named re-exports from another module (`export { foo as bar } from`) use getters when `liveBindings: 'strict'`, otherwise snapshot assignment; `default` re-exports still use the interop helper.

## Top-level await

- `topLevelAwait: 'error'` (default) throws during transform when a TLA is found while targeting CommonJS.
- `topLevelAwait: 'wrap'` wraps the entire CJS output in `const __tla = (async () => { ...; return module.exports; })();` and attaches `__tla` to both `module.exports` and the resolved value. Consumers can await `require('./file').__tla` for readiness.
- `topLevelAwait: 'preserve'` runs the lowered code inside an immediately-invoked async function but does not attach a promise; consumers must rely on the natural scheduling of async effects.
- In both allowed modes, default exports write to `exports.default` instead of replacing `module.exports` to keep the exports object consistent while async initialization completes.
- Fixture: `test/fixtures/topLevelAwait.mjs` plus tests in `test/module.ts` cover both `wrap` and `preserve` behaviors.

## import.meta rewriting

- `import.meta.url` → `require("node:url").pathToFileURL(__filename).href`
- `import.meta.filename` → `__filename`
- `import.meta.dirname` → `__dirname`
- `import.meta.resolve` → `require.resolve`
- `import.meta.main` → configurable: default shim to `process.argv[1] === __filename`; with `importMetaMain: 'warn'` a warning is embedded for Node <22.18/24.2; with `importMetaMain: 'error'` the transform throws on those runtimes.
- Bare `import.meta` becomes `module` so property accesses stay valid in CJS output.

## Fixtures for this phase

- `test/fixtures/esmDefault.mjs`: default import from the CJS provider, re-exported as default for interop checks (default import yields the CJS module object).
- `test/fixtures/esmNamed.mjs`: named import and aliasing from the CJS provider, re-exported for assertions.
- `test/fixtures/esmNamespace.mjs`: namespace import shape from the CJS provider, re-exported as `ns`.
- `test/fixtures/esmReexport.mjs`: named + star re-exports from the CJS provider (includes live binding passthrough).
- `test/fixtures/liveReexport.mjs`: re-export from a mutating CJS source; exercised when `liveBindings: 'strict'` to verify getter-based live bindings.
- `test/fixtures/esmProvider.cjs`: CJS source exposing default/named exports plus a mutating `live` counter for binding checks (interval is `unref()`ed; tests wait ≥30ms to observe changes).
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/module",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"description": "Transforms differences between ES modules and CommonJS.",
"type": "module",
"main": "dist/module.js",
Expand Down
Loading