Skip to content

fix(NODE-7603): load os runtime adapter via dynamic import for ESM bundle safety#4979

Open
PavelSafronov wants to merge 7 commits into
mainfrom
node-7603-esm-fix
Open

fix(NODE-7603): load os runtime adapter via dynamic import for ESM bundle safety#4979
PavelSafronov wants to merge 7 commits into
mainfrom
node-7603-esm-fix

Conversation

@PavelSafronov

Copy link
Copy Markdown
Contributor

Description

Summary of Changes

Load os runtime adapter via dynamic import, so ESM bundle works again.

Release Highlight

Bundling the driver into ESM no longer throws ReferenceError: require is not defined

v7.2.0 introduced the experimental runtimeAdapters option and, as part of it, replaced the driver’s static import of Node’s os module with a runtime require('os'). That works in a CommonJS build, but when the driver is bundled into ESM output (e.g. a Vite/esbuild/rollup server build with "type": "module"), there is no require in module scope, so constructing a client threw ReferenceError: require is not defined. The driver now loads the default os adapter through a dynamic import() that survives bundling, so new MongoClient() works in ESM bundles. CommonJS usage is unchanged, and supplying your own runtimeAdapters.os continues to work.

Double check the following

  • Lint is passing (npm run check:lint)
  • Self-review completed using the steps outlined here
  • PR title follows the correct format: type(NODE-xxxx)[!]: description
    • Example: feat(NODE-1234)!: rewriting everything in coffeescript
  • Changes are covered by tests
  • New TODOs have a related JIRA ticket

PavelSafronov and others added 2 commits June 26, 2026 14:58
…ndle safety

resolveRuntimeAdapters no longer calls require('os'), which throws in bundled
ESM output (no `require` in module scope). It resolves the default os adapter
via a dynamic import() constructed with `new Function`, so it survives
TypeScript's module:commonjs downleveling and downstream bundlers. Resolution
is now async: resolveRuntimeAdapters returns Promise<Runtime> with concrete
adapters, and the two consumers (makeClientMetadata, makeKerberosClient) await
it. The CJS-bundled test sandbox (vm) is allowed to use dynamic import so it
keeps working with the new import().

Adds a focused ESM bundling smoke test: bundle resolveRuntimeAdapters to ESM
and run it in Node with no global require.
@PavelSafronov PavelSafronov marked this pull request as ready for review June 29, 2026 14:22
@PavelSafronov PavelSafronov requested a review from a team as a code owner June 29, 2026 14:22
Copilot AI review requested due to automatic review settings June 29, 2026 14:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes ESM bundling regressions introduced by the runtimeAdapters work by replacing the driver’s require('os') fallback with a dynamic import() approach that survives TypeScript CJS downleveling and downstream bundlers.

Changes:

  • Make runtime adapter resolution asynchronous (resolveRuntimeAdapters(): Promise<Runtime>) and thread Promise<Runtime> through internal options.
  • Load the default Node os adapter via a bundler-resistant dynamic import helper.
  • Update/expand unit tests to validate async runtime resolution and add an esbuild-based ESM bundling regression test; update VM test harness to support dynamic import.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/runtime_adapters.ts Switches default os adapter loading to a dynamic import helper; makes adapter resolution async.
src/mongo_client.ts Updates internal MongoOptions.runtime typing to Promise<Runtime>.
src/cmap/connection.ts Updates ConnectionOptions.runtime typing to Promise<Runtime>.
src/cmap/handshake/client_metadata.ts Awaits runtime before reading os for client metadata construction.
src/cmap/auth/gssapi.ts Awaits runtime before using os in Kerberos SPN construction.
test/unit/runtime_adapters.test.ts Adjusts tests to await async runtime resolution and validate adapter behavior.
test/unit/bundling.test.ts Adds regression test that bundles to ESM and verifies no require-scope failure.
test/tools/utils.ts Updates shared test runtime helper to a Promise<Runtime> backed by live Node os.
test/tools/runner/vm_context_helper.ts Configures VM scripts to support dynamic import() used by the driver bundle.

Comment thread src/runtime_adapters.ts Outdated
}
function dynamicImport<T>(specifier: string): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
return new Function('specifier', 'return import(specifier)')(specifier);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This breaks things like bundler package aliasing (which I know the whole runtime adapter options work is intended to make unnecessary) and Node.js's --disallow-code-generation-from-strings. We're good with that?

