Skip to content

Commit 40e71c6

Browse files
authored
feat: detect and warn about unsupported ESM features (#202)
* feat: better detection of unsupported esm features * feat: add tests for unsupported ESM features detection * feat: add unlikelyJavascript function to filter non-JS files and integrate it into ESM transformation
1 parent 45cbc2c commit 40e71c6

File tree

11 files changed

+376
-7
lines changed

11 files changed

+376
-7
lines changed

lib/common.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ export function isDotNODE(file: string) {
100100
return path.extname(file) === '.node';
101101
}
102102

103+
export function unlikelyJavascript(file: string): boolean {
104+
const ext = path.extname(file);
105+
// Check single extensions
106+
if (['.css', '.html', '.json', '.vue'].includes(ext)) {
107+
return true;
108+
}
109+
// Check for .d.ts files (compound extension)
110+
if (file.endsWith('.d.ts')) {
111+
return true;
112+
}
113+
return false;
114+
}
115+
103116
function replaceSlashes(file: string, slash: string) {
104117
if (/^.:\\/.test(file)) {
105118
if (slash === '/') {

lib/esm-transformer.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,133 @@
11
import * as babel from '@babel/core';
2+
import traverse, { NodePath } from '@babel/traverse';
23
import { log } from './log';
4+
import { unlikelyJavascript } from './common';
35

46
export interface TransformResult {
57
code: string;
68
isTransformed: boolean;
79
}
810

11+
interface UnsupportedFeature {
12+
feature: string;
13+
line: number | null;
14+
column: number | null;
15+
}
16+
17+
/**
18+
* Detect ESM features that cannot be safely transformed to CommonJS
19+
* These include:
20+
* - Top-level await (no CJS equivalent)
21+
* - import.meta (no CJS equivalent)
22+
*
23+
* @param code - The ESM source code to check
24+
* @param filename - The filename for error reporting
25+
* @returns Array of unsupported features found, or null if parse fails
26+
*/
27+
function detectUnsupportedESMFeatures(
28+
code: string,
29+
filename: string,
30+
): UnsupportedFeature[] | null {
31+
try {
32+
const ast = babel.parseSync(code, {
33+
filename,
34+
sourceType: 'module',
35+
plugins: [],
36+
});
37+
38+
if (!ast) {
39+
return null;
40+
}
41+
42+
const unsupportedFeatures: UnsupportedFeature[] = [];
43+
44+
traverse(ast, {
45+
// Detect import.meta usage
46+
MetaProperty(path) {
47+
if (
48+
path.node.meta.name === 'import' &&
49+
path.node.property.name === 'meta'
50+
) {
51+
unsupportedFeatures.push({
52+
feature: 'import.meta',
53+
line: path.node.loc?.start.line ?? null,
54+
column: path.node.loc?.start.column ?? null,
55+
});
56+
}
57+
},
58+
59+
// Detect top-level await
60+
AwaitExpression(path) {
61+
// Check if await is at top level (not inside a function)
62+
let parent: NodePath | null = path.parentPath;
63+
let isTopLevel = true;
64+
65+
while (parent) {
66+
if (
67+
parent.isFunctionDeclaration() ||
68+
parent.isFunctionExpression() ||
69+
parent.isArrowFunctionExpression() ||
70+
parent.isObjectMethod() ||
71+
parent.isClassMethod()
72+
) {
73+
isTopLevel = false;
74+
break;
75+
}
76+
parent = parent.parentPath;
77+
}
78+
79+
if (isTopLevel) {
80+
unsupportedFeatures.push({
81+
feature: 'top-level await',
82+
line: path.node.loc?.start.line ?? null,
83+
column: path.node.loc?.start.column ?? null,
84+
});
85+
}
86+
},
87+
88+
// Detect for-await-of at top level
89+
ForOfStatement(path) {
90+
if (path.node.await) {
91+
let parent: NodePath | null = path.parentPath;
92+
let isTopLevel = true;
93+
94+
while (parent) {
95+
if (
96+
parent.isFunctionDeclaration() ||
97+
parent.isFunctionExpression() ||
98+
parent.isArrowFunctionExpression() ||
99+
parent.isObjectMethod() ||
100+
parent.isClassMethod()
101+
) {
102+
isTopLevel = false;
103+
break;
104+
}
105+
parent = parent.parentPath;
106+
}
107+
108+
if (isTopLevel) {
109+
unsupportedFeatures.push({
110+
feature: 'top-level for-await-of',
111+
line: path.node.loc?.start.line ?? null,
112+
column: path.node.loc?.start.column ?? null,
113+
});
114+
}
115+
}
116+
},
117+
});
118+
119+
return unsupportedFeatures;
120+
} catch (error) {
121+
// If we can't parse, return null to let the transform attempt proceed
122+
log.debug(
123+
`Could not parse ${filename} to detect unsupported ESM features: ${
124+
error instanceof Error ? error.message : String(error)
125+
}`,
126+
);
127+
return null;
128+
}
129+
}
130+
9131
/**
10132
* Transform ESM code to CommonJS using Babel
11133
* This allows ESM modules to be compiled to bytecode via vm.Script
@@ -18,6 +140,48 @@ export function transformESMtoCJS(
18140
code: string,
19141
filename: string,
20142
): TransformResult {
143+
// Skip files that are unlikely to be JavaScript (e.g., .d.ts, .json, .css)
144+
// to avoid Babel parse errors
145+
if (unlikelyJavascript(filename)) {
146+
return {
147+
code,
148+
isTransformed: false,
149+
};
150+
}
151+
152+
// First, check for unsupported ESM features that can't be safely transformed
153+
const unsupportedFeatures = detectUnsupportedESMFeatures(code, filename);
154+
155+
if (unsupportedFeatures && unsupportedFeatures.length > 0) {
156+
const featureList = unsupportedFeatures
157+
.map((f) => {
158+
const location = f.line !== null ? ` at line ${f.line}` : '';
159+
return ` - ${f.feature}${location}`;
160+
})
161+
.join('\n');
162+
163+
const errorMessage = [
164+
`Cannot transform ESM module ${filename} to CommonJS:`,
165+
`The following ESM features have no CommonJS equivalent:`,
166+
featureList,
167+
'',
168+
'These features are not supported when compiling to bytecode.',
169+
'Consider one of the following:',
170+
' 1. Refactor to avoid these features',
171+
' 2. Use --no-bytecode flag to keep the module as source code',
172+
' 3. Mark the package as public to distribute with sources',
173+
].join('\n');
174+
175+
log.warn(errorMessage);
176+
177+
// Return untransformed code rather than throwing
178+
// This allows the file to be included as content instead of bytecode
179+
return {
180+
code,
181+
isTransformed: false,
182+
};
183+
}
184+
21185
try {
22186
const result = babel.transformSync(code, {
23187
filename,

lib/walker.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
isDotJSON,
1919
isDotNODE,
2020
isPackageJson,
21+
unlikelyJavascript,
2122
normalizePath,
2223
toNormalizedRealPath,
2324
isESMFile,
@@ -96,12 +97,6 @@ function isBuiltin(moduleName: string) {
9697
return builtinModules.includes(moduleNameWithoutPrefix);
9798
}
9899

99-
function unlikelyJavascript(file: string) {
100-
return ['.css', '.html', '.json', '.vue', '.d.ts'].includes(
101-
path.extname(file),
102-
);
103-
}
104-
105100
function isPublic(config: PackageJson) {
106101
if (config.private) {
107102
return false;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@babel/generator": "^7.23.0",
2727
"@babel/parser": "^7.23.0",
2828
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
29+
"@babel/traverse": "^7.23.0",
2930
"@babel/types": "^7.23.0",
3031
"@yao-pkg/pkg-fetch": "3.5.32",
3132
"into-stream": "^6.0.0",
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env node
2+
3+
'use strict';
4+
5+
const path = require('path');
6+
const assert = require('assert');
7+
const utils = require('../utils.js');
8+
9+
assert(!module.parent);
10+
assert(__dirname === process.cwd());
11+
12+
const target = process.argv[2] || 'host';
13+
14+
console.log('Testing unsupported ESM features detection...');
15+
16+
// Test 1: import.meta detection
17+
console.log('\n=== Test 1: import.meta ===');
18+
{
19+
const input = './test-import-meta.mjs';
20+
const output = './run-time/test-import-meta.exe';
21+
const newcomers = ['run-time/test-import-meta.exe'];
22+
23+
const before = utils.filesBefore(newcomers);
24+
utils.mkdirp.sync(path.dirname(output));
25+
26+
// Capture stdout to check for warnings
27+
const result = utils.pkg.sync(
28+
['--target', target, '--output', output, input],
29+
['inherit', 'pipe', 'inherit'],
30+
);
31+
32+
// Verify warning was emitted
33+
assert(
34+
result.includes('import.meta') ||
35+
result.includes('Cannot transform ESM module'),
36+
'Should warn about import.meta usage',
37+
);
38+
console.log('✓ import.meta detection working');
39+
40+
// Cleanup
41+
utils.filesAfter(before, newcomers);
42+
}
43+
44+
// Test 2: top-level await detection
45+
console.log('\n=== Test 2: top-level await ===');
46+
{
47+
const input = './test-top-level-await.mjs';
48+
const output = './run-time/test-top-level-await.exe';
49+
const newcomers = ['run-time/test-top-level-await.exe'];
50+
51+
const before = utils.filesBefore(newcomers);
52+
utils.mkdirp.sync(path.dirname(output));
53+
54+
const result = utils.pkg.sync(
55+
['--target', target, '--output', output, input],
56+
['inherit', 'pipe', 'inherit'],
57+
);
58+
59+
// Verify warning was emitted
60+
assert(
61+
result.includes('top-level await') ||
62+
result.includes('Cannot transform ESM module'),
63+
'Should warn about top-level await usage',
64+
);
65+
console.log('✓ top-level await detection working');
66+
67+
// Cleanup
68+
utils.filesAfter(before, newcomers);
69+
}
70+
71+
// Test 3: top-level for-await-of detection
72+
console.log('\n=== Test 3: top-level for-await-of ===');
73+
{
74+
const input = './test-for-await-of.mjs';
75+
const output = './run-time/test-for-await-of.exe';
76+
const newcomers = ['run-time/test-for-await-of.exe'];
77+
78+
const before = utils.filesBefore(newcomers);
79+
utils.mkdirp.sync(path.dirname(output));
80+
81+
const result = utils.pkg.sync(
82+
['--target', target, '--output', output, input],
83+
['inherit', 'pipe', 'inherit'],
84+
);
85+
86+
// Verify warning was emitted
87+
assert(
88+
result.includes('for-await-of') ||
89+
result.includes('Cannot transform ESM module'),
90+
'Should warn about top-level for-await-of usage',
91+
);
92+
console.log('✓ top-level for-await-of detection working');
93+
94+
// Cleanup
95+
utils.filesAfter(before, newcomers);
96+
}
97+
98+
// Test 4: multiple unsupported features detection
99+
console.log('\n=== Test 4: multiple unsupported features ===');
100+
{
101+
const input = './test-multiple-features.mjs';
102+
const output = './run-time/test-multiple.exe';
103+
const newcomers = ['run-time/test-multiple.exe'];
104+
105+
const before = utils.filesBefore(newcomers);
106+
utils.mkdirp.sync(path.dirname(output));
107+
108+
const result = utils.pkg.sync(
109+
['--target', target, '--output', output, input],
110+
['inherit', 'pipe', 'inherit'],
111+
);
112+
113+
// Verify multiple warnings were emitted
114+
const hasImportMeta = result.includes('import.meta');
115+
const hasTopLevelAwait = result.includes('top-level await');
116+
const hasForAwaitOf = result.includes('for-await-of');
117+
const hasGeneralWarning = result.includes('Cannot transform ESM module');
118+
119+
assert(
120+
hasImportMeta || hasTopLevelAwait || hasForAwaitOf || hasGeneralWarning,
121+
'Should warn about multiple unsupported features',
122+
);
123+
124+
console.log('✓ Multiple features detection working');
125+
console.log(' - import.meta detected:', hasImportMeta);
126+
console.log(' - top-level await detected:', hasTopLevelAwait);
127+
console.log(' - top-level for-await-of detected:', hasForAwaitOf);
128+
129+
// Cleanup
130+
utils.filesAfter(before, newcomers);
131+
}
132+
133+
console.log('\n✅ All unsupported ESM features correctly detected!');
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "test-50-esm-unsupported",
3+
"version": "1.0.0",
4+
"private": true
5+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Test file with top-level for-await-of
2+
async function* generateNumbers() {
3+
yield 1;
4+
yield 2;
5+
yield 3;
6+
}
7+
8+
// Top-level for-await-of - not allowed in CJS
9+
for await (const num of generateNumbers()) {
10+
console.log('Number:', num);
11+
}
12+
13+
console.log('Top-level for-await-of completed');
14+
15+
export default function test() {
16+
return 'ok';
17+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Test file with import.meta usage
2+
console.log('import.meta.url:', import.meta.url);
3+
console.log('import.meta.dirname:', import.meta.dirname);
4+
5+
export default function test() {
6+
return 'ok';
7+
}

0 commit comments

Comments
 (0)