From 2b71ce0de028329c1f3b86631db30f014077f30d Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 10 Feb 2026 15:16:44 +0100 Subject: [PATCH 1/3] feat: better detection of unsupported esm features --- lib/esm-transformer.ts | 155 +++++++++++++++++++++++++++++++++++++++++ package.json | 1 + test/test.js | 3 +- 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/lib/esm-transformer.ts b/lib/esm-transformer.ts index f69f9a88..82602f4e 100644 --- a/lib/esm-transformer.ts +++ b/lib/esm-transformer.ts @@ -1,4 +1,5 @@ import * as babel from '@babel/core'; +import traverse, { NodePath } from '@babel/traverse'; import { log } from './log'; export interface TransformResult { @@ -6,6 +7,126 @@ export interface TransformResult { isTransformed: boolean; } +interface UnsupportedFeature { + feature: string; + line: number | null; + column: number | null; +} + +/** + * Detect ESM features that cannot be safely transformed to CommonJS + * These include: + * - Top-level await (no CJS equivalent) + * - import.meta (no CJS equivalent) + * + * @param code - The ESM source code to check + * @param filename - The filename for error reporting + * @returns Array of unsupported features found, or null if parse fails + */ +function detectUnsupportedESMFeatures( + code: string, + filename: string, +): UnsupportedFeature[] | null { + try { + const ast = babel.parseSync(code, { + filename, + sourceType: 'module', + plugins: [], + }); + + if (!ast) { + return null; + } + + const unsupportedFeatures: UnsupportedFeature[] = []; + + traverse(ast, { + // Detect import.meta usage + MetaProperty(path) { + if ( + path.node.meta.name === 'import' && + path.node.property.name === 'meta' + ) { + unsupportedFeatures.push({ + feature: 'import.meta', + line: path.node.loc?.start.line ?? null, + column: path.node.loc?.start.column ?? null, + }); + } + }, + + // Detect top-level await + AwaitExpression(path) { + // Check if await is at top level (not inside a function) + let parent: NodePath | null = path.parentPath; + let isTopLevel = true; + + while (parent) { + if ( + parent.isFunctionDeclaration() || + parent.isFunctionExpression() || + parent.isArrowFunctionExpression() || + parent.isObjectMethod() || + parent.isClassMethod() + ) { + isTopLevel = false; + break; + } + parent = parent.parentPath; + } + + if (isTopLevel) { + unsupportedFeatures.push({ + feature: 'top-level await', + line: path.node.loc?.start.line ?? null, + column: path.node.loc?.start.column ?? null, + }); + } + }, + + // Detect for-await-of at top level + ForOfStatement(path) { + if (path.node.await) { + let parent: NodePath | null = path.parentPath; + let isTopLevel = true; + + while (parent) { + if ( + parent.isFunctionDeclaration() || + parent.isFunctionExpression() || + parent.isArrowFunctionExpression() || + parent.isObjectMethod() || + parent.isClassMethod() + ) { + isTopLevel = false; + break; + } + parent = parent.parentPath; + } + + if (isTopLevel) { + unsupportedFeatures.push({ + feature: 'top-level for-await-of', + line: path.node.loc?.start.line ?? null, + column: path.node.loc?.start.column ?? null, + }); + } + } + }, + }); + + return unsupportedFeatures; + } catch (error) { + // If we can't parse, return null to let the transform attempt proceed + log.debug( + `Could not parse ${filename} to detect unsupported ESM features: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + } +} + /** * Transform ESM code to CommonJS using Babel * This allows ESM modules to be compiled to bytecode via vm.Script @@ -13,11 +134,45 @@ export interface TransformResult { * @param code - The ESM source code to transform * @param filename - The filename for error reporting * @returns Object with transformed code and success flag + * @throws Error if unsupported ESM features are detected */ export function transformESMtoCJS( code: string, filename: string, ): TransformResult { + // First, check for unsupported ESM features that can't be safely transformed + const unsupportedFeatures = detectUnsupportedESMFeatures(code, filename); + + if (unsupportedFeatures && unsupportedFeatures.length > 0) { + const featureList = unsupportedFeatures + .map((f) => { + const location = f.line !== null ? ` at line ${f.line}` : ''; + return ` - ${f.feature}${location}`; + }) + .join('\n'); + + const errorMessage = [ + `Cannot transform ESM module ${filename} to CommonJS:`, + `The following ESM features have no CommonJS equivalent:`, + featureList, + '', + 'These features are not supported when compiling to bytecode.', + 'Consider one of the following:', + ' 1. Refactor to avoid these features', + ' 2. Use --no-bytecode flag to keep the module as source code', + ' 3. Mark the package as public to distribute with sources', + ].join('\n'); + + log.warn(errorMessage); + + // Return untransformed code rather than throwing + // This allows the file to be included as content instead of bytecode + return { + code, + isTransformed: false, + }; + } + try { const result = babel.transformSync(code, { filename, diff --git a/package.json b/package.json index 29bf1d3d..ecb1d7aa 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@babel/generator": "^7.23.0", "@babel/parser": "^7.23.0", "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/traverse": "^7.23.0", "@babel/types": "^7.23.0", "@yao-pkg/pkg-fetch": "3.5.32", "into-stream": "^6.0.0", diff --git a/test/test.js b/test/test.js index 4b348a64..2981f9ea 100644 --- a/test/test.js +++ b/test/test.js @@ -161,7 +161,8 @@ const clearLastLine = () => { if ( isCI || !process.stdout.isTTY || - typeof process.stdout.moveCursor !== 'function' + typeof process.stdout.moveCursor !== 'function' || + typeof process.stdout.clearLine !== 'function' ) return; process.stdout.moveCursor(0, -1); // up one line From 0916c618bd7f824b75f9fd35b6600938702ec2bc Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 10 Feb 2026 15:36:11 +0100 Subject: [PATCH 2/3] feat: add tests for unsupported ESM features detection --- lib/esm-transformer.ts | 1 - test/test-50-esm-unsupported/main.js | 133 ++++++++++++++++++ test/test-50-esm-unsupported/package.json | 5 + .../test-for-await-of.mjs | 17 +++ .../test-import-meta.mjs | 7 + .../test-multiple-features.mjs | 22 +++ .../test-top-level-await.mjs | 11 ++ 7 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 test/test-50-esm-unsupported/main.js create mode 100644 test/test-50-esm-unsupported/package.json create mode 100644 test/test-50-esm-unsupported/test-for-await-of.mjs create mode 100644 test/test-50-esm-unsupported/test-import-meta.mjs create mode 100644 test/test-50-esm-unsupported/test-multiple-features.mjs create mode 100644 test/test-50-esm-unsupported/test-top-level-await.mjs diff --git a/lib/esm-transformer.ts b/lib/esm-transformer.ts index 82602f4e..b6e7d611 100644 --- a/lib/esm-transformer.ts +++ b/lib/esm-transformer.ts @@ -134,7 +134,6 @@ function detectUnsupportedESMFeatures( * @param code - The ESM source code to transform * @param filename - The filename for error reporting * @returns Object with transformed code and success flag - * @throws Error if unsupported ESM features are detected */ export function transformESMtoCJS( code: string, diff --git a/test/test-50-esm-unsupported/main.js b/test/test-50-esm-unsupported/main.js new file mode 100644 index 00000000..d0e70395 --- /dev/null +++ b/test/test-50-esm-unsupported/main.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +'use strict'; + +const path = require('path'); +const assert = require('assert'); +const utils = require('../utils.js'); + +assert(!module.parent); +assert(__dirname === process.cwd()); + +const target = process.argv[2] || 'host'; + +console.log('Testing unsupported ESM features detection...'); + +// Test 1: import.meta detection +console.log('\n=== Test 1: import.meta ==='); +{ + const input = './test-import-meta.mjs'; + const output = './run-time/test-import-meta.exe'; + const newcomers = ['run-time/test-import-meta.exe']; + + const before = utils.filesBefore(newcomers); + utils.mkdirp.sync(path.dirname(output)); + + // Capture stdout to check for warnings + const result = utils.pkg.sync( + ['--target', target, '--output', output, input], + ['inherit', 'pipe', 'inherit'], + ); + + // Verify warning was emitted + assert( + result.includes('import.meta') || + result.includes('Cannot transform ESM module'), + 'Should warn about import.meta usage', + ); + console.log('✓ import.meta detection working'); + + // Cleanup + utils.filesAfter(before, newcomers); +} + +// Test 2: top-level await detection +console.log('\n=== Test 2: top-level await ==='); +{ + const input = './test-top-level-await.mjs'; + const output = './run-time/test-top-level-await.exe'; + const newcomers = ['run-time/test-top-level-await.exe']; + + const before = utils.filesBefore(newcomers); + utils.mkdirp.sync(path.dirname(output)); + + const result = utils.pkg.sync( + ['--target', target, '--output', output, input], + ['inherit', 'pipe', 'inherit'], + ); + + // Verify warning was emitted + assert( + result.includes('top-level await') || + result.includes('Cannot transform ESM module'), + 'Should warn about top-level await usage', + ); + console.log('✓ top-level await detection working'); + + // Cleanup + utils.filesAfter(before, newcomers); +} + +// Test 3: top-level for-await-of detection +console.log('\n=== Test 3: top-level for-await-of ==='); +{ + const input = './test-for-await-of.mjs'; + const output = './run-time/test-for-await-of.exe'; + const newcomers = ['run-time/test-for-await-of.exe']; + + const before = utils.filesBefore(newcomers); + utils.mkdirp.sync(path.dirname(output)); + + const result = utils.pkg.sync( + ['--target', target, '--output', output, input], + ['inherit', 'pipe', 'inherit'], + ); + + // Verify warning was emitted + assert( + result.includes('for-await-of') || + result.includes('Cannot transform ESM module'), + 'Should warn about top-level for-await-of usage', + ); + console.log('✓ top-level for-await-of detection working'); + + // Cleanup + utils.filesAfter(before, newcomers); +} + +// Test 4: multiple unsupported features detection +console.log('\n=== Test 4: multiple unsupported features ==='); +{ + const input = './test-multiple-features.mjs'; + const output = './run-time/test-multiple.exe'; + const newcomers = ['run-time/test-multiple.exe']; + + const before = utils.filesBefore(newcomers); + utils.mkdirp.sync(path.dirname(output)); + + const result = utils.pkg.sync( + ['--target', target, '--output', output, input], + ['inherit', 'pipe', 'inherit'], + ); + + // Verify multiple warnings were emitted + const hasImportMeta = result.includes('import.meta'); + const hasTopLevelAwait = result.includes('top-level await'); + const hasForAwaitOf = result.includes('for-await-of'); + const hasGeneralWarning = result.includes('Cannot transform ESM module'); + + assert( + hasImportMeta || hasTopLevelAwait || hasForAwaitOf || hasGeneralWarning, + 'Should warn about multiple unsupported features', + ); + + console.log('✓ Multiple features detection working'); + console.log(' - import.meta detected:', hasImportMeta); + console.log(' - top-level await detected:', hasTopLevelAwait); + console.log(' - top-level for-await-of detected:', hasForAwaitOf); + + // Cleanup + utils.filesAfter(before, newcomers); +} + +console.log('\n✅ All unsupported ESM features correctly detected!'); diff --git a/test/test-50-esm-unsupported/package.json b/test/test-50-esm-unsupported/package.json new file mode 100644 index 00000000..25725d4e --- /dev/null +++ b/test/test-50-esm-unsupported/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-50-esm-unsupported", + "version": "1.0.0", + "private": true +} diff --git a/test/test-50-esm-unsupported/test-for-await-of.mjs b/test/test-50-esm-unsupported/test-for-await-of.mjs new file mode 100644 index 00000000..ec97847d --- /dev/null +++ b/test/test-50-esm-unsupported/test-for-await-of.mjs @@ -0,0 +1,17 @@ +// Test file with top-level for-await-of +async function* generateNumbers() { + yield 1; + yield 2; + yield 3; +} + +// Top-level for-await-of - not allowed in CJS +for await (const num of generateNumbers()) { + console.log('Number:', num); +} + +console.log('Top-level for-await-of completed'); + +export default function test() { + return 'ok'; +} diff --git a/test/test-50-esm-unsupported/test-import-meta.mjs b/test/test-50-esm-unsupported/test-import-meta.mjs new file mode 100644 index 00000000..d517a56e --- /dev/null +++ b/test/test-50-esm-unsupported/test-import-meta.mjs @@ -0,0 +1,7 @@ +// Test file with import.meta usage +console.log('import.meta.url:', import.meta.url); +console.log('import.meta.dirname:', import.meta.dirname); + +export default function test() { + return 'ok'; +} diff --git a/test/test-50-esm-unsupported/test-multiple-features.mjs b/test/test-50-esm-unsupported/test-multiple-features.mjs new file mode 100644 index 00000000..f3f5d809 --- /dev/null +++ b/test/test-50-esm-unsupported/test-multiple-features.mjs @@ -0,0 +1,22 @@ +// Test file with multiple unsupported ESM features +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Top-level await +await delay(50); + +// import.meta usage +console.log('Module URL:', import.meta.url); + +// Top-level for-await-of +async function* generateItems() { + yield 'a'; + yield 'b'; +} + +for await (const item of generateItems()) { + console.log('Item:', item); +} + +export default function test() { + return 'ok with multiple features'; +} diff --git a/test/test-50-esm-unsupported/test-top-level-await.mjs b/test/test-50-esm-unsupported/test-top-level-await.mjs new file mode 100644 index 00000000..de420931 --- /dev/null +++ b/test/test-50-esm-unsupported/test-top-level-await.mjs @@ -0,0 +1,11 @@ +// Test file with top-level await +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Top-level await - not allowed in CJS +await delay(100); + +console.log('Top-level await completed'); + +export default function test() { + return 'ok'; +} From ba681c0df117afc6252b2958dec95fb36182a947 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 10 Feb 2026 15:45:44 +0100 Subject: [PATCH 3/3] feat: add unlikelyJavascript function to filter non-JS files and integrate it into ESM transformation --- lib/common.ts | 13 +++++++++++++ lib/esm-transformer.ts | 10 ++++++++++ lib/walker.ts | 7 +------ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/common.ts b/lib/common.ts index 8298d492..058aa779 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -100,6 +100,19 @@ export function isDotNODE(file: string) { return path.extname(file) === '.node'; } +export function unlikelyJavascript(file: string): boolean { + const ext = path.extname(file); + // Check single extensions + if (['.css', '.html', '.json', '.vue'].includes(ext)) { + return true; + } + // Check for .d.ts files (compound extension) + if (file.endsWith('.d.ts')) { + return true; + } + return false; +} + function replaceSlashes(file: string, slash: string) { if (/^.:\\/.test(file)) { if (slash === '/') { diff --git a/lib/esm-transformer.ts b/lib/esm-transformer.ts index b6e7d611..9ec28186 100644 --- a/lib/esm-transformer.ts +++ b/lib/esm-transformer.ts @@ -1,6 +1,7 @@ import * as babel from '@babel/core'; import traverse, { NodePath } from '@babel/traverse'; import { log } from './log'; +import { unlikelyJavascript } from './common'; export interface TransformResult { code: string; @@ -139,6 +140,15 @@ export function transformESMtoCJS( code: string, filename: string, ): TransformResult { + // Skip files that are unlikely to be JavaScript (e.g., .d.ts, .json, .css) + // to avoid Babel parse errors + if (unlikelyJavascript(filename)) { + return { + code, + isTransformed: false, + }; + } + // First, check for unsupported ESM features that can't be safely transformed const unsupportedFeatures = detectUnsupportedESMFeatures(code, filename); diff --git a/lib/walker.ts b/lib/walker.ts index 676a72cc..fc28cdd4 100644 --- a/lib/walker.ts +++ b/lib/walker.ts @@ -18,6 +18,7 @@ import { isDotJSON, isDotNODE, isPackageJson, + unlikelyJavascript, normalizePath, toNormalizedRealPath, isESMFile, @@ -96,12 +97,6 @@ function isBuiltin(moduleName: string) { return builtinModules.includes(moduleNameWithoutPrefix); } -function unlikelyJavascript(file: string) { - return ['.css', '.html', '.json', '.vue', '.d.ts'].includes( - path.extname(file), - ); -} - function isPublic(config: PackageJson) { if (config.private) { return false;