Skip to content

feat: add no-top-level-await rule #440

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

Open
wants to merge 4 commits into
base: master
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ For [Shareable Configs](https://eslint.org/docs/latest/developer-guide/shareable
| [no-restricted-import](docs/rules/no-restricted-import.md) | disallow specified modules when loaded by `import` declarations | | | |
| [no-restricted-require](docs/rules/no-restricted-require.md) | disallow specified modules when loaded by `require` | | | |
| [no-sync](docs/rules/no-sync.md) | disallow synchronous methods | | | |
| [no-top-level-await](docs/rules/no-top-level-await.md) | disallow top-level `await` in published modules | | | |
| [no-unpublished-bin](docs/rules/no-unpublished-bin.md) | disallow `bin` files that npm ignores | 🟢 ✅ | | |
| [no-unpublished-import](docs/rules/no-unpublished-import.md) | disallow `import` declarations which import private modules | 🟢 ✅ | | |
| [no-unpublished-require](docs/rules/no-unpublished-require.md) | disallow `require()` expressions which import private modules | 🟢 ✅ | | |
Expand Down
64 changes: 64 additions & 0 deletions docs/rules/no-top-level-await.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Disallow top-level `await` in published modules (`n/no-top-level-await`)

<!-- end auto-generated rule header -->

Node.js v20.19 introduced `require(esm)`, but ES modules with top-level `await` cannot be loaded with `require(esm)`. It is a good idea to disallow top-level `await` to ensure interoperability of modules published as Node.js libraries.

## 📖 Rule Details

If a source code file satisfies all of the following conditions, the file is \*published\*.

- `"files"` field of `package.json` includes the file or `"files"` field of `package.json` does not exist.
- `.npmignore` does not include the file.

Then this rule warns top-level `await` in \*published\* files.

Examples of 👎 **incorrect** code for this rule:

```js
/*eslint n/no-top-level-await: error*/

const foo = await import('foo');
for await (const e of asyncIterate()) {
// ...
}
```

### Options

```json
{
"rules": {
"n/no-top-level-await": ["error", {
"ignoreBin": false,
"convertPath": null
}]
}
}
```

#### ignoreBin

If `true`, this rule ignores top-level `await` in files that are specified in the `bin` field of `package.json` or in files that contain a `#!/usr/bin/env` in their source code.

Examples of 👍 **correct** code for the `"ignoreBin": true` option:

```js
#!/usr/bin/env node
/*eslint n/no-top-level-await: ["error", { "ignoreBin": true }]*/

const foo = await import('foo');
for await (const e of asyncIterate()) {
// ...
}
```

#### convertPath

This can be configured in the rule options or as a shared setting [`settings.convertPath`](../shared-settings.md#convertpath).
Please see the shared settings documentation for more information.

## 🔎 Implementation

- [Rule source](../../lib/rules/no-top-level-await.js)
- [Test source](../../tests/lib/rules/no-top-level-await.js)
1 change: 1 addition & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const base = {
"no-restricted-import": require("./rules/no-restricted-import"),
"no-restricted-require": require("./rules/no-restricted-require"),
"no-sync": require("./rules/no-sync"),
"no-top-level-await": require("./rules/no-top-level-await"),
"no-unpublished-bin": require("./rules/no-unpublished-bin"),
"no-unpublished-import": require("./rules/no-unpublished-import"),
"no-unpublished-require": require("./rules/no-unpublished-require"),
Expand Down
132 changes: 132 additions & 0 deletions lib/rules/no-top-level-await.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @author Yosuke Ota <https://github.com/ota-meshi>
* See LICENSE file in root directory for full license.
*/
"use strict"

const path = require("path")
const { getSourceCode } = require("../util/eslint-compat")
const getConvertPath = require("../util/get-convert-path")
const { getPackageJson } = require("../util/get-package-json")
const { isBinFile } = require("../util/is-bin-file")
const getNpmignore = require("../util/get-npmignore")

const HASHBANG_ENV = "#!/usr/bin/env"

/**
* @typedef {[
* {
* ignoreBin?:boolean
* convertPath?: import('../util/get-convert-path').ConvertPath;
* }?
* ]} RuleOptions
*/

/**
* Checks whether the code has a hashbang comment or not.
* @param {import('./rule-module').PluginRuleContext<{RuleOptions: RuleOptions}>} context
*/
function hasHashbang(context) {
const sourceCode = getSourceCode(context)
return Boolean(sourceCode.text.startsWith(HASHBANG_ENV))
}

/** @param {import('./rule-module').PluginRuleContext<{RuleOptions: RuleOptions}>} context */
function ignore(context) {
const options = context.options[0] || {}
const ignoreBin = options.ignoreBin ?? false
if (ignoreBin && hasHashbang(context)) {
// If the code has a hashbang comment, it is considered an executable file.
return true
}

const filePath = context.filename ?? context.getFilename()
if (filePath === "<input>") {
// The file path is "<input>" (not specified), so it will be ignored.
return true
}
const originalAbsolutePath = path.resolve(filePath)

// Find package.json
const packageJson = getPackageJson(originalAbsolutePath)
if (typeof packageJson?.filePath !== "string") {
// The file is not in a package, so it will be ignored.
return true
}

// Convert by convertPath option
const packageDirectory = path.dirname(packageJson.filePath)
const convertedRelativePath = getConvertPath(context)(
path
.relative(packageDirectory, originalAbsolutePath)
.replace(/\\/gu, "/")
)
const convertedAbsolutePath = path.resolve(
packageDirectory,
convertedRelativePath
)

if (
ignoreBin &&
isBinFile(convertedAbsolutePath, packageJson.bin, packageDirectory)
) {
// The file is defined in the `bin` field of `package.json`
return true
}

// Check ignored or not
const npmignore = getNpmignore(convertedAbsolutePath)
if (npmignore.match(convertedRelativePath)) {
// The file is unpublished file, so it will be ignored.
return true
}

return false
}

/** @type {import('./rule-module').RuleModule<{RuleOptions: RuleOptions}>} */
module.exports = {
meta: {
docs: {
description: "disallow top-level `await` in published modules",
recommended: false,
url: "https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/docs/rules/no-top-level-await.md",
},
fixable: null,
messages: {
forbidden: "Top-level `await` is forbidden in published modules.",
},
schema: [
{
type: "object",
properties: {
ignoreBin: { type: "boolean" },
convertPath: getConvertPath.schema,
},
additionalProperties: false,
},
],
type: "problem",
},
create(context) {
if (ignore(context)) {
return {}
}
let functionDepth = 0
return {
":function"() {
functionDepth++
},
":function:exit"() {
functionDepth--
},
"AwaitExpression, ForOfStatement[await=true]"(node) {
if (functionDepth > 0) {
// not top-level
return
}
context.report({ node, messageId: "forbidden" })
},
}
},
}
5 changes: 5 additions & 0 deletions tests/fixtures/no-top-level-await/simple-bin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "test",
"version": "1.0.0",
"bin": "a.js"
}
7 changes: 7 additions & 0 deletions tests/fixtures/no-top-level-await/simple-files/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "test",
"version": "1.0.0",
"files": [
"lib"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/src
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "test",
"version": "1.0.0"
}
Loading