@splitsoftware/splitio-commons
Version:
Split JavaScript SDK common components
318 lines (317 loc) • 16.8 kB
JavaScript
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;
;