Skip to content

Commit 452c955

Browse files
feat: better tla import.meta.main.
1 parent b991fd3 commit 452c955

10 files changed

Lines changed: 228 additions & 36 deletions

File tree

docs/esm-to-cjs.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- Translates ESM syntax to CommonJS when `target: 'commonjs'` with `transformSyntax` enabled.
66
- Assumes Node 20.11+ runtime with `__filename`/`__dirname` shims supplied by the host.
77
- 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'`.
810

911
## Imports and interop
1012

@@ -14,20 +16,28 @@
1416
- Re-exporting a default (`export { default as x } from`) routes through the interop helper to match bundler behavior for CJS sources.
1517
- When the interop helper is emitted, `exports.__esModule = true` is also seeded for downstream consumers.
1618

17-
## Exports
19+
## Exports and live bindings
1820

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+
- 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.
2123
- `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`.
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.
2333

2434
## import.meta rewriting
2535

2636
- `import.meta.url``require("node:url").pathToFileURL(__filename).href`
2737
- `import.meta.filename``__filename`
2838
- `import.meta.dirname``__dirname`
2939
- `import.meta.resolve``require.resolve`
30-
- `import.meta.main``process.argv[1] === __filename`
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.
3141
- Bare `import.meta` becomes `module` so property accesses stay valid in CJS output.
3242

3343
## Fixtures for this phase
@@ -36,4 +46,5 @@
3646
- `test/fixtures/esmNamed.mjs`: named import and aliasing from the CJS provider, re-exported for assertions.
3747
- `test/fixtures/esmNamespace.mjs`: namespace import shape from the CJS provider, re-exported as `ns`.
3848
- `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.
3950
- `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: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ const hasTopLevelAwait = (program: any) => {
7474
return found
7575
}
7676

77-
const lowerEsmToCjs = (program: any, code: MagicString, opts: FormatterOptions) => {
77+
const lowerEsmToCjs = (
78+
program: any,
79+
code: MagicString,
80+
opts: FormatterOptions,
81+
containsTopLevelAwait: boolean,
82+
) => {
7883
const live = opts.liveBindings ?? 'strict'
7984
const importTransforms: ImportTransform[] = []
8085
const exportTransforms: ExportTransform[] = []
@@ -221,28 +226,38 @@ const lowerEsmToCjs = (program: any, code: MagicString, opts: FormatterOptions)
221226

222227
if (node.type === 'ExportDefaultDeclaration') {
223228
const decl = node.declaration
229+
const useExportsObject = containsTopLevelAwait && opts.topLevelAwait !== 'error'
224230
if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') {
225231
if (decl.id?.name) {
226232
const declSrc = code.slice(decl.start, decl.end)
233+
const assign = useExportsObject
234+
? `exports.default = ${decl.id.name};`
235+
: `module.exports = ${decl.id.name};`
227236
exportTransforms.push({
228237
start: node.start,
229238
end: node.end,
230-
code: `${declSrc}\nmodule.exports = ${decl.id.name};\n`,
239+
code: `${declSrc}\n${assign}\n`,
231240
})
232241
} else {
233242
const declSrc = code.slice(decl.start, decl.end)
243+
const assign = useExportsObject
244+
? `exports.default = ${declSrc};`
245+
: `module.exports = ${declSrc};`
234246
exportTransforms.push({
235247
start: node.start,
236248
end: node.end,
237-
code: `module.exports = ${declSrc};\n`,
249+
code: `${assign}\n`,
238250
})
239251
}
240252
} else {
241253
const exprSrc = code.slice(decl.start, decl.end)
254+
const assign = useExportsObject
255+
? `exports.default = ${exprSrc};`
256+
: `module.exports = ${exprSrc};`
242257
exportTransforms.push({
243258
start: node.start,
244259
end: node.end,
245-
code: `module.exports = ${exprSrc};\n`,
260+
code: `${assign}\n`,
246261
})
247262
}
248263
}
@@ -308,32 +323,21 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) =>
308323
const exportTable =
309324
opts.target === 'module' ? await collectCjsExports(ast.program) : null
310325
await collectModuleIdentifiers(ast.program)
311-
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,
326+
const shouldCheckTopLevelAwait = opts.target === 'commonjs' && opts.transformSyntax
327+
const containsTopLevelAwait = shouldCheckTopLevelAwait
328+
? hasTopLevelAwait(ast.program)
329+
: false
330+
331+
const shouldLowerCjs = opts.target === 'commonjs' && opts.transformSyntax
332+
let pendingCjsTransforms: {
333+
transforms: Array<ImportTransform | ExportTransform>
334+
needsInterop: boolean
335+
} | null = null
336+
337+
if (shouldLowerCjs && opts.topLevelAwait === 'error' && containsTopLevelAwait) {
338+
throw new Error(
339+
'Top-level await is not supported when targeting CommonJS (set topLevelAwait to "wrap" or "preserve" to override).',
323340
)
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-
}
337341
}
338342

339343
if (opts.target === 'module' && opts.transformSyntax) {
@@ -456,6 +460,32 @@ void import.meta.filename;
456460
},
457461
})
458462

