@getanthill/datastore
Version:
Event-Sourced Datastore
421 lines (345 loc) • 10.7 kB
text/typescript
import type { JSONSchemaType } from 'ajv';
import type { Access, AnyObject, Telemetry } from '../typings';
import type { Route } from './broker';
import amqplib, { ConsumeMessage } from 'amqplib';
import merge from 'lodash/merge';
import omit from 'lodash/omit';
import Broker from './broker';
const REGEXP_MATCH_SLASHES = /\//g;
const REGEXP_MATCH_WILDCARD = /\*/g;
export default class AMQPClient extends Broker {
static ERRORS = {
NOT_CONNECTED: new Error('AMQP disconnected'),
};
private _connection: amqplib.Connection | null = null;
private _channel: amqplib.Channel | null = null;
private _queue: {
topic: string;
payload: object;
options: object;
}[] = [];
private _alreadyConnected = false;
private _connectionId = 0;
private _connectionUrls: string[] = [];
private _closing = false;
private _consumerTag = '';
private _consuming: Map<string, Promise<void>> = new Map();
private _consumerTags: Map<
string,
{
topic: string;
schema: JSONSchemaType<AnyObject>;
queueConfig: object;
}
> = new Map();
constructor(config: any, telemetry?: Telemetry) {
super(config, telemetry);
this._connectionUrls =
Array.isArray(config.url) === false ? [config.url] : config.url;
}
get connection() {
if (this._connection === null) {
throw AMQPClient.ERRORS.NOT_CONNECTED;
}
return this._connection;
}
get channel() {
if (this._channel === null) {
throw AMQPClient.ERRORS.NOT_CONNECTED;
}
return this._channel;
}
mapRoutingKey(
topic: string,
schema: JSONSchemaType<AnyObject>,
): Route & { routingKey: string } {
const topicWithNamespace = this.topicWithNamespace(topic);
return {
original: topic,
topic: topicWithNamespace.replace(
Broker.REGEXP_MATCH_PATH_PARAMETERS,
'+',
),
routingKey: topicWithNamespace
.replace(REGEXP_MATCH_SLASHES, '.')
.replace(Broker.REGEXP_MATCH_PATH_PARAMETERS, '*'),
regexp: new RegExp(
topicWithNamespace
.replace(Broker.REGEXP_MATCH_PATH_PARAMETERS, '([^\\/]+)')
.replace(REGEXP_MATCH_WILDCARD, '.*'),
),
paramNames: (Broker.REGEXP_MATCH_PATH_PARAMETERS.exec(topic) ?? []).map(
(p) => p.slice(1, -1),
),
params: {},
validate: this.ajv.compile(schema),
};
}
async connect(): Promise<AMQPClient> {
if (this._connection !== null) {
return this;
}
try {
this.telemetry?.logger.debug('[AMQP] Connecting...');
const connectionUrl = this._connectionUrls[this._connectionId];
this._connectionId =
(this._connectionId + 1) % this._connectionUrls.length;
this._connection = await amqplib.connect(
connectionUrl + '?heartbeat=10',
this.config.options,
);
this._alreadyConnected = true;
this.telemetry?.logger.debug('[AMQP] Connected ✅', {
url: connectionUrl,
});
this._connection.on('error', (err) => {
this.telemetry?.logger.error('[AMQP] Error', err);
});
this._connection.on('close', () => {
if (this._closing === true) {
return;
}
this.telemetry?.logger.debug('[AMQP] Reconnecting...');
this._channel = null;
this._connection = null;
setTimeout(
this.connect.bind(this),
this.config.failover?.reconnectionTimeoutInMilliseconds,
);
});
this._channel = await this._connection.createChannel();
await this.channel.prefetch(this.config.channel.prefetch);
await this.init();
await this.resubscribe();
await this.emptyQueue();
} catch (err) {
if (this._alreadyConnected === false && this._connectionId === 0) {
throw err;
}
setTimeout(
this.connect.bind(this),
this.config.failover?.reconnectionTimeoutInMilliseconds,
);
}
return this;
}
async init(): Promise<AMQPClient> {
await Promise.all([
this.channel.assertExchange(
this.config.exchange.consumer.name,
this.config.exchange.consumer.type,
this.config.exchange.consumer.options,
),
this.channel.assertExchange(
this.config.exchange.producer.name,
this.config.exchange.producer.type,
this.config.exchange.producer.options,
),
this.channel.assertQueue(
this.config.queue.consumer.name,
this.config.queue.consumer.options,
),
this.config.queue.errors.isEnabled === true &&
this.channel.assertQueue(
this.config.queue.errors.name,
this.config.queue.errors.options,
),
]);
if (this.config.queue.errors.isEnabled === true) {
await this.channel.bindQueue(
this.config.queue.errors.name,
this.config.exchange.producer.name,
'*.*.errors',
);
}
return this;
}
async end(): Promise<AMQPClient> {
this._closing = true;
this._channel !== null &&
this._consumerTag &&
(await this.channel.cancel(this._consumerTag));
this._consumerTag = '';
this._consuming = new Map();
this._consumerTags = new Map();
this._channel !== null && (await this._channel.close());
this._channel = null;
this._connection !== null && (await this._connection.close());
this._connection = null;
this._closing = false;
return this;
}
authenticate(tokens: Access[], handler: (...args: any[]) => Promise<void>) {
return (event: any, route: any, headers: any, opts: any) => {
const token = headers?.['authorization'];
if (tokens.find((t) => t.token === token)) {
return handler(event, route, headers, opts);
}
typeof opts?.ack === 'function' && opts.ack();
this.telemetry?.logger.debug(
'[events#authenticate] Authentication failed',
{
event,
},
);
};
}
nackIfFirstSeen(message: ConsumeMessage) {
message?.fields?.redelivered === true
? this.channel.ack(message)
: this.channel.nack(message);
}
onMessage(message: ConsumeMessage | null) {
if (!message) {
return;
}
try {
const { fields, content, properties } = message;
this.telemetry?.logger.debug('[amqp#onMessage] Received a new message', {
fields,
properties,
});
const routingKey = fields.routingKey.replace(/\./g, '/');
const route = this.getRoute(routingKey);
if (route === null) {
this.telemetry?.logger.debug(
'[services#amqp] No route matching the routing key',
{
routingKey,
acked: false,
},
);
return this.nackIfFirstSeen(message);
}
const event = JSON.parse(content.toString());
const headers = properties.headers ?? {};
this.telemetry?.logger.debug('[services#amqp] Handling event', {
routingKey,
event,
headers: omit(headers, 'authorization'),
});
const isValid = route.validate(event);
if (isValid === false) {
this.logOnInvalid(event, route);
return this.channel.ack(message);
}
this.emit(
route.original,
event,
this.parseTopic(routingKey, route),
headers,
{
source: 'amqp',
original: message,
delivery: fields.redelivered === true ? 1 : 0,
ack: () => this.channel.ack(message),
nack: () => this.channel.nack(message),
},
);
} catch (err) {
this.telemetry?.logger.warn(
'[services#amqp] Failed processing message',
err,
);
this.nackIfFirstSeen(message);
}
}
async emptyQueue() {
while (this._queue.length > 0) {
const { topic, payload, options } = this._queue.shift()!;
await this.publish(topic, payload, options);
}
}
async publish(topic: string, payload: object, options: object = {}) {
try {
return await this.channel.publish(
this.config.exchange.producer.name,
this.topicWithNamespace(topic).replace(REGEXP_MATCH_SLASHES, '.'),
Buffer.from(JSON.stringify(payload)),
merge({}, options, {
headers: this.config.headers,
}),
);
} catch (err) {
this._queue.push({
topic,
payload,
options,
});
}
}
async consume(
queueConfig: { name: string },
consumeOptions: amqplib.Options.Consume = this.config.consume?.options,
) {
if (this._consuming.has(queueConfig.name)) {
return this._consuming.get(queueConfig.name);
}
const promise = new Promise<void>((resolve, reject) => {
this.channel
.consume(queueConfig.name, this.onMessage.bind(this), consumeOptions)
.then(({ consumerTag }) => {
this._consumerTag = consumerTag;
resolve();
})
.catch(reject);
});
this._consuming.set(queueConfig.name, promise);
return promise;
}
async resubscribe() {
const subscriptions = Array.from(this._consumerTags.values());
this._consumerTag = '';
this._consuming = new Map();
this._consumerTags = new Map();
return Promise.all(
subscriptions.map(({ topic, schema, queueConfig }) =>
this.subscribe(topic, schema, queueConfig),
),
);
}
async subscribe(
topic: string,
schema: JSONSchemaType<AnyObject>,
queueConfig = this.config.queue.consumer,
) {
const mappedTopic = this.mapRoutingKey(topic, schema);
this.topics.set(mappedTopic.routingKey, mappedTopic);
this.addChannelToSpec(topic, schema);
await this.channel.bindQueue(
queueConfig.name,
this.config.exchange.consumer.name,
mappedTopic.routingKey,
);
this._consumerTags.set(topic, {
topic,
schema,
queueConfig,
});
await this.consume(queueConfig);
return this;
}
async next(
topic: string,
timeout = 5000,
cb = async (opts: any) => opts.ack(),
): Promise<string> {
let resolve: (value: string | PromiseLike<string>) => void;
let _timeout: NodeJS.Timeout;
const promise = new Promise<string>((_resolve, _reject) => {
resolve = _resolve;
_timeout = setTimeout(
() => _reject(new Error('[amqp#next] Message timeout')),
timeout,
);
});
const handler = async (event: any, route: any, headers: any, opts: any) => {
clearTimeout(_timeout);
resolve(event);
this.removeListener(topic, handler);
opts !== undefined && (await cb(opts));
};
this.on(topic, handler);
return promise;
}
}