graphql-mongoose-subscriptions
Version:
A graphql-subscriptions PubSub Engine using mongoose
269 lines (227 loc) • 7.21 kB
JavaScript
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-this-alias */
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable no-undef */
'use strict';
var util = require('util');
var EventEmitter = require('events').EventEmitter;
function Messenger(options) {
EventEmitter.apply(this, arguments);
var o = options || {};
if (o.mongooseOptions) {
this.mongooseOptions = o.mongooseOptions;
}
var mongoose = o.mongoose || require('mongoose');
this.mongoose = mongoose;
var MessageSchema = new this.mongoose.Schema({
channel: String,
createdAt: { type: Date, expires: '15s', default: Date.now },
message: mongoose.Schema.Types.Mixed
});
this.Message = mongoose.models['PubSubMessage'] || mongoose.model('PubSubMessage', MessageSchema);
this.subscribed = {};
this.lastMessageId = null;
this.lastMessageTimestamp = null;
this.startingMessageTimestamp = new Date();
this.retryInterval = o.retryInterval || 100;
this.stream = null; // Store stream reference to prevent memory leaks
// Enhanced error handling properties
this.maxRetries = o.maxRetries || 10;
this.retryCount = 0;
this.baseRetryDelay = o.baseRetryDelay || 1000; // 1 second base delay
this.maxRetryDelay = o.maxRetryDelay || 30000; // 30 seconds max delay
this.connectionState = 'disconnected'; // 'connecting', 'connected', 'disconnected', 'error'
this.reconnecting = false;
}
util.inherits(Messenger, EventEmitter);
Messenger.prototype.send = async function (channel, msg, callback) {
var cb = function noop() {};
if (typeof callback === 'function') {
cb = callback;
}
var message = new this.Message({
channel: channel,
message: msg
});
try {
await message.save();
cb(message);
} catch (e) {
cb(e);
}
};
Messenger.prototype.connect = function (callback) {
var self = this;
// Prevent multiple connection attempts
if (this.connectionState === 'connecting') {
if (callback) callback(new Error('Connection already in progress'));
return;
}
this.connectionState = 'connecting';
this.emit('connecting');
// Clean up existing stream to prevent memory leaks
if (this.stream) {
this.stream.removeAllListeners();
if (this.stream.destroy) {
this.stream.destroy();
}
this.stream = null;
}
try {
if (this.mongoose.connection.readyState == 0 && self.mongooseOptions) {
this.mongoose.connect(self.mongooseOptions.url, self.mongooseOptions.options);
}
var pipeline = [{
$match: {
$or: [{ operationType: 'insert' }]
}
}];
this.stream = this.Message.watch(pipeline, { fullDocument: 'updateLookup' });
this.stream.on('change', function data(doc) {
var fullDocument = doc.fullDocument;
if (fullDocument && self.subscribed[fullDocument.channel] && self.lastMessageId != fullDocument._id) {
self.lastMessageId = fullDocument._id;
self.lastMessageTimestamp = fullDocument.createdAt;
self.emit(fullDocument.channel, fullDocument.message);
}
});
// Enhanced error handling
this.stream.on('error', function streamError(error) {
self.connectionState = 'error';
self.emit('error', error);
self._handleStreamError(error);
});
this.stream.on('close', function streamClose() {
if (self.connectionState !== 'disconnected') {
self.connectionState = 'disconnected';
self.emit('disconnected');
self._handleStreamClose();
}
});
// Mark as connected when stream is ready
this.stream.on('resumeToken', function () {
self.connectionState = 'connected';
self.retryCount = 0; // Reset retry count on successful connection
self.reconnecting = false;
self.emit('connected');
});
if (callback) callback();
} catch (error) {
this.connectionState = 'error';
this.emit('error', error);
if (callback) callback(error);
this._scheduleReconnect(error);
}
};
Messenger.prototype.subscribe = function (channel, bool) {
var self = this;
if (channel && bool) {
self.subscribed[channel] = bool;
return;
}
if (channel && self.subscribed[channel]) {
delete self.subscribed[channel];
}
};
Messenger.prototype.disconnect = function () {
// Set state to prevent reconnection
this.connectionState = 'disconnected';
this.reconnecting = false;
// Clean up stream to prevent memory leaks
if (this.stream) {
this.stream.removeAllListeners();
if (this.stream.destroy) {
this.stream.destroy();
}
this.stream = null;
}
this.emit('disconnected');
};
// Enhanced error handling methods
Messenger.prototype._handleStreamError = function (error) {
var self = this;
// Clean up current stream
if (this.stream) {
this.stream.removeAllListeners();
if (this.stream.destroy) {
this.stream.destroy();
}
this.stream = null;
}
// Emit error details
this.emit('error', {
type: 'stream_error',
error: error,
retryCount: this.retryCount,
willRetry: this.retryCount < this.maxRetries
});
this._scheduleReconnect(error);
};
Messenger.prototype._handleStreamClose = function () {
var self = this;
// Clean up current stream
if (this.stream) {
this.stream.removeAllListeners();
if (this.stream.destroy) {
this.stream.destroy();
}
this.stream = null;
}
// Only reconnect if not intentionally disconnected
if (this.connectionState !== 'disconnected') {
this.emit('disconnected', {
type: 'stream_close',
retryCount: this.retryCount,
willRetry: this.retryCount < this.maxRetries
});
this._scheduleReconnect(new Error('Stream closed unexpectedly'));
}
};
Messenger.prototype._scheduleReconnect = function (error) {
var self = this;
// Don't reconnect if explicitly disconnected or max retries exceeded
if (this.connectionState === 'disconnected' || this.reconnecting) {
return;
}
if (this.retryCount >= this.maxRetries) {
this.connectionState = 'error';
this.emit('maxRetriesExceeded', {
error: error,
retryCount: this.retryCount,
maxRetries: this.maxRetries
});
return;
}
this.reconnecting = true;
this.retryCount++;
// Calculate exponential backoff delay
var delay = Math.min(this.baseRetryDelay * Math.pow(2, this.retryCount - 1), this.maxRetryDelay);
// Add jitter to prevent thundering herd
delay = delay + Math.random() * 1000;
this.emit('reconnecting', {
attempt: this.retryCount,
maxRetries: this.maxRetries,
delay: delay,
error: error
});
setTimeout(function () {
if (self.connectionState !== 'disconnected') {
self.reconnecting = false;
self.connect();
}
}, delay);
};
// Get current connection status
Messenger.prototype.getConnectionState = function () {
return {
state: this.connectionState,
retryCount: this.retryCount,
maxRetries: this.maxRetries,
reconnecting: this.reconnecting
};
};
// Reset retry counter (useful for manual reconnection)
Messenger.prototype.resetRetryCount = function () {
this.retryCount = 0;
};
module.exports = Messenger;