463+
if (shouldLowerCjs) {
464+
const { importTransforms, exportTransforms, needsInterop } = lowerEsmToCjs(
465+
ast.program,
466+
code,
467+
opts,
468+
containsTopLevelAwait,
469+
)
470+
471+
pendingCjsTransforms = {
472+
transforms: [...importTransforms, ...exportTransforms].sort(
473+
(a, b) => a.start - b.start,
474+
),
475+
needsInterop,
476+
}
477+
}
478+
479+
if (pendingCjsTransforms) {
480+
for (const t of pendingCjsTransforms.transforms) {
481+
code.overwrite(t.start, t.end, t.code)
482+
}
483+
484+
if (pendingCjsTransforms.needsInterop) {
485+
code.prepend(`${interopHelper}exports.__esModule = true;\n`)
486+
}
487+
}
488+
459489
if (opts.target === 'module' && opts.transformSyntax && exportTable) {
460490
const isValidExportName = (name: string) => /^[$A-Z_a-z][$\w]*$/.test(name)
461491
const asExportName = (name: string) =>
@@ -495,6 +525,19 @@ void import.meta.filename;
495525
}
496526
}
497527

528+
if (opts.target === 'commonjs' && opts.transformSyntax && containsTopLevelAwait) {
529+
const body = code.toString()
530+
531+
if (opts.topLevelAwait === 'wrap') {
532+
const tlaPromise = `const __tla = (async () => {\n${body}\nreturn module.exports;\n})();\n`
533+
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`
534+
const attach = `__setTla(module.exports);\n__tla.then(resolved => __setTla(resolved), err => { throw err; });\n`
535+
return `${tlaPromise}${setPromise}${attach}`
536+
}
537+
538+
return `;(async () => {\n${body}\n})();\n`
539+
}
540+
498541
return code.toString()
499542
}
500543

src/formatters/metaProperty.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@ import type { Node, MetaProperty } from 'oxc-parser'
33

44
import type { FormatterOptions } from '../types.js'
55

6+
const importMetaMainSupport =
7+
'(() => { const [__nmaj, __nmin] = process.versions.node.split(".").map(n => parseInt(n, 10) || 0); return (__nmaj > 24 || (__nmaj === 24 && __nmin >= 2) || (__nmaj === 22 && __nmin >= 18)); })()'
8+
const importMetaMainShim = 'process.argv[1] === __filename'
9+
10+
const importMetaMainExpr = (mode: FormatterOptions['importMetaMain']) => {
11+
switch (mode) {
12+
case 'warn':
13+
return `(${importMetaMainSupport} ? ${importMetaMainShim} : (console.warn("import.meta.main is not supported before Node 22.18/24.2; falling back to shim."), ${importMetaMainShim}))`
14+
case 'error':
15+
return `(${importMetaMainSupport} ? ${importMetaMainShim} : (() => { throw new Error("import.meta.main is not supported before Node 22.18/24.2"); })())`
16+
case 'shim':
17+
default:
18+
return importMetaMainShim
19+
}
20+
}
21+
622
export const metaProperty = (
723
node: MetaProperty,
824
parent: Node | null,
@@ -34,12 +50,14 @@ export const metaProperty = (
3450
break
3551
case 'resolve':
3652
/**
37-
* Should this be `require('node:url').pathToFileURL(require.resolve(<parsed specifier>)).href`?
53+
* Map to require.resolve intentionally: matches CJS resolution semantics.
54+
* Wrapping in pathToFileURL(...) would change the return shape (URL string)
55+
* without truly emulating ESM import.meta.resolve rules.
3856
*/
3957
src.update(parent.start, parent.end, 'require.resolve')
4058
break
4159
case 'main':
42-
src.update(parent.start, parent.end, 'process.argv[1] === __filename')
60+
src.update(parent.start, parent.end, importMetaMainExpr(options.importMetaMain))
4361
break
4462
}
4563
}

src/module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const defaultOptions = {
1616
rewriteSpecifier: undefined,
1717
dirFilename: 'inject',
1818
importMeta: 'shim',
19+
importMetaMain: 'shim',
1920
requireSource: 'builtin',
2021
cjsDefault: 'auto',
2122
topLevelAwait: 'error',

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type ModuleOptions = {
2525
rewriteSpecifier?: RewriteSpecifier
2626
dirFilename?: 'inject' | 'preserve' | 'error'
2727
importMeta?: 'preserve' | 'shim' | 'error'
28+
importMetaMain?: 'shim' | 'warn' | 'error'
2829
requireSource?: 'builtin' | 'create-require'
2930
cjsDefault?: 'module-exports' | 'auto' | 'none'
3031
topLevelAwait?: 'error' | 'wrap' | 'preserve'

test/fixtures/import.meta.main.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const isMain = import.meta.main

test/fixtures/liveReexport.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { counter, bump } from './liveSource.cjs'

test/fixtures/liveSource.cjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
let counter = 0
2+
3+
const bump = () => {
4+
counter += 1
5+
}
6+
7+
setTimeout(() => {
8+
counter += 1
9+
}, 20)
10+
11+
module.exports = {
12+
get counter() {
13+
return counter
14+
},
15+
bump,
16+
}

test/fixtures/topLevelAwait.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const first = await Promise.resolve(2)
2+
const second = await new Promise(resolve => setTimeout(() => resolve(3), 5))
3+
4+
export const value = first + second
5+
export default second

0 commit comments

Comments
 (0)