UNPKG

@jayzyaj/centrifuge-js-cyy

Version:

Centrifuge and Centrifugo client for NodeJS and browser

1,780 lines (1,578 loc) 46.8 kB
import EventEmitter from 'events'; import Subscription from './subscription'; import { JsonEncoder, JsonDecoder, JsonMethodType, JsonPushType } from './json'; import { isFunction, log, startsWith, errorExists, backoff, extend } from './utils'; const _errorTimeout = 'timeout'; const _errorConnectionClosed = 'connection closed'; export class Centrifuge extends EventEmitter { constructor(url, options) { super(); this._url = url; this._websocket = null; this._sockjs = null; this._isSockjs = false; this._binary = false; this._methodType = null; this._pushType = null; this._encoder = null; this._decoder = null; this._status = 'disconnected'; this._reconnect = true; this._reconnecting = false; this._transport = null; this._transportName = null; this._transportClosed = true; this._messageId = 0; this._clientID = null; this._refreshRequired = false; this._subs = {}; this._serverSubs = {}; this._lastSeq = {}; this._lastGen = {}; this._lastOffset = {}; this._lastEpoch = {}; this._messages = []; this._isBatching = false; this._isSubscribeBatching = false; this._privateChannels = {}; this._numRefreshFailed = 0; this._refreshTimeout = null; this._pingTimeout = null; this._pongTimeout = null; this._subRefreshTimeouts = {}; this._retries = 0; this._callbacks = {}; this._latency = null; this._latencyStart = null; this._connectData = null; this._token = null; this._xhrID = 0; this._xhrs = {}; this._dispatchPromise = Promise.resolve(); this._config = { debug: false, websocket: null, sockjs: null, promise: null, minRetry: 1000, maxRetry: 20000, timeout: 5000, ping: true, pingInterval: 25000, pongWaitTimeout: 5000, privateChannelPrefix: '$', onTransportClose: null, sockjsServer: null, sockjsTransports: [ 'websocket', 'xdr-streaming', 'xhr-streaming', 'eventsource', 'iframe-eventsource', 'iframe-htmlfile', 'xdr-polling', 'xhr-polling', 'iframe-xhr-polling', 'jsonp-polling' ], refreshEndpoint: '/centrifuge/refresh', refreshHeaders: {}, refreshParams: {}, refreshData: {}, refreshAttempts: null, refreshInterval: 1000, onRefreshFailed: null, onRefresh: null, subscribeEndpoint: '/centrifuge/subscribe', subscribeHeaders: {}, subscribeParams: {}, subRefreshInterval: 1000, onPrivateSubscribe: null }; this._configure(options); } setToken(token) { this._token = token; } setConnectData(data) { this._connectData = data; } setRefreshHeaders(headers) { this._config.refreshHeaders = headers; } setRefreshParams(params) { this._config.refreshParams = params; } setRefreshData(data) { this._config.refreshData = data; } setSubscribeHeaders(headers) { this._config.subscribeHeaders = headers; } setSubscribeParams(params) { this._config.subscribeParams = params; } _ajax(url, params, headers, data, callback) { let query = ''; this._debug('sending AJAX request to', url, 'with data', JSON.stringify(data)); const xhr = (global.XMLHttpRequest ? new global.XMLHttpRequest() : new global.ActiveXObject('Microsoft.XMLHTTP')); for (const i in params) { if (params.hasOwnProperty(i)) { if (query.length > 0) { query += '&'; } query += encodeURIComponent(i) + '=' + encodeURIComponent(params[i]); } } if (query.length > 0) { query = '?' + query; } xhr.open('POST', url + query, true); if ('withCredentials' in xhr) { xhr.withCredentials = true; } xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.setRequestHeader('Content-Type', 'application/json'); for (const headerName in headers) { if (headers.hasOwnProperty(headerName)) { xhr.setRequestHeader(headerName, headers[headerName]); } } xhr.onreadystatechange = () => { if (xhr.readyState === 4) { if (xhr.status === 200) { let data, parsed = false; try { data = JSON.parse(xhr.responseText); parsed = true; } catch (e) { callback({ error: 'Invalid JSON. Data was: ' + xhr.responseText, status: 200, data: null }); } if (parsed) { // prevents double execution. callback({ data: data, status: 200 }); } } else { this._log('wrong status code in AJAX response', xhr.status); callback({ status: xhr.status, data: null }); } } }; setTimeout(() => xhr.send(JSON.stringify(data)), 20); return xhr; }; _log() { log('info', arguments); }; _debug() { if (this._config.debug === true) { log('debug', arguments); } }; _websocketSupported() { if (this._config.websocket !== null) { return true; } return !(typeof WebSocket !== 'function' && typeof WebSocket !== 'object'); }; _setFormat(format) { if (this._formatOverride(format)) { return; } if (format === 'protobuf') { throw new Error('not implemented by JSON only Centrifuge client – use client with Protobuf'); } this._binary = false; this._methodType = JsonMethodType; this._pushType = JsonPushType; this._encoder = new JsonEncoder(); this._decoder = new JsonDecoder(); } _formatOverride(format) { return false; } _configure(configuration) { if (!('Promise' in global)) { throw new Error('Promise polyfill required'); } extend(this._config, configuration || {}); this._debug('centrifuge config', this._config); if (!this._url) { throw new Error('url required'); } if (startsWith(this._url, 'ws') && this._url.indexOf('format=protobuf') > -1) { this._setFormat('protobuf'); } else { this._setFormat('json'); } if (startsWith(this._url, 'http')) { this._debug('client will try to connect to SockJS endpoint'); if (this._config.sockjs !== null) { this._debug('SockJS explicitly provided in options'); this._sockjs = this._config.sockjs; } else { if (typeof global.SockJS === 'undefined') { throw new Error('SockJS not found, use ws:// in url or include SockJS'); } this._debug('use globally defined SockJS'); this._sockjs = global.SockJS; } } else { this._debug('client will connect to websocket endpoint'); } }; _setStatus(newStatus) { if (this._status !== newStatus) { this._debug('Status', this._status, '->', newStatus); this._status = newStatus; } }; _isDisconnected() { return this._status === 'disconnected'; }; _isConnecting() { return this._status === 'connecting'; }; _isConnected() { return this._status === 'connected'; }; _nextMessageId() { return ++this._messageId; }; _resetRetry() { this._debug('reset retries count to 0'); this._retries = 0; }; _getRetryInterval() { const interval = backoff(this._retries, this._config.minRetry, this._config.maxRetry); this._retries += 1; return interval; }; _abortInflightXHRs() { for (const xhrID in this._xhrs) { try { this._xhrs[xhrID].abort(); } catch (e) { this._debug('error aborting xhr', e); } delete this._xhrs[xhrID]; } }; _clearConnectedState(reconnect) { this._clientID = null; this._stopPing(); // fire errbacks of registered outgoing calls. for (const id in this._callbacks) { if (this._callbacks.hasOwnProperty(id)) { const callbacks = this._callbacks[id]; clearTimeout(callbacks.timeout); const errback = callbacks.errback; if (!errback) { continue; } errback({error: this._createErrorObject('disconnected')}); } } this._callbacks = {}; // fire unsubscribe events for (const channel in this._subs) { if (this._subs.hasOwnProperty(channel)) { const sub = this._subs[channel]; if (reconnect) { if (sub._isSuccess()) { sub._triggerUnsubscribe(); sub._recover = true; } if (sub._shouldResubscribe()) { sub._setSubscribing(); } } else { sub._setUnsubscribed(); } } } this._abortInflightXHRs(); // clear refresh timer if (this._refreshTimeout !== null) { clearTimeout(this._refreshTimeout); this._refreshTimeout = null; } // clear sub refresh timers for (const channel in this._subRefreshTimeouts) { if (this._subRefreshTimeouts.hasOwnProperty(channel) && this._subRefreshTimeouts[channel]) { this._clearSubRefreshTimeout(channel); } } this._subRefreshTimeouts = {}; if (!this._reconnect) { // completely clear subscriptions this._subs = {}; } }; _isTransportOpen() { if (this._isSockjs) { return this._transport && this._transport.transport && this._transport.transport.readyState === this._transport.transport.OPEN; } return this._transport && this._transport.readyState === this._transport.OPEN; }; _transportSend(commands) { if (!commands.length) { return true; } if (!this._isTransportOpen()) { // resolve pending commands with error if transport is not open for (let command in commands) { let id = command.id; if (!(id in this._callbacks)) { continue; } const callbacks = this._callbacks[id]; clearTimeout(this._callbacks[id].timeout); delete this._callbacks[id]; const errback = callbacks.errback; errback({error: this._createErrorObject(_errorConnectionClosed, 0)}); } return false; } this._transport.send(this._encoder.encodeCommands(commands)); return true; } _setupTransport() { this._isSockjs = false; // detect transport to use - SockJS or Websocket if (this._sockjs !== null) { const sockjsOptions = { transports: this._config.sockjsTransports }; if (this._config.sockjsServer !== null) { sockjsOptions.server = this._config.sockjsServer; } this._isSockjs = true; this._transport = new this._sockjs(this._url, null, sockjsOptions); } else { if (!this._websocketSupported()) { this._debug('No Websocket support and no SockJS configured, can not connect'); return; } if (this._config.websocket !== null) { this._websocket = this._config.websocket; } else { this._websocket = WebSocket; } this._transport = new this._websocket(this._url); if (this._binary === true) { this._transport.binaryType = 'arraybuffer'; } } this._transport.onopen = () => { this._transportClosed = false; if (this._isSockjs) { this._transportName = 'sockjs-' + this._transport.transport; this._transport.onheartbeat = () => this._restartPing(); } else { this._transportName = 'websocket'; } // Can omit method here due to zero value. const msg = { // method: this._methodType.CONNECT }; if (this._token || this._connectData) { msg.params = {}; } if (this._token) { msg.params.token = this._token; } if (this._connectData) { msg.params.data = this._connectData; } let subs = {}; let hasSubs = false; for (const channel in this._serverSubs) { if (this._serverSubs.hasOwnProperty(channel) && this._serverSubs[channel].recoverable) { hasSubs = true; let sub = { 'recover': true }; if (this._serverSubs[channel].seq || this._serverSubs[channel].gen) { if (this._serverSubs[channel].seq) { sub['seq'] = this._serverSubs[channel].seq; } if (this._serverSubs[channel].gen) { sub['gen'] = this._serverSubs[channel].gen; } } else { if (this._serverSubs[channel].offset) { sub['offset'] = this._serverSubs[channel].offset; } } if (this._serverSubs[channel].epoch) { sub['epoch'] = this._serverSubs[channel].epoch; } subs[channel] = sub; } } if (hasSubs) { if (!msg.params) {msg.params = {};} msg.params.subs = subs; } this._latencyStart = new Date(); this._call(msg).then(resolveCtx => { this._connectResponse(this._decoder.decodeCommandResult(this._methodType.CONNECT, resolveCtx.result), hasSubs); if (resolveCtx.next) { resolveCtx.next(); } }, rejectCtx => { const err = rejectCtx.error; if (err.code === 109) { // token expired. this._refreshRequired = true; } this._disconnect('connect error', true); if (rejectCtx.next) { rejectCtx.next(); } }); }; this._transport.onerror = error => { this._debug('transport level error', error); }; this._transport.onclose = closeEvent => { this._transportClosed = true; let reason = _errorConnectionClosed; let needReconnect = true; if (closeEvent && 'reason' in closeEvent && closeEvent.reason) { try { const advice = JSON.parse(closeEvent.reason); this._debug('reason is an advice object', advice); reason = advice.reason; needReconnect = advice.reconnect; } catch (e) { reason = closeEvent.reason; this._debug('reason is a plain string', reason); } } // onTransportClose callback should be executed every time transport was closed. // This can be helpful to catch failed connection events (because our disconnect // event only called once and every future attempts to connect do not fire disconnect // event again). if (this._config.onTransportClose !== null) { this._config.onTransportClose({ event: closeEvent, reason: reason, reconnect: needReconnect }); } this._disconnect(reason, needReconnect); if (this._reconnect === true) { this._reconnecting = true; const interval = this._getRetryInterval(); this._debug('reconnect after ' + interval + ' milliseconds'); setTimeout(() => { if (this._reconnect === true) { if (this._refreshRequired) { this._refresh(); } else { this._connect(); } } }, interval); } }; this._transport.onmessage = event => { this._dataReceived(event.data); }; }; rpc(data) { return this._rpc('', data); } namedRPC(method, data) { return this._rpc(method, data); } _rpc(method, data) { let params = { data: data }; if (method !== '') { params.method = method; }; const msg = { method: this._methodType.RPC, params: params }; if (!this.isConnected()) { return Promise.reject(this._createErrorObject(_errorConnectionClosed, 0)); } return this._call(msg).then(resolveCtx => { if (resolveCtx.next) { resolveCtx.next(); } return this._decoder.decodeCommandResult(this._methodType.RPC, resolveCtx.result); }, rejectCtx => { if (rejectCtx.next) { rejectCtx.next(); } return Promise.reject(rejectCtx.error); }); } send(data) { const msg = { method: this._methodType.SEND, params: { data: data } }; if (!this.isConnected()) { return Promise.reject(this._createErrorObject(_errorConnectionClosed, 0)); } const sent = this._transportSend([msg]); // can send async message to server without id set if (!sent) { return Promise.reject(this._createErrorObject(_errorConnectionClosed, 0)); }; return Promise.resolve({}); } publish(channel, data) { const msg = { method: this._methodType.PUBLISH, params: { channel: channel, data: data } }; if (!this.isConnected()) { return Promise.reject(this._createErrorObject(_errorConnectionClosed, 0)); } return this._call(msg).then(result => { if (result.next) { result.next(); } return {}; }); } _dataReceived(data) { const replies = this._decoder.decodeReplies(data); // we have to guarantee order of events in replies processing - i.e. start processing // next reply only when we finished processing of current one. Without syncing things in // this way we could get wrong publication events order as reply promises resolve // on next loop tick so for loop continues before we finished emitting all reply events. this._dispatchPromise = this._dispatchPromise.then(() => { let finishDispatch; this._dispatchPromise = new Promise(resolve =>{ finishDispatch = resolve; }); this._dispatchSynchronized(replies, finishDispatch); }); this._restartPing(); } _dispatchSynchronized(replies, finishDispatch) { let p = Promise.resolve(); for (const i in replies) { if (replies.hasOwnProperty(i)) { p = p.then(() => { return this._dispatchReply(replies[i]); }); } } p = p.then(() => { finishDispatch(); }); } _dispatchReply(reply) { var next; const p = new Promise(resolve =>{ next = resolve; }); if (reply === undefined || reply === null) { this._debug('dispatch: got undefined or null reply'); next(); return p; } const id = reply.id; if (id && id > 0) { this._handleReply(reply, next); } else { this._handlePush(reply.result, next); } return p; }; _call(msg) { return new Promise((resolve, reject) => { const id = this._addMessage(msg); this._registerCall(id, resolve, reject); }); } _connect() { if (this.isConnected()) { this._debug('connect called when already connected'); return; } if (this._status === 'connecting') { return; } this._debug('start connecting'); this._setStatus('connecting'); this._clientID = null; this._reconnect = true; this._setupTransport(); }; _disconnect(reason, shouldReconnect) { const reconnect = shouldReconnect || false; if (reconnect === false) { this._reconnect = false; } if (this._isDisconnected()) { if (!reconnect) { this._clearConnectedState(reconnect); } return; } this._clearConnectedState(reconnect); this._debug('disconnected:', reason, shouldReconnect); this._setStatus('disconnected'); if (this._refreshTimeout) { clearTimeout(this._refreshTimeout); this._refreshTimeout = null; } if (this._reconnecting === false) { // fire unsubscribe events for server side subs. for (const channel in this._serverSubs) { if (this._serverSubs.hasOwnProperty(channel)) { this.emit('unsubscribe', {channel: channel}); } } this.emit('disconnect', { reason: reason, reconnect: reconnect }); } if (reconnect === false) { this._subs = {}; this._serverSubs = {}; } if (!this._transportClosed) { this._transport.close(); } }; _refreshFailed() { this._numRefreshFailed = 0; if (!this._isDisconnected()) { this._disconnect('refresh failed', false); } if (this._config.onRefreshFailed !== null) { this._config.onRefreshFailed(); } }; _refresh() { // ask application for new connection token. this._debug('refresh token'); if (this._config.refreshAttempts === 0) { this._debug('refresh attempts set to 0, do not send refresh request at all'); this._refreshFailed(); return; } if (this._refreshTimeout !== null) { clearTimeout(this._refreshTimeout); this._refreshTimeout = null; } const clientID = this._clientID; const xhrID = this._newXHRID(); const cb = (resp) => { if (xhrID in this._xhrs) { delete this._xhrs[xhrID]; } if (this._clientID !== clientID) { return; } if (resp.error || resp.status !== 200) { // We don't perform any connection status related actions here as we are // relying on server that must close connection eventually. if (resp.error) { this._debug('error refreshing connection token', resp.error); } else { this._debug('error refreshing connection token: wrong status code', resp.status); } this._numRefreshFailed++; if (this._refreshTimeout !== null) { clearTimeout(this._refreshTimeout); this._refreshTimeout = null; } if (this._config.refreshAttempts !== null && this._numRefreshFailed >= this._config.refreshAttempts) { this._refreshFailed(); return; } const jitter = Math.round(Math.random() * 1000 * Math.max(this._numRefreshFailed, 20)); const interval = this._config.refreshInterval + jitter; this._refreshTimeout = setTimeout(() => this._refresh(), interval); return; } this._numRefreshFailed = 0; this._token = resp.data.token; if (!this._token) { this._refreshFailed(); return; } if (this._isDisconnected() && this._reconnect) { this._debug('token refreshed, connect from scratch'); this._connect(); } else { this._debug('send refreshed token'); const msg = { method: this._methodType.REFRESH, params: { token: this._token } }; this._call(msg).then(resolveCtx => { this._refreshResponse(this._decoder.decodeCommandResult(this._methodType.REFRESH, resolveCtx.result)); if (resolveCtx.next) { resolveCtx.next(); } }, rejectCtx => { this._refreshError(rejectCtx.error); if (rejectCtx.next) { rejectCtx.next(); } }); } }; if (this._config.onRefresh !== null) { const context = {}; this._config.onRefresh(context, cb); } else { const xhr = this._ajax( this._config.refreshEndpoint, this._config.refreshParams, this._config.refreshHeaders, this._config.refreshData, cb ); this._xhrs[xhrID] = xhr; } }; _refreshError(err) { this._debug('refresh error', err); if (this._refreshTimeout) { clearTimeout(this._refreshTimeout); this._refreshTimeout = null; } const interval = this._config.refreshInterval + Math.round(Math.random() * 1000); this._refreshTimeout = setTimeout(() => this._refresh(), interval); } _refreshResponse(result) { if (this._refreshTimeout) { clearTimeout(this._refreshTimeout); this._refreshTimeout = null; } if (result.expires) { this._clientID = result.client; this._refreshTimeout = setTimeout(() => this._refresh(), this._getTTLMilliseconds(result.ttl)); } }; _newXHRID() { this._xhrID++; return this._xhrID; } _subRefresh(channel) { this._debug('refresh subscription token for channel', channel); if (this._subRefreshTimeouts[channel] !== undefined) { this._clearSubRefreshTimeout(channel); } else { return; } const clientID = this._clientID; const xhrID = this._newXHRID(); const cb = (resp) => { if (xhrID in this._xhrs) { delete this._xhrs[xhrID]; } if (resp.error || resp.status !== 200 || this._clientID !== clientID) { return; } let channelsData = {}; if (resp.data.channels) { for (const i in resp.data.channels) { const channelData = resp.data.channels[i]; if (!channelData.channel) { continue; } channelsData[channelData.channel] = channelData.token; } } const token = channelsData[channel]; if (!token) { return; } const msg = { method: this._methodType.SUB_REFRESH, params: { channel: channel, token: token } }; const sub = this._getSub(channel); if (sub === null) { return; } this._call(msg).then(resolveCtx => { this._subRefreshResponse( channel, this._decoder.decodeCommandResult(this._methodType.SUB_REFRESH, resolveCtx.result) ); if (resolveCtx.next) { resolveCtx.next(); } }, rejectCtx => { this._subRefreshError(channel, rejectCtx.error); if (rejectCtx.next) { rejectCtx.next(); } }); }; const data = { client: this._clientID, channels: [channel] }; if (this._config.onPrivateSubscribe !== null) { this._config.onPrivateSubscribe({ data: data }, cb); } else { const xhr = this._ajax( this._config.subscribeEndpoint, this._config.subscribeParams, this._config.subscribeHeaders, data, cb); this._xhrs[xhrID] = xhr; } }; _clearSubRefreshTimeout(channel) { if (this._subRefreshTimeouts[channel] !== undefined) { clearTimeout(this._subRefreshTimeouts[channel]); delete this._subRefreshTimeouts[channel]; } } _subRefreshError(channel, err) { this._debug('subscription refresh error', channel, err); this._clearSubRefreshTimeout(channel); const sub = this._getSub(channel); if (sub === null) { return; } const jitter = Math.round(Math.random() * 1000); let subRefreshTimeout = setTimeout(() => this._subRefresh(channel), this._config.subRefreshInterval + jitter); this._subRefreshTimeouts[channel] = subRefreshTimeout; return; } _subRefreshResponse(channel, result) { this._debug('subscription refresh success', channel); this._clearSubRefreshTimeout(channel); const sub = this._getSub(channel); if (sub === null) { return; } if (result.expires === true) { let subRefreshTimeout = setTimeout(() => this._subRefresh(channel), this._getTTLMilliseconds(result.ttl)); this._subRefreshTimeouts[channel] = subRefreshTimeout; } return; }; _subscribe(sub, isResubscribe) { this._debug('subscribing on', sub.channel); const channel = sub.channel; if (!(channel in this._subs)) { this._subs[channel] = sub; } if (!this.isConnected()) { // subscribe will be called later sub._setNew(); return; } sub._setSubscribing(isResubscribe); const msg = { method: this._methodType.SUBSCRIBE, params: { channel: channel } }; // If channel name does not start with privateChannelPrefix - then we // can just send subscription message to Centrifuge. If channel name // starts with privateChannelPrefix - then this is a private channel // and we should ask web application backend for permission first. if (startsWith(channel, this._config.privateChannelPrefix)) { // private channel. if (this._isSubscribeBatching) { this._privateChannels[channel] = true; } else { this.startSubscribeBatching(); this._subscribe(sub); this.stopSubscribeBatching(); } } else { const recover = sub._needRecover(); if (recover === true) { msg.params.recover = true; const seq = this._getLastSeq(channel); const gen = this._getLastGen(channel); if (seq || gen) { if (seq) { msg.params.seq = seq; } if (gen) { msg.params.gen = gen; } } else { const offset = this._getLastOffset(channel); if (offset) { msg.params.offset = offset; } } const epoch = this._getLastEpoch(channel); if (epoch) { msg.params.epoch = epoch; } } this._call(msg).then(resolveCtx => { this._subscribeResponse( channel, recover, this._decoder.decodeCommandResult(this._methodType.SUBSCRIBE, resolveCtx.result) ); if (resolveCtx.next) { resolveCtx.next(); } }, rejectCtx => { this._subscribeError(channel, rejectCtx.error); if (rejectCtx.next) { rejectCtx.next(); } }); } }; _unsubscribe(sub) { delete this._subs[sub.channel]; delete this._lastOffset[sub.channel]; delete this._lastSeq[sub.channel]; delete this._lastGen[sub.channel]; if (this.isConnected()) { // No need to unsubscribe in disconnected state - i.e. client already unsubscribed. this._addMessage({ method: this._methodType.UNSUBSCRIBE, params: { channel: sub.channel } }); } }; _getTTLMilliseconds(ttl) { // https://stackoverflow.com/questions/12633405/what-is-the-maximum-delay-for-setinterval return Math.min(ttl * 1000, 2147483647); } getSub(channel) { return this._getSub(channel); } _getSub(channel) { const sub = this._subs[channel]; if (!sub) { return null; } return sub; }; _isServerSub(channel) { return this._serverSubs[channel] !== undefined; }; _connectResponse(result, isRecover) { const wasReconnecting = this._reconnecting; this._reconnecting = false; this._resetRetry(); this._refreshRequired = false; if (this.isConnected()) { return; } if (this._latencyStart !== null) { this._latency = (new Date()).getTime() - this._latencyStart.getTime(); this._latencyStart = null; } this._clientID = result.client; this._setStatus('connected'); if (this._refreshTimeout) { clearTimeout(this._refreshTimeout); } if (result.expires) { this._refreshTimeout = setTimeout(() => this._refresh(), this._getTTLMilliseconds(result.ttl)); } this.startBatching(); this.startSubscribeBatching(); for (const channel in this._subs) { if (this._subs.hasOwnProperty(channel)) { const sub = this._subs[channel]; if (sub._shouldResubscribe()) { this._subscribe(sub, wasReconnecting); } } } this.stopSubscribeBatching(); this.stopBatching(); this._startPing(); const ctx = { client: result.client, transport: this._transportName, latency: this._latency }; if (result.data) { ctx.data = result.data; } this.emit('connect', ctx); if (result.subs) { this._processServerSubs(result.subs, isRecover); } }; _processServerSubs(subs, isRecover) { for (const channel in subs) { if (subs.hasOwnProperty(channel)) { const sub = subs[channel]; const recovered = sub.recovered === true; let subCtx = {channel: channel, isResubscribe: isRecover, recovered: recovered}; this.emit('subscribe', subCtx); } } for (const channel in subs) { if (subs.hasOwnProperty(channel)) { const sub = subs[channel]; if (sub.recovered) { let pubs = sub.publications; if (pubs && pubs.length > 0) { // handle legacy order. // TODO: remove as soon as Centrifuge v1 released. if (pubs.length > 1 && (!pubs[0].offset || pubs[0].offset > pubs[1].offset)) { pubs = pubs.reverse(); } for (let i in pubs) { if (pubs.hasOwnProperty(i)) { this._handlePublication(channel, pubs[i]); } } } } this._serverSubs[channel] = { 'seq': sub.seq, 'gen': sub.gen, 'offset': sub.offset, 'epoch': sub.epoch, 'recoverable': sub.recoverable }; } } }; _stopPing() { if (this._pongTimeout !== null) { clearTimeout(this._pongTimeout); this._pongTimeout = null; } if (this._pingTimeout !== null) { clearTimeout(this._pingTimeout); this._pingTimeout = null; } }; _startPing() { if (this._config.ping !== true || this._config.pingInterval <= 0) { return; } if (!this.isConnected()) { return; } this._pingTimeout = setTimeout(() => { if (!this.isConnected()) { this._stopPing(); return; } this.ping(); this._pongTimeout = setTimeout(() => { this._disconnect('no ping', true); }, this._config.pongWaitTimeout); }, this._config.pingInterval); }; _restartPing() { this._stopPing(); this._startPing(); }; _subscribeError(channel, error) { const sub = this._getSub(channel); if (!sub) { return; } if (!sub._isSubscribing()) { return; } if (error.code === 0 && error.message === _errorTimeout) { // client side timeout. this._disconnect('timeout', true); return; } sub._setSubscribeError(error); }; _subscribeResponse(channel, isRecover, result) { const sub = this._getSub(channel); if (!sub) { return; } if (!sub._isSubscribing()) { return; } let recovered = false; if ('recovered' in result) { recovered = result.recovered; } sub._setSubscribeSuccess(recovered); let pubs = result.publications; if (pubs && pubs.length > 0) { if (pubs.length >= 2 && !pubs[0].offset && !pubs[1].offset) { // handle legacy order. pubs = pubs.reverse(); } for (let i in pubs) { if (pubs.hasOwnProperty(i)) { this._handlePublication(channel, pubs[i]); } } } if (result.recoverable && (!isRecover || !recovered)) { this._lastSeq[channel] = result.seq || 0; this._lastGen[channel] = result.gen || 0; this._lastOffset[channel] = result.offset || 0; } this._lastEpoch[channel] = result.epoch || ''; if (result.recoverable) { sub._recoverable = true; } if (result.expires === true) { let subRefreshTimeout = setTimeout(() => this._subRefresh(channel), this._getTTLMilliseconds(result.ttl)); this._subRefreshTimeouts[channel] = subRefreshTimeout; } }; _handleReply(reply, next) { const id = reply.id; const result = reply.result; if (!(id in this._callbacks)) { next(); return; } const callbacks = this._callbacks[id]; clearTimeout(this._callbacks[id].timeout); delete this._callbacks[id]; if (!errorExists(reply)) { const callback = callbacks.callback; if (!callback) { return; } callback({result, next}); } else { const errback = callbacks.errback; if (!errback) { next(); return; } const error = reply.error; errback({error, next}); } } _handleJoin(channel, join) { const ctx = {'info': join.info}; const sub = this._getSub(channel); if (!sub) { if (this._isServerSub(channel)) { ctx.channel = channel; this.emit('join', ctx); } return; } sub.emit('join', ctx); }; _handleLeave(channel, leave) { const ctx = {'info': leave.info}; const sub = this._getSub(channel); if (!sub) { if (this._isServerSub(channel)) { ctx.channel = channel; this.emit('leave', ctx); } return; } sub.emit('leave', ctx); }; _handleUnsub(channel, unsub) { const ctx = {}; const sub = this._getSub(channel); if (!sub) { if (this._isServerSub(channel)) { delete this._serverSubs[channel]; ctx.channel = channel; this.emit('unsubscribe', ctx); } return; } sub.unsubscribe(); if (unsub.resubscribe === true) { sub.subscribe(); } }; _handleSub(channel, sub) { this._serverSubs[channel] = { 'seq': sub.seq, 'gen': sub.gen, 'offset': sub.offset, 'epoch': sub.epoch, 'recoverable': sub.recoverable }; const ctx = {'channel': channel, isResubscribe: false, recovered: false}; this.emit('subscribe', ctx); }; _handlePublication(channel, pub) { const sub = this._getSub(channel); const ctx = { 'data': pub.data, 'seq': pub.seq, 'gen': pub.gen, 'offset': pub.offset }; if (pub.info) { ctx.info = pub.info; } if (!sub) { if (this._isServerSub(channel)) { if (pub.seq !== undefined) { this._serverSubs[channel].seq = pub.seq; } if (pub.gen !== undefined) { this._serverSubs[channel].gen = pub.gen; } if (pub.offset !== undefined) { this._serverSubs[channel].offset = pub.offset; } ctx.channel = channel; this.emit('publish', ctx); } return; } if (pub.seq !== undefined) { this._lastSeq[channel] = pub.seq; } if (pub.gen !== undefined) { this._lastGen[channel] = pub.gen; } if (pub.offset !== undefined) { this._lastOffset[channel] = pub.offset; } sub.emit('publish', ctx); }; _handleMessage(message) { this.emit('message', message.data); }; _handlePush(data, next) { const push = this._decoder.decodePush(data); let type = 0; if ('type' in push) { type = push['type']; } const channel = push.channel; if (type === this._pushType.PUBLICATION) { const pub = this._decoder.decodePushData(this._pushType.PUBLICATION, push.data); this._handlePublication(channel, pub); } else if (type === this._pushType.MESSAGE) { const message = this._decoder.decodePushData(this._pushType.MESSAGE, push.data); this._handleMessage(message); } else if (type === this._pushType.JOIN) { const join = this._decoder.decodePushData(this._pushType.JOIN, push.data); this._handleJoin(channel, join); } else if (type === this._pushType.LEAVE) { const leave = this._decoder.decodePushData(this._pushType.LEAVE, push.data); this._handleLeave(channel, leave); } else if (type === this._pushType.UNSUB) { const unsub = this._decoder.decodePushData(this._pushType.UNSUB, push.data); this._handleUnsub(channel, unsub); } else if (type === this._pushType.SUB) { const sub = this._decoder.decodePushData(this._pushType.SUB, push.data); this._handleSub(channel, sub); } next(); } _flush() { const messages = this._messages.slice(0); this._messages = []; this._transportSend(messages); }; _ping() { const msg = { method: this._methodType.PING }; this._call(msg).then(resolveCtx => { this._pingResponse(this._decoder.decodeCommandResult(this._methodType.PING, resolveCtx.result)); if (resolveCtx.next) { resolveCtx.next(); } }, rejectCtx => { this._debug('ping error', rejectCtx.error); if (rejectCtx.next) { rejectCtx.next(); } }); }; _pingResponse(result) { if (!this.isConnected()) { return; } this._stopPing(); this._startPing(); } _getLastSeq(channel) { const lastSeq = this._lastSeq[channel]; if (lastSeq) { return lastSeq; } return 0; }; _getLastOffset(channel) { const lastOffset = this._lastOffset[channel]; if (lastOffset) { return lastOffset; } return 0; }; _getLastGen(channel) { const lastGen = this._lastGen[channel]; if (lastGen) { return lastGen; } return 0; }; _getLastEpoch(channel) { const lastEpoch = this._lastEpoch[channel]; if (lastEpoch) { return lastEpoch; } return ''; }; _createErrorObject(message, code) { const errObject = { message: message, code: code || 0 }; return errObject; }; _registerCall(id, callback, errback) { this._callbacks[id] = { callback: callback, errback: errback, timeout: null }; this._callbacks[id].timeout = setTimeout(() => { delete this._callbacks[id]; if (isFunction(errback)) { errback({error: this._createErrorObject(_errorTimeout)}); } }, this._config.timeout); }; _addMessage(message) { let id = this._nextMessageId(); message.id = id; if (this._isBatching === true) { this._messages.push(message); } else { this._transportSend([message]); } return id; }; isConnected() { return this._isConnected(); } connect() { this._connect(); }; disconnect() { this._disconnect('client', false); }; ping() { return this._ping(); } startBatching() { // start collecting messages without sending them to Centrifuge until flush // method called this._isBatching = true; }; stopBatching() { this._isBatching = false; this._flush(); }; startSubscribeBatching() { // start collecting private channels to create bulk authentication // request to subscribeEndpoint when stopSubscribeBatching will be called this._isSubscribeBatching = true; }; stopSubscribeBatching() { // create request to subscribeEndpoint with collected private channels // to ask if this client can subscribe on each channel this._isSubscribeBatching = false; const authChannels = this._privateChannels; this._privateChannels = {}; const channels = []; for (const channel in authChannels) { if (authChannels.hasOwnProperty(channel)) { const sub = this._getSub(channel); if (!sub) { continue; } channels.push(channel); } } if (channels.length === 0) { this._debug('no private channels found, no need to make request'); return; } const data = { client: this._clientID, channels: channels }; const clientID = this._clientID; const xhrID = this._newXHRID(); const cb = (resp) => { if (xhrID in this._xhrs) { delete this._xhrs[xhrID]; } if (this._clientID !== clientID) { return; } if (resp.error || resp.status !== 200) { this._debug('authorization request failed'); for (const i in channels) { if (channels.hasOwnProperty(i)) { const channel = channels[i]; this._subscribeError(channel, this._createErrorObject('authorization request failed')); } } return; } let channelsData = {}; if (resp.data.channels) { for (const i in resp.data.channels) { const channelData = resp.data.channels[i]; if (!channelData.channel) { continue; } channelsData[channelData.channel] = channelData.token; } } // try to send all subscriptions in one request. let batch = false; if (!this._isBatching) { this.startBatching(); batch = true; } for (const i in channels) { if (channels.hasOwnProperty(i)) { const channel = channels[i]; const token = channelsData[channel]; if (!token) { // subscription:error this._subscribeError(channel, this._createErrorObject('permission denied', 103)); continue; } else { const msg = { method: this._methodType.SUBSCRIBE, params: { channel: channel, token: token } }; const sub = this._getSub(channel); if (sub === null) { continue; } const recover = sub._needRecover(); if (recover === true) { msg.params.recover = true; const seq = this._getLastSeq(channel); const gen = this._getLastGen(channel); if (seq || gen) { if (seq) { msg.params.seq = seq; } if (gen) { msg.params.gen = gen; } } else { const offset = this._getLastOffset(channel); if (offset) { msg.params.offset = offset; } } const epoch = this._getLastEpoch(channel); if (epoch) { msg.params.epoch = epoch; } } this._call(msg).then(resolveCtx => { this._subscribeResponse( channel, recover, this._decoder.decodeCommandResult(this._methodType.SUBSCRIBE, resolveCtx.result) ); if (resolveCtx.next) { resolveCtx.next(); } }, rejectCtx => { this._subscribeError(channel, rejectCtx.error); if (rejectCtx.next) { rejectCtx.next(); } }); } } } if (batch) { this.stopBatching(); } }; if (this._config.onPrivateSubscribe !== null) { this._config.onPrivateSubscribe({ data: data }, cb); } else { const xhr = this._ajax( this._config.subscribeEndpoint, this._config.subscribeParams, this._config.subscribeHeaders, data, cb); this._xhrs[xhrID] = xhr; } }; subscribe(channel, events, options) { const currentSub = this._getSub(channel); if (currentSub !== null) { currentSub._setEvents(events); if (currentSub._isUnsubscribed()) { currentSub.subscribe(); } return currentSub; } const sub = new Subscription(this, channel, events); sub._recover = options.recover; sub._recoverable = options.recoverable; this._lastOffset[channel] = options.offset; this._subs[channel] = sub; sub.subscribe(); return sub; }; }