Skip to content
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ lib/
# type definition tests
!test/types
!global.d.ts
# hand-written type declaration for the runtime_import.js CommonJS shim (NODE-7603); must be
# tracked because it is authored, not generated by tsc like every other *.d.ts.
!shims/runtime_import.d.ts

.vscode
output
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"files": [
"lib",
"src",
"shims",
"etc/prepare.js",
"mongodb.d.ts",
"tsconfig.json"
Expand Down
10 changes: 10 additions & 0 deletions shims/runtime_import.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @internal
*
* Type declarations for the hand-written CommonJS shim in `runtime_import.js`. That file lives
* outside `src/` so the TypeScript build never compiles it, which would downlevel its dynamic
* `import()` to `require()`.
*
* @returns A promise that resolves to the `os` module namespace.
*/
export declare function importOs(): Promise<typeof import('os')>;
26 changes: 26 additions & 0 deletions shims/runtime_import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// This file is hand-written CommonJS. It lives OUTSIDE `src/` on purpose: the TypeScript build
// only compiles `src/**/*` (see tsconfig.json "include"), so tsc never touches this file. If it
// did, it would downlevel `import('os')` into `Promise.resolve().then(() => require('os'))`
// under `module: commonjs`.
//
// Keeping the dynamic import in a file the compiler never sees preserves it as a genuine runtime
// `import()`, which:
// - survives TypeScript downleveling (the file is not compiled), and
// - survives downstream bundlers as a real dynamic import, unlike a
// `new Function('return import(...)')` trick.
//
// The specifier is deliberately a literal, not a parameter: a literal `import('os')` remains
// statically analyzable, so bundlers can see, resolve, and alias the target (NODE-3199), whereas
// `import(someVariable)` is opaque to static analysis.
//
// It ships as-is via the package.json "files" array and is required by the compiled
// `lib/runtime_adapters.js` as `../shims/runtime_import`.
//
// NODE-7133 (ESM-only packages) will eventually let us use `import(...)` directly from TypeScript
// source and delete this shim.

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 🤔

};
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>;
}
43 changes: 32 additions & 11 deletions src/runtime_adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type * as os from 'os';

import { importOs } from '../shims/runtime_import';
import { type MongoClientOptions } from './mongo_client';

/**
Expand Down Expand Up @@ -37,7 +38,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 +49,37 @@ 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 function resolveRuntimeAdapters(options: MongoClientOptions): Runtime {
(globalThis as any)[ALLOWED_DRIVER_REQUIRE_PROPERTY_NAME] = true;
try {
const runtime = {
export async function resolveRuntimeAdapters(options: MongoClientOptions): Promise<Runtime> {
return {
os: options.runtimeAdapters?.os ?? (await loadNodeOsAdapter())
};
}

/**
* @internal
*/
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.
}
}

// Fall back to a genuine dynamic `import()`. This lives in the hand-written CommonJS shim
// `../shims/runtime_import`, which is kept out of the TypeScript build so the `import()` is not
// downleveled to `require()`.
return importOs();
}
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
84 changes: 84 additions & 0 deletions test/unit/bundling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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 }
});

// Mirror the published package layout: the compiled module lives in lib/ and requires the
// hand-written shim as `../shims/runtime_import`, so reproduce those sibling directories.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mongodb-esm-bundle-'));
fs.mkdirSync(path.join(tmpDir, 'lib'));
fs.mkdirSync(path.join(tmpDir, 'shims'));
fs.writeFileSync(path.join(tmpDir, 'lib', 'runtime_adapters.js'), compiledCjs);
// runtime_import.js is the hand-written CommonJS shim that resolveRuntimeAdapters imports for
// its dynamic import() fallback. It is deliberately NOT compiled (that is the whole point of
// NODE-7603), so copy it verbatim into the sibling shims/ dir, exactly as it ships.
fs.copyFileSync(
path.join(repoRoot, 'shims', 'runtime_import.js'),
path.join(tmpDir, 'shims', 'runtime_import.js')
);
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 './lib/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