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

module: resolve format for all situations with auto module detection on #53044

Closed
Closed
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
21 changes: 13 additions & 8 deletions lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ function underNodeModules(url) {
return StringPrototypeIncludes(url.pathname, '/node_modules/');
}

/**
* Determine whether the given source contains CJS or ESM module syntax.
* @param {string | Buffer | undefined} source
* @param {URL} url
*/
function detectModuleFormat(source, url) {
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
const moduleSource = source ? `${source}` : undefined;
const modulePath = fileURLToPath(url);
return containsModuleSyntax(moduleSource, modulePath, url) ? 'module' : 'commonjs';
}

let typelessPackageJsonFilesWarnedAbout;
/**
* @param {URL} url
Expand Down Expand Up @@ -113,9 +124,7 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE
// `source` is undefined when this is called from `defaultResolve`;
// but this gets called again from `defaultLoad`/`defaultLoadSync`.
if (getOptionValue('--experimental-detect-module')) {
const format = source ?
(containsModuleSyntax(`${source}`, fileURLToPath(url), url) ? 'module' : 'commonjs') :
null;
const format = detectModuleFormat(source, url);
if (format === 'module') {
// This module has a .js extension, a package.json with no `type` field, and ESM syntax.
// Warn about the missing `type` field so that the user can avoid the performance penalty of detection.
Expand Down Expand Up @@ -155,12 +164,8 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE
}
default: { // The user did not pass `--experimental-default-type`.
if (getOptionValue('--experimental-detect-module')) {
if (!source) { return null; }
const format = getFormatOfExtensionlessFile(url);
if (format === 'module') {
return containsModuleSyntax(`${source}`, fileURLToPath(url), url) ? 'module' : 'commonjs';
}
return format;
return (format === 'module') ? detectModuleFormat(source, url) : format;
}
return 'commonjs';
}
Expand Down
36 changes: 31 additions & 5 deletions src/node_contextify.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1705,14 +1705,33 @@ static void ContainsModuleSyntax(const FunctionCallbackInfo<Value>& args) {

CHECK_GE(args.Length(), 2);

// Argument 1: source code
CHECK(args[0]->IsString());
Local<String> code = args[0].As<String>();

// Argument 2: filename
CHECK(args[1]->IsString());
Local<String> filename = args[1].As<String>();

// Argument 1: source code; if undefined, read from filename in argument 2
Local<String> code;
if (args[0]->IsUndefined()) {
CHECK(!filename.IsEmpty());
Utf8Value utf8Value(isolate, filename);
const char* filename_str = utf8Value.out();
std::string contents;
int result = ReadFileSync(&contents, filename_str);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading from disk from the native layer here doesn’t seem right - it’s not guaranteed that this is on disk and at a location pointed to by filename (the filename can be some artificial ID). The JS land should be refactored to only call this after source code is confirmed instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comes as part of the trials to cover the module type resolution for the resolve. I would expect if any of that happens (correct me if I'm wrong) that ReadFileSync will return != 0 here and this would cause the early exit with an exception.

Additionally we call this from managed code here. I am guessing that at this stage the fileUrl was already validated but that is at this stage just an assumption. This test made me think that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception would be surprising for a hook - resolve shouldn't be poking into the file system, that should be load's job.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is changed now, the comment above is obsolete. The function would return undefined on file access errors. But I am still looking for a way to remove this file access without losing the test.

if (result != 0) {
// error reading file and no source available => undefined
args.GetReturnValue().SetUndefined();
return;
}
code = String::NewFromUtf8(isolate,
contents.c_str(),
v8::NewStringType::kNormal,
contents.length())
.ToLocalChecked();
} else {
CHECK(args[0]->IsString());
code = args[0].As<String>();
}

// Argument 3: resource name (URL for ES module).
Local<String> resource_name = filename;
if (args[2]->IsString()) {
Expand All @@ -1729,6 +1748,7 @@ static void ContainsModuleSyntax(const FunctionCallbackInfo<Value>& args) {
Local<Function> fn;
TryCatchScope try_catch(env);
ShouldNotAbortOnUncaughtScope no_abort_scope(env);

if (CompileFunctionForCJSLoader(
env, context, code, filename, &cache_rejected, cjs_var)
.ToLocal(&fn)) {
Expand All @@ -1740,7 +1760,13 @@ static void ContainsModuleSyntax(const FunctionCallbackInfo<Value>& args) {
}

bool result = ShouldRetryAsESM(realm, message, code, resource_name);
args.GetReturnValue().Set(result);
if (result) {
// successfully parsed as ESM after failing to parse as CJS => ESM syntax
args.GetReturnValue().Set(result);
return;
}

args.GetReturnValue().SetUndefined();
}

static void StartSigintWatchdog(const FunctionCallbackInfo<Value>& args) {
Expand Down
63 changes: 61 additions & 2 deletions test/es-module/test-esm-detect-ambiguous.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ describe('--experimental-detect-module', { concurrency: !process.env.TEST_PARALL
});
}

it('should not hint wrong format in resolve hook', async () => {
it('should hint format correctly for the resolve hook for extensionless modules', async () => {
let writeSync;
const { stdout, stderr, code, signal } = await spawnPromisified(process.execPath, [
'--experimental-detect-module',
Expand All @@ -185,7 +185,7 @@ describe('--experimental-detect-module', { concurrency: !process.env.TEST_PARALL
]);

strictEqual(stderr, '');
strictEqual(stdout, 'null\nexecuted\n');
strictEqual(stdout, 'module\nexecuted\n');
strictEqual(code, 0);
strictEqual(signal, null);

Expand Down Expand Up @@ -385,6 +385,65 @@ describe('--experimental-detect-module', { concurrency: !process.env.TEST_PARALL
strictEqual(signal, null);
});
});

describe('should work with module customization hooks', { concurrency: true }, () => {
it('should not break basic hooks functionality of substituting a module', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
'--experimental-detect-module',
'--import',
fixtures.fileURL('es-module-loaders/builtin-named-exports.mjs'),
fixtures.path('es-modules/require-esm-throws-with-loaders.js'),
]);

strictEqual(stderr, '');
strictEqual(stdout, '');
strictEqual(code, 0);
strictEqual(signal, null);
});

it('should detect the syntax of the source as returned by a custom load hook', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
'--no-warnings',
'--experimental-detect-module',
'--import',
`data:text/javascript,${encodeURIComponent(
'import { register } from "node:module";' +
'import { pathToFileURL } from "node:url";' +
'register("./transpile-esm-to-cjs.mjs", pathToFileURL("./"));'
)}`,
fixtures.path('es-modules/package-without-type/module.js'),
], { cwd: fixtures.fileURL('es-module-loaders/') });

strictEqual(stderr, '');
strictEqual(stdout, `
Resolved format: module
Loaded original format: module
executed
Evaluated format: commonjs
`.replace(/^\s+/gm, ''));
strictEqual(code, 0);
strictEqual(signal, null);
});

it('should throw the usual error for a missing file', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
'--no-warnings',
'--experimental-detect-module',
'--import',
`data:text/javascript,${encodeURIComponent(
'import { register } from "node:module";' +
'import { pathToFileURL } from "node:url";' +
'register("./transpile-esm-to-cjs.mjs", pathToFileURL("./"));'
)}`,
fixtures.path('es-modules/package-without-type/imports-nonexistent.js'),
], { cwd: fixtures.fileURL('es-module-loaders/') });

match(stderr, /ERR_MODULE_NOT_FOUND.+does-not-exist\.js/);
strictEqual(stdout, 'Resolved format: module\nLoaded original format: module\n');
strictEqual(code, 1);
strictEqual(signal, null);
});
});
});

// Validate temporarily disabling `--abort-on-uncaught-exception`
Expand Down
13 changes: 13 additions & 0 deletions test/es-module/test-esm-loader-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,19 @@ describe('Loader hooks', { concurrency: !process.env.TEST_PARALLEL }, () => {
assert.strictEqual(signal, null);
});

describe('should use hooks', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
'--import',
fixtures.fileURL('es-module-loaders/builtin-named-exports.mjs'),
fixtures.path('es-modules/require-esm-throws-with-loaders.js'),
]);

assert.strictEqual(stderr, '');
assert.strictEqual(stdout, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
});

it('should support source maps in commonjs translator', async () => {
const readFile = async () => {};
const hook = `
Expand Down
11 changes: 0 additions & 11 deletions test/es-module/test-esm-named-exports.js

This file was deleted.

28 changes: 28 additions & 0 deletions test/fixtures/es-module-loaders/transpile-esm-to-cjs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { writeSync } from "node:fs";

export async function resolve(specifier, context, next) {
const result = await next(specifier, context);
if (specifier.startsWith("file://")) {
writeSync(1, `Resolved format: ${result.format}\n`);
}
return result;
}

export async function load(url, context, next) {
const output = await next(url, context);
writeSync(1, `Loaded original format: ${output.format}\n`);

let source = `${output.source}`

// This is a very incomplete and naively done implementation for testing purposes only
if (source?.includes('export default')) {
source = source.replace('export default', 'module.exports =');

source += '\nconsole.log(`Evaluated format: ${this === undefined ? "module" : "commonjs"}`);';

output.source = source;
output.format = 'commonjs';
}

return output;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "./does-not-exist.js";
8 changes: 8 additions & 0 deletions test/fixtures/es-modules/require-esm-throws-with-loaders.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use strict';
const { readFile, __fromLoader } = require('fs');
const assert = require('assert');

assert.throws(() => require('./test-esm-ok.mjs'), { code: 'ERR_REQUIRE_ESM' });

assert(readFile);
assert(__fromLoader);
Loading