Skip to content

Commit b991fd3

Browse files
feat: basic esm to cjs lowering.
1 parent a0fc62a commit b991fd3

15 files changed

Lines changed: 475 additions & 5 deletions

File tree

docs/esm-to-cjs.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
9+
## Imports and interop
10+
11+
- `import` statements become `require` calls; side-effect imports become bare `require()` calls.
12+
- 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.
13+
- Namespace imports bind to the full `require` result; named imports destructure from the same binding.
14+
- Re-exporting a default (`export { default as x } from`) routes through the interop helper to match bundler behavior for CJS sources.
15+
- When the interop helper is emitted, `exports.__esModule = true` is also seeded for downstream consumers.
16+
17+
## Exports
18+
19+
- Local named exports emit to `exports.<name>` (or bracket access) with `Object.defineProperty` getters when `liveBindings: 'strict'`.
20+
- `export default` writes to `module.exports = <expr>`; default functions/classes preserve their identifier before exporting.
21+
- `export * from` lowers to a `for...in` over the required module, skipping `default`, guarding on `hasOwnProperty`, and defining getters to mirror live bindings.
22+
- Named re-exports from another module (`export { foo as bar } from`) assign using either a direct property read or the interop helper for `default`.
23+
24+
## import.meta rewriting
25+
26+
- `import.meta.url``require("node:url").pathToFileURL(__filename).href`
27+
- `import.meta.filename``__filename`
28+
- `import.meta.dirname``__dirname`
29+
- `import.meta.resolve``require.resolve`
30+
- `import.meta.main``process.argv[1] === __filename`
31+
- Bare `import.meta` becomes `module` so property accesses stay valid in CJS output.
32+
33+
## Fixtures for this phase
34+
35+
- `test/fixtures/esmDefault.mjs`: default import from the CJS provider, re-exported as default for interop checks (default import yields the CJS module object).
36+
- `test/fixtures/esmNamed.mjs`: named import and aliasing from the CJS provider, re-exported for assertions.
37+
- `test/fixtures/esmNamespace.mjs`: namespace import shape from the CJS provider, re-exported as `ns`.
38+
- `test/fixtures/esmReexport.mjs`: named + star re-exports from the CJS provider (includes live binding passthrough).
39+
- `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).

src/format.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,286 @@ import { collectModuleIdentifiers } from '#utils/identifiers.js'
1212
import { isIdentifierName } from '#helpers/identifier.js'
1313
import { ancestorWalk } from '#walk'
1414

15+
const isValidIdent = (name: string) => /^[$A-Z_a-z][$\w]*$/.test(name)
16+
17+
const exportAssignment = (
18+
name: string,
19+
expr: string,
20+
live: 'strict' | 'loose' | 'off',
21+
) => {
22+
const prop = isValidIdent(name) ? `.${name}` : `[${JSON.stringify(name)}]`
23+
if (live === 'strict') {
24+
const key = JSON.stringify(name)
25+
return `Object.defineProperty(exports, ${key}, { enumerable: true, get: () => ${expr} });`
26+
}
27+
return `exports${prop} = ${expr};`
28+
}
29+
30+
const defaultInteropName = '__interopDefault'
31+
const interopHelper = `const ${defaultInteropName} = mod => (mod && mod.__esModule ? mod.default : mod);\n`
32+
33+
const hasTopLevelAwait = (program: any) => {
34+
let found = false
35+
36+
const walkNode = (node: any, inFunction: boolean) => {
37+
if (found) return
38+
39+
switch (node.type) {
40+
case 'FunctionDeclaration':
41+
case 'FunctionExpression':
42+
case 'ArrowFunctionExpression':
43+
case 'ClassDeclaration':
44+
case 'ClassExpression':
45+
inFunction = true
46+
break
47+
}
48+
49+
if (!inFunction && node.type === 'AwaitExpression') {
50+
found = true
51+
return
52+
}
53+
54+
const keys = Object.keys(node)
55+
for (const key of keys) {
56+
const value = (node as any)[key]
57+
if (!value) continue
58+
59+
if (Array.isArray(value)) {
60+
for (const item of value) {
61+
if (item && typeof item === 'object') {
62+
walkNode(item, inFunction)
63+
if (found) return
64+
}
65+
}
66+
} else if (value && typeof value === 'object') {
67+
walkNode(value, inFunction)
68+
if (found) return
69+
}
70+
}
71+
}
72+
73+
walkNode(program, false)
74+
return found
75+
}
76+
77+
const lowerEsmToCjs = (program: any, code: MagicString, opts: FormatterOptions) => {
78+
const live = opts.liveBindings ?? 'strict'
79+
const importTransforms: ImportTransform[] = []
80+
const exportTransforms: ExportTransform[] = []
81+
let needsInterop = false
82+
let importIndex = 0
83+
84+
for (const node of program.body as any[]) {
85+
if (node.type === 'ImportDeclaration') {
86+
const srcLiteral = code.slice(node.source.start, node.source.end)
87+
const specifiers = node.specifiers ?? []
88+
const defaultSpec = specifiers.find((s: any) => s.type === 'ImportDefaultSpecifier')
89+
const namespaceSpec = specifiers.find(
90+
(s: any) => s.type === 'ImportNamespaceSpecifier',
91+
)
92+
const namedSpecs = specifiers.filter((s: any) => s.type === 'ImportSpecifier')
93+
94+
// Side-effect import
95+
if (!specifiers.length) {
96+
importTransforms.push({
97+
start: node.start,
98+
end: node.end,
99+
code: `require(${srcLiteral});\n`,
100+
needsInterop: false,
101+
})
102+
continue
103+
}
104+
105+
const modIdent = `__mod${importIndex++}`
106+
const lines: string[] = []
107+
108+
lines.push(`const ${modIdent} = require(${srcLiteral});`)
109+
110+
if (namespaceSpec) {
111+
lines.push(`const ${namespaceSpec.local.name} = ${modIdent};`)
112+
}
113+
114+
if (defaultSpec) {
115+
let init = modIdent
116+
switch (opts.cjsDefault) {
117+
case 'module-exports':
118+
init = modIdent
119+
break
120+
case 'none':
121+
init = `${modIdent}.default`
122+
break
123+
case 'auto':
124+
default:
125+
init = `${defaultInteropName}(${modIdent})`
126+
needsInterop = true
127+
break
128+
}
129+
lines.push(`const ${defaultSpec.local.name} = ${init};`)
130+
}
131+
132+
if (namedSpecs.length) {
133+
const pairs = namedSpecs.map((s: any) => {
134+
const imported = s.imported.name
135+
const local = s.local.name
136+
return imported === local ? imported : `${imported}: ${local}`
137+
})
138+
lines.push(`const { ${pairs.join(', ')} } = ${modIdent};`)
139+
}
140+
141+
importTransforms.push({
142+
start: node.start,
143+
end: node.end,
144+
code: `${lines.join('\n')}\n`,
145+
needsInterop,
146+
})
147+
}
148+
149+
if (node.type === 'ExportNamedDeclaration') {
150+
// Handle declaration exports
151+
if (node.declaration) {
152+
const decl = node.declaration
153+
const declSrc = code.slice(decl.start, decl.end)
154+
const exportedNames: string[] = []
155+
156+
if (decl.type === 'VariableDeclaration') {
157+
for (const d of decl.declarations) {
158+
if (d.id.type === 'Identifier') {
159+
exportedNames.push(d.id.name)
160+
}
161+
}
162+
} else if ((decl as any).id?.type === 'Identifier') {
163+
exportedNames.push((decl as any).id.name)
164+
}
165+
166+
const exportLines = exportedNames.map(name =>
167+
exportAssignment(name, name, live as any),
168+
)
169+
170+
exportTransforms.push({
171+
start: node.start,
172+
end: node.end,
173+
code: `${declSrc}\n${exportLines.join('\n')}\n`,
174+
})
175+
continue
176+
}
177+
178+
// Handle re-export or local specifiers
179+
if (node.specifiers?.length) {
180+
if (node.source) {
181+
const srcLiteral = code.slice(node.source.start, node.source.end)
182+
const modIdent = `__mod${importIndex++}`
183+
const lines = [`const ${modIdent} = require(${srcLiteral});`]
184+
185+
for (const spec of node.specifiers) {
186+
if (spec.type !== 'ExportSpecifier') continue
187+
const exported = spec.exported.name
188+
const imported = spec.local.name
189+
190+
let rhs = `${modIdent}.${imported}`
191+
if (imported === 'default') {
192+
rhs = `${defaultInteropName}(${modIdent})`
193+
needsInterop = true
194+
}
195+
196+
lines.push(exportAssignment(exported, rhs, live as any))
197+
}
198+
199+
exportTransforms.push({
200+
start: node.start,
201+
end: node.end,
202+
code: `${lines.join('\n')}\n`,
203+
needsInterop,
204+
})
205+
} else {
206+
const lines: string[] = []
207+
for (const spec of node.specifiers) {
208+
if (spec.type !== 'ExportSpecifier') continue
209+
const exported = spec.exported.name
210+
const local = spec.local.name
211+
lines.push(exportAssignment(exported, local, live as any))
212+
}
213+
exportTransforms.push({
214+
start: node.start,
215+
end: node.end,
216+
code: `${lines.join('\n')}\n`,
217+
})
218+
}
219+
}
220+
}
221+
222+
if (node.type === 'ExportDefaultDeclaration') {
223+
const decl = node.declaration
224+
if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') {
225+
if (decl.id?.name) {
226+
const declSrc = code.slice(decl.start, decl.end)
227+
exportTransforms.push({
228+
start: node.start,
229+
end: node.end,
230+
code: `${declSrc}\nmodule.exports = ${decl.id.name};\n`,
231+
})
232+
} else {
233+
const declSrc = code.slice(decl.start, decl.end)
234+
exportTransforms.push({
235+
start: node.start,
236+
end: node.end,
237+
code: `module.exports = ${declSrc};\n`,
238+
})
239+
}
240+
} else {
241+
const exprSrc = code.slice(decl.start, decl.end)
242+
exportTransforms.push({
243+
start: node.start,
244+
end: node.end,
245+
code: `module.exports = ${exprSrc};\n`,
246+
})
247+
}
248+
}
249+
250+
if (node.type === 'ExportAllDeclaration') {
251+
const srcLiteral = code.slice(node.source.start, node.source.end)
252+
if ((node as any).exported) {
253+
const exported = (node as any).exported.name
254+
const modIdent = `__mod${importIndex++}`
255+
const lines = [
256+
`const ${modIdent} = require(${srcLiteral});`,
257+
exportAssignment(exported, modIdent, live as any),
258+
]
259+
exportTransforms.push({
260+
start: node.start,
261+
end: node.end,
262+
code: `${lines.join('\n')}\n`,
263+
})
264+
} else {
265+
const modIdent = `__mod${importIndex++}`
266+
const lines = [`const ${modIdent} = require(${srcLiteral});`]
267+
const loop = `for (const k in ${modIdent}) {\n if (k === 'default') continue;\n if (!Object.prototype.hasOwnProperty.call(${modIdent}, k)) continue;\n Object.defineProperty(exports, k, { enumerable: true, get: () => ${modIdent}[k] });\n}`
268+
lines.push(loop)
269+
exportTransforms.push({
270+
start: node.start,
271+
end: node.end,
272+
code: `${lines.join('\n')}\n`,
273+
})
274+
}
275+
}
276+
}
277+
278+
return { importTransforms, exportTransforms, needsInterop }
279+
}
280+
281+
type ImportTransform = {
282+
start: number
283+
end: number
284+
code: string
285+
needsInterop: boolean
286+
}
287+
288+
type ExportTransform = {
289+
start: number
290+
end: number
291+
code: string
292+
needsInterop?: boolean
293+
}
294+
15295
/**
16296
* Node added support for import.meta.main.
17297
* Added in: v24.2.0, v22.18.0
@@ -29,6 +309,33 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
29309
opts.target === 'module' ? await collectCjsExports(ast.program) : null
30310
await collectModuleIdentifiers(ast.program)
31311

312+
if (opts.target === 'commonjs' && opts.transformSyntax) {
313+
if (opts.topLevelAwait === 'error' && hasTopLevelAwait(ast.program)) {
314+
throw new Error(
315+
'Top-level await is not supported when targeting CommonJS (set topLevelAwait to "wrap" or "preserve" to override).',
316+
)
317+
}
318+
319+
const { importTransforms, exportTransforms, needsInterop } = lowerEsmToCjs(
320+
ast.program,
321+
code,
322+
opts,
323+
)
324+
325+
// Apply transforms in source order
326+
const allTransforms = [...importTransforms, ...exportTransforms].sort(
327+
(a, b) => a.start - b.start,
328+
)
329+
330+
for (const t of allTransforms) {
331+
code.overwrite(t.start, t.end, t.code)
332+
}
333+
334+
if (needsInterop) {
335+
code.prepend(`${interopHelper}exports.__esModule = true;\n`)
336+
}
337+
}
338+
32339
if (opts.target === 'module' && opts.transformSyntax) {
33340
/**
34341
* Prepare ESM output by renaming `exports` to `__exports` and seeding an

src/formatters/metaProperty.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export const metaProperty = (
3838
*/
3939
src.update(parent.start, parent.end, 'require.resolve')
4040
break
41+
case 'main':
42+
src.update(parent.start, parent.end, 'process.argv[1] === __filename')
43+
break
4144
}
4245
}
4346
}

test/fixtures/esmDefault.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import foo from './esmProvider.cjs'
2+
3+
export default foo

test/fixtures/esmNamed.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { foo, bar as baz } from './esmProvider.cjs'
2+
3+
export { foo, baz }

test/fixtures/esmNamespace.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import * as ns from './esmProvider.cjs'
2+
3+
export { ns }

0 commit comments

Comments
 (0)