UNPKG

@bbc/sofie-server-core-integration

Version:
522 lines 17.5 kB
"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