@bbc/sofie-server-core-integration
Version:
Library for connecting to Core
522 lines • 17.5 kB
JavaScript
"use strict";
/**
* DDP client. Based on:
*
* * https://github.com/nytamin/node-ddp-client
* * https://github.com/oortcloud/node-ddp-client
*
* Brought into this project for maintenance reasons, including conversion to Typescript.
*/
/// <reference types="../types/faye-websocket" />
Object.defineProperty(exports, "__esModule", { value: true });
exports.DDPClient = void 0;
const tslib_1 = require("tslib");
const WebSocket = tslib_1.__importStar(require("faye-websocket"));
const EJSON = tslib_1.__importStar(require("ejson"));
const events_1 = require("events");
const got_1 = tslib_1.__importDefault(require("got"));
/**
* Class reprsenting a DDP client and its connection.
*/
class DDPClient extends events_1.EventEmitter {
// very very simple collections (name -> [{id -> document}])
collections = {};
socket;
session;
hostInt;
get host() {
return this.hostInt;
}
portInt;
get port() {
return this.portInt;
}
headersInt = {};
get headers() {
return this.headersInt;
}
pathInt;
get path() {
return this.pathInt;
}
sslInt;
get ssl() {
return this.sslInt;
}
useSockJSInt;
get useSockJS() {
return this.useSockJSInt;
}
autoReconnectInt;
get autoReconnect() {
return this.autoReconnectInt;
}
autoReconnectTimerInt;
get autoReconnectTimer() {
return this.autoReconnectTimerInt;
}
ddpVersionInt;
get ddpVersion() {
return this.ddpVersionInt;
}
urlInt;
get url() {
return this.urlInt;
}
maintainCollectionsInt;
get maintainCollections() {
return this.maintainCollectionsInt;
}
static ERRORS = {
DISCONNECTED: {
error: 'DISCONNECTED',
message: 'DDPClient: Disconnected from DDP server',
errorType: 'Meteor.Error',
},
};
static supportedDdpVersions = ['1', 'pre2', 'pre1'];
tlsOpts;
isConnecting = false;
isReconnecting = false;
isClosing = false;
connectionFailed = false;
nextId = 0;
callbacks = {};
updatedCallbacks = {};
pendingMethods = {};
observers = {};
reconnectTimeout = null;
constructor(opts) {
super();
opts = opts || { host: '127.0.0.1', port: 3000, tlsOpts: {} };
this.resetOptions(opts);
this.ddpVersionInt = opts.ddpVersion || '1';
}
resetOptions(opts) {
// console.log(opts)
this.hostInt = opts.host || '127.0.0.1';
this.portInt = opts.port || 3000;
this.headersInt = opts.headers || {};
this.pathInt = opts.path;
this.sslInt = opts.ssl || this.port === 443;
this.tlsOpts = opts.tlsOpts || {};
this.useSockJSInt = opts.useSockJs || false;
this.autoReconnectInt = opts.autoReconnect === false ? false : true;
this.autoReconnectTimerInt = opts.autoReconnectTimer || 500;
this.maintainCollectionsInt = opts.maintainCollections || true;
this.urlInt = opts.url;
this.ddpVersionInt = opts.ddpVersion || '1';
}
clearReconnectTimeout() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
}
recoverNetworkError(err) {
// console.log('autoReconnect', this.autoReconnect, 'connectionFailed', this.connectionFailed, 'isClosing', this.isClosing)
if (this.autoReconnect && !this.connectionFailed && !this.isClosing) {
this.clearReconnectTimeout();
this.reconnectTimeout = setTimeout(() => {
this.connect();
}, this.autoReconnectTimer);
this.isReconnecting = true;
}
else {
if (err) {
throw err;
}
}
}
///////////////////////////////////////////////////////////////////////////
// RAW, low level functions
send(data) {
if (data.msg !== 'connect' && this.isConnecting) {
this.endPendingMethodCalls();
}
else {
if (!this.socket)
throw new Error('Not connected');
this.socket.send(EJSON.stringify(data));
}
}
failed(data) {
if (DDPClient.supportedDdpVersions.indexOf(data.version) !== -1) {
this.ddpVersionInt = data.version;
this.connect();
}
else {
this.autoReconnectInt = false;
this.emit('failed', new Error('Cannot negotiate DDP version'));
}
}
connected(data) {
this.session = data.session;
this.isConnecting = false;
this.isReconnecting = false;
this.emit('connected');
}
result(data) {
if (data.id) {
// console.log('Received result', data, this.callbacks, this.callbacks[data.id])
const cb = this.callbacks[data.id] || undefined;
if (cb) {
delete this.callbacks[data.id];
cb(data.error, data.result);
}
}
}
updated(data) {
if (data.methods) {
data.methods.forEach((method) => {
const cb = this.updatedCallbacks[method];
if (cb) {
delete this.updatedCallbacks[method];
cb();
}
});
}
}
nosub(data) {
if (data.id) {
const cb = this.callbacks[data.id];
if (cb) {
delete this.callbacks[data.id];
cb(data.error);
}
}
}
added(data) {
// console.log('Received added', data, this.maintainCollections)
if (this.maintainCollections) {
const name = data.collection;
const id = data.id || 'unknown';
if (!this.collections[name]) {
this.collections[name] = {};
}
const addedDocument = this.collections[name][id] ? { ...this.collections[name][id] } : { _id: id };
if (data.fields) {
Object.entries(data.fields).forEach(([key, value]) => {
addedDocument[key] = value;
});
}
this.collections[name][id] = addedDocument;
if (this.observers[name]) {
Object.values(this.observers[name]).forEach((ob) => ob.added(id, data.fields));
}
}
}
removed(data) {
if (this.maintainCollections) {
const name = data.collection;
const id = data.id || 'unknown';
if (!this.collections[name][id]) {
return;
}
const oldValue = this.collections[name][id];
delete this.collections[name][id];
if (this.observers[name]) {
Object.values(this.observers[name]).forEach((ob) => ob.removed(id, oldValue));
}
}
}
changed(data) {
if (this.maintainCollections) {
const name = data.collection;
const id = data.id || 'unknown';
if (!this.collections[name]) {
return;
}
if (!this.collections[name][id]) {
return;
}
const oldFields = {};
const clearedFields = data.cleared || [];
const newFields = {};
// cloning allows detection of changed objects in `find` results using shallow comparison
const updatedDocument = { ...this.collections[name][id] };
if (data.fields) {
Object.entries(data.fields).forEach(([key, value]) => {
oldFields[key] = updatedDocument[key];
newFields[key] = value;
updatedDocument[key] = value;
});
}
if (data.cleared) {
data.cleared.forEach((value) => {
delete updatedDocument[value];
});
}
this.collections[name][id] = updatedDocument;
if (this.observers[name]) {
Object.values(this.observers[name]).forEach((ob) => ob.changed(id, oldFields, clearedFields, newFields));
}
}
}
ready(data) {
// console.log('Received ready', data, this.callbacks)
data.subs.forEach((id) => {
const cb = this.callbacks[id];
if (cb) {
cb();
delete this.callbacks[id];
}
});
}
ping(data) {
this.send((data.id && { msg: 'pong', id: data.id }) || { msg: 'pong' });
}
messageWork = {
failed: this.failed.bind(this),
connected: this.connected.bind(this),
result: this.result.bind(this),
updated: this.updated.bind(this),
nosub: this.nosub.bind(this),
added: this.added.bind(this),
removed: this.removed.bind(this),
changed: this.changed.bind(this),
ready: this.ready.bind(this),
ping: this.ping.bind(this),
pong: () => {
/* Do nothing */
},
error: () => {
/* Do nothing */
}, // TODO - really do nothing!?!
};
// handle a message from the server
message(rawData) {
// console.log('Received message', rawData)
const data = EJSON.parse(rawData);
if (this.messageWork[data.msg]) {
this.messageWork[data.msg](data);
}
}
getNextId() {
return (this.nextId += 1).toString();
}
addObserver(observer) {
if (!this.observers[observer.name]) {
this.observers[observer.name] = {};
}
this.observers[observer.name][observer.id] = observer;
}
removeObserver(observer) {
if (!this.observers[observer.name]) {
return;
}
delete this.observers[observer.name][observer.id];
}
//////////////////////////////////////////////////////////////////////////
// USER functions -- use these to control the client
/* open the connection to the server
*
* connected(): Called when the 'connected' message is received
* If autoReconnect is true (default), the callback will be
* called each time the connection is opened.
*/
connect(connected) {
this.isConnecting = true;
this.connectionFailed = false;
this.isClosing = false;
if (connected) {
this.addListener('connected', () => {
this.clearReconnectTimeout();
this.isConnecting = false;
this.isReconnecting = false;
connected(undefined, this.isReconnecting);
});
this.addListener('failed', (error) => {
this.isConnecting = false;
this.connectionFailed = true;
connected(error, this.isReconnecting);
});
}
if (this.useSockJS) {
this.makeSockJSConnection().catch((e) => {
this.emit('failed', e);
});
}
else {
const url = this.buildWsUrl();
this.makeWebSocketConnection(url);
}
}
endPendingMethodCalls() {
const ids = Object.keys(this.pendingMethods);
this.pendingMethods = {};
ids.forEach((id) => {
if (this.callbacks[id]) {
this.callbacks[id](DDPClient.ERRORS.DISCONNECTED);
delete this.callbacks[id];
}
if (this.updatedCallbacks[id]) {
this.updatedCallbacks[id]();
delete this.updatedCallbacks[id];
}
});
}
getHeadersWithDefaults() {
return {
dnt: 'gateway', // Provide the header needed for the header based auth to work when not connected through a reverse proxy
...this.headers,
};
}
async makeSockJSConnection() {
const protocol = this.ssl ? 'https://' : 'http://';
if (this.path && !this.path?.endsWith('/')) {
this.pathInt = this.path + '/';
}
const url = `${protocol}${this.host}:${this.port}/${this.path || ''}sockjs/info`;
try {
const response = await (0, got_1.default)(url, {
headers: this.getHeadersWithDefaults(),
https: {
certificateAuthority: this.tlsOpts.ca,
key: this.tlsOpts.key,
certificate: this.tlsOpts.cert,
checkServerIdentity: this.tlsOpts.checkServerIdentity,
},
responseType: 'json',
});
// Info object defined here(?): https://github.com/sockjs/sockjs-node/blob/master/lib/info.js
const info = response.body;
if (!info || !info.base_url) {
const url = this.buildWsUrl();
this.makeWebSocketConnection(url);
}
else if (info.base_url.indexOf('http') === 0) {
const url = (info.base_url + '/websocket').replace(/^http/, 'ws');
this.makeWebSocketConnection(url);
}
else {
const path = info.base_url + '/websocket';
const url = this.buildWsUrl(path);
this.makeWebSocketConnection(url);
}
}
catch (err) {
this.recoverNetworkError(err);
}
}
buildWsUrl(path) {
let url;
path = path || this.path || 'websocket';
const protocol = this.ssl ? 'wss://' : 'ws://';
if (this.url && !this.useSockJS) {
url = this.url;
}
else {
url = `${protocol}${this.host}:${this.port}${path.indexOf('/') === 0 ? path : '/' + path}`;
}
return url;
}
makeWebSocketConnection(url) {
// console.log('About to create WebSocket client')
this.socket = new WebSocket.Client(url, null, { tls: this.tlsOpts, headers: this.getHeadersWithDefaults() });
this.socket.on('open', () => {
// just go ahead and open the connection on connect
this.send({
msg: 'connect',
version: this.ddpVersion,
support: DDPClient.supportedDdpVersions,
});
});
this.socket.on('error', (error) => {
// error received before connection was established
if (this.isConnecting) {
this.emit('failed', error);
}
this.emit('socket-error', error);
});
this.socket.on('close', (event) => {
this.emit('socket-close', event.code, event.reason);
this.endPendingMethodCalls();
this.recoverNetworkError();
});
this.socket.on('message', (event) => {
this.message(event.data);
this.emit('message', event.data);
});
}
close() {
this.isClosing = true;
this.socket?.close(); // with mockJS connection, might not get created
this.removeAllListeners('connected');
this.removeAllListeners('failed');
}
call(methodName, data, callback, updatedCallback) {
// console.log('Call', methodName, 'with this.isConnecting = ', this.isConnecting)
const id = this.getNextId();
this.callbacks[id] = (error, result) => {
delete this.pendingMethods[id];
if (callback) {
callback.apply(this, [error, result]);
}
};
this.updatedCallbacks[id] = () => {
delete this.pendingMethods[id];
if (updatedCallback) {
updatedCallback.apply(this, []);
}
};
this.pendingMethods[id] = true;
this.send({
msg: 'method',
id: id,
method: methodName,
params: data,
});
}
// open a subscription on the server, callback should handle on ready and nosub
subscribe(subscriptionName, data, callback, reuseId) {
const id = reuseId || this.getNextId();
if (callback) {
this.callbacks[id] = callback;
}
this.send({
msg: 'sub',
id: id,
name: subscriptionName,
params: data,
});
return id;
}
unsubscribe(subscriptionId) {
this.send({
msg: 'unsub',
id: subscriptionId,
});
}
/**
* Adds an observer to a collection and returns the observer.
* Observation can be stopped by calling the stop() method on the observer.
* Functions for added, changed and removed can be added to the observer
* afterward.
*/
observe(collectionName, added, changed, removed) {
const observer = {
id: this.getNextId(),
name: collectionName,
added: added ||
(() => {
/* Do nothing */
}),
changed: changed ||
(() => {
/* Do nothing */
}),
removed: removed ||
(() => {
/* Do nothing */
}),
stop: () => {
this.removeObserver(observer);
},
};
this.addObserver(observer);
return observer;
}
}
exports.DDPClient = DDPClient;
//# sourceMappingURL=ddpClient.js.map