From b991fd3a8acd184cc481371cbf0182774d9b4064 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 11:54:14 -0600 Subject: [PATCH 1/5] feat: basic esm to cjs lowering. --- docs/esm-to-cjs.md | 39 +++ src/format.ts | 307 ++++++++++++++++++++ src/formatters/metaProperty.ts | 3 + test/fixtures/esmDefault.mjs | 3 + test/fixtures/esmNamed.mjs | 3 + test/fixtures/esmNamespace.mjs | 3 + test/fixtures/esmProvider.cjs | 17 ++ test/fixtures/esmReexport.mjs | 2 + test/fixtures/identifiers/package.json | 3 + test/fixtures/specifier/file.js | 3 + test/fixtures/specifier/filefilefile.js | 3 + test/fixtures/specifier/package.json | 3 + test/fixtures/{ => specifier}/specifier.cjs | 0 test/fixtures/{ => specifier}/specifier.mjs | 0 test/module.ts | 91 +++++- 15 files changed, 475 insertions(+), 5 deletions(-) create mode 100644 docs/esm-to-cjs.md create mode 100644 test/fixtures/esmDefault.mjs create mode 100644 test/fixtures/esmNamed.mjs create mode 100644 test/fixtures/esmNamespace.mjs create mode 100644 test/fixtures/esmProvider.cjs create mode 100644 test/fixtures/esmReexport.mjs create mode 100644 test/fixtures/identifiers/package.json create mode 100644 test/fixtures/specifier/file.js create mode 100644 test/fixtures/specifier/filefilefile.js create mode 100644 test/fixtures/specifier/package.json rename test/fixtures/{ => specifier}/specifier.cjs (100%) rename test/fixtures/{ => specifier}/specifier.mjs (100%) diff --git a/docs/esm-to-cjs.md b/docs/esm-to-cjs.md new file mode 100644 index 0000000..8b49194 --- /dev/null +++ b/docs/esm-to-cjs.md @@ -0,0 +1,39 @@ +# 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). + +## 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 + +- Local named exports emit to `exports.` (or bracket access) with `Object.defineProperty` getters when `liveBindings: 'strict'`. +- `export default` writes to `module.exports = `; default functions/classes preserve their identifier before exporting. +- `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`) assign using either a direct property read or the interop helper for `default`. + +## 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` → `process.argv[1] === __filename` +- 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/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). diff --git a/src/format.ts b/src/format.ts index 3d77552..8913166 100644 --- a/src/format.ts +++ b/src/format.ts @@ -12,6 +12,286 @@ import { collectModuleIdentifiers } from '#utils/identifiers.js' import { isIdentifierName } from '#helpers/identifier.js' import { ancestorWalk } from '#walk' +const isValidIdent = (name: string) => /^[$A-Z_a-z][$\w]*$/.test(name) + +const exportAssignment = ( + name: string, + expr: string, + live: 'strict' | 'loose' | 'off', +) => { + const prop = isValidIdent(name) ? `.${name}` : `[${JSON.stringify(name)}]` + if (live === 'strict') { + const key = JSON.stringify(name) + return `Object.defineProperty(exports, ${key}, { enumerable: true, get: () => ${expr} });` + } + return `exports${prop} = ${expr};` +} + +const defaultInteropName = '__interopDefault' +const interopHelper = `const ${defaultInteropName} = mod => (mod && mod.__esModule ? mod.default : mod);\n` + +const hasTopLevelAwait = (program: any) => { + let found = false + + const walkNode = (node: any, inFunction: boolean) => { + if (found) return + + switch (node.type) { + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ArrowFunctionExpression': + case 'ClassDeclaration': + case 'ClassExpression': + inFunction = true + break + } + + if (!inFunction && node.type === 'AwaitExpression') { + found = true + return + } + + const keys = Object.keys(node) + for (const key of keys) { + const value = (node as any)[key] + if (!value) continue + + if (Array.isArray(value)) { + for (const item of value) { + if (item && typeof item === 'object') { + walkNode(item, inFunction) + if (found) return + } + } + } else if (value && typeof value === 'object') { + walkNode(value, inFunction) + if (found) return + } + } + } + + walkNode(program, false) + return found +} + +const lowerEsmToCjs = (program: any, code: MagicString, opts: FormatterOptions) => { + const live = opts.liveBindings ?? 'strict' + const importTransforms: ImportTransform[] = [] + const exportTransforms: ExportTransform[] = [] + let needsInterop = false + let importIndex = 0 + + for (const node of program.body as any[]) { + if (node.type === 'ImportDeclaration') { + const srcLiteral = code.slice(node.source.start, node.source.end) + const specifiers = node.specifiers ?? [] + const defaultSpec = specifiers.find((s: any) => s.type === 'ImportDefaultSpecifier') + const namespaceSpec = specifiers.find( + (s: any) => s.type === 'ImportNamespaceSpecifier', + ) + const namedSpecs = specifiers.filter((s: any) => s.type === 'ImportSpecifier') + + // Side-effect import + if (!specifiers.length) { + importTransforms.push({ + start: node.start, + end: node.end, + code: `require(${srcLiteral});\n`, + needsInterop: false, + }) + continue + } + + const modIdent = `__mod${importIndex++}` + const lines: string[] = [] + + lines.push(`const ${modIdent} = require(${srcLiteral});`) + + if (namespaceSpec) { + lines.push(`const ${namespaceSpec.local.name} = ${modIdent};`) + } + + if (defaultSpec) { + let init = modIdent + switch (opts.cjsDefault) { + case 'module-exports': + init = modIdent + break + case 'none': + init = `${modIdent}.default` + break + case 'auto': + default: + init = `${defaultInteropName}(${modIdent})` + needsInterop = true + break + } + lines.push(`const ${defaultSpec.local.name} = ${init};`) + } + + if (namedSpecs.length) { + const pairs = namedSpecs.map((s: any) => { + const imported = s.imported.name + const local = s.local.name + return imported === local ? imported : `${imported}: ${local}` + }) + lines.push(`const { ${pairs.join(', ')} } = ${modIdent};`) + } + + importTransforms.push({ + start: node.start, + end: node.end, + code: `${lines.join('\n')}\n`, + needsInterop, + }) + } + + if (node.type === 'ExportNamedDeclaration') { + // Handle declaration exports + if (node.declaration) { + const decl = node.declaration + const declSrc = code.slice(decl.start, decl.end) + const exportedNames: string[] = [] + + if (decl.type === 'VariableDeclaration') { + for (const d of decl.declarations) { + if (d.id.type === 'Identifier') { + exportedNames.push(d.id.name) + } + } + } else if ((decl as any).id?.type === 'Identifier') { + exportedNames.push((decl as any).id.name) + } + + const exportLines = exportedNames.map(name => + exportAssignment(name, name, live as any), + ) + + exportTransforms.push({ + start: node.start, + end: node.end, + code: `${declSrc}\n${exportLines.join('\n')}\n`, + }) + continue + } + + // Handle re-export or local specifiers + if (node.specifiers?.length) { + if (node.source) { + const srcLiteral = code.slice(node.source.start, node.source.end) + const modIdent = `__mod${importIndex++}` + const lines = [`const ${modIdent} = require(${srcLiteral});`] + + for (const spec of node.specifiers) { + if (spec.type !== 'ExportSpecifier') continue + const exported = spec.exported.name + const imported = spec.local.name + + let rhs = `${modIdent}.${imported}` + if (imported === 'default') { + rhs = `${defaultInteropName}(${modIdent})` + needsInterop = true + } + + lines.push(exportAssignment(exported, rhs, live as any)) + } + + exportTransforms.push({ + start: node.start, + end: node.end, + code: `${lines.join('\n')}\n`, + needsInterop, + }) + } else { + const lines: string[] = [] + for (const spec of node.specifiers) { + if (spec.type !== 'ExportSpecifier') continue + const exported = spec.exported.name + const local = spec.local.name + lines.push(exportAssignment(exported, local, live as any)) + } + exportTransforms.push({ + start: node.start, + end: node.end, + code: `${lines.join('\n')}\n`, + }) + } + } + } + + if (node.type === 'ExportDefaultDeclaration') { + const decl = node.declaration + if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') { + if (decl.id?.name) { + const declSrc = code.slice(decl.start, decl.end) + exportTransforms.push({ + start: node.start, + end: node.end, + code: `${declSrc}\nmodule.exports = ${decl.id.name};\n`, + }) + } else { + const declSrc = code.slice(decl.start, decl.end) + exportTransforms.push({ + start: node.start, + end: node.end, + code: `module.exports = ${declSrc};\n`, + }) + } + } else { + const exprSrc = code.slice(decl.start, decl.end) + exportTransforms.push({ + start: node.start, + end: node.end, + code: `module.exports = ${exprSrc};\n`, + }) + } + } + + if (node.type === 'ExportAllDeclaration') { + const srcLiteral = code.slice(node.source.start, node.source.end) + if ((node as any).exported) { + const exported = (node as any).exported.name + const modIdent = `__mod${importIndex++}` + const lines = [ + `const ${modIdent} = require(${srcLiteral});`, + exportAssignment(exported, modIdent, live as any), + ] + exportTransforms.push({ + start: node.start, + end: node.end, + code: `${lines.join('\n')}\n`, + }) + } else { + const modIdent = `__mod${importIndex++}` + const lines = [`const ${modIdent} = require(${srcLiteral});`] + 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}` + lines.push(loop) + exportTransforms.push({ + start: node.start, + end: node.end, + code: `${lines.join('\n')}\n`, + }) + } + } + } + + return { importTransforms, exportTransforms, needsInterop } +} + +type ImportTransform = { + start: number + end: number + code: string + needsInterop: boolean +} + +type ExportTransform = { + start: number + end: number + code: string + needsInterop?: boolean +} + /** * Node added support for import.meta.main. * Added in: v24.2.0, v22.18.0 @@ -29,6 +309,33 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => opts.target === 'module' ? await collectCjsExports(ast.program) : null await collectModuleIdentifiers(ast.program) + if (opts.target === 'commonjs' && opts.transformSyntax) { + if (opts.topLevelAwait === 'error' && hasTopLevelAwait(ast.program)) { + throw new Error( + 'Top-level await is not supported when targeting CommonJS (set topLevelAwait to "wrap" or "preserve" to override).', + ) + } + + const { importTransforms, exportTransforms, needsInterop } = lowerEsmToCjs( + ast.program, + code, + opts, + ) + + // Apply transforms in source order + const allTransforms = [...importTransforms, ...exportTransforms].sort( + (a, b) => a.start - b.start, + ) + + for (const t of allTransforms) { + code.overwrite(t.start, t.end, t.code) + } + + if (needsInterop) { + code.prepend(`${interopHelper}exports.__esModule = true;\n`) + } + } + if (opts.target === 'module' && opts.transformSyntax) { /** * Prepare ESM output by renaming `exports` to `__exports` and seeding an diff --git a/src/formatters/metaProperty.ts b/src/formatters/metaProperty.ts index 17d8c5a..50df3ae 100644 --- a/src/formatters/metaProperty.ts +++ b/src/formatters/metaProperty.ts @@ -38,6 +38,9 @@ export const metaProperty = ( */ src.update(parent.start, parent.end, 'require.resolve') break + case 'main': + src.update(parent.start, parent.end, 'process.argv[1] === __filename') + break } } } diff --git a/test/fixtures/esmDefault.mjs b/test/fixtures/esmDefault.mjs new file mode 100644 index 0000000..551cf04 --- /dev/null +++ b/test/fixtures/esmDefault.mjs @@ -0,0 +1,3 @@ +import foo from './esmProvider.cjs' + +export default foo diff --git a/test/fixtures/esmNamed.mjs b/test/fixtures/esmNamed.mjs new file mode 100644 index 0000000..ef61b32 --- /dev/null +++ b/test/fixtures/esmNamed.mjs @@ -0,0 +1,3 @@ +import { foo, bar as baz } from './esmProvider.cjs' + +export { foo, baz } diff --git a/test/fixtures/esmNamespace.mjs b/test/fixtures/esmNamespace.mjs new file mode 100644 index 0000000..8968ae3 --- /dev/null +++ b/test/fixtures/esmNamespace.mjs @@ -0,0 +1,3 @@ +import * as ns from './esmProvider.cjs' + +export { ns } diff --git a/test/fixtures/esmProvider.cjs b/test/fixtures/esmProvider.cjs new file mode 100644 index 0000000..541a2d9 --- /dev/null +++ b/test/fixtures/esmProvider.cjs @@ -0,0 +1,17 @@ +'use strict' + +module.exports = { + default: 'default-val', + foo: 'foo-val', + bar: 'bar-val', + live: 1, +} + +const timer = setInterval(() => { + module.exports.live += 1 +}, 10) + +// Avoid keeping the event loop alive in tests +if (typeof timer.unref === 'function') { + timer.unref() +} diff --git a/test/fixtures/esmReexport.mjs b/test/fixtures/esmReexport.mjs new file mode 100644 index 0000000..903a233 --- /dev/null +++ b/test/fixtures/esmReexport.mjs @@ -0,0 +1,2 @@ +export { foo as renamed } from './esmProvider.cjs' +export * from './esmProvider.cjs' diff --git a/test/fixtures/identifiers/package.json b/test/fixtures/identifiers/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/test/fixtures/identifiers/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/fixtures/specifier/file.js b/test/fixtures/specifier/file.js new file mode 100644 index 0000000..39445bc --- /dev/null +++ b/test/fixtures/specifier/file.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports = 'file' diff --git a/test/fixtures/specifier/filefilefile.js b/test/fixtures/specifier/filefilefile.js new file mode 100644 index 0000000..1e7ab8d --- /dev/null +++ b/test/fixtures/specifier/filefilefile.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports = 'filefilefile' diff --git a/test/fixtures/specifier/package.json b/test/fixtures/specifier/package.json new file mode 100644 index 0000000..5bbefff --- /dev/null +++ b/test/fixtures/specifier/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/test/fixtures/specifier.cjs b/test/fixtures/specifier/specifier.cjs similarity index 100% rename from test/fixtures/specifier.cjs rename to test/fixtures/specifier/specifier.cjs diff --git a/test/fixtures/specifier.mjs b/test/fixtures/specifier/specifier.mjs similarity index 100% rename from test/fixtures/specifier.mjs rename to test/fixtures/specifier/specifier.mjs diff --git a/test/module.ts b/test/module.ts index 4ec8ee8..23e0a3a 100644 --- a/test/module.ts +++ b/test/module.ts @@ -3,12 +3,14 @@ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' import { resolve, join } from 'node:path' import { pathToFileURL } from 'node:url' +import { createRequire } from 'node:module' import { rm, stat, writeFile } from 'node:fs/promises' import type { Stats } from 'node:fs' import { transform } from '../src/module.js' const fixtures = resolve(import.meta.dirname, 'fixtures') +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const isValidFilename = async (filename: string) => { let stats: Stats @@ -205,6 +207,61 @@ describe('@knighted/module', () => { }) }) + const transformEsmToCjs = async (t: any, file: string) => { + const fixturePath = join(fixtures, file) + const result = await transform(fixturePath, { target: 'commonjs' }) + const outFile = join(fixtures, `${file.replace('.mjs', '')}.out.cjs`) + const requireCjs = createRequire(import.meta.url) + + t.after(() => { + rm(outFile, { force: true }) + }) + + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const exportsObj = requireCjs(outFile) + + return { exportsObj, result } + } + + it('lowers default import with interop when targeting commonjs', async t => { + const { exportsObj, result } = await transformEsmToCjs(t, 'esmDefault.mjs') + + assert.equal(exportsObj.default, 'default-val') + assert.equal(exportsObj.foo, 'foo-val') + assert.equal(exportsObj.bar, 'bar-val') + assert.ok(result.indexOf('__interopDefault') > -1) + assert.ok(result.indexOf('exports.__esModule = true') > -1) + }) + + it('lowers named imports when targeting commonjs', async t => { + const { exportsObj } = await transformEsmToCjs(t, 'esmNamed.mjs') + + assert.equal(exportsObj.foo, 'foo-val') + assert.equal(exportsObj.baz, 'bar-val') + }) + + it('lowers namespace imports when targeting commonjs', async t => { + const { exportsObj } = await transformEsmToCjs(t, 'esmNamespace.mjs') + + assert.equal(exportsObj.ns.default, 'default-val') + assert.equal(exportsObj.ns.foo, 'foo-val') + assert.equal(exportsObj.ns.bar, 'bar-val') + }) + + it('preserves re-exports and live bindings from commonjs sources', async t => { + const { exportsObj } = await transformEsmToCjs(t, 'esmReexport.mjs') + + assert.equal(exportsObj.renamed, 'foo-val') + + const first = exportsObj.live + await delay(30) + const second = exportsObj.live + + assert.ok(second > first) + }) + it('transforms import.meta', async t => { const result = await transform(join(fixtures, 'import.meta.mjs'), { target: 'commonjs', @@ -306,8 +363,18 @@ describe('@knighted/module', () => { assert.equal(status, 0) }) - it('transforms es module globals to commonjs globals', async () => { - const result = await transform(join(fixtures, 'file.mjs'), { target: 'commonjs' }) + it('transforms es module globals to commonjs globals', async t => { + const fixturePath = join(fixtures, 'file.mjs') + const result = await transform(fixturePath, { target: 'commonjs' }) + const outFile = join(fixtures, 'file.out.cjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) assert.equal(result.indexOf('import.meta.url'), -1) assert.equal(result.indexOf('import.meta.filename'), -1) @@ -338,16 +405,27 @@ describe('@knighted/module', () => { assert.ok(result.indexOf('{}') > -1) }) - it('updates specifiers when option enabled', async () => { - const result = await transform(join(fixtures, 'specifier.mjs'), { + it('updates specifiers when option enabled', async t => { + const specifierRoot = join(fixtures, 'specifier') + const fixturePath = join(specifierRoot, 'specifier.mjs') + const result = await transform(fixturePath, { target: 'commonjs', rewriteSpecifier: '.js', }) - const cjsResult = await transform(join(fixtures, 'specifier.cjs'), { + const outFile = join(specifierRoot, 'specifier.out.cjs') + const cjsResult = await transform(join(specifierRoot, 'specifier.cjs'), { target: 'module', rewriteSpecifier: '.mjs', }) + t.after(() => { + rm(outFile, { force: true }) + }) + + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + assert.equal((result.match(/\.\/file\.js/g) ?? []).length, 6) assert.equal((result.match(/require\.resolve\('\.\/file\.js'\)/g) ?? []).length, 2) assert.equal((cjsResult.match(/\.\/file\.mjs/g) ?? []).length, 3) @@ -372,6 +450,9 @@ describe('@knighted/module', () => { assert.equal(await isValidFilename(mjs), true) assert.equal(await isValidFilename(cjs), true) + const { status: statusCjs } = spawnSync('node', [cjs], { stdio: 'inherit' }) + assert.equal(statusCjs, 0) + // When option `modules` is complete /* // Check for runtime errors against Node.js From 452c955ea360d31a9ac17830b54146c841ff4bb6 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 12:25:12 -0600 Subject: [PATCH 2/5] feat: better tla import.meta.main. --- docs/esm-to-cjs.md | 21 ++++-- src/format.ts | 101 ++++++++++++++++++++--------- src/formatters/metaProperty.ts | 22 ++++++- src/module.ts | 1 + src/types.ts | 1 + test/fixtures/import.meta.main.mjs | 1 + test/fixtures/liveReexport.mjs | 1 + test/fixtures/liveSource.cjs | 16 +++++ test/fixtures/topLevelAwait.mjs | 5 ++ test/module.ts | 95 +++++++++++++++++++++++++++ 10 files changed, 228 insertions(+), 36 deletions(-) create mode 100644 test/fixtures/import.meta.main.mjs create mode 100644 test/fixtures/liveReexport.mjs create mode 100644 test/fixtures/liveSource.cjs create mode 100644 test/fixtures/topLevelAwait.mjs diff --git a/docs/esm-to-cjs.md b/docs/esm-to-cjs.md index 8b49194..ba9241c 100644 --- a/docs/esm-to-cjs.md +++ b/docs/esm-to-cjs.md @@ -5,6 +5,8 @@ - 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 @@ -14,12 +16,20 @@ - 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 +## Exports and live bindings -- Local named exports emit to `exports.` (or bracket access) with `Object.defineProperty` getters when `liveBindings: 'strict'`. -- `export default` writes to `module.exports = `; default functions/classes preserve their identifier before exporting. +- Local named exports emit to `exports.` (or bracket access) with `Object.defineProperty` getters when `liveBindings: 'strict'`; snapshot assignment is used for `loose`/`off`. +- `export default` writes to `module.exports = ` in the common case; when TLA is present and allowed (`wrap`/`preserve`), default exports write to `exports.default = ` 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`) assign using either a direct property read or the interop helper for `default`. +- 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 @@ -27,7 +37,7 @@ - `import.meta.filename` → `__filename` - `import.meta.dirname` → `__dirname` - `import.meta.resolve` → `require.resolve` -- `import.meta.main` → `process.argv[1] === __filename` +- `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 @@ -36,4 +46,5 @@ - `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). diff --git a/src/format.ts b/src/format.ts index 8913166..b08843c 100644 --- a/src/format.ts +++ b/src/format.ts @@ -74,7 +74,12 @@ const hasTopLevelAwait = (program: any) => { return found } -const lowerEsmToCjs = (program: any, code: MagicString, opts: FormatterOptions) => { +const lowerEsmToCjs = ( + program: any, + code: MagicString, + opts: FormatterOptions, + containsTopLevelAwait: boolean, +) => { const live = opts.liveBindings ?? 'strict' const importTransforms: ImportTransform[] = [] const exportTransforms: ExportTransform[] = [] @@ -221,28 +226,38 @@ const lowerEsmToCjs = (program: any, code: MagicString, opts: FormatterOptions) if (node.type === 'ExportDefaultDeclaration') { const decl = node.declaration + const useExportsObject = containsTopLevelAwait && opts.topLevelAwait !== 'error' if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') { if (decl.id?.name) { const declSrc = code.slice(decl.start, decl.end) + const assign = useExportsObject + ? `exports.default = ${decl.id.name};` + : `module.exports = ${decl.id.name};` exportTransforms.push({ start: node.start, end: node.end, - code: `${declSrc}\nmodule.exports = ${decl.id.name};\n`, + code: `${declSrc}\n${assign}\n`, }) } else { const declSrc = code.slice(decl.start, decl.end) + const assign = useExportsObject + ? `exports.default = ${declSrc};` + : `module.exports = ${declSrc};` exportTransforms.push({ start: node.start, end: node.end, - code: `module.exports = ${declSrc};\n`, + code: `${assign}\n`, }) } } else { const exprSrc = code.slice(decl.start, decl.end) + const assign = useExportsObject + ? `exports.default = ${exprSrc};` + : `module.exports = ${exprSrc};` exportTransforms.push({ start: node.start, end: node.end, - code: `module.exports = ${exprSrc};\n`, + code: `${assign}\n`, }) } } @@ -308,32 +323,21 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => const exportTable = opts.target === 'module' ? await collectCjsExports(ast.program) : null await collectModuleIdentifiers(ast.program) - - if (opts.target === 'commonjs' && opts.transformSyntax) { - if (opts.topLevelAwait === 'error' && hasTopLevelAwait(ast.program)) { - throw new Error( - 'Top-level await is not supported when targeting CommonJS (set topLevelAwait to "wrap" or "preserve" to override).', - ) - } - - const { importTransforms, exportTransforms, needsInterop } = lowerEsmToCjs( - ast.program, - code, - opts, + const shouldCheckTopLevelAwait = opts.target === 'commonjs' && opts.transformSyntax + const containsTopLevelAwait = shouldCheckTopLevelAwait + ? hasTopLevelAwait(ast.program) + : false + + const shouldLowerCjs = opts.target === 'commonjs' && opts.transformSyntax + let pendingCjsTransforms: { + transforms: Array + needsInterop: boolean + } | null = null + + if (shouldLowerCjs && opts.topLevelAwait === 'error' && containsTopLevelAwait) { + throw new Error( + 'Top-level await is not supported when targeting CommonJS (set topLevelAwait to "wrap" or "preserve" to override).', ) - - // Apply transforms in source order - const allTransforms = [...importTransforms, ...exportTransforms].sort( - (a, b) => a.start - b.start, - ) - - for (const t of allTransforms) { - code.overwrite(t.start, t.end, t.code) - } - - if (needsInterop) { - code.prepend(`${interopHelper}exports.__esModule = true;\n`) - } } if (opts.target === 'module' && opts.transformSyntax) { @@ -456,6 +460,32 @@ void import.meta.filename; }, }) + if (shouldLowerCjs) { + const { importTransforms, exportTransforms, needsInterop } = lowerEsmToCjs( + ast.program, + code, + opts, + containsTopLevelAwait, + ) + + pendingCjsTransforms = { + transforms: [...importTransforms, ...exportTransforms].sort( + (a, b) => a.start - b.start, + ), + needsInterop, + } + } + + if (pendingCjsTransforms) { + for (const t of pendingCjsTransforms.transforms) { + code.overwrite(t.start, t.end, t.code) + } + + if (pendingCjsTransforms.needsInterop) { + code.prepend(`${interopHelper}exports.__esModule = true;\n`) + } + } + if (opts.target === 'module' && opts.transformSyntax && exportTable) { const isValidExportName = (name: string) => /^[$A-Z_a-z][$\w]*$/.test(name) const asExportName = (name: string) => @@ -495,6 +525,19 @@ void import.meta.filename; } } + if (opts.target === 'commonjs' && opts.transformSyntax && containsTopLevelAwait) { + const body = code.toString() + + if (opts.topLevelAwait === 'wrap') { + const tlaPromise = `const __tla = (async () => {\n${body}\nreturn module.exports;\n})();\n` + const setPromise = `const __setTla = target => {\n if (!target) return;\n const type = typeof target;\n if (type !== 'object' && type !== 'function') return;\n target.__tla = __tla;\n};\n` + const attach = `__setTla(module.exports);\n__tla.then(resolved => __setTla(resolved), err => { throw err; });\n` + return `${tlaPromise}${setPromise}${attach}` + } + + return `;(async () => {\n${body}\n})();\n` + } + return code.toString() } diff --git a/src/formatters/metaProperty.ts b/src/formatters/metaProperty.ts index 50df3ae..0a88a99 100644 --- a/src/formatters/metaProperty.ts +++ b/src/formatters/metaProperty.ts @@ -3,6 +3,22 @@ import type { Node, MetaProperty } from 'oxc-parser' import type { FormatterOptions } from '../types.js' +const importMetaMainSupport = + '(() => { const [__nmaj, __nmin] = process.versions.node.split(".").map(n => parseInt(n, 10) || 0); return (__nmaj > 24 || (__nmaj === 24 && __nmin >= 2) || (__nmaj === 22 && __nmin >= 18)); })()' +const importMetaMainShim = 'process.argv[1] === __filename' + +const importMetaMainExpr = (mode: FormatterOptions['importMetaMain']) => { + switch (mode) { + case 'warn': + return `(${importMetaMainSupport} ? ${importMetaMainShim} : (console.warn("import.meta.main is not supported before Node 22.18/24.2; falling back to shim."), ${importMetaMainShim}))` + case 'error': + return `(${importMetaMainSupport} ? ${importMetaMainShim} : (() => { throw new Error("import.meta.main is not supported before Node 22.18/24.2"); })())` + case 'shim': + default: + return importMetaMainShim + } +} + export const metaProperty = ( node: MetaProperty, parent: Node | null, @@ -34,12 +50,14 @@ export const metaProperty = ( break case 'resolve': /** - * Should this be `require('node:url').pathToFileURL(require.resolve()).href`? + * Map to require.resolve intentionally: matches CJS resolution semantics. + * Wrapping in pathToFileURL(...) would change the return shape (URL string) + * without truly emulating ESM import.meta.resolve rules. */ src.update(parent.start, parent.end, 'require.resolve') break case 'main': - src.update(parent.start, parent.end, 'process.argv[1] === __filename') + src.update(parent.start, parent.end, importMetaMainExpr(options.importMetaMain)) break } } diff --git a/src/module.ts b/src/module.ts index 7e882bd..84c5ace 100644 --- a/src/module.ts +++ b/src/module.ts @@ -16,6 +16,7 @@ const defaultOptions = { rewriteSpecifier: undefined, dirFilename: 'inject', importMeta: 'shim', + importMetaMain: 'shim', requireSource: 'builtin', cjsDefault: 'auto', topLevelAwait: 'error', diff --git a/src/types.ts b/src/types.ts index 99fb341..81bbb22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,7 @@ export type ModuleOptions = { rewriteSpecifier?: RewriteSpecifier 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' diff --git a/test/fixtures/import.meta.main.mjs b/test/fixtures/import.meta.main.mjs new file mode 100644 index 0000000..3edb285 --- /dev/null +++ b/test/fixtures/import.meta.main.mjs @@ -0,0 +1 @@ +export const isMain = import.meta.main diff --git a/test/fixtures/liveReexport.mjs b/test/fixtures/liveReexport.mjs new file mode 100644 index 0000000..7fbc266 --- /dev/null +++ b/test/fixtures/liveReexport.mjs @@ -0,0 +1 @@ +export { counter, bump } from './liveSource.cjs' diff --git a/test/fixtures/liveSource.cjs b/test/fixtures/liveSource.cjs new file mode 100644 index 0000000..3c56719 --- /dev/null +++ b/test/fixtures/liveSource.cjs @@ -0,0 +1,16 @@ +let counter = 0 + +const bump = () => { + counter += 1 +} + +setTimeout(() => { + counter += 1 +}, 20) + +module.exports = { + get counter() { + return counter + }, + bump, +} diff --git a/test/fixtures/topLevelAwait.mjs b/test/fixtures/topLevelAwait.mjs new file mode 100644 index 0000000..0398ab1 --- /dev/null +++ b/test/fixtures/topLevelAwait.mjs @@ -0,0 +1,5 @@ +const first = await Promise.resolve(2) +const second = await new Promise(resolve => setTimeout(() => resolve(3), 5)) + +export const value = first + second +export default second diff --git a/test/module.ts b/test/module.ts index 23e0a3a..4075a6c 100644 --- a/test/module.ts +++ b/test/module.ts @@ -262,6 +262,33 @@ describe('@knighted/module', () => { assert.ok(second > first) }) + it('preserves live re-exports when liveBindings is strict', async t => { + const fixturePath = join(fixtures, 'liveReexport.mjs') + const outFile = join(fixtures, 'liveReexport.out.cjs') + const requireCjs = createRequire(import.meta.url) + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { + target: 'commonjs', + liveBindings: 'strict', + }) + + await writeFile(outFile, result) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = requireCjs(outFile) + assert.equal(mod.counter, 0) + mod.bump() + assert.equal(mod.counter, 1) + await delay(30) + assert.ok(mod.counter >= 2) + }) + it('transforms import.meta', async t => { const result = await transform(join(fixtures, 'import.meta.mjs'), { target: 'commonjs', @@ -342,6 +369,53 @@ describe('@knighted/module', () => { assert.equal(status, 0) }) + it('wraps top-level await when targeting commonjs (wrap)', async t => { + const fixturePath = join(fixtures, 'topLevelAwait.mjs') + const result = await transform(fixturePath, { + target: 'commonjs', + topLevelAwait: 'wrap', + }) + const outFile = join(fixtures, 'topLevelAwait.wrap.cjs') + const requireCjs = createRequire(import.meta.url) + + t.after(() => { + rm(outFile, { force: true }) + }) + + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const mod = requireCjs(outFile) + + assert.equal(typeof mod.__tla?.then, 'function') + await mod.__tla + assert.equal(mod.value, 5) + assert.equal(mod.default, 3) + }) + + it('preserves exports when top-level await targeting commonjs (preserve)', async t => { + const fixturePath = join(fixtures, 'topLevelAwait.mjs') + const result = await transform(fixturePath, { + target: 'commonjs', + topLevelAwait: 'preserve', + }) + const outFile = join(fixtures, 'topLevelAwait.preserve.cjs') + const requireCjs = createRequire(import.meta.url) + + t.after(() => { + rm(outFile, { force: true }) + }) + + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const mod = requireCjs(outFile) + + await delay(10) + assert.equal(mod.value, 5) + assert.equal(mod.default, 3) + }) + it('transforms import.meta.resolve', async t => { const result = await transform(join(fixtures, 'import.meta.resolve.mjs'), { target: 'commonjs', @@ -363,6 +437,27 @@ describe('@knighted/module', () => { assert.equal(status, 0) }) + it('gates import.meta.main shimming when requested', async t => { + const fixturePath = join(fixtures, 'import.meta.main.mjs') + const outFile = join(fixtures, 'import.meta.main.cjs') + const result = await transform(fixturePath, { + target: 'commonjs', + importMetaMain: 'warn', + }) + + t.after(() => { + rm(outFile, { force: true }) + }) + + await writeFile(outFile, result) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + assert.ok(result.includes('import.meta.main is not supported before Node 22.18/24.2')) + assert.ok(result.includes('process.versions.node')) + }) + it('transforms es module globals to commonjs globals', async t => { const fixturePath = join(fixtures, 'file.mjs') const result = await transform(fixturePath, { target: 'commonjs' }) From e7e9de2b9c5b6ccff65212b80f837fb4dc2629fc Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 12:30:28 -0600 Subject: [PATCH 3/5] docs: update. --- README.md | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0d2e2d6..2787fb9 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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** @@ -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' @@ -51,7 +63,7 @@ await transform('./file.js', { }) ``` -Which produces +Which produces: **file.cjs** @@ -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' @@ -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. From f9f7f577ea9481872d3fa2f834833d98cf1b1187 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 12:32:19 -0600 Subject: [PATCH 4/5] chore: bump version. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddb8b53..d760f5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/module", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/module", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "license": "MIT", "dependencies": { "@knighted/specifier": "^2.0.9", diff --git a/package.json b/package.json index 46c9634..f28a993 100644 --- a/package.json +++ b/package.json @@ -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", From 742fe1cb5bc9f274b0ebc5c420d81df3df5ce884 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 12:35:26 -0600 Subject: [PATCH 5/5] fix: node 22. --- test/fixtures/specifier/specifier.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/specifier/specifier.mjs b/test/fixtures/specifier/specifier.mjs index 1f0a801..c589961 100644 --- a/test/fixtures/specifier/specifier.mjs +++ b/test/fixtures/specifier/specifier.mjs @@ -3,7 +3,7 @@ import bar from './file.cts' import baz from './file.ts' import(`./${foo}${bar}${baz}.cjs`) -import(new String('not-relative.js')) +import(new String('not-relative.js')).catch(() => {}) import(new String('./file.mjs')) import.meta.resolve('./file.cjs')