UNPKG

hypertune

Version:

[Hypertune](https://www.hypertune.com/) is the most flexible platform for feature flags, A/B testing, analytics and app configuration. Built with full end-to-end type-safety, Git-style version control and local, synchronous, in-memory flag evaluation. Opt

438 lines 22.3 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (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()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const p_retry_1 = __importDefault(require("p-retry")); const shared_1 = require("../shared"); const environment_1 = require("./environment"); const LRUCache_1 = __importDefault(require("../shared/helpers/LRUCache")); const getMetadata_1 = __importDefault(require("../shared/helpers/getMetadata")); /** @internal: Not part of the Hypertune public API */ class Context { // @internal // eslint-disable-next-line max-params constructor({ traceId, initData, lastInitDataRefreshTime, initDataProvider, initDataRefreshIntervalMs, shouldRefreshInitData, shouldRefreshInitDataOnCreate, shouldSkipInitDataUpdateOnRefresh, query, initQuery, variableValues, logger, cacheSize, override, }) { this.shouldClose = false; this.updateTimeout = null; // @internal this.initData = null; // @internal this.lastInitDataRefreshTime = null; // @internal this.getFieldCache = null; // @internal this.getItemsCache = null; // @internal this.evaluateCache = null; // @internal this.override = null; this.initDataProvider = initDataProvider; this.initDataRefreshIntervalMs = initDataRefreshIntervalMs; this.shouldSkipInitDataUpdateOnRefresh = shouldSkipInitDataUpdateOnRefresh; this.query = query; this.initQuery = initQuery; this.variableValues = variableValues; this.updateListeners = new Map(); this.logger = logger; this.override = override; if (cacheSize > 0) { this.getFieldCache = new LRUCache_1.default(cacheSize); this.getItemsCache = new LRUCache_1.default(cacheSize); this.evaluateCache = new LRUCache_1.default(cacheSize); } if (initData) { this.log(traceId, shared_1.LogLevel.Info, "Initializing from snapshot data..."); this.updateInitData(traceId, "snapshot", initData, lastInitDataRefreshTime); } this.initAndStartIntervals(traceId, shouldRefreshInitDataOnCreate, shouldRefreshInitData); if (environment_1.isBrowser) { window.addEventListener("beforeunload", () => __awaiter(this, void 0, void 0, function* () { if (this.shouldClose) { return; } yield this.close(/* traceId */ (0, shared_1.uniqueId)()); })); } } // eslint-disable-next-line max-params updateInitData(traceId, initSourceName, newInitData, newDataProviderInitTime) { var _a, _b, _c, _d, _e, _f, _g; try { const currentCommitId = (_b = (_a = this.initData) === null || _a === void 0 ? void 0 : _a.commitId) !== null && _b !== void 0 ? _b : -1; const currentCommitHash = (_d = (_c = this.initData) === null || _c === void 0 ? void 0 : _c.hash) !== null && _d !== void 0 ? _d : ""; if (newInitData.commitId < currentCommitId) { this.log(traceId, shared_1.LogLevel.Info, `Skipped initialization from ${initSourceName} data as commit with id "${newInitData.commitId}" isn't newer than "${currentCommitId}".`); return false; } if (newInitData.commitId === currentCommitId && newInitData.hash === currentCommitHash) { this.updateLastInitDataRefreshTime(newDataProviderInitTime); this.log(traceId, shared_1.LogLevel.Info, `Skipped initialization from ${initSourceName} data as commit id "${newInitData.commitId}" with hash "${newInitData.hash}" is already active.`); return false; } // If initializing from Hypertune Edge, the expression should already be // reduced given the query and variables. If initializing from the // fallback, it should already be reduced given the query but not the // variables. If initializing from Vercel Edge Config, it won't be reduced // at all. In all cases, we reduce the returned expression again anyway // given the query and variables. const reducedExpression = (0, shared_1.prefixError)(() => (0, shared_1.reduce)(newInitData.splits, newInitData.commitConfig, this.query, this.variableValues, newInitData.reducedExpression, /* allowMissingVariables */ false), "Reduction Error: "); this.initData = Object.assign(Object.assign({}, newInitData), { reducedExpression }); this.updateLastInitDataRefreshTime(newDataProviderInitTime); this.log(traceId, shared_1.LogLevel.Info, `Initialized successfully from ${initSourceName} data.`, { commitId: newInitData.commitId, hash: newInitData.hash }); (_e = this.getFieldCache) === null || _e === void 0 ? void 0 : _e.purge(); (_f = this.getItemsCache) === null || _f === void 0 ? void 0 : _f.purge(); (_g = this.evaluateCache) === null || _g === void 0 ? void 0 : _g.purge(); return true; } catch (error) { this.log(traceId, shared_1.LogLevel.Error, `Error initializing from ${initSourceName} data.`, (0, getMetadata_1.default)(error)); return false; } } // @internal initIfNeeded(traceId, retries) { const { lastInitDataRefreshTime, initDataProvider, initDataRefreshIntervalMs, } = this; if (!initDataProvider) { this.log(traceId, shared_1.LogLevel.Info, "Not initializing from data provider as it's null."); return Promise.resolve(); } if (lastInitDataRefreshTime) { const msSinceLastInitDataRefresh = Date.now() - lastInitDataRefreshTime; if (msSinceLastInitDataRefresh < initDataRefreshIntervalMs) { this.log(traceId, shared_1.LogLevel.Debug, `Not initializing from data provider as already did ${msSinceLastInitDataRefresh}ms ago which is less than the update interval of ${initDataRefreshIntervalMs}ms.`); return Promise.resolve(); } } return this.initFromDataProvider(traceId, initDataProvider, retries); } initFromDataProvider(traceId, initDataProvider, retries) { return this.withUpdateNotificationAsync(() => __awaiter(this, void 0, void 0, function* () { let checkingHash = true; const initSourceName = initDataProvider.getName(); try { let newInitData = null; // If already initialized, first get the latest commit hash // to check if we need to update if (this.initData) { let hashData; if (initDataProvider.getHashData) { hashData = yield this.getHashData(traceId, initDataProvider.getHashData.bind(initDataProvider), retries); } else { newInitData = yield this.getInitData(traceId, initSourceName, initDataProvider.getInitData.bind(initDataProvider), retries); hashData = { hash: newInitData.hash, commitId: newInitData.commitId, }; } if (this.initData.hash === hashData.hash) { // Log this as latest init time as verifying that we already have // the latest commit is equivalent to initialization from server. this.log(traceId, shared_1.LogLevel.Debug, "Commit hash is already latest."); this.updateLastInitDataRefreshTime(Date.now()); return { updateTrigger: "initDataProvider", notify: false, hasUpdated: false, }; } if (hashData.commitId < this.initData.commitId) { // This happens in the unlikely case when the hash data is for an // older commit than the init data. this.log(traceId, shared_1.LogLevel.Info, `Skipped initialization from ${initSourceName} as hash data is for commit with ID "${hashData.commitId}" which isn't newer than "${this.initData.commitId}".`); return { updateTrigger: "initDataProvider", notify: false, hasUpdated: false, }; } this.log(traceId, shared_1.LogLevel.Info, `Commit hash (${this.initData.hash}) is not latest (${hashData.hash}).`); if (this.shouldSkipInitDataUpdateOnRefresh) { return { updateTrigger: "initDataProvider", notify: true, hasUpdated: false, }; } } checkingHash = false; this.log(traceId, shared_1.LogLevel.Info, `Initializing from ${initSourceName}...`); if (!newInitData) { newInitData = yield this.getInitData(traceId, initSourceName, initDataProvider.getInitData.bind(initDataProvider), retries); } const hasUpdated = this.updateInitData(traceId, initSourceName, newInitData, Date.now()); return { updateTrigger: "initDataProvider", notify: hasUpdated, hasUpdated, }; } catch (error) { this.log(traceId, shared_1.LogLevel.Error, `All attempts to ${checkingHash ? "check for updates" : "initialize"} from ${initSourceName} failed.`, (0, getMetadata_1.default)(error)); return { updateTrigger: "initDataProvider", notify: false, hasUpdated: false, }; } })); } getHashData(traceId, getInitDataHash, retries) { return __awaiter(this, void 0, void 0, function* () { this.log(traceId, shared_1.LogLevel.Debug, "Getting latest commit hash..."); const latestInitDataHash = yield (0, p_retry_1.default)((attemptNumber) => { this.log(traceId, shared_1.LogLevel.Debug, `Attempt ${attemptNumber} to get latest commit hash...`); return getInitDataHash({ traceId, initQuery: this.initQuery, variableValues: this.variableValues, }); }, { retries, maxTimeout: 6000, onFailedAttempt: (error) => { this.log(traceId, shared_1.LogLevel.Debug, `Attempt ${error.attemptNumber} to get latest commit hash failed. There are ${error.retriesLeft} retries left.`, (0, getMetadata_1.default)(error)); if (this.shouldClose) { throw new p_retry_1.default.AbortError(`Stopped trying to get latest commit hash.`); } }, }); return latestInitDataHash; }); } getInitData(traceId, initSourceName, getInitData, retries) { return (0, p_retry_1.default)((attemptNumber) => { this.log(traceId, shared_1.LogLevel.Debug, `Attempt ${attemptNumber} to initialize from ${initSourceName}...`); return getInitData({ traceId, initQuery: this.initQuery, variableValues: this.variableValues, }); }, { retries, maxTimeout: 6000, onFailedAttempt: (error) => { this.log(traceId, shared_1.LogLevel.Debug, `Attempt ${error.attemptNumber} to initialize from ${initSourceName} failed. There are ${error.retriesLeft} retries left.`, (0, getMetadata_1.default)(error)); if (this.shouldClose) { throw new p_retry_1.default.AbortError(`Stopped trying to initialize from ${initSourceName}.`); } }, }); } initAndStartIntervals(initTraceId, shouldRefreshInitDataOnCreate, shouldRefreshInitData) { if (!this.initDataProvider) { this.log(initTraceId, shared_1.LogLevel.Info, "Not checking for updates."); return; } const providerName = this.initDataProvider.getName(); // eslint-disable-next-line func-style const update = (traceId = (0, shared_1.uniqueId)()) => { if (this.shouldClose) { // No need to get updates when the SDK is shutting down. this.updateTimeout = null; this.log(traceId, shared_1.LogLevel.Debug, `Stopped checking for updates from ${providerName}.`); return; } this.initIfNeeded(traceId, shared_1.defaultRetries) .catch((error) => this.log(traceId, shared_1.LogLevel.Error, `Error updating from ${providerName}.`, (0, getMetadata_1.default)(error))) .finally(() => { if (this.shouldClose) { this.updateTimeout = null; this.log(traceId, shared_1.LogLevel.Debug, `Stopped checking for updates from ${providerName}.`); return; } if (shouldRefreshInitData) { this.updateTimeout = setTimeout(update, this.initDataRefreshIntervalMs); } }); }; if (shouldRefreshInitData) { this.log(initTraceId, shared_1.LogLevel.Info, `Started checking for updates from ${providerName}.`); } else { this.log(initTraceId, shared_1.LogLevel.Info, `Not checking for updates from ${providerName}.`); } if (shouldRefreshInitDataOnCreate && // Only need to check for updates immediately when not skipping update // on refresh as otherwise it would trigger an unnecessary notification. !this.shouldSkipInitDataUpdateOnRefresh) { update(initTraceId); // Refresh immediately return; } if (shouldRefreshInitData) { // Refresh after interval this.updateTimeout = setTimeout(update, this.initDataRefreshIntervalMs); } } // @internal isReady() { return this.initDataProvider ? !!this.lastInitDataRefreshTime : !!this.initData; } // @internal close(traceId) { return __awaiter(this, void 0, void 0, function* () { this.log(traceId, shared_1.LogLevel.Info, "Closing..."); this.shouldClose = true; if (this.updateTimeout) { clearTimeout(this.updateTimeout); this.updateTimeout = null; } yield this.logger.close(traceId); this.log(traceId, shared_1.LogLevel.Info, "Closed."); }); } // @internal getStateHash() { var _a, _b; const initDataHash = (_b = (_a = this.initData) === null || _a === void 0 ? void 0 : _a.hash) !== null && _b !== void 0 ? _b : null; const ssOverride = (0, shared_1.stableStringify)(this.override); const ssVariableValues = (0, shared_1.stableStringify)(this.variableValues); return (0, shared_1.hash)(`${ssVariableValues}/${initDataHash}/${ssOverride}`).toString(); } // @internal addUpdateListener(listener) { this.updateListeners.set(listener, true); } // @internal removeUpdateListener(listener) { this.updateListeners.delete(listener); } // @internal setOverride(traceId, override) { this.withUpdateNotification(() => { const hasUpdated = this.updateOverride(traceId, override); return { updateTrigger: "override", hasUpdated }; }); } updateOverride(traceId, override) { if ((0, shared_1.stableStringify)(override) === (0, shared_1.stableStringify)(this.override)) { if (override) { this.log(traceId, shared_1.LogLevel.Debug, "Skipped setting override as it's equal to the one already set."); } return false; } this.override = override; this.log(traceId, shared_1.LogLevel.Info, "Set override.", { override }); return true; } // @internal dehydrate(query, variableValues) { // Create a copy of the init data as we are modifying it below. const initData = this.initData ? Object.assign({}, this.initData) : null; if (!initData) { return null; } const { lastInitDataRefreshTime, override } = this; const dehydrateQuery = query !== null && query !== void 0 ? query : this.query; const dehydrateQueryVariableValues = variableValues !== null && variableValues !== void 0 ? variableValues : this.variableValues; initData.reducedExpression = (0, shared_1.prefixError)(() => (0, shared_1.reduce)(initData.splits, initData.commitConfig, dehydrateQuery, dehydrateQueryVariableValues, initData.reducedExpression, /* allowMissingVariables */ false), "Reduction Error: "); const { splits, commitConfig } = (0, shared_1.getSplitsAndCommitConfigForExpression)(initData.reducedExpression, initData.splits, initData.commitConfig); initData.splits = splits; initData.commitConfig = commitConfig; return { initData, lastInitDataRefreshTime, override: override, variableValues: dehydrateQueryVariableValues, }; } // @internal hydrate(traceId, dehydratedState) { return this.withUpdateNotification(() => { var _a; this.log(traceId, shared_1.LogLevel.Info, "Hydrating..."); const { initData, override, variableValues, lastInitDataRefreshTime } = dehydratedState; const variableValuesUpdated = (0, shared_1.stableStringify)(this.variableValues) !== (0, shared_1.stableStringify)(variableValues); if (variableValuesUpdated) { this.variableValues = variableValues; } const overrideUpdated = this.updateOverride(traceId, override); let initDataUpdated = initData && initData.hash !== ((_a = this.initData) === null || _a === void 0 ? void 0 : _a.hash); if (initDataUpdated) { initDataUpdated = this.updateInitData(traceId, "hydration", initData, lastInitDataRefreshTime); } else { this.updateLastInitDataRefreshTime(lastInitDataRefreshTime); } this.log(traceId, shared_1.LogLevel.Info, "Hydrated."); return { updateTrigger: "hydration", hasUpdated: variableValuesUpdated || overrideUpdated || initDataUpdated, }; }); } updateLastInitDataRefreshTime(newTime) { if (!this.initDataProvider || !newTime) { return; } // Don't set the time to a future value. const now = Date.now(); this.lastInitDataRefreshTime = newTime > now ? now : newTime; } withUpdateNotification(performUpdate) { const wasReady = this.isReady(); const { updateTrigger, hasUpdated } = performUpdate(); this.notifyUpdateListenersIfNeeded({ notify: hasUpdated, wasReady, updateTrigger, hasUpdated, }); } withUpdateNotificationAsync(performUpdate) { return __awaiter(this, void 0, void 0, function* () { const wasReady = this.isReady(); const { updateTrigger, notify, hasUpdated } = yield performUpdate(); this.notifyUpdateListenersIfNeeded({ notify, wasReady, updateTrigger, hasUpdated, }); }); } notifyUpdateListenersIfNeeded({ notify, wasReady, updateTrigger, hasUpdated, }) { const stateHash = this.getStateHash(); const becameReady = !wasReady && this.isReady(); if (becameReady || notify) { this.updateListeners.forEach((_, listener) => { listener(stateHash, { becameReady, updateTrigger, hasUpdated }); }); } } // @internal reduce(fieldQuery, expression) { const { splits, commitConfig } = (0, shared_1.nullThrows)(this.initData, "No init data so cannot reduce expression."); return (0, shared_1.prefixError)(() => (0, shared_1.reduce)(splits, commitConfig, fieldQuery ? { variableDefinitions: {}, fragmentDefinitions: {}, fieldQuery, } : null, /* variableValues */ {}, expression, /* allowMissingVariables */ false), "Reduction error: "); } log(traceId, level, message, metadata = {}) { var _a, _b; this.logger.log(level, (_b = (_a = this.initData) === null || _a === void 0 ? void 0 : _a.commitId.toString()) !== null && _b !== void 0 ? _b : null, message, Object.assign({ traceId }, metadata)); } } exports.default = Context; //# sourceMappingURL=Context.js.map