diff --git a/src/cmap/wire_protocol/compression.ts b/src/cmap/wire_protocol/compression.ts index 7701b0d403..6e55268c54 100644 --- a/src/cmap/wire_protocol/compression.ts +++ b/src/cmap/wire_protocol/compression.ts @@ -2,7 +2,7 @@ import { promisify } from 'util'; import * as zlib from 'zlib'; import { LEGACY_HELLO_COMMAND } from '../../constants'; -import { getZstdLibrary, Snappy, type ZStandard } from '../../deps'; +import { getSnappy, getZstdLibrary, type SnappyLib, type ZStandard } from '../../deps'; import { MongoDecompressionError, MongoInvalidArgumentError } from '../../error'; /** @public */ @@ -38,6 +38,17 @@ const zlibInflate = promisify(zlib.inflate.bind(zlib)); const zlibDeflate = promisify(zlib.deflate.bind(zlib)); let zstd: typeof ZStandard; +let Snappy: SnappyLib | null = null; +function loadSnappy() { + if (Snappy == null) { + const snappyImport = getSnappy(); + if ('kModuleError' in snappyImport) { + throw snappyImport.kModuleError; + } + Snappy = snappyImport; + } + return Snappy; +} // Facilitate compressing a message using an agreed compressor export async function compress( @@ -47,9 +58,7 @@ export async function compress( const zlibOptions = {} as zlib.ZlibOptions; switch (options.agreedCompressor) { case 'snappy': { - if ('kModuleError' in Snappy) { - throw Snappy['kModuleError']; - } + Snappy ??= loadSnappy(); return Snappy.compress(dataToBeCompressed); } case 'zstd': { @@ -88,9 +97,7 @@ export async function decompress(compressorID: number, compressedData: Buffer): switch (compressorID) { case Compressor.snappy: { - if ('kModuleError' in Snappy) { - throw Snappy['kModuleError']; - } + Snappy ??= loadSnappy(); return Snappy.uncompress(compressedData, { asBuffer: true }); } case Compressor.zstd: { diff --git a/src/deps.ts b/src/deps.ts index 008a275796..df2efada11 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -97,7 +97,8 @@ export function getAwsCredentialProvider(): } } -type SnappyLib = { +/** @internal */ +export type SnappyLib = { /** * In order to support both we must check the return value of the function * @param buf - Buffer to be compressed @@ -111,16 +112,19 @@ type SnappyLib = { uncompress(buf: Buffer, opt: { asBuffer: true }): Promise; }; -export let Snappy: SnappyLib | { kModuleError: MongoMissingDependencyError } = makeErrorModule( - new MongoMissingDependencyError( - 'Optional module `snappy` not found. Please install it to enable snappy compression' - ) -); - -try { - // Ensure you always wrap an optional require in the try block NODE-3199 - Snappy = require('snappy'); -} catch {} // eslint-disable-line +export function getSnappy(): SnappyLib | { kModuleError: MongoMissingDependencyError } { + try { + // Ensure you always wrap an optional require in the try block NODE-3199 + const value = require('snappy'); + return value; + } catch (cause) { + const kModuleError = new MongoMissingDependencyError( + 'Optional module `snappy` not found. Please install it to enable snappy compression', + { cause } + ); + return { kModuleError }; + } +} export let saslprep: typeof import('saslprep') | { kModuleError: MongoMissingDependencyError } = makeErrorModule( diff --git a/src/error.ts b/src/error.ts index f839cda2df..8c9f495626 100644 --- a/src/error.ts +++ b/src/error.ts @@ -692,8 +692,9 @@ export class MongoMissingCredentialsError extends MongoAPIError { * @category Error */ export class MongoMissingDependencyError extends MongoAPIError { - constructor(message: string) { + constructor(message: string, { cause }: { cause?: Error } = {}) { super(message); + if (cause) this.cause = cause; } override get name(): string { diff --git a/test/action/dependency.test.ts b/test/action/dependency.test.ts index ffc2a7def1..7035124fb5 100644 --- a/test/action/dependency.test.ts +++ b/test/action/dependency.test.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { expect } from 'chai'; import { dependencies, peerDependencies, peerDependenciesMeta } from '../../package.json'; +import { itInNodeProcess } from '../tools/utils'; const EXPECTED_DEPENDENCIES = ['bson', 'mongodb-connection-string-url', 'socks']; const EXPECTED_PEER_DEPENDENCIES = [ @@ -65,11 +66,24 @@ describe('package.json', function () { expect(result).to.include('import success!'); }); + + if (depName === 'snappy') { + itInNodeProcess( + 'getSnappy returns rejected import', + async function ({ expect, mongodb }) { + const snappyImport = mongodb.getSnappy(); + expect(snappyImport).to.have.nested.property( + 'kModuleError.name', + 'MongoMissingDependencyError' + ); + } + ); + } }); context(`when ${depName} is installed`, () => { beforeEach(async () => { - execSync(`npm install --no-save "${depName}"@${depMajor}`); + execSync(`npm install --no-save "${depName}"@"${depMajor}"`); }); it(`driver is importable`, () => { @@ -81,7 +95,66 @@ describe('package.json', function () { expect(result).to.include('import success!'); }); + + if (depName === 'snappy') { + itInNodeProcess( + 'getSnappy returns fulfilled import', + async function ({ expect, mongodb }) { + const snappyImport = mongodb.getSnappy(); + expect(snappyImport).to.have.property('compress').that.is.a('function'); + expect(snappyImport).to.have.property('uncompress').that.is.a('function'); + } + ); + } }); } }); + + const EXPECTED_IMPORTS = [ + 'bson', + 'saslprep', + 'sparse-bitfield', + 'memory-pager', + 'mongodb-connection-string-url', + 'whatwg-url', + 'webidl-conversions', + 'tr46', + 'socks', + 'ip', + 'smart-buffer' + ]; + + describe('mongodb imports', () => { + let imports: string[]; + beforeEach(async function () { + for (const key of Object.keys(require.cache)) delete require.cache[key]; + require('../../src'); + imports = Array.from( + new Set( + Object.entries(require.cache) + .filter(([modKey]) => modKey.includes('/node_modules/')) + .map(([modKey]) => { + const leadingPkgName = modKey.split('/node_modules/')[1]; + const [orgName, pkgName] = leadingPkgName.split('/'); + if (orgName.startsWith('@')) { + return `${orgName}/${pkgName}`; + } + return orgName; + }) + ) + ); + }); + + context('when importing mongodb', () => { + it('only contains the expected imports', function () { + expect(imports).to.deep.equal(EXPECTED_IMPORTS); + }); + + it('does not import optional dependencies', () => { + for (const peerDependency of EXPECTED_PEER_DEPENDENCIES) { + expect(imports).to.not.include(peerDependency); + } + }); + }); + }); }); diff --git a/test/tools/utils.ts b/test/tools/utils.ts index 1954489792..6139ac0a13 100644 --- a/test/tools/utils.ts +++ b/test/tools/utils.ts @@ -1,3 +1,8 @@ +import * as child_process from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + import { EJSON } from 'bson'; import * as BSON from 'bson'; import { expect } from 'chai'; @@ -499,3 +504,63 @@ export function topologyWithPlaceholderClient( options as TopologyOptions ); } + +export async function itInNodeProcess( + title: string, + fn: (d: { expect: typeof import('chai').expect; mongodb: typeof import('../mongodb') }) => void +) { + it(title, async () => { + const script = ` + import { expect } from 'chai'; + import * as mongodb from './test/mongodb'; + const run = ${fn}; + run({ expect, mongodb }).then( + () => { + process.exitCode = 0; + }, + error => { + console.error(error) + process.exitCode = 1; + } + );\n`; + + const scriptName = `./testing_${title.split(/\s/).join('_')}_script.cts`; + const cwd = path.resolve(__dirname, '..', '..'); + const tsNode = path.resolve(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node'); + try { + await fs.writeFile(scriptName, script, { encoding: 'utf8' }); + const scriptInstance = child_process.fork(scriptName, { + signal: AbortSignal.timeout(50_000), + cwd, + stdio: 'pipe', + execArgv: [tsNode] + }); + + scriptInstance.stdout?.setEncoding('utf8'); + scriptInstance.stderr?.setEncoding('utf8'); + + let stdout = ''; + scriptInstance.stdout?.addListener('data', data => { + stdout += data; + }); + + let stderr = ''; + scriptInstance.stderr?.addListener('data', (data: string) => { + stderr += data; + }); + + // do not fail the test if the debugger is running + stderr = stderr + .split('\n') + .filter(line => !line.startsWith('Debugger') && !line.startsWith('For help')) + .join('\n'); + + const [exitCode] = await once(scriptInstance, 'close'); + + if (stderr.length) console.log(stderr); + expect({ exitCode, stdout, stderr }).to.deep.equal({ exitCode: 0, stdout: '', stderr: '' }); + } finally { + await fs.unlink(scriptName); + } + }); +} diff --git a/test/unit/error.test.ts b/test/unit/error.test.ts index 7a6551e336..cfeb854b6e 100644 --- a/test/unit/error.test.ts +++ b/test/unit/error.test.ts @@ -16,6 +16,7 @@ import { MONGODB_ERROR_CODES, MongoError, MongoErrorLabel, + MongoMissingDependencyError, MongoNetworkError, MongoNetworkTimeoutError, MongoParseError, @@ -155,6 +156,22 @@ describe('MongoErrors', () => { }); }); + describe('MongoMissingDependencyError#constructor', () => { + context('when options.cause is set', () => { + it('attaches the cause property to the instance', () => { + const error = new MongoMissingDependencyError('missing!', { cause: new Error('hello') }); + expect(error).to.have.property('cause'); + }); + }); + + context('when options.cause is not set', () => { + it('attaches the cause property to the instance', () => { + const error = new MongoMissingDependencyError('missing!', { cause: undefined }); + expect(error).to.not.have.property('cause'); + }); + }); + }); + describe('#isSDAMUnrecoverableError', function () { context('when the error is a MongoParseError', function () { it('returns true', function () {