UNPKG

centrifuge

Version:

JavaScript client SDK for bidirectional communication with Centrifugo and Centrifuge-based server from browser, NodeJS and React Native

1,372 lines (1,281 loc) 208 kB
/****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var events = {exports: {}}; var hasRequiredEvents; function requireEvents () { if (hasRequiredEvents) return events.exports; hasRequiredEvents = 1; var R = typeof Reflect === 'object' ? Reflect : null; var ReflectApply = R && typeof R.apply === 'function' ? R.apply : function ReflectApply(target, receiver, args) { return Function.prototype.apply.call(target, receiver, args); }; var ReflectOwnKeys; if (R && typeof R.ownKeys === 'function') { ReflectOwnKeys = R.ownKeys; } else if (Object.getOwnPropertySymbols) { ReflectOwnKeys = function ReflectOwnKeys(target) { return Object.getOwnPropertyNames(target) .concat(Object.getOwnPropertySymbols(target)); }; } else { ReflectOwnKeys = function ReflectOwnKeys(target) { return Object.getOwnPropertyNames(target); }; } function ProcessEmitWarning(warning) { if (console && console.warn) console.warn(warning); } var NumberIsNaN = Number.isNaN || function NumberIsNaN(value) { return value !== value; }; function EventEmitter() { EventEmitter.init.call(this); } events.exports = EventEmitter; events.exports.once = once; // Backwards-compat with node 0.10.x EventEmitter.EventEmitter = EventEmitter; EventEmitter.prototype._events = undefined; EventEmitter.prototype._eventsCount = 0; EventEmitter.prototype._maxListeners = undefined; // By default EventEmitters will print a warning if more than 10 listeners are // added to it. This is a useful default which helps finding memory leaks. var defaultMaxListeners = 10; function checkListener(listener) { if (typeof listener !== 'function') { throw new TypeError('The "listener" argument must be of type Function. Received type ' + typeof listener); } } Object.defineProperty(EventEmitter, 'defaultMaxListeners', { enumerable: true, get: function() { return defaultMaxListeners; }, set: function(arg) { if (typeof arg !== 'number' || arg < 0 || NumberIsNaN(arg)) { throw new RangeError('The value of "defaultMaxListeners" is out of range. It must be a non-negative number. Received ' + arg + '.'); } defaultMaxListeners = arg; } }); EventEmitter.init = function() { if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { this._events = Object.create(null); this._eventsCount = 0; } this._maxListeners = this._maxListeners || undefined; }; // Obviously not all Emitters should be limited to 10. This function allows // that to be increased. Set to zero for unlimited. EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) { if (typeof n !== 'number' || n < 0 || NumberIsNaN(n)) { throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received ' + n + '.'); } this._maxListeners = n; return this; }; function _getMaxListeners(that) { if (that._maxListeners === undefined) return EventEmitter.defaultMaxListeners; return that._maxListeners; } EventEmitter.prototype.getMaxListeners = function getMaxListeners() { return _getMaxListeners(this); }; EventEmitter.prototype.emit = function emit(type) { var args = []; for (var i = 1; i < arguments.length; i++) args.push(arguments[i]); var doError = (type === 'error'); var events = this._events; if (events !== undefined) doError = (doError && events.error === undefined); else if (!doError) return false; // If there is no 'error' event listener then throw. if (doError) { var er; if (args.length > 0) er = args[0]; if (er instanceof Error) { // Note: The comments on the `throw` lines are intentional, they show // up in Node's output if this results in an unhandled exception. throw er; // Unhandled 'error' event } // At least give some kind of context to the user var err = new Error('Unhandled error.' + (er ? ' (' + er.message + ')' : '')); err.context = er; throw err; // Unhandled 'error' event } var handler = events[type]; if (handler === undefined) return false; if (typeof handler === 'function') { ReflectApply(handler, this, args); } else { var len = handler.length; var listeners = arrayClone(handler, len); for (var i = 0; i < len; ++i) ReflectApply(listeners[i], this, args); } return true; }; function _addListener(target, type, listener, prepend) { var m; var events; var existing; checkListener(listener); events = target._events; if (events === undefined) { events = target._events = Object.create(null); target._eventsCount = 0; } else { // To avoid recursion in the case that type === "newListener"! Before // adding it to the listeners, first emit "newListener". if (events.newListener !== undefined) { target.emit('newListener', type, listener.listener ? listener.listener : listener); // Re-assign `events` because a newListener handler could have caused the // this._events to be assigned to a new object events = target._events; } existing = events[type]; } if (existing === undefined) { // Optimize the case of one listener. Don't need the extra array object. existing = events[type] = listener; ++target._eventsCount; } else { if (typeof existing === 'function') { // Adding the second element, need to change to array. existing = events[type] = prepend ? [listener, existing] : [existing, listener]; // If we've already got an array, just append. } else if (prepend) { existing.unshift(listener); } else { existing.push(listener); } // Check for listener leak m = _getMaxListeners(target); if (m > 0 && existing.length > m && !existing.warned) { existing.warned = true; // No error code for this since it is a Warning // eslint-disable-next-line no-restricted-syntax var w = new Error('Possible EventEmitter memory leak detected. ' + existing.length + ' ' + String(type) + ' listeners ' + 'added. Use emitter.setMaxListeners() to ' + 'increase limit'); w.name = 'MaxListenersExceededWarning'; w.emitter = target; w.type = type; w.count = existing.length; ProcessEmitWarning(w); } } return target; } EventEmitter.prototype.addListener = function addListener(type, listener) { return _addListener(this, type, listener, false); }; EventEmitter.prototype.on = EventEmitter.prototype.addListener; EventEmitter.prototype.prependListener = function prependListener(type, listener) { return _addListener(this, type, listener, true); }; function onceWrapper() { if (!this.fired) { this.target.removeListener(this.type, this.wrapFn); this.fired = true; if (arguments.length === 0) return this.listener.call(this.target); return this.listener.apply(this.target, arguments); } } function _onceWrap(target, type, listener) { var state = { fired: false, wrapFn: undefined, target: target, type: type, listener: listener }; var wrapped = onceWrapper.bind(state); wrapped.listener = listener; state.wrapFn = wrapped; return wrapped; } EventEmitter.prototype.once = function once(type, listener) { checkListener(listener); this.on(type, _onceWrap(this, type, listener)); return this; }; EventEmitter.prototype.prependOnceListener = function prependOnceListener(type, listener) { checkListener(listener); this.prependListener(type, _onceWrap(this, type, listener)); return this; }; // Emits a 'removeListener' event if and only if the listener was removed. EventEmitter.prototype.removeListener = function removeListener(type, listener) { var list, events, position, i, originalListener; checkListener(listener); events = this._events; if (events === undefined) return this; list = events[type]; if (list === undefined) return this; if (list === listener || list.listener === listener) { if (--this._eventsCount === 0) this._events = Object.create(null); else { delete events[type]; if (events.removeListener) this.emit('removeListener', type, list.listener || listener); } } else if (typeof list !== 'function') { position = -1; for (i = list.length - 1; i >= 0; i--) { if (list[i] === listener || list[i].listener === listener) { originalListener = list[i].listener; position = i; break; } } if (position < 0) return this; if (position === 0) list.shift(); else { spliceOne(list, position); } if (list.length === 1) events[type] = list[0]; if (events.removeListener !== undefined) this.emit('removeListener', type, originalListener || listener); } return this; }; EventEmitter.prototype.off = EventEmitter.prototype.removeListener; EventEmitter.prototype.removeAllListeners = function removeAllListeners(type) { var listeners, events, i; events = this._events; if (events === undefined) return this; // not listening for removeListener, no need to emit if (events.removeListener === undefined) { if (arguments.length === 0) { this._events = Object.create(null); this._eventsCount = 0; } else if (events[type] !== undefined) { if (--this._eventsCount === 0) this._events = Object.create(null); else delete events[type]; } return this; } // emit removeListener for all listeners on all events if (arguments.length === 0) { var keys = Object.keys(events); var key; for (i = 0; i < keys.length; ++i) { key = keys[i]; if (key === 'removeListener') continue; this.removeAllListeners(key); } this.removeAllListeners('removeListener'); this._events = Object.create(null); this._eventsCount = 0; return this; } listeners = events[type]; if (typeof listeners === 'function') { this.removeListener(type, listeners); } else if (listeners !== undefined) { // LIFO order for (i = listeners.length - 1; i >= 0; i--) { this.removeListener(type, listeners[i]); } } return this; }; function _listeners(target, type, unwrap) { var events = target._events; if (events === undefined) return []; var evlistener = events[type]; if (evlistener === undefined) return []; if (typeof evlistener === 'function') return unwrap ? [evlistener.listener || evlistener] : [evlistener]; return unwrap ? unwrapListeners(evlistener) : arrayClone(evlistener, evlistener.length); } EventEmitter.prototype.listeners = function listeners(type) { return _listeners(this, type, true); }; EventEmitter.prototype.rawListeners = function rawListeners(type) { return _listeners(this, type, false); }; EventEmitter.listenerCount = function(emitter, type) { if (typeof emitter.listenerCount === 'function') { return emitter.listenerCount(type); } else { return listenerCount.call(emitter, type); } }; EventEmitter.prototype.listenerCount = listenerCount; function listenerCount(type) { var events = this._events; if (events !== undefined) { var evlistener = events[type]; if (typeof evlistener === 'function') { return 1; } else if (evlistener !== undefined) { return evlistener.length; } } return 0; } EventEmitter.prototype.eventNames = function eventNames() { return this._eventsCount > 0 ? ReflectOwnKeys(this._events) : []; }; function arrayClone(arr, n) { var copy = new Array(n); for (var i = 0; i < n; ++i) copy[i] = arr[i]; return copy; } function spliceOne(list, index) { for (; index + 1 < list.length; index++) list[index] = list[index + 1]; list.pop(); } function unwrapListeners(arr) { var ret = new Array(arr.length); for (var i = 0; i < ret.length; ++i) { ret[i] = arr[i].listener || arr[i]; } return ret; } function once(emitter, name) { return new Promise(function (resolve, reject) { function errorListener(err) { emitter.removeListener(name, resolver); reject(err); } function resolver() { if (typeof emitter.removeListener === 'function') { emitter.removeListener('error', errorListener); } resolve([].slice.call(arguments)); } eventTargetAgnosticAddListener(emitter, name, resolver, { once: true }); if (name !== 'error') { addErrorHandlerIfEventEmitter(emitter, errorListener, { once: true }); } }); } function addErrorHandlerIfEventEmitter(emitter, handler, flags) { if (typeof emitter.on === 'function') { eventTargetAgnosticAddListener(emitter, 'error', handler, flags); } } function eventTargetAgnosticAddListener(emitter, name, listener, flags) { if (typeof emitter.on === 'function') { if (flags.once) { emitter.once(name, listener); } else { emitter.on(name, listener); } } else if (typeof emitter.addEventListener === 'function') { // EventTarget does not have `error` event semantics like Node // EventEmitters, we do not listen for `error` events here. emitter.addEventListener(name, function wrapListener(arg) { // IE does not have builtin `{ once: true }` support so we // have to do it manually. if (flags.once) { emitter.removeEventListener(name, wrapListener); } listener(arg); }); } else { throw new TypeError('The "emitter" argument must be of type EventEmitter. Received type ' + typeof emitter); } } return events.exports; } var eventsExports = requireEvents(); var EventEmitter = /*@__PURE__*/getDefaultExportFromCjs(eventsExports); var errorCodes; (function (errorCodes) { errorCodes[errorCodes["timeout"] = 1] = "timeout"; errorCodes[errorCodes["transportClosed"] = 2] = "transportClosed"; errorCodes[errorCodes["clientDisconnected"] = 3] = "clientDisconnected"; errorCodes[errorCodes["clientClosed"] = 4] = "clientClosed"; errorCodes[errorCodes["clientConnectToken"] = 5] = "clientConnectToken"; errorCodes[errorCodes["clientRefreshToken"] = 6] = "clientRefreshToken"; errorCodes[errorCodes["subscriptionUnsubscribed"] = 7] = "subscriptionUnsubscribed"; errorCodes[errorCodes["subscriptionSubscribeToken"] = 8] = "subscriptionSubscribeToken"; errorCodes[errorCodes["subscriptionRefreshToken"] = 9] = "subscriptionRefreshToken"; errorCodes[errorCodes["transportWriteError"] = 10] = "transportWriteError"; errorCodes[errorCodes["connectionClosed"] = 11] = "connectionClosed"; errorCodes[errorCodes["badConfiguration"] = 12] = "badConfiguration"; errorCodes[errorCodes["subscriptionGetState"] = 13] = "subscriptionGetState"; errorCodes[errorCodes["sharedPollGetSignature"] = 14] = "sharedPollGetSignature"; })(errorCodes || (errorCodes = {})); var connectingCodes; (function (connectingCodes) { connectingCodes[connectingCodes["connectCalled"] = 0] = "connectCalled"; connectingCodes[connectingCodes["transportClosed"] = 1] = "transportClosed"; connectingCodes[connectingCodes["noPing"] = 2] = "noPing"; connectingCodes[connectingCodes["subscribeTimeout"] = 3] = "subscribeTimeout"; connectingCodes[connectingCodes["unsubscribeError"] = 4] = "unsubscribeError"; })(connectingCodes || (connectingCodes = {})); var disconnectedCodes; (function (disconnectedCodes) { disconnectedCodes[disconnectedCodes["disconnectCalled"] = 0] = "disconnectCalled"; disconnectedCodes[disconnectedCodes["unauthorized"] = 1] = "unauthorized"; disconnectedCodes[disconnectedCodes["badProtocol"] = 2] = "badProtocol"; disconnectedCodes[disconnectedCodes["messageSizeLimit"] = 3] = "messageSizeLimit"; })(disconnectedCodes || (disconnectedCodes = {})); var subscribingCodes; (function (subscribingCodes) { subscribingCodes[subscribingCodes["subscribeCalled"] = 0] = "subscribeCalled"; subscribingCodes[subscribingCodes["transportClosed"] = 1] = "transportClosed"; })(subscribingCodes || (subscribingCodes = {})); var unsubscribedCodes; (function (unsubscribedCodes) { unsubscribedCodes[unsubscribedCodes["unsubscribeCalled"] = 0] = "unsubscribeCalled"; unsubscribedCodes[unsubscribedCodes["unauthorized"] = 1] = "unauthorized"; unsubscribedCodes[unsubscribedCodes["clientClosed"] = 2] = "clientClosed"; })(unsubscribedCodes || (unsubscribedCodes = {})); var subscriptionFlags; (function (subscriptionFlags) { subscriptionFlags[subscriptionFlags["channelCompaction"] = 1] = "channelCompaction"; subscriptionFlags[subscriptionFlags["rejectUnrecovered"] = 2] = "rejectUnrecovered"; })(subscriptionFlags || (subscriptionFlags = {})); /** State of client. */ var State; (function (State) { State["Disconnected"] = "disconnected"; State["Connecting"] = "connecting"; State["Connected"] = "connected"; })(State || (State = {})); /** State of Subscription */ var SubscriptionState; (function (SubscriptionState) { SubscriptionState["Unsubscribed"] = "unsubscribed"; SubscriptionState["Subscribing"] = "subscribing"; SubscriptionState["Subscribed"] = "subscribed"; })(SubscriptionState || (SubscriptionState = {})); /** @internal */ function startsWith(value, prefix) { return value.lastIndexOf(prefix, 0) === 0; } /** @internal */ function isFunction(value) { if (value === undefined || value === null) { return false; } return typeof value === 'function'; } /** @internal */ function log(level, args) { if (globalThis.console) { const logger = globalThis.console[level]; if (isFunction(logger)) { logger.apply(globalThis.console, args); } } } function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1) + min); } /** @internal */ function backoff(step, min, max) { // Full jitter technique, see: // https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ if (step > 31) { step = 31; } const interval = randomInt(0, Math.min(max, min * Math.pow(2, step))); return Math.min(max, min + interval); } /** @internal */ function errorExists(data) { return 'error' in data && data.error !== null; } /** @internal */ function ttlMilliseconds(ttl) { // https://stackoverflow.com/questions/12633405/what-is-the-maximum-delay-for-setinterval return Math.min(ttl * 1000, 2147483647); } // Internal-only — phases the SDK walks through during a map subscribe. // Not exposed on the public surface; kept here so it doesn't leak via // `export * from "./types"` in index.ts. var MapPhase; (function (MapPhase) { MapPhase[MapPhase["Live"] = 0] = "Live"; MapPhase[MapPhase["Stream"] = 1] = "Stream"; MapPhase[MapPhase["State"] = 2] = "State"; })(MapPhase || (MapPhase = {})); /** Base subscription to a channel — all subscription logic lives here. */ class BaseSubscription extends EventEmitter { /** Subscription constructor should not be used directly, create subscriptions using Client method. */ constructor(centrifuge, channel, options) { super(); this._resubscribeTimeout = null; this._refreshTimeout = null; // Stream getState callback (external state for stream subscriptions) this._getState = null; // Map subscription state this._map = false; this._mapPresenceType = 1; // 1=MAP, 2=MAP_CLIENTS, 3=MAP_USERS // @ts-ignore – this is used for tracking map subscription phase state. this._mapPhase = null; this._mapStateBuffer = []; // Buffer snapshot entries this._mapStreamBuffer = []; // Buffer stream entries during catch-up this._mapCursor = ''; // Pagination cursor this._mapPageSize = 0; // Page size (0 = use server default) this._mapUnrecoverableStrategy = 'from_scratch'; // Publish debounce state (protocol-level, controlled by server) this._debounceMs = 0; this._debouncePending = new Map(); // Shared poll subscription state this._sharedPoll = false; this._sharedPollEpoch = ''; this._sharedPollTrackedItems = new Map(); // key → version this._sharedPollGetSignature = null; // TTL-driven and 109-driven consolidating refresh (one call to getSignature // covering all tracked keys, replaces the library with one entry). this._sharedPollSignatureRefreshTimeout = null; this._sharedPollSignatureRefreshAttempts = 0; // Per-track-command transient retry (track call failed with temporary error). this._sharedPollTrackRetryTimeout = null; this._sharedPollTrackRetryAttempts = 0; // Replay-getSignature retry (Phase 2 of _sharedPollReplayTrack failed). Held // separately so it can't clobber the refresh timer or accumulate backoff // attempts onto refresh failures. this._sharedPollReplayRetryTimeout = null; this._sharedPollReplayRetryAttempts = 0; // Library of HMAC signatures previously obtained for track() calls. // Each entry covers the exact key set originally signed; reused on reconnect // so we don't hit the application's getSignature endpoint for every client // during mass reconnect storms. Periodic refresh consolidates entries into // one combined signature covering all currently tracked keys. this._sharedPollSignatures = []; // Earliest scheduled refresh time (unix milliseconds) across all received // track responses. Server returns MIN ttl per response; SDK keeps the // single earliest deadline as the refresh target. Cleared on consolidation // refresh and on explicit unsubscribe. this._sharedPollSignatureRefreshTargetMs = null; // Guard against concurrent refresh calls — multiple 109 errors during // reconnect replay could otherwise trigger overlapping getSignature calls. this._sharedPollSignatureRefreshInFlight = false; this.channel = channel; this.state = SubscriptionState.Unsubscribed; this._centrifuge = centrifuge; this._token = ''; this._getToken = null; this._data = null; this._getData = null; this._recover = false; this._offset = null; this._epoch = null; this._id = 0; this._recoverable = false; this._positioned = false; this._joinLeave = false; this._minResubscribeDelay = 500; this._maxResubscribeDelay = 20000; this._resubscribeTimeout = null; this._resubscribeAttempts = 0; this._promises = {}; this._promiseId = 0; this._inflight = false; this._refreshTimeout = null; this._delta = ''; this._delta_negotiated = false; this._tagsFilter = null; this._prevValueMap = new Map(); this._unsubPromise = Promise.resolve(); this._deltaNumPubs = 0; this._deltaNumFull = 0; this._deltaNumDelta = 0; this._deltaBytesReceived = 0; this._deltaBytesDecoded = 0; this._setOptions(options); this.type = this._sharedPoll ? 'shared_poll' : this._map ? 'map' : 'stream'; // @ts-ignore – we are hiding some symbols from public API autocompletion. if (this._centrifuge._debugEnabled) { this.on('state', (ctx) => { this._debug('subscription state', channel, ctx.oldState, '->', ctx.newState); }); this.on('error', (ctx) => { this._debug('subscription error', channel, ctx); }); } else { // Avoid unhandled exception in EventEmitter for non-set error handler. this.on('error', function () { Function.prototype(); }); } } /** ready returns a Promise which resolves upon subscription goes to Subscribed * state and rejects in case of subscription goes to Unsubscribed state. * Optional timeout can be passed.*/ ready(timeout) { if (this.state === SubscriptionState.Unsubscribed) { return Promise.reject({ code: errorCodes.subscriptionUnsubscribed, message: this.state }); } if (this.state === SubscriptionState.Subscribed) { return Promise.resolve(); } return new Promise((res, rej) => { const ctx = { resolve: res, reject: rej }; if (timeout) { ctx.timeout = setTimeout(function () { rej({ code: errorCodes.timeout, message: 'timeout' }); }, timeout); } this._promises[this._nextPromiseId()] = ctx; }); } /** subscribe to a channel.*/ subscribe() { if (this._isSubscribed()) { return; } this._resubscribeAttempts = 0; this._setSubscribing(subscribingCodes.subscribeCalled, 'subscribe called'); } /** unsubscribe from a channel, keeping position state.*/ unsubscribe() { this._unsubPromise = this._setUnsubscribed(unsubscribedCodes.unsubscribeCalled, 'unsubscribe called', true); } _debouncedPublish(key, data, isMap) { const existing = this._debouncePending.get(key); if (existing) { // Update pending value, mark as dirty, keep existing timer. existing.data = data; existing.dirty = true; return Promise.resolve({}); } // First publish for this key — send immediately, start debounce timer. const entry = { data, dirty: false, timer: null }; entry.timer = setTimeout(() => { const pending = this._debouncePending.get(key); if (!pending || !pending.dirty) { // No new data since last send — just clean up. this._debouncePending.delete(key); return; } // Send the latest pending value, reset dirty flag, restart timer. pending.dirty = false; const sendData = pending.data; const sendFn = isMap ? this._centrifuge.mapPublish(this.channel, key, sendData) : this._centrifuge.publish(this.channel, sendData); sendFn.catch(() => { }); // Restart timer for next window. pending.timer = setTimeout(() => { const p = this._debouncePending.get(key); if (!p || !p.dirty) { this._debouncePending.delete(key); return; } // Recursive — but in practice debounce windows are short. this._debouncePending.delete(key); this._debouncedPublish(key, p.data, isMap); }, this._debounceMs); }, this._debounceMs); this._debouncePending.set(key, entry); // Send first publish immediately. const sendFn = isMap ? this._centrifuge.mapPublish(this.channel, key, data) : this._centrifuge.publish(this.channel, data); return sendFn; } _cancelDebounce(key) { const existing = this._debouncePending.get(key); if (existing) { clearTimeout(existing.timer); this._debouncePending.delete(key); } } _cancelAllDebounce() { for (const [, entry] of this._debouncePending) { clearTimeout(entry.timer); } this._debouncePending.clear(); } /** get online presence for a channel.*/ presence() { return __awaiter(this, void 0, void 0, function* () { yield this._methodCall(); return this._centrifuge.presence(this.channel); }); } /** presence stats for a channel (num clients and unique users).*/ presenceStats() { return __awaiter(this, void 0, void 0, function* () { yield this._methodCall(); return this._centrifuge.presenceStats(this.channel); }); } /** * Sets server-side tags filter for the subscription. * This only applies on the next subscription attempt, not the current one. * Cannot be used together with delta option. * * @param tagsFilter - Filter configuration object or null to remove filter * @throws {Error} If both delta and tagsFilter are configured * * @example * ```typescript * // Simple equality filter * sub.setTagsFilter({ * key: 'ticker', * cmp: 'eq', * val: 'BTC' * }); * ``` * * @example * ```typescript * // Complex filter with logical operators * sub.setTagsFilter({ * op: 'and', * nodes: [ * { key: 'ticker', cmp: 'eq', val: 'BTC' }, * { key: 'price', cmp: 'gt', val: '50000' } * ] * }); * ``` * * @example * ```typescript * // Filter with IN operator * sub.setTagsFilter({ * key: 'ticker', * cmp: 'in', * vals: ['BTC', 'ETH', 'SOL'] * }); * ``` */ setTagsFilter(tagsFilter) { if (tagsFilter && this._delta) { throw new Error('cannot use delta and tagsFilter together'); } this._tagsFilter = tagsFilter; // For map subscriptions, changing the filter invalidates the local state — // the next subscribe must go through full STATE phase, not stream recovery. if (this._map) { this._recover = false; this._offset = null; this._epoch = null; } } /** setData allows setting subscription data. This only applied on the next subscription attempt, * Note that if getData callback is configured, it will override this value during resubscriptions. */ setData(data) { this._data = data; } /** deltaStats returns delta compression statistics for this subscription. * Only meaningful when delta compression is enabled (delta: 'fossil'). */ deltaStats() { const bytesDecoded = this._deltaBytesDecoded; return { numPublications: this._deltaNumPubs, numFullPayloads: this._deltaNumFull, numDeltaPayloads: this._deltaNumDelta, bytesReceived: this._deltaBytesReceived, bytesDecoded: bytesDecoded, compressionRatio: bytesDecoded > 0 ? 1 - this._deltaBytesReceived / bytesDecoded : 0, }; } _methodCall() { if (this._isSubscribed()) { return Promise.resolve(); } if (this._isUnsubscribed()) { return Promise.reject({ code: errorCodes.subscriptionUnsubscribed, message: this.state }); } return new Promise((resolve, reject) => { // @ts-ignore – we are hiding some symbols from public API autocompletion. const timeoutDuration = this._centrifuge._config.timeout; const timeout = setTimeout(() => { reject({ code: errorCodes.timeout, message: 'timeout' }); }, timeoutDuration); this._promises[this._nextPromiseId()] = { timeout, resolve, reject }; }); } _nextPromiseId() { return ++this._promiseId; } _needRecover() { return this._recover === true; } _isUnsubscribed() { return this.state === SubscriptionState.Unsubscribed; } _isSubscribing() { return this.state === SubscriptionState.Subscribing; } _isSubscribed() { return this.state === SubscriptionState.Subscribed; } _setState(newState) { if (this.state !== newState) { const oldState = this.state; this.state = newState; this.emit('state', { newState, oldState, channel: this.channel }); return true; } return false; } _usesToken() { return this._token !== '' || this._getToken !== null; } _clearSubscribingState() { this._resubscribeAttempts = 0; this._clearResubscribeTimeout(); } _clearSubscribedState() { this._clearRefreshTimeout(); this._clearSharedPollSignatureRefresh(); this._clearSharedPollTrackRetry(); this._clearSharedPollReplayRetry(); // Drop the stale refresh target — the replay after reconnect will set a // fresh one from the next batch of track responses. this._sharedPollSignatureRefreshTargetMs = null; // Reset the in-flight guard so refresh can run again after reconnect. this._sharedPollSignatureRefreshInFlight = false; // NOTE: do NOT clear _sharedPollSignatures or _sharedPollTrackedItems. // They persist across reconnects so _sharedPollReplayTrack can re-send // tracks using the cached signatures — avoids hitting getSignature for // every client during mass reconnect storms. Cleared only on explicit // unsubscribe via _setUnsubscribed. } /** Called when server sends "state invalidated" disconnect (code 3014). * Clears subscription token and resets cached state so next subscribe * obtains a fresh token and does a full state re-sync. Delta base is * cleared for every subscription type (stream/map/shared_poll all use * _prevValueMap for fossil delta) — a stale base would corrupt decoding * of the first publication after re-subscribe. */ _invalidateState() { this._token = ''; this._prevValueMap = new Map(); if (this._map) { this._offset = null; this._epoch = null; this._recover = false; this._mapStateBuffer = []; this._mapStreamBuffer = []; this._mapCursor = ''; this._mapPhase = null; } } _setSubscribed(result) { if (!this._isSubscribing()) { return; } this._clearSubscribingState(); if (result.id) { this._id = result.id; } if (result.recoverable) { this._recover = true; this._offset = result.offset || 0; this._epoch = result.epoch || ''; } if (result.delta) { this._delta_negotiated = true; } else { this._delta_negotiated = false; } if (result.publish_debounce) { this._debounceMs = result.publish_debounce; } if (this._sharedPoll) { const newEpoch = result.epoch || ''; if (this._sharedPollEpoch !== '' && this._sharedPollEpoch !== newEpoch) { // Epoch changed (server restart, mode change, node switch) — // stored synthetic versions are invalid, reset to 0. for (const key of this._sharedPollTrackedItems.keys()) { this._sharedPollTrackedItems.set(key, 0); } } this._sharedPollEpoch = newEpoch; } this._setState(SubscriptionState.Subscribed); // @ts-ignore – we are hiding some methods from public API autocompletion. const ctx = this._centrifuge._getSubscribeContext(this.channel, result); this.emit('subscribed', ctx); this._resolvePromises(); const pubs = result.publications; if (pubs && pubs.length > 0) { for (const i in pubs) { if (!pubs.hasOwnProperty(i)) { continue; } this._handlePublication(pubs[i]); } } if (result.expires === true) { this._refreshTimeout = setTimeout(() => this._refresh(), ttlMilliseconds(result.ttl)); } } _setSubscribing(code, reason) { return __awaiter(this, void 0, void 0, function* () { if (this._isSubscribing()) { return; } if (this._isSubscribed()) { this._clearSubscribedState(); } if (this._setState(SubscriptionState.Subscribing)) { this.emit('subscribing', { channel: this.channel, code: code, reason: reason }); } // @ts-ignore – for performance reasons only await _unsubPromise for emulution case where it's required. if (this._centrifuge._transport && this._centrifuge._transport.emulation()) { yield this._unsubPromise; } if (!this._isSubscribing()) { return; } this._subscribe(); }); } _subscribe() { this._debug('subscribing on', this.channel); if (!this._isTransportOpen()) { this._debug('delay subscribe on', this.channel, 'till connected'); return null; } if (this._inflight) { return null; } this._inflight = true; // Route to map subscribe flow if map mode is enabled if (this._map) { this._mapSubscribe(); return null; } // Stream getState: call app's callback to load state + get position. // Only called when we don't have a saved position (first subscribe or after // position reset due to failed recovery). On normal reconnects with a valid // saved position, we skip getState and let the server try recovery — getState // is only called again if recovery fails (see _setSubscribed). if (this._getState && this._offset === null) { this._loadStreamState(); return null; } if (this._canSubscribeWithoutGettingToken()) { return this._subscribeWithoutToken(); } this._getSubscriptionToken() .then(token => this._handleTokenResponse(token)) .catch(e => this._handleTokenError(e)); return null; } _isTransportOpen() { // @ts-ignore – we are hiding some symbols from public API autocompletion. return this._centrifuge._transportIsOpen; } _canSubscribeWithoutGettingToken() { return !this._usesToken() || !!this._token; } _subscribeWithoutToken() { if (this._getData) { this._getDataAndSubscribe(this._token); return null; } else { return this._sendSubscribe(this._token); } } /** Load stream position from app via getState callback, then proceed to subscribe * with recovery from that position. Called only when _offset is null: * - Initial subscribe (no saved position) * - After position reset due to failed recovery (see _setSubscribed) * * NOT called on normal reconnects where the SDK has a saved position — in that * case recovery is attempted first, and getState is only invoked if recovery fails. * * The app's getState callback should: * 1. Read cf_stream_top_position (or equivalent) FIRST to capture the stream position * 2. Then read its own data from the database/API * 3. Render/update the UI * 4. Return the captured stream position * * This order is critical: reading position first ensures it's a lower bound. * Recovered publications may overlap with data the app already loaded — this * requires idempotent updates or offset-based dedup. */ _loadStreamState() { if (!this._isSubscribing()) { this._inflight = false; return; } this._getState().then(result => { if (!this._isSubscribing()) { this._inflight = false; return; } // Store stream position from app's source of truth. this._offset = result.offset; this._epoch = result.epoch; this._recover = true; // Proceed with normal subscribe flow (token → sendSubscribe). if (this._canSubscribeWithoutGettingToken()) { this._subscribeWithoutToken(); } else { this._getSubscriptionToken() .then(token => this._handleTokenResponse(token)) .catch(e => this._handleTokenError(e)); } }).catch(e => { if (!this._isSubscribing()) { this._inflight = false; return; } // Match map subscription error handling: route through _subscribeError // which emits error event and schedules resubscribe for temporary errors // (code < 100), matching the map's _handleMapSubscribeError pattern. this._inflight = false; this._subscribeError({ code: errorCodes.subscriptionGetState, message: (e === null || e === void 0 ? void 0 : e.toString()) || 'getState failed', temporary: true, }); }); } _getDataAndSubscribe(token) { if (!this._getData) { this._inflight = false; return; } this._getData({ channel: this.channel }) .then(data => { if (!this._isSubscribing()) { this._inflight = false; return; } this._data = data; this._sendSubscribe(token); }) .catch(e => this._handleGetDataError(e)); } _handleGetDataError(error) { if (!this._isSubscribing()) { this._inflight = false; return; } if (error instanceof UnauthorizedError) { this._inflight = false; this._failUnauthorized(); return; } this.emit('error', { type: 'subscribeData', channel: this.channel, error: { code: errorCodes.badConfiguration, message: (error === null || error === void 0 ? void 0 : error.toString()) || '' } }); this._inflight = false; this._scheduleResubscribe(); } _handleTokenResponse(token) { if (!this._isSubscribing()) { this._inflight = false; return; } if (!token) { this._inflight = false; this._failUnauthorized(); return; } this._token = token; if (this._getData) { this._getDataAndSubscribe(token); } else { this._sendSubscribe(token); } } _handleTokenError(error) { if (!this._isSubscribing()) { this._inflight = false; return; } if (error instanceof UnauthorizedError) { this._inflight = false; this._failUnauthorized(); return; } this.emit('error', { type: 'subscribeToken', channel: this.channel, error: { code: errorCodes.subscriptionSubscribeToken, message: (error === null || error === void 0 ? void 0 : error.toString()) || '' } }); this._inflight = false; this._scheduleResubscribe(); } _sendSubscribe(token) { if (!this._isTransportOpen()) { this._inflight = false; return null; } const cmd = this._buildSubscribeCommand(token); // @ts-ignore – we are hiding some symbols from public API autocompletion. this._centrifuge._call(cmd).then(resolveCtx => { this._inflight = false; const result = resolveCtx.reply.subscribe; this._handleSubscribeResponse(result); if (resolveCtx.next) { resolveCtx.next(); } }, rejectCtx => { this._inflight = false; this._handleSubscribeError(rejectCtx.error); if (rejectCtx.next) { rejectCtx.next(); } }); return cmd; } _buildSubscribeCommand(token) { const req = { channel: this.channel }; if (token) req.token = token; if (this._data) req.data = this._data; // Shared poll: simple subscribe with type=4, no positioning/recovery fields. if (this._sharedPoll) { req.type = 4; if (this._delta) req.delta = this._delta; return { subscribe: req }; } if (this._positioned) req.positioned = true; if (this._recoverable) req.recoverable = true; if (this._joinLeave) req.join_leave = true; req.flag = subscriptionFlags.channelCompaction; if (this._getState) { req.flag |= subscriptionFlags.rejectUnrecovered; } if (this._needRecover()) { req.recover = true; const offset = this._getOffset(); if (offset) req.offset = offset; const epoch = this._getEpoch(); if (epoch) req.epoch = epoch; } if (this._delta) req.delta = this._delta; if (this._tagsFilter) req.tf = this._tagsFilter; return { subscribe: req }; } _debug(...args) { // @ts-ignore – we are hiding some symbols from public API autocompletion. this._centrifuge._debug(...args); } _handleSubscribeError(error) { if (!this._isSubscribing()) { return; } if (error.code === errorCodes.timeout) { // @ts-ignore – we are hiding some symbols from public API autocompletion. this._centrifuge._disconnect(connectingCodes.subscribeTimeout, 'subscribe timeout', true); return; } if (error.code === 112 && this._getState) { // Unrecoverable position with getState: reset position so next // subscribe attempt calls getState() to reload app state from scratch. this._offset = null; this._epoch = null; this._recover = false; this._prevValueMap = new Map(); this._scheduleResubscribe(); return; } this._subscribeError(error); } _handleSubscribeResponse(result) { if (!this._isSubscribing()) { return; } this._setSubscribed(result); // After shared poll subscribe, replay tracked items if any. if (this._sharedPoll) { this._sharedPollReplayTrack(); } } _setUnsubscribed(code, reason, sendUnsubscribe) { if (this._isUnsubscribed()) { return Promise.resolve(); } let promise = Promise.resolve(); if (this._isSubscribed()) { if (sendUnsubscribe) { // @ts-ignore – we are hiding some methods from public API autocompletion. promise = this._centrifuge._unsubscribe(this); } this._clearSubscribedState(); } else if (this._isSubscribing()) { if (this._inflight && sendUnsubscribe) { // @ts-ignore – we are hiding some methods from public API autocompletion. promise = this._centrifuge._unsubscribe(this); } this._clearSubscribingState();