mongodb-memory-server-core
Version:
MongoDB Server for testing (core package, without autodownload). The server will allow you to connect your favourite ODM or client library to the MongoDB Server and run parallel integration tests isolated from each other.
435 lines • 22.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.MongoInstance = exports.MongoInstanceEvents = void 0;
const tslib_1 = require("tslib");
const child_process_1 = require("child_process");
const path = tslib_1.__importStar(require("path"));
const MongoBinary_1 = require("./MongoBinary");
const debug_1 = tslib_1.__importDefault(require("debug"));
const utils_1 = require("./utils");
const semver_1 = require("semver");
const events_1 = require("events");
const mongodb_1 = require("mongodb");
const errors_1 = require("./errors");
// ignore the nodejs warning for coverage
/* istanbul ignore next */
if ((0, semver_1.lt)(process.version, '20.19.0')) {
console.warn('Using NodeJS below 20.19.0');
}
const log = (0, debug_1.default)('MongoMS:MongoInstance');
var MongoInstanceEvents;
(function (MongoInstanceEvents) {
MongoInstanceEvents["instanceReplState"] = "instanceReplState";
MongoInstanceEvents["instancePrimary"] = "instancePrimary";
MongoInstanceEvents["instanceReady"] = "instanceReady";
MongoInstanceEvents["instanceSTDOUT"] = "instanceSTDOUT";
MongoInstanceEvents["instanceSTDERR"] = "instanceSTDERR";
MongoInstanceEvents["instanceClosed"] = "instanceClosed";
/** Only Raw Error (emitted by mongodProcess) */
MongoInstanceEvents["instanceRawError"] = "instanceRawError";
/** Raw Errors and Custom Errors */
MongoInstanceEvents["instanceError"] = "instanceError";
MongoInstanceEvents["killerLaunched"] = "killerLaunched";
MongoInstanceEvents["instanceLaunched"] = "instanceLaunched";
MongoInstanceEvents["instanceStarted"] = "instanceStarted";
})(MongoInstanceEvents || (exports.MongoInstanceEvents = MongoInstanceEvents = {}));
/**
* MongoDB Instance Handler Class
* This Class starts & stops the "mongod" process directly and handles stdout, sterr and close events
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
class MongoInstance extends events_1.EventEmitter {
// Mark these values as "readonly" & "Readonly" because modifying them after starting will have no effect
// readonly is required otherwise the property can still be changed on the root level
instanceOpts;
binaryOpts;
spawnOpts;
/**
* Extra options to append to "mongoclient.connect"
* Mainly used for authentication
*/
extraConnectionOptions;
/**
* The "mongod" Process reference
*/
mongodProcess;
/**
* The "mongo_killer" Process reference
*/
killerProcess;
/**
* This boolean is "true" if the instance is elected to be PRIMARY
*/
isInstancePrimary = false;
/**
* This boolean is "true" if the instance is successfully started
*/
isInstanceReady = false;
/**
* This boolean is "true" if the instance is part of an replset
*/
isReplSet = false;
/**
* Extra promise to avoid multiple calls of `.stop` at the same time
*
* @see https://github.com/typegoose/mongodb-memory-server/issues/802
*/
// NOTE: i am not sure how to properly test this
stopPromise;
constructor(opts) {
super();
this.instanceOpts = { ...opts.instance };
this.binaryOpts = { ...opts.binary };
this.spawnOpts = { ...opts.spawn };
this.on(MongoInstanceEvents.instanceReady, () => {
this.isInstanceReady = true;
this.debug('constructor: Instance is ready!');
});
this.on(MongoInstanceEvents.instanceError, async (err) => {
this.debug(`constructor: Instance has thrown an Error: ${err.toString()}`);
this.isInstanceReady = false;
this.isInstancePrimary = false;
await this.stop();
});
}
/**
* Debug-log with template applied
* @param msg The Message to log
*/
debug(msg, ...extra) {
const port = this.instanceOpts.port ?? 'unknown';
log(`Mongo[${port}]: ${msg}`, ...extra);
}
/**
* Create an new instance an call method "start"
* @param opts Options passed to the new instance
*/
static async create(opts) {
log('create: Called .create() method');
const instance = new this(opts);
await instance.start();
return instance;
}
/**
* Create an array of arguments for the mongod instance
*/
prepareCommandArgs() {
this.debug('prepareCommandArgs');
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(this.instanceOpts.port), new Error('"instanceOpts.port" is required to be set!'));
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(this.instanceOpts.dbPath), new Error('"instanceOpts.dbPath" is required to be set!'));
const result = [];
result.push('--port', this.instanceOpts.port.toString());
result.push('--dbpath', this.instanceOpts.dbPath);
// "!!" converts the value to an boolean (double-invert) so that no "falsy" values are added
if (this.instanceOpts.replSet) {
this.isReplSet = true;
result.push('--replSet', this.instanceOpts.replSet);
}
if (this.instanceOpts.storageEngine) {
result.push('--storageEngine', this.instanceOpts.storageEngine);
}
if (this.instanceOpts.ip) {
result.push('--bind_ip', this.instanceOpts.ip);
}
if (this.instanceOpts.auth) {
result.push('--auth');
if (this.isReplSet) {
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(this.instanceOpts.keyfileLocation), new errors_1.KeyFileMissingError());
result.push('--keyFile', this.instanceOpts.keyfileLocation);
}
}
else {
result.push('--noauth');
}
const final = result.concat(this.instanceOpts.args ?? []);
this.debug('prepareCommandArgs: final argument array:' + JSON.stringify(final));
return final;
}
/**
* Create the mongod process
* @fires MongoInstance#instanceStarted
*/
async start() {
this.debug('start');
if (!(0, utils_1.isNullOrUndefined)(this.mongodProcess?.pid)) {
throw new errors_1.GenericMMSError(`Cannot run "MongoInstance.start" because "mongodProcess.pid" is still defined (pid: ${this.mongodProcess?.pid})`);
}
this.isInstancePrimary = false;
this.isInstanceReady = false;
this.isReplSet = false;
let timeout;
const mongoBin = await MongoBinary_1.MongoBinary.getPath(this.binaryOpts);
await (0, utils_1.checkBinaryPermissions)(mongoBin);
const launch = new Promise((res, rej) => {
this.once(MongoInstanceEvents.instanceReady, res);
this.once(MongoInstanceEvents.instanceError, rej);
this.once(MongoInstanceEvents.instanceClosed, function launchInstanceClosed() {
rej(new Error('Instance Exited before being ready and without throwing an error!'));
});
// extra conditions just to be sure that the custom defined timeout is valid
const timeoutTime = !!this.instanceOpts.launchTimeout && this.instanceOpts.launchTimeout >= 1000
? this.instanceOpts.launchTimeout
: 1000 * 10; // default 10 seconds
timeout = setTimeout(() => {
const err = new errors_1.GenericMMSError(`Instance failed to start within ${timeoutTime}ms`);
this.emit(MongoInstanceEvents.instanceError, err);
rej(err);
}, timeoutTime);
}).finally(() => {
// always clear the timeout after the promise somehow resolves
clearTimeout(timeout);
});
this.debug('start: Starting Processes');
this.mongodProcess = this._launchMongod(mongoBin);
// This assertion is here because somewhere between nodejs 12 and 16 the types for "childprocess.pid" changed to include "| undefined"
// it is tested and a error is thrown in "this_launchMongod", but typescript somehow does not see this yet as of 4.3.5
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(this.mongodProcess.pid), new Error('MongoD Process failed to spawn'));
this.killerProcess = this._launchKiller(process.pid, this.mongodProcess.pid);
await launch;
this.emit(MongoInstanceEvents.instanceStarted);
this.debug('start: Processes Started');
}
/**
* Shutdown all related processes (Mongod Instance & Killer Process)
*/
async stop() {
this.debug('stop');
if (!this.mongodProcess && !this.killerProcess) {
this.debug('stop: nothing to shutdown, returning');
return false;
}
if (!(0, utils_1.isNullOrUndefined)(this.stopPromise)) {
this.debug('stop: stopPromise is already set, using that');
return this.stopPromise;
}
// wrap the actual stop in a promise, so it can be awaited in multiple calls
// for example a instanceError while stop is already running would cause another stop
this.stopPromise = (async () => {
if (!(0, utils_1.isNullOrUndefined)(this.mongodProcess) && (0, utils_1.isAlive)(this.mongodProcess.pid)) {
// try to run "shutdown" before running "killProcess" (gracefull "SIGINT")
// using this, otherwise on windows nodejs will handle "SIGINT" & "SIGTERM" & "SIGKILL" the same (instant exit)
if (this.isReplSet) {
let con;
try {
this.debug('stop: trying shutdownServer');
const port = this.instanceOpts.port;
const ip = this.instanceOpts.ip;
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(port), new Error('Cannot shutdown replset gracefully, no "port" is provided'));
(0, utils_1.assertion)(!(0, utils_1.isNullOrUndefined)(ip), new Error('Cannot shutdown replset gracefully, no "ip" is provided'));
con = await mongodb_1.MongoClient.connect((0, utils_1.uriTemplate)(ip, port, 'admin'), {
// stopping a instance should not take long to connect to, default would be 30 seconds
serverSelectionTimeoutMS: 5000, // 5 seconds
...this.extraConnectionOptions,
directConnection: true,
});
const admin = con.db('admin'); // just to ensure it is actually the "admin" database
// "timeoutSecs" is set to "1" otherwise it will take at least "10" seconds to stop (very long tests)
await admin.command({ shutdown: 1, force: true, timeoutSecs: 1 });
this.debug('stop: after admin shutdown command');
}
catch (err) {
// Quote from MongoDB Documentation (https://docs.mongodb.com/manual/reference/command/replSetStepDown/#client-connections):
// > Starting in MongoDB 4.2, replSetStepDown command no longer closes all client connections.
// > In MongoDB 4.0 and earlier, replSetStepDown command closes all client connections during the step down.
// so error "MongoNetworkError: connection 1 to 127.0.0.1:41485 closed" will get thrown below 4.2
if (!(err instanceof mongodb_1.MongoNetworkError &&
/^connection \d+ to [\d.]+:\d+ closed$/i.test(err.message)) &&
// "Connection Reset" may happen in the middle of the operation, for whatever reason before the command finishes,
// this is not a error we need to log. (re https://github.com/typegoose/mongodb-memory-server/issues/900)
!(err instanceof Error && 'read ECONNRESET' === err.message)) {
console.warn(err);
}
}
finally {
if (!(0, utils_1.isNullOrUndefined)(con)) {
// even if it errors out, somehow the connection stays open
await con.close();
}
}
}
await (0, utils_1.killProcess)(this.mongodProcess, 'mongodProcess', this.instanceOpts.port);
this.mongodProcess = undefined; // reset reference to the childProcess for "mongod"
}
else {
this.debug('stop: mongodProcess: nothing to shutdown, skipping');
}
if (!(0, utils_1.isNullOrUndefined)(this.killerProcess)) {
await (0, utils_1.killProcess)(this.killerProcess, 'killerProcess', this.instanceOpts.port);
this.killerProcess = undefined; // reset reference to the childProcess for "mongo_killer"
}
else {
this.debug('stop: killerProcess: nothing to shutdown, skipping');
}
this.debug('stop: Instance Finished Shutdown');
return true;
})().finally(() => (this.stopPromise = undefined));
return this.stopPromise;
}
/**
* Actually launch mongod
* @param mongoBin The binary to run
* @fires MongoInstance#instanceLaunched
*/
_launchMongod(mongoBin) {
this.debug('_launchMongod: Launching Mongod Process');
const childProcess = (0, child_process_1.spawn)(path.resolve(mongoBin), this.prepareCommandArgs(), {
...this.spawnOpts,
stdio: 'pipe', // ensure that stdio is always an pipe, regardless of user input
});
childProcess.stderr?.on('data', this.stderrHandler.bind(this));
childProcess.stdout?.on('data', this.stdoutHandler.bind(this));
childProcess.on('close', this.closeHandler.bind(this));
childProcess.on('error', this.errorHandler.bind(this));
if ((0, utils_1.isNullOrUndefined)(childProcess.pid)) {
throw new errors_1.StartBinaryFailedError(path.resolve(mongoBin));
}
childProcess.unref();
this.emit(MongoInstanceEvents.instanceLaunched);
return childProcess;
}
/**
* Spawn an seperate process to kill the parent and the mongod instance to ensure "mongod" gets stopped in any case
* @param parentPid Parent nodejs process
* @param childPid Mongod process to kill
* @fires MongoInstance#killerLaunched
*/
_launchKiller(parentPid, childPid) {
this.debug(`_launchKiller: Launching Killer Process (parent: ${parentPid}, child: ${childPid})`);
// spawn process which kills itself and mongo process if current process is dead
const killer = (0, child_process_1.fork)(path.resolve(__dirname, '../../scripts/mongo_killer.js'), [parentPid.toString(), childPid.toString()], {
execArgv: [],
detached: true,
stdio: 'ignore', // stdio cannot be done with an detached process cross-systems and without killing the fork on parent termination
});
killer.unref(); // dont force an exit on the fork when parent is exiting
this.emit(MongoInstanceEvents.killerLaunched);
return killer;
}
/**
* Event "error" handler
* @param err The Error to handle
* @fires MongoInstance#instanceRawError
* @fires MongoInstance#instanceError
*/
errorHandler(err) {
this.emit(MongoInstanceEvents.instanceRawError, err);
this.emit(MongoInstanceEvents.instanceError, err);
}
/**
* Write the CLOSE event to the debug function
* @param code The Exit code to handle
* @param signal The Signal to handle
* @fires MongoInstance#instanceClosed
*/
closeHandler(code, signal) {
// check if the platform is windows, if yes check if the code is not "12" or "0" otherwise just check code is not "0"
// because for mongodb any event on windows (like SIGINT / SIGTERM) will result in an code 12
// https://docs.mongodb.com/manual/reference/exit-codes/#12
if ((process.platform === 'win32' && code != 12 && code != 0) ||
(process.platform !== 'win32' && code != 0)) {
this.debug('closeHandler: Mongod instance closed with an non-0 (or non 12 on windows) code!');
// Note: this also emits when a signal is present, which is expected because signals are not expected here
this.emit(MongoInstanceEvents.instanceError, new errors_1.UnexpectedCloseError(code, signal));
}
this.debug(`closeHandler: code: "${code}", signal: "${signal}"`);
this.emit(MongoInstanceEvents.instanceClosed, code, signal);
}
/**
* Write STDERR to debug function
* @param message The STDERR line to write
* @fires MongoInstance#instanceSTDERR
*/
stderrHandler(message) {
const line = message.toString().trim();
this.debug(`stderrHandler: ""${line}""`); // denoting the STDERR string with double quotes, because the stdout might also use quotes
this.emit(MongoInstanceEvents.instanceSTDERR, line);
this.checkErrorInLine(line);
}
/**
* Write STDOUT to debug function and process some special messages
* @param message The STDOUT line to write/parse
* @fires MongoInstance#instanceSTDOUT
* @fires MongoInstance#instanceReady
* @fires MongoInstance#instanceError
* @fires MongoInstance#instancePrimary
* @fires MongoInstance#instanceReplState
*/
stdoutHandler(message) {
const line = message.toString().trim(); // trimming to remove extra new lines and spaces around the message
this.debug(`stdoutHandler: ""${line}""`); // denoting the STDOUT string with double quotes, because the stdout might also use quotes
this.emit(MongoInstanceEvents.instanceSTDOUT, line);
// dont use "else if", because input can be multiple lines and match multiple things
if (/waiting for connections/i.test(line)) {
this.emit(MongoInstanceEvents.instanceReady);
}
this.checkErrorInLine(line);
// this case needs to be infront of "transition to primary complete", otherwise it might reset "isInstancePrimary" to "false"
if (/transition to \w+ from \w+/i.test(line)) {
const state = /transition to (\w+) from \w+/i.exec(line)?.[1] ?? 'UNKNOWN';
this.emit(MongoInstanceEvents.instanceReplState, state);
if (state !== 'PRIMARY') {
this.isInstancePrimary = false;
}
}
if (/transition to primary complete; database writes are now permitted/i.test(line)) {
this.isInstancePrimary = true;
this.debug('stdoutHandler: emitting "instancePrimary"');
this.emit(MongoInstanceEvents.instancePrimary);
}
}
/**
* Run Checks on the line if the lines contain any thrown errors
* @param line The Line to check
*/
checkErrorInLine(line) {
if (/address already in use/i.test(line)) {
this.emit(MongoInstanceEvents.instanceError, new errors_1.StdoutInstanceError(`Port "${this.instanceOpts.port}" already in use`));
}
{
const execptionMatch = /\bexception in initAndListen: (\w+): /i.exec(line);
if (!(0, utils_1.isNullOrUndefined)(execptionMatch)) {
// in pre-4.0 mongodb this exception may have been "permission denied" and "Data directory /path not found"
this.emit(MongoInstanceEvents.instanceError, new errors_1.StdoutInstanceError(`Instance Failed to start with "${execptionMatch[1] ?? 'unknown'}". Original Error:\n` +
line.substring(execptionMatch.index + execptionMatch[0].length)));
}
// special handling for when mongodb outputs this error as json
const execptionMatchJson = /\bDBException in initAndListen,/i.test(line);
if (execptionMatchJson) {
const loadedJSON = JSON.parse(line) ?? {};
this.emit(MongoInstanceEvents.instanceError, new errors_1.StdoutInstanceError(`Instance Failed to start with "DBException in initAndListen". Original Error:\n` +
loadedJSON?.attr?.error));
}
}
if (/CURL_OPENSSL_3['\s]+not found/i.test(line)) {
this.emit(MongoInstanceEvents.instanceError, new errors_1.StdoutInstanceError('libcurl3 is not available on your system. Mongod requires it and cannot be started without it.\n' +
'You should manually install libcurl3 or try to use an newer version of MongoDB'));
}
if (/CURL_OPENSSL_4['\s]+not found/i.test(line)) {
this.emit(MongoInstanceEvents.instanceError, new errors_1.StdoutInstanceError('libcurl4 is not available on your system. Mongod requires it and cannot be started without it.\n' +
'You need to manually install libcurl4'));
}
{
/*
The following regex matches something like "libsomething.so.1: cannot open shared object"
and is optimized to only start matching at a word boundary ("\b") and using atomic-group replacement "(?=inner)\1"
*/
const liberrormatch = line.match(/\b(?=(lib[^:]+))\1: cannot open shared object/i);
if (!(0, utils_1.isNullOrUndefined)(liberrormatch)) {
const lib = liberrormatch[1].toLocaleLowerCase() ?? 'unknown';
this.emit(MongoInstanceEvents.instanceError, new errors_1.StdoutInstanceError(`Instance failed to start because a library is missing or cannot be opened: "${lib}"`));
}
}
if (/\*\*\*aborting after/i.test(line)) {
const match = line.match(/\*\*\*aborting after ([^\n]+)/i);
const extra = match?.[1] ? ` (${match[1]})` : '';
this.emit(MongoInstanceEvents.instanceError, new errors_1.StdoutInstanceError('Mongod internal error' + extra));
}
}
/// Symbol for "Explicit Resource Management"
async [Symbol.asyncDispose]() {
await this.stop();
}
}
exports.MongoInstance = MongoInstance;
exports.default = MongoInstance;
//# sourceMappingURL=MongoInstance.js.map