Skip to content

Commit 99d81ef

Browse files
feat: esm to cjs lowering. (#17)
1 parent a0fc62a commit 99d81ef

24 files changed

Lines changed: 706 additions & 14 deletions

README.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Node.js utility for transforming a JavaScript or TypeScript file from an ES modu
99
- ES module ➡️ CommonJS
1010
- CommonJS ➡️ ES module
1111

12+
Highlights
13+
14+
- Defaults to safe CommonJS output: strict live bindings, import.meta shims, and specifier preservation.
15+
- Opt into stricter/looser behaviors: live binding enforcement, import.meta.main gating, and top-level await strategies.
16+
- Can optionally rewrite relative specifiers and write transformed output to disk.
17+
1218
> [!IMPORTANT]
1319
> 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).
1420
@@ -18,9 +24,15 @@ By default `@knighted/module` transforms the one-to-one [differences between ES
1824

1925
- Node >= 20.11.0
2026

27+
## Install
28+
29+
```bash
30+
npm install @knighted/module
31+
```
32+
2133
## Example
2234

23-
Given an ES module
35+
Given an ES module:
2436

2537
**file.js**
2638

@@ -40,7 +52,7 @@ const detectCalledFromCli = async path => {
4052
detectCalledFromCli(argv[1])
4153
```
4254
43-
You can transform it to the equivalent CommonJS module
55+
Transform it to CommonJS:
4456
4557
```js
4658
import { transform } from '@knighted/module'
@@ -51,7 +63,7 @@ await transform('./file.js', {
5163
})
5264
```
5365
54-
Which produces
66+
Which produces:
5567
5668
**file.cjs**
5769
@@ -99,6 +111,7 @@ type ModuleOptions = {
99111
| ((value: string) => string | null | undefined)
100112
dirFilename?: 'inject' | 'preserve' | 'error'
101113
importMeta?: 'preserve' | 'shim' | 'error'
114+
importMetaMain?: 'shim' | 'warn' | 'error'
102115
requireSource?: 'builtin' | 'create-require'
103116
cjsDefault?: 'module-exports' | 'auto' | 'none'
104117
topLevelAwait?: 'error' | 'wrap' | 'preserve'
@@ -107,7 +120,24 @@ type ModuleOptions = {
107120
}
108121
```
109122
123+
Behavior notes (defaults in parentheses)
124+
125+
- `target` (`commonjs`): output module system.
126+
- `transformSyntax` (true): enable/disable the ESM↔CJS lowering pass.
127+
- `liveBindings` (`strict`): getter-based live bindings, or snapshot (`loose`/`off`).
128+
- `dirFilename` (`inject`): inject `__dirname`/`__filename`, preserve existing, or throw.
129+
- `importMeta` (`shim`): rewrite `import.meta.*` to CommonJS equivalents.
130+
- `importMetaMain` (`shim`): gate `import.meta.main` with shimming/warning/error when Node support is too old.
131+
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
132+
- `rewriteSpecifier` (off): rewrite relative specifiers to a chosen extension or via a callback.
133+
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
134+
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
135+
- `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only.
136+
137+
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.
138+
110139
## Roadmap
111140
112141
- Remove `@knighted/specifier` and avoid double parsing.
113-
- Flesh out live-binding and top-level await handling.
142+
- Emit source maps and clearer diagnostics for transform choices.
143+
- Broaden fixtures covering live-binding and top-level await edge cases across Node versions.

docs/esm-to-cjs.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# ESM to CommonJS lowering
2+
3+
## Scope
4+
5+
- Translates ESM syntax to CommonJS when `target: 'commonjs'` with `transformSyntax` enabled.
6+
- Assumes Node 20.11+ runtime with `__filename`/`__dirname` shims supplied by the host.
7+
- Keeps optional live-binding behavior via `liveBindings` (strict/loose/off).
8+
- Top-level await (TLA) handling is controlled by `topLevelAwait: 'error' | 'wrap' | 'preserve'` when lowering to CommonJS.
9+
- Optional import.meta.main gating via `importMetaMain: 'shim' | 'warn' | 'error'`.
10+
11+
## Imports and interop
12+
13+
- `import` statements become `require` calls; side-effect imports become bare `require()` calls.
14+
- 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.
15+
- Namespace imports bind to the full `require` result; named imports destructure from the same binding.
16+
- Re-exporting a default (`export { default as x } from`) routes through the interop helper to match bundler behavior for CJS sources.
17+
- When the interop helper is emitted, `exports.__esModule = true` is also seeded for downstream consumers.
18+
19+
## Exports and live bindings
20+
21+
- Local named exports emit to `exports.<name>` (or bracket access) with `Object.defineProperty` getters when `liveBindings: 'strict'`; snapshot assignment is used for `loose`/`off`.
22+
- `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.
23+
- `export * from` lowers to a `for...in` over the required module, skipping `default`, guarding on `hasOwnProperty`, and defining getters to mirror live bindings.
24+
- 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.
25+
26+
## Top-level await
27+
28+
- `topLevelAwait: 'error'` (default) throws during transform when a TLA is found while targeting CommonJS.
29+
- `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.
30+
- `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.
31+
- 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.
32+
- Fixture: `test/fixtures/topLevelAwait.mjs` plus tests in `test/module.ts` cover both `wrap` and `preserve` behaviors.
33+
34+
## import.meta rewriting
35+
36+
- `import.meta.url``require("node:url").pathToFileURL(__filename).href`
37+
- `import.meta.filename``__filename`
38+
- `import.meta.dirname``__dirname`
39+
- `import.meta.resolve``require.resolve`
40+
- `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.
41+
- Bare `import.meta` becomes `module` so property accesses stay valid in CJS output.
42+
43+
## Fixtures for this phase
44+
45+
- `test/fixtures/esmDefault.mjs`: default import from the CJS provider, re-exported as default for interop checks (default import yields the CJS module object).
46+
- `test/fixtures/esmNamed.mjs`: named import and aliasing from the CJS provider, re-exported for assertions.
47+
- `test/fixtures/esmNamespace.mjs`: namespace import shape from the CJS provider, re-exported as `ns`.
48+
- `test/fixtures/esmReexport.mjs`: named + star re-exports from the CJS provider (includes live binding passthrough).
49+
- `test/fixtures/liveReexport.mjs`: re-export from a mutating CJS source; exercised when `liveBindings: 'strict'` to verify getter-based live bindings.
50+
- `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).

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/module",
3-
"version": "1.0.0-beta.1",
3+
"version": "1.0.0-beta.2",
44
"description": "Transforms differences between ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",

0 commit comments

Comments
 (0)