Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ module.exports = [
'test/test-51-esm-import-meta/esm-module/test-import-meta-basic.js',
'lib/log.js', // ESM re-export file
'test/test-50-extensions/test-y-esnext.js', // ESM test file
'test/test-94-sea-esm-import-meta/app/lib/decorated.js', // decorator syntax fixture

'prelude/sea-bootstrap.bundle.js', // Generated by esbuild
'prelude/sea-bootstrap-esm.bundle.mjs', // Generated by esbuild
],
Expand Down
79 changes: 76 additions & 3 deletions lib/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { log } from './log';

import { ALIAS_AS_RELATIVE, ALIAS_AS_RESOLVABLE } from './common';

/** Type guard for plain literal nodes; rejects template literals with interpolations. */
function isLiteral(node: babelTypes.Node): node is babelTypes.Literal {
if (node == null) {
return false;
Expand All @@ -21,6 +22,7 @@ function isLiteral(node: babelTypes.Node): node is babelTypes.Literal {
return true;
}

/** Extracts the runtime value of a literal. Throws on null/regexp — never valid module specifiers. */
function getLiteralValue(node: babelTypes.Literal) {
if (node.type === 'TemplateLiteral') {
return node.quasis[0].value.raw;
Expand All @@ -37,6 +39,7 @@ function getLiteralValue(node: babelTypes.Literal) {
return node.value;
}

/** Renders an import specifier list back to source (`a, { b, c as d }`) for log output. */
function reconstructSpecifiers(
specs: (
| babelTypes.ImportDefaultSpecifier
Expand Down Expand Up @@ -79,6 +82,7 @@ function reconstructSpecifiers(
return defaults.join(', ');
}

/** Prints any AST node back to a single-line source string, used when an arg isn't a literal. */
function reconstruct(node: babelTypes.Node) {
let v = generate(node, { comments: false }).code.replace(/\n/g, '');
let v2;
Expand All @@ -102,6 +106,7 @@ interface Was {
v3?: string;
}

/** Fills a template (e.g. `require({v1}{c2}{v2})`) with captured args to produce the printable form of a match. */
function forge(pattern: string, was: Was) {
return pattern
.replace('{c1}', ', ')
Expand All @@ -112,6 +117,7 @@ function forge(pattern: string, was: Was) {
.replace('{v3}', was.v3 ? was.v3 : '');
}

/** Guards the 2nd arg of require/require.resolve — only pkg's `must-exclude`/`may-exclude` markers are honored. */
function valid2(v2?: Was['v2']) {
return (
v2 === undefined ||
Expand All @@ -121,6 +127,7 @@ function valid2(v2?: Was['v2']) {
);
}

/** Matches `require.resolve("lit"[, "lit"])`. Returns captured args or null. */
function visitorRequireResolve(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
Expand Down Expand Up @@ -150,6 +157,7 @@ function visitorRequireResolve(n: babelTypes.Node) {
};
}

/** Matches `require("lit"[, "lit"])`. Returns captured args or null. */
function visitorRequire(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
Expand All @@ -173,6 +181,7 @@ function visitorRequire(n: babelTypes.Node) {
};
}

/** Matches a static ESM `import … from "lit"` declaration. */
function visitorImport(n: babelTypes.Node) {
if (!babelTypes.isImportDeclaration(n)) {
return null;
Expand All @@ -181,6 +190,24 @@ function visitorImport(n: babelTypes.Node) {
return { v1: n.source.value, v3: reconstructSpecifiers(n.specifiers) };
}

/** Matches dynamic `import("lit")` so bundler-emitted chunk splits get walked like static imports. */
function visitorDynamicImport(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
}

if (n.callee.type !== 'Import') {
return null;
}

if (!n.arguments || !isLiteral(n.arguments[0])) {
return null;
}

return { v1: getLiteralValue(n.arguments[0] as babelTypes.Literal) };
}
Comment thread
robertsLando marked this conversation as resolved.

/** Matches `path.join(__dirname, "lit")` — treats the joined path as a snapshot asset reference. */
function visitorPathJoin(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
Expand Down Expand Up @@ -221,6 +248,11 @@ function visitorPathJoin(n: babelTypes.Node) {
return { v1: getLiteralValue(n.arguments[1] as babelTypes.StringLiteral) };
}

/**
* Runs each literal-arg matcher in order and returns the first hit as a
* `{alias, aliasType, mustExclude?, mayExclude?}` derivative for the walker to
* bundle. When `test` is true returns a printable form (used by unit tests).
*/
export function visitorSuccessful(node: babelTypes.Node, test = false) {
let was: Was | null = visitorRequireResolve(node);

Expand Down Expand Up @@ -270,6 +302,16 @@ export function visitorSuccessful(node: babelTypes.Node, test = false) {
return { alias: was.v1, aliasType: ALIAS_AS_RESOLVABLE };
}

was = visitorDynamicImport(node);

if (was) {
if (test) {
return forge('import({v1})', was);
}

return { alias: was.v1, aliasType: ALIAS_AS_RESOLVABLE };
}

was = visitorPathJoin(node);

if (was) {
Expand All @@ -283,6 +325,7 @@ export function visitorSuccessful(node: babelTypes.Node, test = false) {
return null;
}

/** Matches `require.resolve(<non-literal>[, "lit"])` — feeds the "Cannot resolve" warning path. */
function nonLiteralRequireResolve(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
Expand Down Expand Up @@ -322,6 +365,7 @@ function nonLiteralRequireResolve(n: babelTypes.Node) {
};
}

/** Matches `require(<non-literal>[, "lit"])` — feeds the "Cannot resolve" warning path. */
function nonLiteralRequire(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
Expand Down Expand Up @@ -355,6 +399,7 @@ function nonLiteralRequire(n: babelTypes.Node) {
};
}

/** Entry visitor for dynamic requires whose target isn't known at build time — returns the alias to warn about. */
export function visitorNonLiteral(n: babelTypes.Node) {
const was = nonLiteralRequireResolve(n) || nonLiteralRequire(n);

Expand All @@ -373,6 +418,7 @@ export function visitorNonLiteral(n: babelTypes.Node) {
return null;
}

/** Loose `require(...)` match (no literal gate) — used only to surface malformed-require diagnostics. */
function isRequire(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
Expand All @@ -395,6 +441,7 @@ function isRequire(n: babelTypes.Node) {
return { v1: reconstruct(n.arguments[0]) };
}

/** Loose `require.resolve(...)` match (no literal gate) — used only for malformed-require diagnostics. */
function isRequireResolve(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
Expand Down Expand Up @@ -423,6 +470,7 @@ function isRequireResolve(n: babelTypes.Node) {
return { v1: reconstruct(n.arguments[0]) };
}

/** Fires on require/require.resolve shapes the literal matchers rejected (wrong arg count, etc.). */
export function visitorMalformed(n: babelTypes.Node) {
const was = isRequireResolve(n) || isRequire(n);

Expand All @@ -433,6 +481,7 @@ export function visitorMalformed(n: babelTypes.Node) {
return null;
}

/** Flags `path.resolve(...)` so the walker can warn that it resolves against `process.cwd()` at runtime, not `__dirname`. */
export function visitorUseSCWD(n: babelTypes.Node) {
if (!babelTypes.isCallExpression(n)) {
return null;
Expand Down Expand Up @@ -463,6 +512,11 @@ export function visitorUseSCWD(n: babelTypes.Node) {

type VisitorFunction = (node: babelTypes.Node, trying?: boolean) => boolean;

/**
* Iterative DFS over the AST. `visitor` returns true to descend into children;
* `trying` is propagated inside try/catch bodies so the walker can downgrade
* downstream warnings to debug.
*/
function traverse(ast: babelTypes.File, visitor: VisitorFunction) {
// modified esprima-walk to support
// visitor return value and "trying" flag
Expand Down Expand Up @@ -495,18 +549,37 @@ function traverse(ast: babelTypes.File, visitor: VisitorFunction) {
}
}

export function parse(body: string) {
/**
* `babel.parse` wrapper. `isEsm` selects `sourceType: 'module'` so `import.meta`
* / top-level await parse cleanly. `decorators-legacy` is enabled so third-party
* sources that ship raw `@decorator` syntax (fontkit, older MobX/Nest builds,
* etc.) don't trip the parser and silently drop their dependency graph.
*/
export function parse(body: string, isEsm = false) {
return babel.parse(body, {
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
sourceType: isEsm ? 'module' : 'script',
plugins: ['decorators-legacy'],
});
}

export function detect(body: string, visitor: VisitorFunction, file?: string) {
/**
* Parses `body` and walks the AST with `visitor`. Parse failures are logged
* (not thrown) so one unparseable file doesn't abort the whole build — but the
* file's dependencies are then skipped, which is why callers must pass the
* correct `isEsm` flag.
*/
export function detect(
body: string,
visitor: VisitorFunction,
file?: string,
isEsm = false,
) {
let json;

try {
json = parse(body);
json = parse(body, isEsm);
} catch (error) {
const fileInfo = file ? ` in ${file}` : '';
log.warn(`Babel parse has failed: ${(error as Error).message}${fileInfo}`);
Expand Down
6 changes: 3 additions & 3 deletions lib/esm-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function hasImportMeta(code: string): boolean {
try {
const ast = babel.parse(code, {
sourceType: 'module',
plugins: [],
plugins: ['decorators-legacy'],
});

if (!ast) {
Expand Down Expand Up @@ -85,7 +85,7 @@ function detectESMFeatures(
try {
const ast = babel.parse(code, {
sourceType: 'module',
plugins: [],
plugins: ['decorators-legacy'],
});

if (!ast) {
Expand Down Expand Up @@ -300,7 +300,7 @@ export function transformESMtoCJS(
// Parse the code to check for exports and collect imports
const ast = babel.parse(code, {
sourceType: 'module',
plugins: [],
plugins: ['decorators-legacy'],
});

let hasExports = false;
Expand Down
1 change: 1 addition & 0 deletions lib/walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ function stepDetect(
return true; // can i go inside?
},
record.file,
isESMFile(record.file),
);
} catch (error) {
log.error((error as Error).message, record.file);
Expand Down
12 changes: 12 additions & 0 deletions test/test-94-sea-esm-import-meta/app/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// `import.meta` is only valid in `sourceType: "module"`. Before the fix for
// issue #264 the SEA walker parsed this body in script mode, so the parse
// failed and the detector never saw the imports below — neither the static
// one nor the dynamic one ended up in the snapshot.
import { greet } from './lib/helper.mjs';

const here = new URL(import.meta.url).pathname;
console.log('here:' + here.split('/').pop());
console.log('static:' + greet('world'));

const dyn = await import('./lib/dyn.mjs');
console.log('dynamic:' + dyn.shout('world'));
16 changes: 16 additions & 0 deletions test/test-94-sea-esm-import-meta/app/lib/decorated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Decorator syntax forces the walker's Babel parser to enable the
// `decorators-legacy` plugin. Without it pkg would log "Babel parse has
// failed: This experimental syntax requires enabling one of the following
// parser plugin(s)" and silently drop this file's dependency graph. This
// file is walked via `pkg.scripts` but never imported at runtime — Node
// can't execute raw decorator syntax.
function log(Cls) {
return Cls;
}

@log
export class Widget {
greet(name) {
return 'widget:' + name;
}
}
3 changes: 3 additions & 0 deletions test/test-94-sea-esm-import-meta/app/lib/dyn.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function shout(name) {
return 'HELLO ' + name.toUpperCase();
}
3 changes: 3 additions & 0 deletions test/test-94-sea-esm-import-meta/app/lib/helper.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function greet(name) {
return 'hello ' + name;
}
12 changes: 12 additions & 0 deletions test/test-94-sea-esm-import-meta/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "test-94-sea-esm-import-meta",
"version": "1.0.0",
"type": "module",
"main": "index.mjs",
"bin": "index.mjs",
"pkg": {
"scripts": [
"lib/decorated.js"
]
}
}
52 changes: 52 additions & 0 deletions test/test-94-sea-esm-import-meta/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env node

'use strict';

const assert = require('assert');
const utils = require('../utils.js');

// Enhanced SEA requires Node.js >= 22
if (utils.getNodeMajorVersion() < 22) {
return;
}

assert(__dirname === process.cwd());

const input = './app/package.json';
const testName = 'test-94-sea-esm-import-meta';

const SEA_PLATFORM_SUFFIX = {
linux: 'linux',
darwin: 'macos',
win32: 'win.exe',
};
const suffix = SEA_PLATFORM_SUFFIX[process.platform];

const newcomers = utils.seaHostOutputs(testName);
const before = utils.filesBefore(newcomers);

// Capture pkg's output so we can assert the Babel parse warning (issue #264)
// never surfaces when walking ESM files that use `import.meta`.
const args = suffix
? [input, '--sea', '--target', 'host', '--output', `${testName}-${suffix}`]
: [input, '--sea'];

const build = utils.pkg.sync(args, { stdio: ['pipe', 'pipe', 'pipe'] });
const buildLog = build.stdout + build.stderr;

assert(
buildLog.indexOf('Babel parse has failed') === -1,
'pkg must parse ESM files as modules (issue #264)\npkg output was:\n' +
buildLog,
);

// A successful parse means both imports were walked and bundled — running the
// binary proves it by printing the imported values. Skip on unsupported hosts.
if (suffix) {
utils.assertSeaOutput(
testName,
'here:index.mjs\nstatic:hello world\ndynamic:HELLO WORLD\n',
);
}

utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true });
1 change: 1 addition & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const npmTests = [
'test-90-sea-worker-threads',
'test-91-sea-esm-entry',
'test-92-sea-tla',
'test-94-sea-esm-import-meta',
];

if (testFilter) {
Expand Down
Loading