amqplib
Version:
An AMQP 0-9-1 (e.g., RabbitMQ) library and client.
472 lines (400 loc) • 12.4 kB
JavaScript
const EventEmitter = require('node:events');
const DEFAULT_RECOVERY = {
initialDelay: 100,
maxDelay: 30000,
factor: 2,
jitter: 0.2,
maxRetries: Infinity,
setup: null,
};
function splitConnectionOptions(connOptions) {
if (!connOptions || typeof connOptions !== 'object') {
return {
connectionOptions: connOptions,
recovery: null,
};
}
if (!Object.hasOwn(connOptions, 'recovery')) {
return {
connectionOptions: connOptions,
recovery: null,
};
}
const connectionOptions = Object.assign({}, connOptions);
const recovery = connectionOptions.recovery;
delete connectionOptions.recovery;
return { connectionOptions, recovery };
}
function recoveryEnabled(recovery) {
if (!recovery) return false;
if (recovery === true) return true;
return recovery.enabled !== false;
}
function toFiniteNumber(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function normaliseRecoveryOptions(recovery) {
const source = recovery === true ? {} : recovery || {};
const initialDelay = Math.max(0, toFiniteNumber(source.initialDelay, DEFAULT_RECOVERY.initialDelay));
const maxDelay = Math.max(initialDelay, toFiniteNumber(source.maxDelay, DEFAULT_RECOVERY.maxDelay));
const factor = Math.max(1, toFiniteNumber(source.factor, DEFAULT_RECOVERY.factor));
const jitter = Math.min(1, Math.max(0, toFiniteNumber(source.jitter, DEFAULT_RECOVERY.jitter)));
const parsedMaxRetries = source.maxRetries == null ? Infinity : toFiniteNumber(source.maxRetries, Infinity);
return {
initialDelay,
maxDelay,
factor,
jitter,
maxRetries: parsedMaxRetries < 0 ? 0 : parsedMaxRetries,
setup: typeof source.setup === 'function' ? source.setup : null,
};
}
function toError(err, fallbackMessage) {
if (err instanceof Error) return err;
return new Error(err ? String(err) : fallbackMessage);
}
function calculateDelay(recovery, attempt) {
const base = Math.min(recovery.maxDelay, recovery.initialDelay * recovery.factor ** (attempt - 1));
const jitterPart = base * recovery.jitter;
const offset = jitterPart > 0 ? Math.random() * jitterPart * 2 - jitterPart : 0;
return Math.max(0, Math.round(base + offset));
}
function runSetup(setup, model) {
if (!setup) return Promise.resolve();
return new Promise((resolve, reject) => {
let settled = false;
function done(err) {
if (settled) return;
settled = true;
if (err) reject(toError(err, 'Recovery setup failed'));
else resolve();
}
try {
if (setup.length >= 2) {
setup(model, done);
} else {
Promise.resolve(setup(model)).then(() => done(), done);
}
} catch (err) {
done(err);
}
});
}
function makeCallback(promise, cb) {
const callback = typeof cb === 'function' ? cb : () => {};
promise.then(
(value) => callback(null, value),
(err) => callback(toError(err, 'Operation failed')),
);
}
class RecoveringCore extends EventEmitter {
constructor(openModel, recovery, adapters) {
super();
this._openModel = openModel;
this._recovery = normaliseRecoveryOptions(recovery);
this._adapters = adapters;
this._model = null;
this._modelListeners = null;
this._waiters = [];
this._connecting = false;
this._stopped = false;
this._timer = null;
this._attempt = 0;
this._initialReady = false;
this._initialWait = new Promise((resolve, reject) => {
this._resolveInitial = resolve;
this._rejectInitial = reject;
});
this._connect();
}
waitForConnect() {
return this._initialWait;
}
async close() {
if (this._stopped) return;
this._stopped = true;
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
const closedError = new Error('Connection closed');
this._rejectPendingWaiters(closedError);
this._rejectInitialConnection(closedError);
if (this._model) {
const model = this._model;
this._model = null;
this._unbindModel();
await this._closeModelNoThrow(model);
}
}
async createChannel(options) {
const model = await this._waitForConnection();
return this._adapters.createChannel(model, options, false);
}
async createConfirmChannel(options) {
const model = await this._waitForConnection();
return this._adapters.createChannel(model, options, true);
}
async updateSecret(newSecret, reason) {
const model = await this._waitForConnection();
return this._adapters.updateSecret(model, newSecret, reason);
}
_resolveInitialConnection() {
if (this._initialReady) return;
this._initialReady = true;
this._resolveInitial();
}
_rejectInitialConnection(err) {
if (this._initialReady) return;
this._initialReady = true;
this._rejectInitial(err);
}
_resolvePendingWaiters(model) {
const waiters = this._waiters.splice(0);
waiters.forEach((waiter) => {
waiter.resolve(model);
});
}
_rejectPendingWaiters(err) {
const waiters = this._waiters.splice(0);
waiters.forEach((waiter) => {
waiter.reject(err);
});
}
_waitForConnection() {
if (this._model) return Promise.resolve(this._model);
if (this._stopped) return Promise.reject(new Error('Connection closed'));
return new Promise((resolve, reject) => {
this._waiters.push({ resolve, reject });
});
}
_bindModel(model) {
const onClose = (maybeErr) => {
if (this._model !== model) return;
this._model = null;
this._unbindModel();
if (this._stopped) return;
const err = toError(maybeErr, 'Connection closed');
this.emit('disconnect', err);
this._scheduleReconnect(err);
};
const onBlocked = (reason) => this.emit('blocked', reason);
const onUnblocked = () => this.emit('unblocked');
const onConnectionError = (err) => this.emit('error', err);
const onUpdateSecretOk = () => this.emit('update-secret-ok');
const onHandlerError = (err, event) => this.emit('handler-error', err, event);
model.on('close', onClose);
model.on('blocked', onBlocked);
model.on('unblocked', onUnblocked);
model.on('error', onConnectionError);
model.on('update-secret-ok', onUpdateSecretOk);
model.on('handler-error', onHandlerError);
this._modelListeners = {
model,
onClose,
onBlocked,
onUnblocked,
onConnectionError,
onUpdateSecretOk,
onHandlerError,
};
}
_unbindModel() {
if (!this._modelListeners) return;
const { model, onClose, onBlocked, onUnblocked, onConnectionError, onUpdateSecretOk, onHandlerError } = this._modelListeners;
model.removeListener('close', onClose);
model.removeListener('blocked', onBlocked);
model.removeListener('unblocked', onUnblocked);
model.removeListener('error', onConnectionError);
model.removeListener('update-secret-ok', onUpdateSecretOk);
model.removeListener('handler-error', onHandlerError);
model.on('error', () => {});
this._modelListeners = null;
}
async _connect() {
if (this._stopped || this._connecting) return;
this._connecting = true;
let model;
try {
model = await this._openModel();
await runSetup(this._recovery.setup, model);
if (this._stopped) {
await this._closeModelNoThrow(model);
return;
}
this._model = model;
this._attempt = 0;
this._bindModel(model);
this._resolveInitialConnection();
this._resolvePendingWaiters(model);
this.emit('connect', model);
} catch (err) {
const error = toError(err, 'Connection recovery failed');
if (model) await this._closeModelNoThrow(model);
this.emit('connect-failed', error);
this._scheduleReconnect(error);
} finally {
this._connecting = false;
}
}
_scheduleReconnect(err) {
if (this._stopped) return;
if (this._timer) return;
if (this._attempt >= this._recovery.maxRetries) {
this._rejectInitialConnection(err);
this._rejectPendingWaiters(err);
this.emit('reconnect-failed', err);
return;
}
const attempt = this._attempt + 1;
const delay = calculateDelay(this._recovery, attempt);
this._attempt = attempt;
this.emit('reconnect-scheduled', { attempt, delay, error: err });
this._timer = setTimeout(() => {
this._timer = null;
this._connect();
}, delay);
}
_closeModelNoThrow(model) {
return this._adapters.closeModel(model).catch(() => {});
}
}
const PROMISE_ADAPTERS = {
closeModel(model) {
return model.close();
},
createChannel(model, options, confirm) {
return confirm ? model.createConfirmChannel(options) : model.createChannel(options);
},
updateSecret(model, newSecret, reason) {
return model.updateSecret(newSecret, reason);
},
};
function callbackClose(model) {
return new Promise((resolve, reject) => {
model.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
function callbackCreateChannel(model, options, confirm) {
return new Promise((resolve, reject) => {
const method = confirm ? 'createConfirmChannel' : 'createChannel';
const cb = (err, channel) => {
if (err) reject(err);
else resolve(channel);
};
if (options === undefined) model[method](cb);
else model[method](options, cb);
});
}
function callbackUpdateSecret(model, newSecret, reason) {
return new Promise((resolve, reject) => {
model.updateSecret(newSecret, reason, (err, ok) => {
if (err) reject(err);
else resolve(ok);
});
});
}
const CALLBACK_ADAPTERS = {
closeModel: callbackClose,
createChannel: callbackCreateChannel,
updateSecret: callbackUpdateSecret,
};
function wireCommonEvents(source, target) {
[
'connect',
'disconnect',
'connect-failed',
'reconnect-scheduled',
'reconnect-failed',
'blocked',
'unblocked',
'error',
'update-secret-ok',
'handler-error',
].forEach((eventName) => {
source.on(eventName, (...args) => target.emit(eventName, ...args));
});
}
class RecoveringPromiseModel extends EventEmitter {
constructor(openModel, recovery) {
super();
this._core = new RecoveringCore(openModel, recovery, PROMISE_ADAPTERS);
wireCommonEvents(this._core, this);
}
async waitForConnect() {
await this._core.waitForConnect();
return this;
}
close() {
return this._core.close();
}
createChannel(options) {
return this._core.createChannel(options);
}
createConfirmChannel(options) {
return this._core.createConfirmChannel(options);
}
updateSecret(newSecret, reason) {
return this._core.updateSecret(newSecret, reason);
}
}
class RecoveringCallbackModel extends EventEmitter {
constructor(openModel, recovery) {
super();
this._core = new RecoveringCore(openModel, recovery, CALLBACK_ADAPTERS);
wireCommonEvents(this._core, this);
}
waitForConnect(cb) {
const callback = typeof cb === 'function' ? cb : () => {};
this._core.waitForConnect().then(
() => callback(null, this),
(err) => callback(toError(err, 'Operation failed')),
);
return this;
}
close(cb) {
makeCallback(this._core.close(), cb);
return this;
}
updateSecret(newSecret, reason, cb) {
makeCallback(this._core.updateSecret(newSecret, reason), cb);
return this;
}
createChannel(options, cb) {
if (typeof options === 'function') {
cb = options;
options = undefined;
}
makeCallback(this._core.createChannel(options), cb);
return this;
}
createConfirmChannel(options, cb) {
if (typeof options === 'function') {
cb = options;
options = undefined;
}
makeCallback(this._core.createConfirmChannel(options), cb);
return this;
}
}
function connectWithRecoveryPromise(openModel, recovery) {
const recovering = new RecoveringPromiseModel(openModel, recovery);
return recovering.waitForConnect();
}
function connectWithRecoveryCallback(openModel, recovery, cb) {
const recovering = new RecoveringCallbackModel(openModel, recovery);
recovering.waitForConnect(cb);
return recovering;
}
module.exports = {
splitConnectionOptions,
recoveryEnabled,
connectWithRecoveryPromise,
connectWithRecoveryCallback,
RecoveringPromiseModel,
RecoveringCallbackModel,
};