diff --git a/.gitignore b/.gitignore index ff5518925b1..283708c7a42 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/package.json b/package.json index 4da3355ea7f..8e55705dad2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "files": [ "lib", "src", + "shims", "etc/prepare.js", "mongodb.d.ts", "tsconfig.json" diff --git a/shims/runtime_import.d.ts b/shims/runtime_import.d.ts new file mode 100644 index 00000000000..5d8b769fb7c --- /dev/null +++ b/shims/runtime_import.d.ts @@ -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; diff --git a/shims/runtime_import.js b/shims/runtime_import.js new file mode 100644 index 00000000000..aa5575624e9 --- /dev/null +++ b/shims/runtime_import.js @@ -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'); +}; diff --git a/src/cmap/auth/gssapi.ts b/src/cmap/auth/gssapi.ts index 12595242f38..99892a337d7 100644 --- a/src/cmap/auth/gssapi.ts +++ b/src/cmap/auth/gssapi.ts @@ -69,10 +69,7 @@ export class GSSAPI extends AuthProvider { } async function makeKerberosClient({ - options: { - hostAddress, - runtime: { os } - }, + options: { hostAddress, runtime }, credentials }: AuthContext): Promise { if (!hostAddress || typeof hostAddress.host !== 'string' || !credentials) { @@ -81,6 +78,8 @@ async function makeKerberosClient({ ); } + const { os } = await runtime; + loadKrb(); if ('kModuleError' in krb) { throw krb['kModuleError']; diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 0c099b8bd8c..f8cc3b2d95e 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -146,7 +146,7 @@ export interface ConnectionOptions /** @internal */ mongoLogger?: MongoLogger | undefined; /** @internal */ - runtime: Runtime; + runtime: Promise; } /** @public */ diff --git a/src/cmap/handshake/client_metadata.ts b/src/cmap/handshake/client_metadata.ts index 00780fbe0ff..587d3420b93 100644 --- a/src/cmap/handshake/client_metadata.ts +++ b/src/cmap/handshake/client_metadata.ts @@ -107,8 +107,9 @@ type MakeClientMetadataOptions = Pick; */ export async function makeClientMetadata( driverInfoList: DriverInfo[], - { appName = '', runtime: { os } }: MakeClientMetadataOptions + { appName = '', runtime }: MakeClientMetadataOptions ): Promise { + const { os } = await runtime; const metadataDocument = new LimitedSizeDocument(512); // Add app name first, it must be sent diff --git a/src/mongo_client.ts b/src/mongo_client.ts index a0e11aaee14..ec299514550 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -1174,5 +1174,5 @@ export interface MongoOptions __skipPingOnConnect?: boolean; /** @internal */ - runtime: Runtime; + runtime: Promise; } diff --git a/src/runtime_adapters.ts b/src/runtime_adapters.ts index 3969e0fc049..022f30cb811 100644 --- a/src/runtime_adapters.ts +++ b/src/runtime_adapters.ts @@ -6,6 +6,7 @@ import type * as os from 'os'; +import { importOs } from '../shims/runtime_import'; import { type MongoClientOptions } from './mongo_client'; /** @@ -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 { @@ -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 { + return { + os: options.runtimeAdapters?.os ?? (await loadNodeOsAdapter()) + }; +} + +/** + * @internal + */ +function loadNodeOsAdapter(): Promise { + if (typeof require === 'function') { + // Some environments (plain Node, CJS bundling, native ESM), have a `require` function available, we try that first. + 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(); } diff --git a/test/tools/runner/vm_context_helper.ts b/test/tools/runner/vm_context_helper.ts index b308b6c42d0..705971a85af 100644 --- a/test/tools/runner/vm_context_helper.ts +++ b/test/tools/runner/vm_context_helper.ts @@ -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 diff --git a/test/tools/utils.ts b/test/tools/utils.ts index 5e6cf310761..1adbb662955 100644 --- a/test/tools/utils.ts +++ b/test/tools/utils.ts @@ -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'; @@ -20,7 +20,6 @@ import { type MongoClientOptions, OP_MSG, processTimeMS, - resolveRuntimeAdapters, runNodelessTests, type Runtime, type ServerApiVersion, @@ -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; @@ -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 = Promise.resolve({ os }); /** * Metadata that can be used to skip tests in nodeless environments. diff --git a/test/unit/bundling.test.ts b/test/unit/bundling.test.ts new file mode 100644 index 00000000000..d38182e9a29 --- /dev/null +++ b/test/unit/bundling.test.ts @@ -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); + }); +}); diff --git a/test/unit/runtime_adapters.test.ts b/test/unit/runtime_adapters.test.ts index 3980f9f1d71..5fba034d776 100644 --- a/test/unit/runtime_adapters.test.ts +++ b/test/unit/runtime_adapters.test.ts @@ -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 }; @@ -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); }); }); });