Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === '/') {
Expand Down
164 changes: 164 additions & 0 deletions lib/esm-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,133 @@
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;
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
Expand All @@ -18,6 +140,48 @@ 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);

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,
Expand Down
7 changes: 1 addition & 6 deletions lib/walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isDotJSON,
isDotNODE,
isPackageJson,
unlikelyJavascript,
normalizePath,
toNormalizedRealPath,
isESMFile,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions test/test-50-esm-unsupported/main.js
Original file line number Diff line number Diff line change
@@ -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!');
5 changes: 5 additions & 0 deletions test/test-50-esm-unsupported/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "test-50-esm-unsupported",
"version": "1.0.0",
"private": true
}
17 changes: 17 additions & 0 deletions test/test-50-esm-unsupported/test-for-await-of.mjs
Original file line number Diff line number Diff line change
@@ -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';
}
7 changes: 7 additions & 0 deletions test/test-50-esm-unsupported/test-import-meta.mjs
Original file line number Diff line number Diff line change
@@ -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';
}
Loading