Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

esm: implement import.meta.command #51538

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion lib/internal/main/eval_stdin.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ readStdin((code) => {
const loadESM = getOptionValue('--import').length > 0;
if (getOptionValue('--input-type') === 'module' ||
(getOptionValue('--experimental-default-type') === 'module' && getOptionValue('--input-type') !== 'commonjs')) {
evalModule(code, print);
evalModule(code, print, true);
} else {
evalScript('[stdin]',
code,
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/main/eval_string.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const print = getOptionValue('--print');
const loadESM = getOptionValue('--import').length > 0 || getOptionValue('--experimental-loader').length > 0;
if (getOptionValue('--input-type') === 'module' ||
(getOptionValue('--experimental-default-type') === 'module' && getOptionValue('--input-type') !== 'commonjs')) {
evalModule(source, print);
evalModule(source, print, true);
} else {
// For backward compatibility, we want the identifier crypto to be the
// `node:crypto` module rather than WebCrypto.
Expand Down
5 changes: 4 additions & 1 deletion lib/internal/main/run_main_module.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ markBootstrapComplete();
// Necessary to reset RegExp statics before user code runs.
RegExpPrototypeExec(/^/, '');

const runMain = require('internal/modules/run_main');
runMain.userEntryPointIsCommandMain();

if (getOptionValue('--experimental-default-type') === 'module') {
require('internal/modules/run_main').executeUserEntryPoint(mainEntry);
runMain.executeUserEntryPoint(mainEntry);
} else {
/**
* To support legacy monkey-patching of `Module.runMain`, we call `runMain` here to have the CommonJS loader begin
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/main/worker_thread.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ port.on('message', (message) => {

case 'module': {
const { evalModule } = require('internal/process/execution');
PromisePrototypeThen(evalModule(filename), undefined, (e) => {
PromisePrototypeThen(evalModule(filename, false, false), undefined, (e) => {
workerOnGlobalUncaughtException(e, true);
});
break;
Expand Down
6 changes: 5 additions & 1 deletion lib/internal/modules/esm/initialize_import_meta.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,17 @@ function createImportMetaResolve(defaultParentURL, loader, allowParentURL) {
* Create the `import.meta` object for a module.
* @param {object} meta
* @param {{url: string}} context
* @param {typeof import('./loader.js').ModuleLoader} loader Reference to the current module loader
* @param {ReturnType<import('./loader.js').createModuleLoader>} loader Reference to the current module loader
* @returns {{dirname?: string, filename?: string, url: string, resolve?: Function}}
*/
function initializeImportMeta(meta, context, loader) {
const { url } = context;

// Alphabetical
if (loader && loader.resolvedCommandMain === url) {
meta.command = true;
}

if (StringPrototypeStartsWith(url, 'file:') === true) {
// These only make sense for locally loaded modules,
// i.e. network modules are not supported.
Expand Down
25 changes: 20 additions & 5 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const {
encodeURIComponent,
hardenRegExp,
} = primordials;

const assert = require('internal/assert');
const {
ERR_REQUIRE_ESM,
ERR_UNKNOWN_MODULE_FORMAT,
Expand Down Expand Up @@ -112,6 +112,12 @@ class ModuleLoader {
*/
allowImportMetaResolve;

/**
* @type {string | undefined} Resolved command main when the process
* top-level entry point.
*/
resolvedCommandMain;

/**
* Customizations to pass requests to.
*
Expand Down Expand Up @@ -187,10 +193,19 @@ class ModuleLoader {
}
}

async eval(
source,
url = pathToFileURL(`${process.cwd()}/[eval${++this.evalIndex}]`).href,
) {
/**
* @param {string} specifier
*/
setCommandMain(specifier) {
assert(this.resolvedCommandMain === undefined, 'only one command main permitted');
this.resolvedCommandMain = specifier;
}

generateEvalUrl() {
return pathToFileURL(`${process.cwd()}/[eval${++this.evalIndex}]`).href;
}

async eval(source, url = this.nextEvalUrl()) {
const evalInstance = (url) => {
const { ModuleWrap } = internalBinding('module_wrap');
const { registerModule } = require('internal/modules/esm/utils');
Expand Down
30 changes: 25 additions & 5 deletions lib/internal/modules/run_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,16 @@ function shouldUseESMLoader(mainPath) {
/**
* Run the main entry point through the ESM Loader.
* @param {string} mainPath - Absolute path for the main entry point
* @param {bool} isCommandMain - whether the main is also the process command main entry point
*/
function runMainESM(mainPath) {
function runMainESM(mainPath, isCommandMain) {
const { loadESM } = require('internal/process/esm_loader');
const { pathToFileURL } = require('internal/url');
const main = pathToFileURL(mainPath).href;

handleMainPromise(loadESM((esmLoader) => {
if (isCommandMain) {
esmLoader.setCommandMain(main);
}
return esmLoader.import(main, undefined, { __proto__: null });
}));
}
Expand Down Expand Up @@ -131,19 +134,36 @@ async function handleMainPromise(promise) {
* Because of backwards compatibility, this function is exposed publicly via `import { runMain } from 'node:module'`.
* @param {string} main - First positional CLI argument, such as `'entry.js'` from `node entry.js`
*/
let isCommandMain = false;
function executeUserEntryPoint(main = process.argv[1]) {
const resolvedMain = resolveMainPath(main);
const useESMLoader = shouldUseESMLoader(resolvedMain);
const mainPath = resolveMainPath(main);
const useESMLoader = shouldUseESMLoader(mainPath);
if (useESMLoader) {
runMainESM(resolvedMain || main);
runMainESM(mainPath || main, isCommandMain);
} else {
// Module._load is the monkey-patchable CJS module loader.
const { Module } = require('internal/modules/cjs/loader');
Module._load(main, null, true);
}
isCommandMain = false;
}

/*
* This is a special function that can be called before executeUserEntryPoint
* to note that the coming entry point call is a command main.
*
* This should really just be implemented as a parameter, but executeUserEntryPoint is
* exposed publicly as `runMain`, and we don't want to expose this functionality to userland
* as setting the command main is an internal-only capability.
*
* Since this ONLY applies to the ESM loader, future simplifications should remain possible here.
*/
function userEntryPointIsCommandMain() {
isCommandMain = true;
}

module.exports = {
executeUserEntryPoint,
userEntryPointIsCommandMain,
handleMainPromise,
};
7 changes: 7 additions & 0 deletions lib/internal/process/esm_loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ const {
} = require('internal/process/execution');
const { kEmptyObject, getCWDURL } = require('internal/util');

/** @typedef {ReturnType<createModuleLoader>} ModuleLoader */

/** @type {ModuleLoader} */
let esmLoader;

module.exports = {
get esmLoader() {
return esmLoader ??= createModuleLoader();
},
/**
* @param {(esmLoader: ModuleLoader) => Promise<void>} callback
* @returns {Promise<void>}
*/
async loadESM(callback) {
esmLoader ??= createModuleLoader();
try {
Expand Down
12 changes: 9 additions & 3 deletions lib/internal/process/execution.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,20 @@ function tryGetCwd() {
}
}

function evalModule(source, print) {
function evalModule(source, print, isCommandMain) {
if (print) {
throw new ERR_EVAL_ESM_CANNOT_PRINT();
}
const { loadESM } = require('internal/process/esm_loader');
const { handleMainPromise } = require('internal/modules/run_main');
RegExpPrototypeExec(/^/, ''); // Necessary to reset RegExp statics before user code runs.
return handleMainPromise(loadESM((loader) => loader.eval(source)));
return handleMainPromise(loadESM((loader) => {
const evalUrl = loader.generateEvalUrl();
if (isCommandMain) {
loader.setCommandMain(evalUrl);
}
return loader.eval(source, evalUrl);
}));
}

function evalScript(name, body, breakFirstLine, print, shouldLoadESM = false) {
Expand All @@ -75,7 +81,7 @@ function evalScript(name, body, breakFirstLine, print, shouldLoadESM = false) {
if (getOptionValue('--experimental-detect-module') &&
getOptionValue('--input-type') === '' && getOptionValue('--experimental-default-type') === '' &&
containsModuleSyntax(body, name)) {
return evalModule(body, print);
return evalModule(body, print, true);
}

const runScript = () => {
Expand Down
30 changes: 30 additions & 0 deletions test/es-module/test-esm-command.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { spawnPromisified } from '../common/index.mjs';
import { strictEqual } from 'node:assert';
import { fileURLToPath } from 'node:url';

{
const { code, stderr, stdout } = await spawnPromisified(process.execPath, [
'--input-type',
'module',
'-e',
'console.log(import.meta.command)',
]);

if (code !== 0) {
console.error(stderr.toString());
}

strictEqual(stdout.toString().trim(), 'true');
}

{
const { code, stderr, stdout } = await spawnPromisified(process.execPath, [
fileURLToPath(new URL('../fixtures/es-modules/command-main.mjs', import.meta.url)),
]);

if (code !== 0) {
console.error(stderr.toString());
}

strictEqual(stdout.toString().trim(), 'ok');
}
guybedford marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 3 additions & 0 deletions test/es-module/test-esm-import-meta.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import '../common/index.mjs';

Check failure on line 1 in test/es-module/test-esm-import-meta.mjs

View workflow job for this annotation

GitHub Actions / test-macOS

--- stderr --- node:internal/process/esm_loader:41 internalBinding('errors').triggerUncaughtException( ^ AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: + actual - expected ... Lines skipped [ + 'command', 'dirname', ... 'resolve', 'url' ] at file:///Users/runner/work/node/node/test/es-module/test-esm-import-meta.mjs:7:8 at ModuleJob.run (node:internal/modules/esm/module_job:218:25) at async ModuleLoader.import (node:internal/modules/esm/loader:338:24) at async loadESM (node:internal/process/esm_loader:35:7) at async handleMainPromise (node:internal/modules/run_main:123:12) { generatedMessage: true, code: 'ERR_ASSERTION', actual: [ 'command', 'dirname', 'filename', 'resolve', 'url' ], expected: [ 'dirname', 'filename', 'resolve', 'url' ], operator: 'deepStrictEqual' } Node.js v22.0.0-pre Command: out/Release/node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=./tools/github_reporter/index.js --test-reporter-destination=stdout /Users/runner/work/node/node/test/es-module/test-esm-import-meta.mjs

Check failure on line 1 in test/es-module/test-esm-import-meta.mjs

View workflow job for this annotation

GitHub Actions / test-asan

--- stderr --- node:internal/process/esm_loader:41 internalBinding('errors').triggerUncaughtException( ^ AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: + actual - expected ... Lines skipped [ + 'command', 'dirname', ... 'resolve', 'url' ] at file:///home/runner/work/node/node/test/es-module/test-esm-import-meta.mjs:7:8 at ModuleJob.run (node:internal/modules/esm/module_job:218:25) at async ModuleLoader.import (node:internal/modules/esm/loader:338:24) at async loadESM (node:internal/process/esm_loader:35:7) at async handleMainPromise (node:internal/modules/run_main:123:12) { generatedMessage: true, code: 'ERR_ASSERTION', actual: [ 'command', 'dirname', 'filename', 'resolve', 'url' ], expected: [ 'dirname', 'filename', 'resolve', 'url' ], operator: 'deepStrictEqual' } Node.js v22.0.0-pre Command: out/Release/node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=./tools/github_reporter/index.js --test-reporter-destination=stdout /home/runner/work/node/node/test/es-module/test-esm-import-meta.mjs

Check failure on line 1 in test/es-module/test-esm-import-meta.mjs

View workflow job for this annotation

GitHub Actions / test-linux

--- stderr --- node:internal/process/esm_loader:41 internalBinding('errors').triggerUncaughtException( ^ AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: + actual - expected ... Lines skipped [ + 'command', 'dirname', ... 'resolve', 'url' ] at file:///home/runner/work/node/node/test/es-module/test-esm-import-meta.mjs:7:8 at ModuleJob.run (node:internal/modules/esm/module_job:218:25) at async ModuleLoader.import (node:internal/modules/esm/loader:338:24) at async loadESM (node:internal/process/esm_loader:35:7) at async handleMainPromise (node:internal/modules/run_main:123:12) { generatedMessage: true, code: 'ERR_ASSERTION', actual: [ 'command', 'dirname', 'filename', 'resolve', 'url' ], expected: [ 'dirname', 'filename', 'resolve', 'url' ], operator: 'deepStrictEqual' } Node.js v22.0.0-pre Command: out/Release/node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=./tools/github_reporter/index.js --test-reporter-destination=stdout /home/runner/work/node/node/test/es-module/test-esm-import-meta.mjs
import assert from 'assert';

assert.strictEqual(Object.getPrototypeOf(import.meta), null);
Expand Down Expand Up @@ -32,3 +32,6 @@
// Verify that `data:` imports do not behave like `file:` imports.
import dataDirname from 'data:text/javascript,export default "dirname" in import.meta';
assert.strictEqual(dataDirname, false);

// Verify that command is never set (property only exists and is truthy for command main)
assert(!('command' in import.meta));
3 changes: 3 additions & 0 deletions test/fixtures/es-modules/command-main.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
if (import.meta.command) {
console.log('ok');
}
Loading