smqp
Version:
Synchronous message queueing package
478 lines (403 loc) • 15.6 kB
JavaScript
import { Exchange, EventExchange } from './Exchange.js';
import { Queue } from './Queue.js';
import { Shovel, Exchange2Exchange } from './Shovel.js';
import { generateId } from './shared.js';
import {
SmqpError,
ERR_EXCHANGE_TYPE_MISMATCH,
ERR_QUEUE_DURABLE_MISMATCH,
ERR_CONSUMER_TAG_CONFLICT,
ERR_QUEUE_NAME_CONFLICT,
ERR_SHOVEL_NAME_CONFLICT,
ERR_QUEUE_NOT_FOUND,
} from './Errors.js';
const kEntities = Symbol.for('entities');
const kEventHandler = Symbol.for('eventHandler');
export function Broker(owner) {
if (!(this instanceof Broker)) {
return new Broker(owner);
}
this.owner = owner;
const events = (this.events = new EventExchange('broker__events'));
const entities = (this[kEntities] = new Map([
['exchanges', new Map()],
['queues', new Map()],
['consumers', new Map()],
['shovels', new Map()],
]));
this[kEventHandler] = new BrokerEventHandler(events, entities);
}
Object.defineProperties(Broker.prototype, {
exchangeCount: {
get() {
return this[kEntities].get('exchanges').size;
},
},
queueCount: {
get() {
return this[kEntities].get('queues').size;
},
},
consumerCount: {
get() {
return this[kEntities].get('consumers').size;
},
},
});
Broker.prototype.subscribe = function subscribe(exchangeName, pattern, queueName, onMessage, options) {
if (!exchangeName || !pattern || typeof onMessage !== 'function')
throw new TypeError('exchange name, pattern, and message callback are required');
if (options?.consumerTag) this.validateConsumerTag(options.consumerTag);
const exchange = this.assertExchange(exchangeName);
const queueOptions = { durable: true, ...options };
const queue = this.assertQueue(queueName, queueOptions);
exchange.bindQueue(queue, pattern, queueOptions);
return queue.assertConsumer(onMessage, queueOptions, this.owner);
};
Broker.prototype.subscribeTmp = function subscribeTmp(exchangeName, pattern, onMessage, options) {
return this.subscribe(exchangeName, pattern, null, onMessage, { ...options, durable: false });
};
Broker.prototype.subscribeOnce = function subscribeOnce(exchangeName, pattern, onMessage, options) {
if (typeof onMessage !== 'function') throw new TypeError('message callback is required');
if (options?.consumerTag) this.validateConsumerTag(options.consumerTag);
const exchange = this.assertExchange(exchangeName);
const onceOptions = { autoDelete: true, durable: false, priority: options?.priority ?? 0 };
const onceQueue = this.createQueue(null, onceOptions);
exchange.bindQueue(onceQueue, pattern, onceOptions);
return this.consume(onceQueue.name, wrappedOnMessage, { noAck: true, consumerTag: options?.consumerTag });
function wrappedOnMessage(...args) {
onceQueue.delete();
onMessage(...args);
}
};
Broker.prototype.unsubscribe = function unsubscribe(queueName, onMessage) {
const queue = this.getQueue(queueName);
if (!queue) return;
queue.dismiss(onMessage);
};
Broker.prototype.assertExchange = function assertExchange(exchangeName, type, options) {
let exchange = this.getExchange(exchangeName);
if (exchange) {
if (type && exchange.type !== type) throw new SmqpError("Type doesn't match", ERR_EXCHANGE_TYPE_MISMATCH);
return exchange;
}
exchange = new Exchange(exchangeName, type || 'topic', options);
this[kEventHandler].listen(exchange.events);
this[kEntities].get('exchanges').set(exchangeName, exchange);
return exchange;
};
Broker.prototype.bindQueue = function bindQueue(queueName, exchangeName, pattern, bindOptions) {
const exchange = this.getExchange(exchangeName);
const queue = this.getQueue(queueName);
return exchange.bindQueue(queue, pattern, bindOptions);
};
Broker.prototype.unbindQueue = function unbindQueue(queueName, exchangeName, pattern) {
const exchange = this.getExchange(exchangeName);
if (!exchange) return;
const queue = this.getQueue(queueName);
if (!queue) return;
exchange.unbindQueue(queue, pattern);
};
Broker.prototype.consume = function consume(queueName, onMessage, options) {
const queue = this.getQueue(queueName);
if (!queue) throw new SmqpError(`Queue with name <${queueName}> was not found`, ERR_QUEUE_NOT_FOUND);
if (options?.consumerTag) this.validateConsumerTag(options.consumerTag);
return queue.consume(onMessage, options, this.owner);
};
Broker.prototype.cancel = function cancel(consumerTag, requeue = true) {
const consumer = this.getConsumer(consumerTag);
if (!consumer) return false;
consumer.cancel(requeue);
return true;
};
Broker.prototype.getConsumers = function getConsumers() {
const result = [];
for (const consumer of this[kEntities].get('consumers').values()) {
result.push({
queue: consumer.queue.name,
consumerTag: consumer.options.consumerTag,
ready: consumer.ready,
options: { ...consumer.options },
});
}
return result;
};
Broker.prototype.getConsumer = function getConsumer(consumerTag) {
if (typeof consumerTag !== 'string') throw new TypeError('consumer tag must be a string');
return this[kEntities].get('consumers').get(consumerTag);
};
Broker.prototype.getExchange = function getExchange(exchangeName) {
if (typeof exchangeName !== 'string') throw new TypeError('exchange name must be a string');
return this[kEntities].get('exchanges').get(exchangeName);
};
Broker.prototype.deleteExchange = function deleteExchange(exchangeName, options) {
const exchange = this.getExchange(exchangeName);
if (!exchange || (options?.ifUnused && exchange.bindingCount)) return false;
this[kEntities].get('exchanges').delete(exchangeName);
exchange.close();
return true;
};
Broker.prototype.stop = function stop() {
const entities = this[kEntities];
for (const exchange of entities.get('exchanges').values()) exchange.stop();
for (const queue of entities.get('queues').values()) queue.stop();
};
Broker.prototype.close = function close() {
const entities = this[kEntities];
for (const shovel of entities.get('shovels').values()) shovel.close();
for (const exchange of entities.get('exchanges').values()) exchange.close();
for (const queue of entities.get('queues').values()) queue.close();
};
Broker.prototype.reset = function reset() {
this.stop();
this.close();
const entities = this[kEntities];
entities.get('exchanges').clear();
entities.get('queues').clear();
entities.get('consumers').clear();
entities.get('shovels').clear();
};
Broker.prototype.getState = function getState(onlyWithContent) {
const exchanges = this._getExchangeState(onlyWithContent);
const queues = this._getQueuesState(onlyWithContent);
if (onlyWithContent && !exchanges && !queues) return;
return {
exchanges,
queues,
};
};
Broker.prototype.recover = function recover(state) {
const boundGetQueue = this.getQueue.bind(this);
if (state) {
if (state.queues) {
for (const qState of state.queues) this.assertQueue(qState.name, qState.options).recover(qState);
}
if (state.exchanges)
for (const eState of state.exchanges) this.assertExchange(eState.name, eState.type, eState.options).recover(eState, boundGetQueue);
} else {
const entities = this[kEntities];
for (const queue of entities.get('queues').values()) {
if (queue.stopped) queue.recover();
}
for (const exchange of entities.get('exchanges').values()) {
if (exchange.stopped) exchange.recover(null, boundGetQueue);
}
}
return this;
};
Broker.prototype.bindExchange = function bindExchange(source, destination, pattern = '#', args) {
const name = `e2e-${source}2${destination}-${pattern}`;
const shovel = this.createShovel(
name,
{
broker: this,
exchange: source,
pattern,
priority: args?.priority,
consumerTag: `smq.ctag-${name}`,
},
{
broker: this,
exchange: destination,
},
{ ...args }
);
return new Exchange2Exchange(shovel);
};
Broker.prototype.unbindExchange = function unbindExchange(source, destination, pattern = '#') {
const name = `e2e-${source}2${destination}-${pattern}`;
return this.closeShovel(name);
};
Broker.prototype.publish = function publish(exchangeName, routingKey, content, properties) {
const exchange = this.getExchange(exchangeName);
if (!exchange) return;
return exchange.publish(routingKey, content, properties);
};
Broker.prototype.purgeQueue = function purgeQueue(queueName) {
const queue = this.getQueue(queueName);
if (!queue) return;
return queue.purge();
};
Broker.prototype.sendToQueue = function sendToQueue(queueName, content, options) {
const queue = this.getQueue(queueName);
if (!queue) throw new SmqpError(`Queue with name <${queueName}> was not found`, ERR_QUEUE_NOT_FOUND);
return queue.queueMessage({}, content, options);
};
Broker.prototype._getQueuesState = function getQueuesState(onlyWithContent) {
let result;
for (const queue of this[kEntities].get('queues').values()) {
if (!queue.options.durable) continue;
if (onlyWithContent && !queue.messageCount) continue;
if (!result) result = [];
result.push(queue.getState());
}
return result;
};
Broker.prototype._getExchangeState = function getExchangeState(onlyWithContent) {
let result;
for (const exchange of this[kEntities].get('exchanges').values()) {
if (!exchange.options.durable) continue;
if (onlyWithContent && !exchange.undeliveredCount) continue;
if (!result) result = [];
result.push(exchange.getState());
}
return result;
};
Broker.prototype.createQueue = function createQueue(queueName, options) {
if (queueName && typeof queueName !== 'string') throw new TypeError('queue name must be a string');
else if (!queueName) queueName = `smq.qname-${generateId()}`;
else if (this.getQueue(queueName)) throw new SmqpError(`Queue named ${queueName} already exists`, ERR_QUEUE_NAME_CONFLICT);
const queueEmitter = new EventExchange(`${queueName}__events`);
this[kEventHandler].listen(queueEmitter);
const queue = new Queue(queueName, options, queueEmitter);
this[kEntities].get('queues').set(queueName, queue);
return queue;
};
Broker.prototype.getQueue = function getQueue(queueName) {
if (!queueName || typeof queueName !== 'string') throw new TypeError('queue name must be a string');
return this[kEntities].get('queues').get(queueName);
};
Broker.prototype.assertQueue = function assertQueue(queueName, options) {
if (queueName && typeof queueName !== 'string') throw new TypeError('queue name must be a string');
else if (!queueName) return this.createQueue(null, options);
const queue = this.getQueue(queueName);
const queueOptions = { durable: true, ...options };
if (!queue) return this.createQueue(queueName, queueOptions);
if (queue.options.durable !== queueOptions?.durable) throw new SmqpError("Durable doesn't match", ERR_QUEUE_DURABLE_MISMATCH);
return queue;
};
Broker.prototype.deleteQueue = function deleteQueue(queueName, options) {
const queue = this.getQueue(queueName);
if (!queue) return;
return queue.delete(options);
};
Broker.prototype.get = function getMessageFromQueue(queueName, options) {
const queue = this.getQueue(queueName);
if (!queue) return;
return queue.get({ noAck: options?.noAck });
};
Broker.prototype.ack = function ack(message, allUpTo) {
message.ack(allUpTo);
};
Broker.prototype.ackAll = function ackAll() {
for (const queue of this[kEntities].get('queues').values()) queue.ackAll();
};
Broker.prototype.nack = function nack(message, allUpTo, requeue) {
message.nack(allUpTo, requeue);
};
Broker.prototype.nackAll = function nackAll(requeue) {
for (const queue of this[kEntities].get('queues').values()) queue.nackAll(requeue);
};
Broker.prototype.reject = function reject(message, requeue) {
message.reject(requeue);
};
Broker.prototype.validateConsumerTag = function validateConsumerTag(consumerTag) {
if (!consumerTag) return true;
if (this[kEntities].get('consumers').has(consumerTag)) {
throw new SmqpError(`Consumer tag must be unique, ${consumerTag} is occupied`, ERR_CONSUMER_TAG_CONFLICT);
}
return true;
};
Broker.prototype.createShovel = function createShovel(name, source, destination, options) {
const shovels = this[kEntities].get('shovels');
if (shovels.has(name)) throw new SmqpError(`Shovel name must be unique, ${name} is occupied`, ERR_SHOVEL_NAME_CONFLICT);
const shovel = new Shovel(name, { ...source, broker: this }, destination, options);
this[kEventHandler].listen(shovel.events);
shovels.set(name, shovel);
return shovel;
};
Broker.prototype.closeShovel = function closeShovel(name) {
const shovel = this.getShovel(name);
if (shovel) {
shovel.close();
return true;
}
return false;
};
Broker.prototype.getShovel = function getShovel(name) {
return this[kEntities].get('shovels').get(name);
};
Broker.prototype.getShovels = function getShovels() {
return [...this[kEntities].get('shovels').values()];
};
Broker.prototype.on = function on(eventName, callback, options) {
return this.events.on(eventName, getEventCallback(), { ...options, origin: callback });
function getEventCallback() {
return function eventCallback(name, msg) {
callback({
name,
...msg.content,
});
};
}
};
Broker.prototype.off = function off(eventName, callbackOrObject) {
const { consumerTag } = callbackOrObject;
for (const binding of this.events.bindings) {
if (binding.pattern === eventName) {
if (consumerTag) {
binding.queue.cancel(consumerTag);
continue;
}
for (const consumer of binding.queue.consumers) {
if (consumer.options && consumer.options.origin === callbackOrObject) {
consumer.cancel();
}
}
}
}
};
Broker.prototype.prefetch = function prefetch() {};
function BrokerEventHandler(eventExchange, entities) {
this.eventExchange = eventExchange;
this.entities = entities;
this.handler = this.handler.bind(this);
}
BrokerEventHandler.prototype.listen = function listen(emitter) {
emitter.on('#', this.handler);
};
BrokerEventHandler.prototype.handler = function eventHandler(eventName, msg) {
switch (eventName) {
case 'exchange.delete': {
this.entities.get('exchanges').delete(msg.content.name);
break;
}
case 'exchange.return': {
this.eventExchange.publish('return', msg.content);
break;
}
case 'exchange.message.undelivered': {
this.eventExchange.publish('message.undelivered', msg.content);
break;
}
case 'queue.delete': {
this.entities.get('queues').delete(msg.content.name);
break;
}
case 'queue.dead-letter': {
const exchange = this.entities.get('exchanges').get(msg.content.deadLetterExchange);
if (!exchange) return;
const { fields, content, properties } = msg.content.message;
exchange.publish(fields.routingKey, content, properties);
break;
}
case 'queue.consume': {
this.entities.get('consumers').set(msg.content.consumerTag, msg.content);
break;
}
case 'queue.consumer.cancel': {
this.entities.get('consumers').delete(msg.content.consumerTag);
break;
}
case 'queue.message.consumed.ack':
case 'queue.message.consumed.nack': {
const { operation, message } = msg.content;
this.eventExchange.publish(`message.${operation}`, message);
break;
}
case 'shovel.close': {
this.entities.get('shovels').delete(msg.content.name);
break;
}
}
};