Skip to content

fix: run on Node 20 — load ESM-only prompts via dynamic import - #28

Open
karaposu wants to merge 1 commit into
brightdata:mainfrom
karaposu:fix/node20-esm-crash
Open

fix: run on Node 20 — load ESM-only prompts via dynamic import#28
karaposu wants to merge 1 commit into
brightdata:mainfrom
karaposu:fix/node20-esm-crash

Conversation

@karaposu

Copy link
Copy Markdown

The bug

package.json declares engines: { "node": ">=20.0.0" }, but the published CLI crashes on startup for a large part of that range.

@inquirer/prompts is ESM-only and is imported statically in src/commands/init.ts and src/commands/add-mcp.ts. Under module: commonjs, tsc compiles those imports to require(), which throws ERR_REQUIRE_ESM on any Node released before the require(ESM) backport (< 20.19, and < 22.12). Because src/index.ts imports both command modules at startup, every invocation fails — including brightdata --version.

Reproduced against current main (v0.3.5) on Node 20.17.0:

$ node dist/index.js --version
dist/commands/init.js:5
const prompts_1 = require("@inquirer/prompts");
                  ^
Error [ERR_REQUIRE_ESM]: require() of ES Module .../@inquirer/prompts/dist/index.js
from .../dist/commands/init.js not supported.
  code: 'ERR_REQUIRE_ESM'

With this PR, same runtime:

$ node dist/index.js --version
0.3.5
$ node dist/index.js pipelines list
amazon_product
amazon_product_reviews
...

The fix

  • src/utils/load-prompts.ts — loads @inquirer/prompts lazily through new Function('specifier', 'return import(specifier);'), which hides the import() from tsc so it is emitted as a genuine dynamic import instead of being down-compiled back to require(). This is the same technique already used by load_open() in src/utils/browser_auth.ts, so the pattern is not new to this codebase. The module promise is cached, so prompts load at most once.
  • init.ts / add-mcp.ts — prompts are destructured inside the handlers that use them. Side benefit: non-interactive commands no longer pay for loading the inquirer graph at startup.
  • src/utils/node-version.ts — a floor-20 guard called first in main(), so anything below the supported floor gets an actionable message instead of a stack trace. The floor is the dependency minimum (commander's own engines), not the require(ESM) boundary — the loader above removes that boundary entirely, which is what keeps Node 20 supported rather than dropping it.

CI

The repo currently has no CI workflow (only release.yml), so type-check and tests have never run on a PR. This adds one, with two deliberate choices:

  • Matrix pinned to 20.17.0 and 24. Using node-version: 20 would install 20.19+, where require(ESM) is legal — it would have passed on the broken code. 20.17.0 is a runtime the declared >=20.0.0 still promises to support, so it is the version that actually guards this.
  • A build-and-run smoke step. Vitest cannot execute the new Function dynamic import, so unit tests structurally cannot cover the loader. Running the built CLI is what catches an ERR_REQUIRE_ESM regression.

Happy to split the CI into a separate PR if you'd prefer this one minimal.

Testing

  • Full suite green: 373 passed (369 existing + 4 for the version guard).
  • tsc --noEmit clean.
  • Verified by hand on Node 20.17.0 (fails before, works after) and Node 24.16.0.

Note on prior work

This is a re-application of #14, which was merged into dev on 2026-06-15 but never reached maindev has had no commits since that merge, while main has moved 18 commits ahead, so the fix has been absent from every release since. This PR targets main directly and is rebased on current main, resolving the overlap with the resolve_key change in add-mcp.ts.

I have a follow-up (#16, currently open against dev) adding a generic datasets trigger command — happy to retarget that to main too if dev is no longer the integration branch.

The CLI declares `engines: node >=20.0.0`, but `@inquirer/prompts` is
ESM-only and was imported statically in init.ts and add-mcp.ts. Under
`module: commonjs` tsc compiles those to require(), which throws
ERR_REQUIRE_ESM on any Node before the require(ESM) backport (< 20.19 /
< 22.12). Since index.ts imports both command modules at startup, every
invocation crashes — including `brightdata --version`.

Reproduced on Node 20.17.0 against v0.3.5: 'Error [ERR_REQUIRE_ESM]:
require() of ES Module ... @inquirer/prompts ... not supported'.

- Load @inquirer/prompts lazily through utils/load-prompts.ts, which hides
  the import() behind new Function() so tsc cannot down-compile it back to
  require(). Same technique already used by load_open() in browser_auth.ts.
- Prompts are now loaded inside the handlers that use them, so
  non-interactive commands no longer pay for the ESM graph at startup.
- Add utils/node-version.ts: a floor-20 guard that prints an actionable
  message instead of a stack trace on older runtimes.
- Add CI (the repo had none): type-check + tests on a Node 20.17.0 / 24
  matrix, plus a build-and-run smoke step. 20.17.0 is pinned because it
  predates the require(ESM) backport — testing only the latest 20.x would
  pass code that still crashes for users on the declared floor. The smoke
  step exists because Vitest cannot execute the dynamic-import loader, so
  unit tests alone cannot catch this class of regression.
- Migrate the add-mcp test mock from @inquirer/prompts to load-prompts.

Full suite green (373); verified on real Node 20.17.0 and Node 24.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant