UNPKG

@splitsoftware/splitio-commons

Version:
318 lines (317 loc) 16.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.pushManagerFactory = void 0; var objectAssign_1 = require("../../utils/lang/objectAssign"); var Backoff_1 = require("../../utils/Backoff"); var SSEHandler_1 = require("./SSEHandler"); var MySegmentsUpdateWorker_1 = require("./UpdateWorkers/MySegmentsUpdateWorker"); var SegmentsUpdateWorker_1 = require("./UpdateWorkers/SegmentsUpdateWorker"); var SplitsUpdateWorker_1 = require("./UpdateWorkers/SplitsUpdateWorker"); var AuthClient_1 = require("./AuthClient"); var lang_1 = require("../../utils/lang"); var SSEClient_1 = require("./SSEClient"); var key_1 = require("../../utils/key"); var constants_1 = require("./constants"); var constants_2 = require("../../logger/constants"); var types_1 = require("./SSEHandler/types"); var parseUtils_1 = require("./parseUtils"); var murmur3_64_1 = require("../../utils/murmur3/murmur3_64"); var constants_3 = require("../../utils/constants"); /** * PushManager factory: * - for server-side if key is not provided in settings. * - for client-side, with support for multiple clients, if key is provided in settings */ function pushManagerFactory(params, pollingManager) { var settings = params.settings, storage = params.storage, splitApi = params.splitApi, readiness = params.readiness, platform = params.platform, telemetryTracker = params.telemetryTracker; // `userKey` is the matching key of main client in client-side SDK. // It can be used to check if running on client-side or server-side SDK. var userKey = settings.core.key ? (0, key_1.getMatching)(settings.core.key) : undefined; var log = settings.log; var sseClient; try { // `useHeaders` false for client-side, even if the platform EventSource supports headers (e.g., React Native). sseClient = new SSEClient_1.SSEClient(settings, platform); } catch (e) { log.warn(constants_2.STREAMING_FALLBACK, [e]); return; } var authenticate = (0, AuthClient_1.authenticateFactory)(splitApi.fetchAuth); // init feedback loop var pushEmitter = new platform.EventEmitter(); var sseHandler = (0, SSEHandler_1.SSEHandlerFactory)(log, pushEmitter, telemetryTracker); sseClient.setEventHandler(sseHandler); // init workers // MySegmentsUpdateWorker (client-side) are initiated in `add` method var segmentsUpdateWorker = userKey ? undefined : (0, SegmentsUpdateWorker_1.SegmentsUpdateWorker)(log, pollingManager.segmentsSyncTask, storage.segments); // For server-side we pass the segmentsSyncTask, used by SplitsUpdateWorker to fetch new segments var splitsUpdateWorker = (0, SplitsUpdateWorker_1.SplitsUpdateWorker)(log, storage, pollingManager.splitsSyncTask, readiness.splits, telemetryTracker, userKey ? undefined : pollingManager.segmentsSyncTask); // [Only for client-side] map of hashes to user keys, to dispatch membership update events to the corresponding MySegmentsUpdateWorker var userKeyHashes = {}; // [Only for client-side] map of user keys to their corresponding hash64 and MySegmentsUpdateWorkers. // Hash64 is used to process membership update events and dispatch actions to the corresponding MySegmentsUpdateWorker. var clients = {}; // [Only for client-side] variable to flag that a new client was added. It is needed to reconnect streaming. var connectForNewClient = false; // flag that indicates if `stop/disconnectPush` was called, either by the SyncManager, when the client is destroyed, or due to a PUSH_NONRETRYABLE_ERROR error. // It is used to halt the `connectPush` process if it was in progress. var disconnected; // flag that indicates a PUSH_NONRETRYABLE_ERROR, condition with which starting pushManager again is ignored. // true if STREAMING_DISABLED control event, or 'pushEnabled: false', or non-recoverable SSE or Auth errors. var disabled; // `disabled` implies `disconnected === true` /** PushManager functions related to initialization */ var connectPushRetryBackoff = new Backoff_1.Backoff(connectPush, settings.scheduler.pushRetryBackoffBase); var timeoutIdTokenRefresh; var timeoutIdSseOpen; function scheduleTokenRefreshAndSse(authData) { // clear scheduled tasks if exist if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh); if (timeoutIdSseOpen) clearTimeout(timeoutIdSseOpen); // Set token refresh 10 minutes before `expirationTime - issuedAt` var decodedToken = authData.decodedToken; var refreshTokenDelay = decodedToken.exp - decodedToken.iat - constants_1.SECONDS_BEFORE_EXPIRATION; // Default connDelay of 60 secs var connDelay = typeof authData.connDelay === 'number' && authData.connDelay >= 0 ? authData.connDelay : 60; log.info(constants_2.STREAMING_REFRESH_TOKEN, [refreshTokenDelay, connDelay]); timeoutIdTokenRefresh = setTimeout(connectPush, refreshTokenDelay * 1000); timeoutIdSseOpen = setTimeout(function () { // halt if disconnected if (disconnected) return; sseClient.open(authData); }, connDelay * 1000); telemetryTracker.streamingEvent(constants_3.TOKEN_REFRESH, decodedToken.exp); } function connectPush() { // Guard condition in case `stop/disconnectPush` has been called (e.g., calling SDK destroy, or app signal close/background) if (disconnected) return; // @TODO distinguish log for 'Connecting' (1st time) and 'Re-connecting' log.info(constants_2.STREAMING_CONNECTING); disconnected = false; var userKeys = userKey ? Object.keys(clients) : undefined; authenticate(userKeys).then(function (authData) { if (disconnected) return; // 'pushEnabled: false' is handled as a PUSH_NONRETRYABLE_ERROR instead of PUSH_SUBSYSTEM_DOWN, in order to // close the sseClient in case the org has been bloqued while the instance was connected to streaming if (!authData.pushEnabled) { log.info(constants_2.STREAMING_DISABLED); pushEmitter.emit(constants_1.PUSH_NONRETRYABLE_ERROR); return; } // [Only for client-side] don't open SSE connection if a new shared client was added, since it means that a new authentication is taking place if (userKeys && userKeys.length < Object.keys(clients).length) return; // Schedule SSE connection and refresh token scheduleTokenRefreshAndSse(authData); }).catch(function (error) { if (disconnected) return; log.error(constants_2.ERROR_STREAMING_AUTH, [error.message]); // Handle 4XX HTTP errors: 401 (invalid SDK Key) or 400 (using incorrect SDK Key, i.e., client-side SDK Key on server-side) if (error.statusCode >= 400 && error.statusCode < 500) { telemetryTracker.streamingEvent(constants_3.AUTH_REJECTION); pushEmitter.emit(constants_1.PUSH_NONRETRYABLE_ERROR); return; } // Handle other HTTP and network errors as recoverable errors pushEmitter.emit(constants_1.PUSH_RETRYABLE_ERROR); }); } // close SSE connection and cancel scheduled tasks function disconnectPush() { // Halt disconnecting, just to avoid redundant logs if called multiple times if (disconnected) return; disconnected = true; sseClient.close(); log.info(constants_2.STREAMING_DISCONNECTING); if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh); if (timeoutIdSseOpen) clearTimeout(timeoutIdSseOpen); connectPushRetryBackoff.reset(); stopWorkers(); } // cancel scheduled fetch retries of Splits, Segments, and MySegments Update Workers function stopWorkers() { splitsUpdateWorker.stop(); if (userKey) (0, lang_1.forOwn)(clients, function (_a) { var worker = _a.worker; return worker.stop(); }); else segmentsUpdateWorker.stop(); } pushEmitter.on(constants_1.PUSH_SUBSYSTEM_DOWN, stopWorkers); // Only required when streaming connects after a PUSH_RETRYABLE_ERROR. // Otherwise it is unnecessary (e.g, STREAMING_RESUMED). pushEmitter.on(constants_1.PUSH_SUBSYSTEM_UP, function () { connectPushRetryBackoff.reset(); }); /** Fallback to polling without retry due to: STREAMING_DISABLED control event, or 'pushEnabled: false', or non-recoverable SSE and Authentication errors */ pushEmitter.on(constants_1.PUSH_NONRETRYABLE_ERROR, function handleNonRetryableError() { disabled = true; // Note: `stopWorkers` is been called twice, but it is not harmful disconnectPush(); pushEmitter.emit(constants_1.PUSH_SUBSYSTEM_DOWN); // no harm if polling already }); /** Fallback to polling with retry due to recoverable SSE and Authentication errors */ pushEmitter.on(constants_1.PUSH_RETRYABLE_ERROR, function handleRetryableError() { // SSE connection is closed to avoid repeated errors due to retries sseClient.close(); // retry streaming reconnect with backoff algorithm var delayInMillis = connectPushRetryBackoff.scheduleCall(); log.info(constants_2.STREAMING_RECONNECT, [delayInMillis / 1000]); pushEmitter.emit(constants_1.PUSH_SUBSYSTEM_DOWN); // no harm if polling already }); /** STREAMING_RESET notification. Unlike a PUSH_RETRYABLE_ERROR, it doesn't emit PUSH_SUBSYSTEM_DOWN to fallback polling */ pushEmitter.on(constants_1.ControlType.STREAMING_RESET, function handleStreamingReset() { if (disconnected) return; // should never happen // Minimum required clean-up. // `disconnectPush` cannot be called because it sets `disconnected` and thus `connectPush` will not execute if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh); connectPush(); }); /** Functions related to synchronization (Queues and Workers in the spec) */ pushEmitter.on(constants_1.SPLIT_KILL, splitsUpdateWorker.killSplit); pushEmitter.on(constants_1.SPLIT_UPDATE, splitsUpdateWorker.put); pushEmitter.on(constants_1.RB_SEGMENT_UPDATE, splitsUpdateWorker.put); function handleMySegmentsUpdate(parsedData) { switch (parsedData.u) { case types_1.UpdateStrategy.BoundedFetchRequest: { var bitmap_1; try { bitmap_1 = (0, parseUtils_1.parseBitmap)(parsedData.d, parsedData.c); } catch (e) { log.warn(constants_2.STREAMING_PARSING_MEMBERSHIPS_UPDATE, ['BoundedFetchRequest', e]); break; } (0, lang_1.forOwn)(clients, function (_a, matchingKey) { var hash64 = _a.hash64, worker = _a.worker; if ((0, parseUtils_1.isInBitmap)(bitmap_1, hash64.hex)) { worker.put(parsedData, undefined, (0, parseUtils_1.getDelay)(parsedData, matchingKey)); } }); return; } case types_1.UpdateStrategy.KeyList: { var keyList = void 0, added_1, removed_1; try { keyList = (0, parseUtils_1.parseKeyList)(parsedData.d, parsedData.c); added_1 = new Set(keyList.a); removed_1 = new Set(keyList.r); } catch (e) { log.warn(constants_2.STREAMING_PARSING_MEMBERSHIPS_UPDATE, ['KeyList', e]); break; } if (!parsedData.n || !parsedData.n.length) { log.warn(constants_2.STREAMING_PARSING_MEMBERSHIPS_UPDATE, ['KeyList', 'No segment name was provided']); break; } (0, lang_1.forOwn)(clients, function (_a) { var hash64 = _a.hash64, worker = _a.worker; var add = added_1.has(hash64.dec) ? true : removed_1.has(hash64.dec) ? false : undefined; if (add !== undefined) { worker.put(parsedData, { added: add ? [parsedData.n[0]] : [], removed: add ? [] : [parsedData.n[0]] }); } }); return; } case types_1.UpdateStrategy.SegmentRemoval: if (!parsedData.n || !parsedData.n.length) { log.warn(constants_2.STREAMING_PARSING_MEMBERSHIPS_UPDATE, ['SegmentRemoval', 'No segment name was provided']); break; } (0, lang_1.forOwn)(clients, function (_a) { var worker = _a.worker; worker.put(parsedData, { added: [], removed: parsedData.n }); }); return; } // `UpdateStrategy.UnboundedFetchRequest` and fallbacks of other cases (0, lang_1.forOwn)(clients, function (_a, matchingKey) { var worker = _a.worker; worker.put(parsedData, undefined, (0, parseUtils_1.getDelay)(parsedData, matchingKey)); }); } if (userKey) { pushEmitter.on(constants_1.MEMBERSHIPS_MS_UPDATE, handleMySegmentsUpdate); pushEmitter.on(constants_1.MEMBERSHIPS_LS_UPDATE, handleMySegmentsUpdate); } else { pushEmitter.on(constants_1.SEGMENT_UPDATE, segmentsUpdateWorker.put); } return (0, objectAssign_1.objectAssign)( // Expose Event Emitter functionality and Event constants Object.create(pushEmitter), { // Stop/pause push mode. // It doesn't emit events. Neither PUSH_SUBSYSTEM_DOWN to start polling. stop: function () { disconnectPush(); // `handleNonRetryableError` cannot be used as `stop`, because it emits PUSH_SUBSYSTEM_DOWN event, which starts polling. if (userKey) this.remove(userKey); // Necessary to properly resume streaming in client-side (e.g., RN SDK transition to foreground). }, // Start/resume push mode. // It eventually emits PUSH_SUBSYSTEM_DOWN, that starts polling, or PUSH_SUBSYSTEM_UP, that executes a syncAll start: function () { // Guard condition to avoid calling `connectPush` again if the `start` method is called multiple times or if push has been disabled. if (disabled || disconnected === false) return; disconnected = false; if (userKey) this.add(userKey, pollingManager.segmentsSyncTask); // client-side else setTimeout(connectPush); // server-side runs in next cycle as in client-side, for consistency with client-side }, // true/false if start or stop was called last respectively isRunning: function () { return disconnected === false; }, // [Only for client-side] add: function (userKey, mySegmentsSyncTask) { var hash = (0, AuthClient_1.hashUserKey)(userKey); if (!userKeyHashes[hash]) { userKeyHashes[hash] = userKey; clients[userKey] = { hash64: (0, murmur3_64_1.hash64)(userKey), worker: (0, MySegmentsUpdateWorker_1.MySegmentsUpdateWorker)(log, storage, mySegmentsSyncTask, telemetryTracker) }; connectForNewClient = true; // we must reconnect on start, to listen the channel for the new user key // Reconnects in case of a new client. // Run in next event-loop cycle to save authentication calls // in case multiple clients are created in the current cycle. if (this.isRunning()) { setTimeout(function checkForReconnect() { if (connectForNewClient) { connectForNewClient = false; connectPush(); } }, 0); } } }, // [Only for client-side] remove: function (userKey) { var hash = (0, AuthClient_1.hashUserKey)(userKey); delete userKeyHashes[hash]; delete clients[userKey]; } }); } exports.pushManagerFactory = pushManagerFactory;