I know it's annoying but trying require('os') first (if require exists and is a function) and then falling back to dynamic import is also an option.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nope, definitely not good with breaking bundler package aliasing or --disallow-code-generation-from-strings. Updating to try require('os') first, which should succeed when the environment has a working require (Node, CJS bundling, native ESM). Then if that fails, falling back to eval.

@tadjik1 tadjik1 Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What do you think about handwriting a bit of .js to make it "survives bundling and TypeScript's" - basically take this code out of TS compiler? As simple as

'use strict';
exports.importOs = () => import('node:os');

(and separate .d.ts file alongside to cover TS types)

It's probably my personal preference, but new Function to me looks a bit like eval from old days, and having it as part of production code is something I would prefer to avoid. No strong opinion though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@tadjik1 , new Function is definitely eval in a trenchcoat, good instinct.

However, the proposed .js file won't survive our build as-is. Our tsconfig.json has allowJs: true, so a hand-written .js dropped into /src will still be compiled by tsc, downleveling the import() right back to the require() we're trying to avoid. We'd have to modify our build process to drop this .js file into the /lib folder after tsc is done. Doable, if that's what we prefer.

What do you think?

@tadjik1 tadjik1 Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, that's what I meant: make it pure import('os'), without new Function. How much we would need to modify build process for this?
In general, I would prefer having slightly extended config. @johnmtll @seanrmilligan @nbbeeken what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

How much we would need to modify build process for this?

Pretty straightforward, actually. We just have to add the shim with import() calls and make sure it doesn't get touched by tsc. Changes added to the PR.

@tadjik1 tadjik1 self-assigned this Jun 30, 2026
@tadjik1 tadjik1 added the Primary Review In Review with primary reviewer, not yet ready for team's eyes label Jun 30, 2026
johnmtll
johnmtll previously approved these changes Jun 30, 2026
Comment thread src/runtime_adapters.ts
try {
const runtime = {
function loadNodeOsAdapter(): Promise<OsAdapter> {
if (typeof require === 'function') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pattern signals a CJS build, can we put a comment here explaining that? It's clear with the context of the PR, but may not be clear during future maintenance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Technically, this can happen in any environment that has a require function, like plain Node, CJS bundling, native ESM. And in some cases (esbuild ESM bundle) the function may be defined, but will always throw, so that's why we have the try/catch around it. Will add this info to the comments.

Comment thread src/cmap/auth/gssapi.ts
);
}

const { os } = await runtime;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you think it makes sense to rename "runtime" into something like "resolveRuntime" (if possible)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

runtimePromise?

We can, but I don't think we should, we already use a similar pattern in other spots, like in https://github.com/mongodb/node-mongodb-native/blob/main/src/mongo_client.ts#L597-L604

  async connect(): Promise<this> {
    if (this.connectionLock) {
      return await this.connectionLock;
    }

    try {
      this.connectionLock = this._connect();
      await this.connectionLock;

Comment thread shims/runtime_import.js Outdated
Comment thread src/runtime_adapters.ts
*/
function loadNodeOsAdapter(): Promise<OsAdapter> {
if (typeof require === 'function') {
// Some environments (plain Node, CJS bundling, native ESM), have a `require` function available, we try that first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we try that first

why can't we just do import?

Comment thread shims/runtime_import.js
Object.defineProperty(exports, '__esModule', { value: true });

exports.importOs = function importOs() {
return import('os');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this should be marked as handled 🤔

@nbbeeken nbbeeken left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Generally speaking I think the approach to pluggable modules would be made a lot easier to maintain through a build tool then via code that handles all the various use cases / platforms / bundling configurations. Emitting the syntax we need here will make it difficult to get wrong, whereas writing javascript and embedding things that build tools normally emit like the __esModule property seems brittle in comparison.

Loosely held opinion! Just offering that esbuild-ing the source will empower a lot more flexibility in the format of the code without having to carefully document exact syntax (like a comment that says "this line must literally appear like this for static import analysis" but isn't actually tested

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Primary Review In Review with primary reviewer, not yet ready for team's eyes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants