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
JavaScript
;
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