diff --git a/.eslintignore b/.eslintignore index 9e901f17..f3f78c83 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ lib-es5 node_modules dist +test/test-51-esm-import-meta/esm-module/test-import-meta-basic.js diff --git a/lib/esm-transformer.ts b/lib/esm-transformer.ts index b1b522a2..52c941d0 100644 --- a/lib/esm-transformer.ts +++ b/lib/esm-transformer.ts @@ -16,11 +16,52 @@ interface UnsupportedFeature { column: number | null; } +/** + * Check if code contains import.meta usage + * + * @param code - The ESM source code to check + * @returns true if import.meta is used, false otherwise + */ +function hasImportMeta(code: string): boolean { + try { + const ast = babel.parse(code, { + sourceType: 'module', + plugins: [], + }); + + if (!ast) { + return false; + } + + let found = false; + + // @ts-expect-error Type mismatch due to @babel/types version in @types/babel__traverse + traverse(ast as t.File, { + // Detect import.meta usage + MetaProperty(path: NodePath) { + if ( + path.node.meta.name === 'import' && + path.node.property.name === 'meta' + ) { + found = true; + path.stop(); // Stop traversal once found + } + }, + }); + + return found; + } catch (error) { + // If we can't parse, assume no import.meta + return false; + } +} + /** * Detect ESM features that cannot be safely transformed to CommonJS * These include: * - Top-level await (no CJS equivalent) - * - import.meta (no CJS equivalent) + * + * Note: import.meta is now supported via polyfills and is no longer unsupported * * @param code - The ESM source code to check * @param filename - The filename for error reporting @@ -44,20 +85,6 @@ function detectUnsupportedESMFeatures( // @ts-expect-error Type mismatch due to @babel/types version in @types/babel__traverse traverse(ast as t.File, { - // Detect import.meta usage - MetaProperty(path: NodePath) { - 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: NodePath) { // Check if await is at top level (not inside a function) @@ -130,6 +157,45 @@ function detectUnsupportedESMFeatures( } } +/** + * Replace esbuild's empty import_meta object with a proper implementation + * + * When esbuild transforms ESM to CJS, it converts `import.meta` to a `const import_meta = {}`. + * This function replaces that empty object with a proper implementation of import.meta properties. + * + * Shims provided: + * - import.meta.url: File URL of the current module + * - import.meta.dirname: Directory path of the current module (Node.js 20.11+) + * - import.meta.filename: File path of the current module (Node.js 20.11+) + * + * Based on approach from tsup and esbuild discussions + * @see https://github.com/egoist/tsup/blob/main/assets/cjs_shims.js + * @see https://github.com/evanw/esbuild/issues/3839 + * + * @param code - The transformed CJS code from esbuild + * @returns Code with import_meta properly implemented + */ +function replaceImportMetaObject(code: string): string { + // esbuild generates: const import_meta = {}; + // We need to replace this with a proper implementation + // Note: We use getters to ensure values are computed at runtime in the correct context + const shimImplementation = `const import_meta = { + get url() { + return require('url').pathToFileURL(__filename).href; + }, + get dirname() { + return __dirname; + }, + get filename() { + return __filename; + } +};`; + + // Replace esbuild's empty import_meta object with our implementation + // Match: const import_meta = {}; + return code.replace(/const import_meta\s*=\s*\{\s*\};/, shimImplementation); +} + /** * Transform ESM code to CommonJS using esbuild * This allows ESM modules to be compiled to bytecode via vm.Script @@ -185,6 +251,9 @@ export function transformESMtoCJS( }; } + // Check if code uses import.meta before transformation + const usesImportMeta = hasImportMeta(code); + try { const result = esbuild.transformSync(code, { loader: 'js', @@ -203,8 +272,14 @@ export function transformESMtoCJS( }; } + // Inject import.meta shims after esbuild transformation if needed + let finalCode = result.code; + if (usesImportMeta) { + finalCode = replaceImportMetaObject(result.code); + } + return { - code: result.code, + code: finalCode, isTransformed: true, }; } catch (error) { diff --git a/test/test-50-esm-unsupported/main.js b/test/test-50-esm-unsupported/main.js index d0e70395..1859c777 100644 --- a/test/test-50-esm-unsupported/main.js +++ b/test/test-50-esm-unsupported/main.js @@ -4,6 +4,7 @@ const path = require('path'); const assert = require('assert'); +const { existsSync } = require('fs'); const utils = require('../utils.js'); assert(!module.parent); @@ -11,10 +12,10 @@ assert(__dirname === process.cwd()); const target = process.argv[2] || 'host'; -console.log('Testing unsupported ESM features detection...'); +console.log('Testing ESM features detection and transformation...'); -// Test 1: import.meta detection -console.log('\n=== Test 1: import.meta ==='); +// Test 1: import.meta support (should now work without warnings) +console.log('\n=== Test 1: import.meta support ==='); { const input = './test-import-meta.mjs'; const output = './run-time/test-import-meta.exe'; @@ -23,19 +24,25 @@ console.log('\n=== Test 1: import.meta ==='); const before = utils.filesBefore(newcomers); utils.mkdirp.sync(path.dirname(output)); - // Capture stdout to check for warnings + // Capture stdout to check that no warnings are emitted const result = utils.pkg.sync( ['--target', target, '--output', output, input], ['inherit', 'pipe', 'inherit'], ); - // Verify warning was emitted + // Verify NO warning was emitted (import.meta should now be supported) assert( - result.includes('import.meta') || - result.includes('Cannot transform ESM module'), - 'Should warn about import.meta usage', + !result.includes('import.meta') && + !result.includes('Cannot transform ESM module'), + 'Should NOT warn about import.meta usage (it is now supported)', + ); + + // Verify the executable was created + assert(existsSync(output), 'Executable should be created successfully'); + + console.log( + '✓ import.meta support working (no warnings, executable created)', ); - console.log('✓ import.meta detection working'); // Cleanup utils.filesAfter(before, newcomers); @@ -110,19 +117,26 @@ console.log('\n=== Test 4: multiple unsupported features ==='); ['inherit', 'pipe', 'inherit'], ); - // Verify multiple warnings were emitted + // Verify warnings were emitted only for truly unsupported features 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'); + // import.meta should NOT trigger a warning anymore (it's now supported) + assert( + !hasImportMeta, + 'Should NOT warn about import.meta (it is now supported)', + ); + + // But top-level await and for-await-of should still warn assert( - hasImportMeta || hasTopLevelAwait || hasForAwaitOf || hasGeneralWarning, - 'Should warn about multiple unsupported features', + hasTopLevelAwait || hasForAwaitOf || hasGeneralWarning, + 'Should warn about truly unsupported features (top-level await, for-await-of)', ); console.log('✓ Multiple features detection working'); - console.log(' - import.meta detected:', hasImportMeta); + console.log(' - import.meta detected:', hasImportMeta, '(should be false)'); console.log(' - top-level await detected:', hasTopLevelAwait); console.log(' - top-level for-await-of detected:', hasForAwaitOf); @@ -130,4 +144,6 @@ console.log('\n=== Test 4: multiple unsupported features ==='); utils.filesAfter(before, newcomers); } -console.log('\n✅ All unsupported ESM features correctly detected!'); +console.log( + '\n✅ All ESM features correctly handled! (import.meta now supported, top-level await/for-await-of still unsupported)', +); diff --git a/test/test-51-esm-import-meta/esm-module/package.json b/test/test-51-esm-import-meta/esm-module/package.json new file mode 100644 index 00000000..6ee7fea1 --- /dev/null +++ b/test/test-51-esm-import-meta/esm-module/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-51-esm-import-meta", + "version": "1.0.0", + "type": "module" +} diff --git a/test/test-51-esm-import-meta/esm-module/test-import-meta-basic.js b/test/test-51-esm-import-meta/esm-module/test-import-meta-basic.js new file mode 100644 index 00000000..d23ae202 --- /dev/null +++ b/test/test-51-esm-import-meta/esm-module/test-import-meta-basic.js @@ -0,0 +1,34 @@ +// Test file to verify import.meta properties work correctly +console.log('Testing import.meta properties...'); + +// Test import.meta.url +if (!import.meta.url) { + console.error('FAIL: import.meta.url is not defined'); + process.exit(1); +} + +if (!import.meta.url.startsWith('file://')) { + console.error('FAIL: import.meta.url should start with file://'); + console.error('Got:', import.meta.url); + process.exit(1); +} + +console.log('✓ import.meta.url works:', import.meta.url); + +// Test import.meta.dirname +if (typeof import.meta.dirname === 'undefined') { + console.error('FAIL: import.meta.dirname is not defined'); + process.exit(1); +} + +console.log('✓ import.meta.dirname works:', import.meta.dirname); + +// Test import.meta.filename +if (typeof import.meta.filename === 'undefined') { + console.error('FAIL: import.meta.filename is not defined'); + process.exit(1); +} + +console.log('✓ import.meta.filename works:', import.meta.filename); + +console.log('\n✅ All import.meta properties work correctly!'); diff --git a/test/test-51-esm-import-meta/main.js b/test/test-51-esm-import-meta/main.js new file mode 100644 index 00000000..47fcf1f5 --- /dev/null +++ b/test/test-51-esm-import-meta/main.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +'use strict'; + +const path = require('path'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const utils = require('../utils.js'); + +assert(!module.parent); +assert(__dirname === process.cwd()); + +const target = process.argv[2] || 'host'; + +console.log('Testing import.meta support in packaged executables...'); + +// Test: Package and run an ESM module that uses import.meta +console.log('\n=== Test: import.meta properties ==='); +{ + const input = './esm-module/test-import-meta-basic.js'; + const output = './run-time/test-import-meta-basic.exe'; + const newcomers = ['run-time/test-import-meta-basic.exe']; + + const before = utils.filesBefore(newcomers); + utils.mkdirp.sync(path.dirname(output)); + + // Package the executable + const buildResult = utils.pkg.sync( + ['--target', target, '--output', output, input], + ['inherit', 'pipe', 'inherit'], + ); + + // Should NOT warn about import.meta + assert( + !buildResult.includes('import.meta') && + !buildResult.includes('Cannot transform ESM module'), + 'Should NOT warn about import.meta usage', + ); + + console.log('✓ Packaging succeeded without warnings'); + + // Run the executable and check output + const runResult = spawnSync(output, [], { + encoding: 'utf8', + timeout: 10000, + }); + + console.log('Executable output:'); + console.log(runResult.stdout); + + if (runResult.stderr) { + console.log('Executable stderr:'); + console.log(runResult.stderr); + } + + assert( + runResult.status === 0, + `Executable should exit with code 0, got ${runResult.status}`, + ); + + assert( + runResult.stdout.includes('import.meta.url works'), + 'Should show import.meta.url working', + ); + + assert( + runResult.stdout.includes('import.meta.dirname works'), + 'Should show import.meta.dirname working', + ); + + assert( + runResult.stdout.includes('import.meta.filename works'), + 'Should show import.meta.filename working', + ); + + assert( + runResult.stdout.includes('All import.meta properties work correctly'), + 'Should show success message', + ); + + console.log('✓ Executable runs correctly with import.meta support'); + + // Cleanup + utils.filesAfter(before, newcomers); +} + +console.log('\n✅ All import.meta tests passed!');