Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
7 changes: 3 additions & 4 deletions src/cmap/auth/gssapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,7 @@ export class GSSAPI extends AuthProvider {
}

async function makeKerberosClient({
options: {
hostAddress,
runtime: { os }
},
options: { hostAddress, runtime },
credentials
}: AuthContext): Promise<KerberosClient> {
if (!hostAddress || typeof hostAddress.host !== 'string' || !credentials) {
Expand All @@ -81,6 +78,8 @@ async function makeKerberosClient({
);
}

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;


loadKrb();
if ('kModuleError' in krb) {
throw krb['kModuleError'];
Expand Down
2 changes: 1 addition & 1 deletion src/cmap/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export interface ConnectionOptions
/** @internal */
mongoLogger?: MongoLogger | undefined;
/** @internal */
runtime: Runtime;
runtime: Promise<Runtime>;
}

/** @public */
Expand Down
3 changes: 2 additions & 1 deletion src/cmap/handshake/client_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ type MakeClientMetadataOptions = Pick<MongoOptions, 'appName' | 'runtime'>;
*/
export async function makeClientMetadata(
driverInfoList: DriverInfo[],
{ appName = '', runtime: { os } }: MakeClientMetadataOptions
{ appName = '', runtime }: MakeClientMetadataOptions
): Promise<ClientMetadata> {
const { os } = await runtime;
const metadataDocument = new LimitedSizeDocument(512);

// Add app name first, it must be sent
Expand Down
2 changes: 1 addition & 1 deletion src/mongo_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,5 +1174,5 @@ export interface MongoOptions
__skipPingOnConnect?: boolean;

/** @internal */
runtime: Runtime;
runtime: Promise<Runtime>;
}
63 changes: 52 additions & 11 deletions src/runtime_adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export interface RuntimeAdapters {
/**
* @internal
*
* Represents a complete, parsed set of runtime adapters. After options parsing, all adapters
* Represents a complete, parsed set of runtime adapters. After resolution, all adapters
* are always present (either using the user's provided adapter, or defaulting to the Node.js module).
*/
export interface Runtime {
Expand All @@ -48,17 +48,58 @@ export interface Runtime {
* @internal
*
* Given a MongoClientOptions, this function resolves the set of runtime options, providing Nodejs implementations if
* not provided by in `options`, and returns a `Runtime`.
* not provided in `options`, and returns a `Runtime`.
*
* Resolution is asynchronous because the default adapters are loaded from Node.js built-ins via a
* dynamic `import()` (see `loadNodeOsAdapter`). The resulting promise is created during synchronous
* options parsing and awaited later by consumers, so the public constructor stays synchronous while
* the `Runtime` itself exposes fully-resolved, concrete adapters.
*/
export async function resolveRuntimeAdapters(options: MongoClientOptions): Promise<Runtime> {
return {
os: options.runtimeAdapters?.os ?? (await loadNodeOsAdapter())
};
}

/**
* @internal
*/
export function resolveRuntimeAdapters(options: MongoClientOptions): Runtime {
(globalThis as any)[ALLOWED_DRIVER_REQUIRE_PROPERTY_NAME] = true;
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.

// 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?

try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
os: options.runtimeAdapters?.os ?? require('os')
};
return runtime;
} finally {
(globalThis as any)[ALLOWED_DRIVER_REQUIRE_PROPERTY_NAME] = false;
const osModule = require('os') as typeof os;
return Promise.resolve(osModule);
} catch {
// If require fails, we fall back to dynamic import below.
// This can happen in ESM bundles where `require` may be available, but will always throw.
}
}
return dynamicImport<typeof os>('os');
}

/**
* @internal
*
* Dynamically imports a module at runtime in a way that survives bundling and TypeScript's
* downleveling. We deliberately avoid both `require(specifier)` and a static `await import(...)`:
* - a raw `require` throws in bundled ESM output, where there is no `require` in module scope
* (NODE-7603), and
* - a literal `import(...)` is downleveled back to `require(...)` by TypeScript under
* `module: commonjs`, which reintroduces the same problem in the published CommonJS build.
*
* Constructing the dynamic `import` through `new Function` hides it from both the TypeScript
* compiler and downstream bundlers, so it survives as a real runtime `import()` in every
* environment. Call this lazily (never at module load) so strict-CSP runtimes that forbid
* `new Function` are unaffected unless they actually need the default adapter.
*
* NODE-7133 (ESM-only packages) will eventually let us use `import(...)` directly and drop this.
*
* @param specifier - The module specifier to import.
* @returns A promise that resolves to the imported module.
*/
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.

}
9 changes: 8 additions & 1 deletion test/tools/runner/vm_context_helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,14 @@ export function loadContextifiedMongoDBModule(): typeof import('../../mongodb_al
// Wrap the bundle in a CommonJS-style wrapper
const wrapper = `(function(exports, module, require) {${bundleCode}})`;

const script = new vm.Script(wrapper, { filename: bundlePath });
// The driver loads Node built-ins (e.g. `os`) via a dynamic `import()` rather than `require`
// (NODE-7603). vm scripts have no dynamic-import callback by default, so route any `import()` in
// the sandbox through the main context's loader; otherwise it throws "A dynamic import callback
// was not specified".
const script = new vm.Script(wrapper, {
filename: bundlePath,
importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER
});
const fn = script.runInContext(sandbox);

// Execute the bundle with the restricted require from the sandbox
Expand Down
12 changes: 7 additions & 5 deletions test/tools/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as child_process from 'node:child_process';
import { on, once } from 'node:events';
import * as fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as os from 'node:os';
import * as path from 'node:path';

import * as BSON from 'bson';
Expand All @@ -20,7 +20,6 @@ import {
type MongoClientOptions,
OP_MSG,
processTimeMS,
resolveRuntimeAdapters,
runNodelessTests,
type Runtime,
type ServerApiVersion,
Expand Down Expand Up @@ -594,7 +593,8 @@ export function configureMongocryptdSpawnHooks(
options: { port?: string; pidfilepath?: string } = {}
): { port: string } {
const port = options.port ?? '27022';
const pidfilepath = options.pidfilepath ?? path.join(tmpdir(), new BSON.ObjectId().toHexString());
const pidfilepath =
options.pidfilepath ?? path.join(os.tmpdir(), new BSON.ObjectId().toHexString());

let childProcess: child_process.ChildProcess;

Expand All @@ -621,9 +621,11 @@ export function configureMongocryptdSpawnHooks(
}

/**
* A `Runtime` that resolves to entirely Nodejs modules, useful when tests must provide a default `runtime` object to an API.
* A resolved `Runtime` backed by Node's own modules, useful when tests must provide a default
* `runtime` to an API. The `os` adapter is the live `os` module (rather than the driver's lazily
* imported default) so tests can `sinon.stub(os, ...)` it.
*/
export const runtime: Runtime = resolveRuntimeAdapters({});
export const runtime: Promise<Runtime> = Promise.resolve({ os });

/**
* Metadata that can be used to skip tests in nodeless environments.
Expand Down
73 changes: 73 additions & 0 deletions test/unit/bundling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

import { expect } from 'chai';
import * as esbuild from 'esbuild';
import * as process from 'process';
import * as ts from 'typescript';

const repoRoot = path.resolve(__dirname, '..', '..');

describe('bundling the runtime adapters into ESM output', function () {
// Transpiling + bundling with esbuild and spawning a fresh node process can exceed the default timeout.
this.timeout(120_000);

let tmpDir: string;
let esmBundlePath: string;

before('compile and bundle resolveRuntimeAdapters into ESM', async function () {
// NODE-7603: resolveRuntimeAdapters used to call require('os'), which throws in bundled ESM
// output (no `require` in module scope). This reproduces the published artifact and a downstream
// ESM bundler: transpile the source the way the build does (module: commonjs) so any literal
// `import()` would be downleveled back to require(), then bundle that to ESM. The fix must
// therefore survive both steps and resolve `os` via a real runtime import().
const source = fs.readFileSync(path.join(repoRoot, 'src', 'runtime_adapters.ts'), 'utf8');
const { outputText: compiledCjs } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2023 }
});

tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mongodb-esm-bundle-'));
fs.writeFileSync(path.join(tmpDir, 'runtime_adapters.js'), compiledCjs);
esmBundlePath = path.join(tmpDir, 'app.mjs');

await esbuild.build({
stdin: {
// No user-provided os adapter, so resolveRuntimeAdapters falls back to loading Node's os.
contents: `
import { resolveRuntimeAdapters } from './runtime_adapters.js';
const runtime = await resolveRuntimeAdapters({});
const osAdapter = await runtime.os;
if (typeof osAdapter.platform !== 'function') {
throw new Error('resolved os adapter is missing platform()');
}
console.log('resolved os adapter');
`,
resolveDir: tmpDir,
loader: 'js'
},
bundle: true,
outfile: esmBundlePath,
platform: 'node',
format: 'esm',
target: 'node20',
logLevel: 'silent'
});
});

after(function () {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('resolves the default os adapter without a global require', function () {
const { status, stdout, stderr } = spawnSync(process.execPath, [esmBundlePath], {
encoding: 'utf8'
});

// Surface the child's stderr in the failure message so a regression is easy to diagnose.
expect(stderr, stderr).to.not.match(/require/i);
expect(stdout).to.include('resolved os adapter');
expect(status).to.equal(0);
});
});
15 changes: 11 additions & 4 deletions test/unit/runtime_adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ import { MongoClient, type OsAdapter } from '../../src';
describe('Runtime Adapters tests', function () {
describe('`os`', function () {
describe('when no os adapter is provided', function () {
it(`defaults to Node's os module`, function () {
it(`defaults to Node's os module, resolved asynchronously`, async function () {
const client = new MongoClient('mongodb://localhost:27017');

expect(client.options.runtime.os).to.equal(os);
// The runtime is resolved asynchronously because the default adapters are loaded from
// Node.js built-ins via a dynamic import (NODE-7603).
const { os: resolved } = await client.options.runtime;
expect(resolved.platform()).to.equal(os.platform());
expect(resolved.arch()).to.equal(os.arch());
expect(resolved.release()).to.equal(os.release());
expect(resolved.type()).to.equal(os.type());
});
});

describe('when an os adapter is provided', function () {
it(`uses the user provided adapter`, function () {
it(`uses the user provided adapter`, async function () {
const osAdapter: OsAdapter = {
...os
};
Expand All @@ -24,7 +30,8 @@ describe('Runtime Adapters tests', function () {
}
});

expect(client.options.runtime.os).to.equal(osAdapter);
const { os: resolved } = await client.options.runtime;
expect(resolved).to.equal(osAdapter);
});
});
});
Expand Down
Loading