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: utility method for detecting ES module syntax #27808

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
24 changes: 24 additions & 0 deletions doc/api/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,29 @@ by the [module wrapper][]. To access it, require the `Module` module:
const builtin = require('module').builtinModules;
```

### module.containsModuleSyntax(source)
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
<!-- YAML
added: REPLACEME
-->

* `source` {string} JavaScript source code
* Returns: {boolean}

Detect whether input JavaScript source code contains [ECMAScript Module][]
syntax, defined as `import` or `export` statements. Returns `true` as soon as
the first `import` or `export` statement is encountered, or `false` if none are
found. Note that dynamic `import()` is not an `import` statement.

```js
const { containsModuleSyntax } = require('module');

containsModuleSyntax('import { fn } from "pkg"'); // true
containsModuleSyntax('console.log(process.version)'); // false

containsModuleSyntax('import "./file.mjs"'); // true
containsModuleSyntax('import("./file.mjs")'); // false
```

### module.createRequire(filename)
<!-- YAML
added: v12.2.0
Expand Down Expand Up @@ -957,6 +980,7 @@ requireUtil('./some-tool');
[`createRequire()`]: #modules_module_createrequire_filename
[`module` object]: #modules_the_module_object
[`path.dirname()`]: path.html#path_path_dirname_path
[ECMAScript Module]: esm.html
[ECMAScript Modules]: esm.html
[an error]: errors.html#errors_err_require_esm
[exports shortcut]: #modules_exports_shortcut
Expand Down
40 changes: 40 additions & 0 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,46 @@ function createRequire(filename) {

Module.createRequire = createRequire;

Module.containsModuleSyntax = (source) => {
// Detect whether input source code contains at least one `import` or `export`
// statement. This can be used by dependent utilities as a way of detecting ES
// module source code from Script/CommonJS source code. Since our detection is
// so simple, we can avoid needing to use Acorn for a full parse; we can
// detect import or export statements just from the tokens. Also as of this
// writing, Acorn doesn't support import() expressions as they are only Stage
// 3; yet Node already supports them.
const acorn = require('internal/deps/acorn/acorn/dist/acorn');
source = stripBOM(source);
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
try {
let prevToken, prevPrevToken;
const acornOptions = { allowHashBang: true };
for (const { type: token } of acorn.tokenizer(source, acornOptions)) {
if (prevToken &&
// By definition import or export must be followed by another token.
(prevToken.keyword === 'import' || prevToken.keyword === 'export') &&
// Skip `import(`; look only for import statements, not expressions.
// import() expressions are allowed in both CommonJS and ES modules.
token.label !== '(' &&
// Also ensure that the keyword we just saw wasn't an allowed use
// of a reserved word as a property name; see
// test/fixtures/es-modules/detect/cjs-with-property-named-import.js.
!(prevPrevToken && prevPrevToken.label === '.') &&
token.label !== ':')
return true; // This source code contains ES module syntax.
prevPrevToken = prevToken;
prevToken = token;
}
} catch {
// If the tokenizer threw, there's a syntax error.
// Compile the script, this will throw with an informative error.
const vm = require('vm');
new vm.Script(source, { displayErrors: true });
}
// This source code does not contain ES module syntax.
// It may or may not be CommonJS, and it may or may not be valid syntax.
return false;
};

Module._initPaths = function() {
var homeDir;
var nodePath;
Expand Down
45 changes: 45 additions & 0 deletions test/es-module/test-esm-contains-module-syntax.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use strict';

require('../common');
const { strictEqual, fail } = require('assert');
const { readFileSync } = require('fs');

const { containsModuleSyntax } = require('module');

expect('esm-with-import-statement.js', 'module');
expect('esm-with-export-statement.js', 'module');
expect('esm-with-import-expression.js', 'module');
expect('esm-with-indented-import-statement.js', 'module');
expect('hashbang.js', 'module');

expect('cjs-with-require.js', 'commonjs');
expect('cjs-with-import-expression.js', 'commonjs');
expect('cjs-with-property-named-import.js', 'commonjs');
expect('cjs-with-property-named-export.js', 'commonjs');
expect('cjs-with-string-containing-import.js', 'commonjs');

expect('print-version.js', 'commonjs');
expect('ambiguous-with-import-expression.js', 'commonjs');

expect('syntax-error.js', 'Invalid or unexpected token', true);

function expect(file, want, wantsError = false) {
const source = readFileSync(
require.resolve(`../fixtures/es-modules/detect/${file}`),
'utf8');
let isModule;
try {
isModule = containsModuleSyntax(source);
} catch (err) {
if (wantsError) {
return strictEqual(err.message, want);
} else {
return fail(
`Expected ${file} to throw '${want}'; received '${err.message}'`);
}
}
if (wantsError)
return fail(`Expected ${file} to throw '${want}'; no error was thrown`);
else
return strictEqual((isModule ? 'module' : 'commonjs'), want);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
(async () => {
await import('./print-version.js');
})();
5 changes: 5 additions & 0 deletions test/fixtures/es-modules/detect/cjs-with-import-expression.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const { version } = require('process');

(async () => {
await import('./print-version.js');
})();
11 changes: 11 additions & 0 deletions test/fixtures/es-modules/detect/cjs-with-property-named-export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// See ./cjs-with-property-named-import.js

global.export = 3;

global['export'] = 3;

const obj = {
export: 3 // Specifically at column 0, to try to trick the detector
}

console.log(require('process').version);
14 changes: 14 additions & 0 deletions test/fixtures/es-modules/detect/cjs-with-property-named-import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// In JavaScript, reserved words cannot be identifiers (the `foo` in `var foo`)
// but they can be properties (`obj.foo`). This file checks that the `import`
// reserved word isn't incorrectly detected as a keyword. For more info see:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#Reserved_word_usage

global.import = 3;

global['import'] = 3;

const obj = {
import: 3 // Specifically at column 0, to try to trick the detector
}

console.log(require('process').version);
3 changes: 3 additions & 0 deletions test/fixtures/es-modules/detect/cjs-with-require.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { version } = require('process');

console.log(version);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const { version } = require('process');

const sneakyString = `
import { version } from 'process';
`;

console.log(version);
6 changes: 6 additions & 0 deletions test/fixtures/es-modules/detect/esm-with-export-statement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const version = process.version;

export default version;

console.log(version);

5 changes: 5 additions & 0 deletions test/fixtures/es-modules/detect/esm-with-import-expression.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { version } from 'process';

(async () => {
await import('./print-version.js');
})();
2 changes: 2 additions & 0 deletions test/fixtures/es-modules/detect/esm-with-import-statement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { version } from 'process';
console.log(version);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { version } from 'process';
console.log(version);
3 changes: 3 additions & 0 deletions test/fixtures/es-modules/detect/hashbang.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env node
import { version } from 'process';
console.log(version);
1 change: 1 addition & 0 deletions test/fixtures/es-modules/detect/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions test/fixtures/es-modules/detect/print-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log(process.version);
2 changes: 2 additions & 0 deletions test/fixtures/es-modules/detect/syntax-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const str = 'import
var foo = 3;