posthog-node
Version:
PostHog Node.js integration
804 lines (803 loc) • 38.5 kB
JavaScript
import { safeSetTimeout } from "@posthog/core";
import { hashSHA1 } from "./crypto.mjs";
const SIXTY_SECONDS = 60000;
const LONG_SCALE = 0xfffffffffffffff;
const NULL_VALUES_ALLOWED_OPERATORS = [
'is_not',
'is_set'
];
class ClientError extends Error {
constructor(message){
super();
Error.captureStackTrace(this, this.constructor);
this.name = 'ClientError';
this.message = message;
Object.setPrototypeOf(this, ClientError.prototype);
}
}
class InconclusiveMatchError extends Error {
constructor(message){
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
Object.setPrototypeOf(this, InconclusiveMatchError.prototype);
}
}
class RequiresServerEvaluation extends Error {
constructor(message){
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
Object.setPrototypeOf(this, RequiresServerEvaluation.prototype);
}
}
class FeatureFlagsPoller {
constructor({ pollingInterval, personalApiKey, projectApiKey, timeout, host, customHeaders, ...options }){
this.debugMode = false;
this.shouldBeginExponentialBackoff = false;
this.backOffCount = 0;
this.pollingInterval = pollingInterval;
this.personalApiKey = personalApiKey;
this.featureFlags = [];
this.featureFlagsByKey = {};
this.groupTypeMapping = {};
this.cohorts = {};
this.loadedSuccessfullyOnce = false;
this.timeout = timeout;
this.projectApiKey = projectApiKey;
this.host = host;
this.poller = void 0;
this.fetch = options.fetch || fetch;
this.onError = options.onError;
this.customHeaders = customHeaders;
this.onLoad = options.onLoad;
this.cacheProvider = options.cacheProvider;
this.strictLocalEvaluation = options.strictLocalEvaluation ?? false;
this.loadFeatureFlags();
}
debug(enabled = true) {
this.debugMode = enabled;
}
logMsgIfDebug(fn) {
if (this.debugMode) fn();
}
createEvaluationContext(distinctId, groups = {}, personProperties = {}, groupProperties = {}, evaluationCache = {}) {
return {
distinctId,
groups,
personProperties,
groupProperties,
evaluationCache
};
}
async getFeatureFlag(key, distinctId, groups = {}, personProperties = {}, groupProperties = {}) {
await this.loadFeatureFlags();
let response;
let featureFlag;
if (!this.loadedSuccessfullyOnce) return response;
featureFlag = this.featureFlagsByKey[key];
if (void 0 !== featureFlag) {
const evaluationContext = this.createEvaluationContext(distinctId, groups, personProperties, groupProperties);
try {
const result = await this.computeFlagAndPayloadLocally(featureFlag, evaluationContext);
response = result.value;
this.logMsgIfDebug(()=>console.debug(`Successfully computed flag locally: ${key} -> ${response}`));
} catch (e) {
if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) this.logMsgIfDebug(()=>console.debug(`${e.name} when computing flag locally: ${key}: ${e.message}`));
else if (e instanceof Error) this.onError?.(new Error(`Error computing flag locally: ${key}: ${e}`));
}
}
return response;
}
async getAllFlagsAndPayloads(evaluationContext, flagKeysToExplicitlyEvaluate) {
await this.loadFeatureFlags();
const response = {};
const payloads = {};
let fallbackToFlags = 0 == this.featureFlags.length;
const flagsToEvaluate = flagKeysToExplicitlyEvaluate ? flagKeysToExplicitlyEvaluate.map((key)=>this.featureFlagsByKey[key]).filter(Boolean) : this.featureFlags;
const sharedEvaluationContext = {
...evaluationContext,
evaluationCache: evaluationContext.evaluationCache ?? {}
};
await Promise.all(flagsToEvaluate.map(async (flag)=>{
try {
const { value: matchValue, payload: matchPayload } = await this.computeFlagAndPayloadLocally(flag, sharedEvaluationContext);
response[flag.key] = matchValue;
if (matchPayload) payloads[flag.key] = matchPayload;
} catch (e) {
if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) this.logMsgIfDebug(()=>console.debug(`${e.name} when computing flag locally: ${flag.key}: ${e.message}`));
else if (e instanceof Error) this.onError?.(new Error(`Error computing flag locally: ${flag.key}: ${e}`));
fallbackToFlags = true;
}
}));
return {
response,
payloads,
fallbackToFlags
};
}
async computeFlagAndPayloadLocally(flag, evaluationContext, options = {}) {
const { matchValue, skipLoadCheck = false } = options;
if (!skipLoadCheck) await this.loadFeatureFlags();
if (!this.loadedSuccessfullyOnce) return {
value: false,
payload: null
};
let flagValue;
flagValue = void 0 !== matchValue ? matchValue : await this.computeFlagValueLocally(flag, evaluationContext);
const payload = this.getFeatureFlagPayload(flag.key, flagValue);
return {
value: flagValue,
payload
};
}
async computeFlagValueLocally(flag, evaluationContext) {
const { distinctId, groups, personProperties, groupProperties } = evaluationContext;
if (!flag.active) return false;
if (flag.ensure_experience_continuity) throw new InconclusiveMatchError('Flag has experience continuity enabled');
const flagFilters = flag.filters || {};
const aggregation_group_type_index = flagFilters.aggregation_group_type_index;
if (void 0 != aggregation_group_type_index) {
const groupName = this.groupTypeMapping[String(aggregation_group_type_index)];
if (!groupName) {
this.logMsgIfDebug(()=>console.warn(`[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`));
throw new InconclusiveMatchError('Flag has unknown group type index');
}
if (!(groupName in groups)) {
this.logMsgIfDebug(()=>console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`));
return false;
}
if ('device_id' === flag.bucketing_identifier && (personProperties?.$device_id === void 0 || personProperties?.$device_id === null || personProperties?.$device_id === '')) this.logMsgIfDebug(()=>console.warn(`[FEATURE FLAGS] Ignoring bucketing_identifier for group flag: ${flag.key}`));
const focusedGroupProperties = groupProperties[groupName];
return await this.matchFeatureFlagProperties(flag, groups[groupName], focusedGroupProperties, evaluationContext);
}
{
const bucketingValue = this.getBucketingValueForFlag(flag, distinctId, personProperties);
if (void 0 === bucketingValue) {
this.logMsgIfDebug(()=>console.warn(`[FEATURE FLAGS] Can't compute feature flag: ${flag.key} without $device_id, falling back to server evaluation`));
throw new InconclusiveMatchError(`Can't compute feature flag: ${flag.key} without $device_id`);
}
return await this.matchFeatureFlagProperties(flag, bucketingValue, personProperties, evaluationContext);
}
}
getBucketingValueForFlag(flag, distinctId, properties) {
if (flag.filters?.aggregation_group_type_index != void 0) return distinctId;
if ('device_id' === flag.bucketing_identifier) {
const deviceId = properties?.$device_id;
if (null == deviceId || '' === deviceId) return;
return deviceId;
}
return distinctId;
}
getFeatureFlagPayload(key, flagValue) {
let payload = null;
if (false !== flagValue && null != flagValue) {
if ('boolean' == typeof flagValue) payload = this.featureFlagsByKey?.[key]?.filters?.payloads?.[flagValue.toString()] || null;
else if ('string' == typeof flagValue) payload = this.featureFlagsByKey?.[key]?.filters?.payloads?.[flagValue] || null;
if (null != payload) {
if ('object' == typeof payload) return payload;
if ('string' == typeof payload) try {
return JSON.parse(payload);
} catch {}
return payload;
}
}
return null;
}
async evaluateFlagDependency(property, properties, evaluationContext) {
const { evaluationCache } = evaluationContext;
const targetFlagKey = property.key;
if (!this.featureFlagsByKey) throw new InconclusiveMatchError('Feature flags not available for dependency evaluation');
if (!('dependency_chain' in property)) throw new InconclusiveMatchError(`Flag dependency property for '${targetFlagKey}' is missing required 'dependency_chain' field`);
const dependencyChain = property.dependency_chain;
if (!Array.isArray(dependencyChain)) throw new InconclusiveMatchError(`Flag dependency property for '${targetFlagKey}' has an invalid 'dependency_chain' (expected array, got ${typeof dependencyChain})`);
if (0 === dependencyChain.length) throw new InconclusiveMatchError(`Circular dependency detected for flag '${targetFlagKey}' (empty dependency chain)`);
for (const depFlagKey of dependencyChain){
if (!(depFlagKey in evaluationCache)) {
const depFlag = this.featureFlagsByKey[depFlagKey];
if (depFlag) if (depFlag.active) try {
const depResult = await this.computeFlagValueLocally(depFlag, evaluationContext);
evaluationCache[depFlagKey] = depResult;
} catch (error) {
throw new InconclusiveMatchError(`Error evaluating flag dependency '${depFlagKey}' for flag '${targetFlagKey}': ${error}`);
}
else evaluationCache[depFlagKey] = false;
else throw new InconclusiveMatchError(`Missing flag dependency '${depFlagKey}' for flag '${targetFlagKey}'`);
}
const cachedResult = evaluationCache[depFlagKey];
if (null == cachedResult) throw new InconclusiveMatchError(`Dependency '${depFlagKey}' could not be evaluated`);
}
const targetFlagValue = evaluationCache[targetFlagKey];
return this.flagEvaluatesToExpectedValue(property.value, targetFlagValue);
}
flagEvaluatesToExpectedValue(expectedValue, flagValue) {
if ('boolean' == typeof expectedValue) return expectedValue === flagValue || 'string' == typeof flagValue && '' !== flagValue && true === expectedValue;
if ('string' == typeof expectedValue) return flagValue === expectedValue;
return false;
}
async matchFeatureFlagProperties(flag, bucketingValue, properties, evaluationContext) {
const flagFilters = flag.filters || {};
const flagConditions = flagFilters.groups || [];
const flagAggregation = flagFilters.aggregation_group_type_index;
const { groups, groupProperties } = evaluationContext;
let isInconclusive = false;
let result;
for (const condition of flagConditions)try {
const conditionAggregation = void 0 !== condition.aggregation_group_type_index ? condition.aggregation_group_type_index : flagAggregation;
let effectiveProperties = properties;
let effectiveBucketingValue = bucketingValue;
if (conditionAggregation !== flagAggregation) {
if (null != conditionAggregation) {
const groupName = this.groupTypeMapping[String(conditionAggregation)];
if (!groupName || !(groupName in groups)) {
this.logMsgIfDebug(()=>console.debug(`[FEATURE FLAGS] Skipping group condition for flag '${flag.key}': group type index ${conditionAggregation} not available`));
continue;
}
if (!(groupName in groupProperties)) {
isInconclusive = true;
continue;
}
effectiveProperties = groupProperties[groupName];
effectiveBucketingValue = groups[groupName];
}
}
if (await this.isConditionMatch(flag, effectiveBucketingValue, condition, effectiveProperties, evaluationContext)) {
const variantOverride = condition.variant;
const flagVariants = flagFilters.multivariate?.variants || [];
result = variantOverride && flagVariants.some((variant)=>variant.key === variantOverride) ? variantOverride : await this.getMatchingVariant(flag, effectiveBucketingValue) || true;
break;
}
} catch (e) {
if (e instanceof RequiresServerEvaluation) throw e;
if (e instanceof InconclusiveMatchError) isInconclusive = true;
else throw e;
}
if (void 0 !== result) return result;
if (isInconclusive) throw new InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties");
return false;
}
async isConditionMatch(flag, bucketingValue, condition, properties, evaluationContext) {
const rolloutPercentage = condition.rollout_percentage;
const warnFunction = (msg)=>{
this.logMsgIfDebug(()=>console.warn(msg));
};
if ((condition.properties || []).length > 0) {
for (const prop of condition.properties){
const propertyType = prop.type;
let matches = false;
matches = 'cohort' === propertyType ? await matchCohort(prop, properties, this.cohorts, this.debugMode, (depProp)=>this.evaluateFlagDependency(depProp, properties, evaluationContext)) : 'flag' === propertyType ? await this.evaluateFlagDependency(prop, properties, evaluationContext) : matchProperty(prop, properties, warnFunction);
if (!matches) return false;
}
if (void 0 == rolloutPercentage) return true;
}
if (void 0 != rolloutPercentage && await _hash(flag.key, bucketingValue) > rolloutPercentage / 100.0) return false;
return true;
}
async getMatchingVariant(flag, bucketingValue) {
const hashValue = await _hash(flag.key, bucketingValue, 'variant');
const matchingVariant = this.variantLookupTable(flag).find((variant)=>hashValue >= variant.valueMin && hashValue < variant.valueMax);
if (matchingVariant) return matchingVariant.key;
}
variantLookupTable(flag) {
const lookupTable = [];
let valueMin = 0;
let valueMax = 0;
const flagFilters = flag.filters || {};
const multivariates = flagFilters.multivariate?.variants || [];
multivariates.forEach((variant)=>{
valueMax = valueMin + variant.rollout_percentage / 100.0;
lookupTable.push({
valueMin,
valueMax,
key: variant.key
});
valueMin = valueMax;
});
return lookupTable;
}
updateFlagState(flagData) {
this.featureFlags = flagData.flags;
this.featureFlagsByKey = flagData.flags.reduce((acc, curr)=>(acc[curr.key] = curr, acc), {});
this.groupTypeMapping = flagData.groupTypeMapping;
this.cohorts = flagData.cohorts;
this.loadedSuccessfullyOnce = true;
}
warnAboutExperienceContinuityFlags(flags) {
if (this.strictLocalEvaluation) return;
const experienceContinuityFlags = flags.filter((f)=>f.ensure_experience_continuity);
if (experienceContinuityFlags.length > 0) console.warn(`[PostHog] You are using local evaluation but ${experienceContinuityFlags.length} flag(s) have experience continuity enabled: ${experienceContinuityFlags.map((f)=>f.key).join(', ')}. Experience continuity is incompatible with local evaluation and will cause a server request on every flag evaluation, negating local evaluation cost savings. To avoid server requests and unexpected costs, either disable experience continuity on these flags in PostHog, use strictLocalEvaluation: true in client init, or pass onlyEvaluateLocally: true per flag call (flags that cannot be evaluated locally will return undefined).`);
}
async loadFromCache(debugMessage) {
if (!this.cacheProvider) return false;
try {
const cached = await this.cacheProvider.getFlagDefinitions();
if (cached) {
this.updateFlagState(cached);
this.logMsgIfDebug(()=>console.debug(`[FEATURE FLAGS] ${debugMessage} (${cached.flags.length} flags)`));
this.onLoad?.(this.featureFlags.length);
this.warnAboutExperienceContinuityFlags(cached.flags);
return true;
}
return false;
} catch (err) {
this.onError?.(new Error(`Failed to load from cache: ${err}`));
return false;
}
}
async loadFeatureFlags(forceReload = false) {
if (this.loadedSuccessfullyOnce && !forceReload) return;
if (!forceReload && this.nextFetchAllowedAt && Date.now() < this.nextFetchAllowedAt) return void this.logMsgIfDebug(()=>console.debug('[FEATURE FLAGS] Skipping fetch, in backoff period'));
if (!this.loadingPromise) this.loadingPromise = this._loadFeatureFlags().catch((err)=>this.logMsgIfDebug(()=>console.debug(`[FEATURE FLAGS] Failed to load feature flags: ${err}`))).finally(()=>{
this.loadingPromise = void 0;
});
return this.loadingPromise;
}
isLocalEvaluationReady() {
return (this.loadedSuccessfullyOnce ?? false) && (this.featureFlags?.length ?? 0) > 0;
}
getFlagDefinitionsLoadedAt() {
return this.flagDefinitionsLoadedAt;
}
getPollingInterval() {
if (!this.shouldBeginExponentialBackoff) return this.pollingInterval;
return Math.min(SIXTY_SECONDS, this.pollingInterval * 2 ** this.backOffCount);
}
beginBackoff() {
this.shouldBeginExponentialBackoff = true;
this.backOffCount += 1;
this.nextFetchAllowedAt = Date.now() + this.getPollingInterval();
}
clearBackoff() {
this.shouldBeginExponentialBackoff = false;
this.backOffCount = 0;
this.nextFetchAllowedAt = void 0;
}
async _loadFeatureFlags() {
if (this.poller) {
clearTimeout(this.poller);
this.poller = void 0;
}
this.poller = setTimeout(()=>this.loadFeatureFlags(true), this.getPollingInterval());
try {
let shouldFetch = true;
if (this.cacheProvider) try {
shouldFetch = await this.cacheProvider.shouldFetchFlagDefinitions();
} catch (err) {
this.onError?.(new Error(`Error in shouldFetchFlagDefinitions: ${err}`));
}
if (!shouldFetch) {
const loaded = await this.loadFromCache('Loaded flags from cache (skipped fetch)');
if (loaded) return;
if (this.loadedSuccessfullyOnce) return;
}
const res = await this._requestFeatureFlagDefinitions();
if (!res) return;
switch(res.status){
case 304:
this.logMsgIfDebug(()=>console.debug('[FEATURE FLAGS] Flags not modified (304), using cached data'));
this.flagsEtag = res.headers?.get('ETag') ?? this.flagsEtag;
this.loadedSuccessfullyOnce = true;
this.clearBackoff();
return;
case 401:
this.beginBackoff();
throw new ClientError(`Your project key or personal API key is invalid. Setting next polling interval to ${this.getPollingInterval()}ms. More information: https://posthog.com/docs/api#rate-limiting`);
case 402:
console.warn('[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts');
this.featureFlags = [];
this.featureFlagsByKey = {};
this.groupTypeMapping = {};
this.cohorts = {};
return;
case 403:
this.beginBackoff();
throw new ClientError(`Your personal API key does not have permission to fetch feature flag definitions for local evaluation. Setting next polling interval to ${this.getPollingInterval()}ms. Are you sure you're using the correct personal and Project API key pair? More information: https://posthog.com/docs/api/overview`);
case 429:
this.beginBackoff();
throw new ClientError(`You are being rate limited. Setting next polling interval to ${this.getPollingInterval()}ms. More information: https://posthog.com/docs/api#rate-limiting`);
case 200:
{
const responseJson = await res.json() ?? {};
if (!('flags' in responseJson)) return void this.onError?.(new Error(`Invalid response when getting feature flags: ${JSON.stringify(responseJson)}`));
this.flagsEtag = res.headers?.get('ETag') ?? void 0;
const flagData = {
flags: responseJson.flags ?? [],
groupTypeMapping: responseJson.group_type_mapping || {},
cohorts: responseJson.cohorts || {}
};
this.updateFlagState(flagData);
this.flagDefinitionsLoadedAt = Date.now();
this.clearBackoff();
if (this.cacheProvider && shouldFetch) try {
await this.cacheProvider.onFlagDefinitionsReceived(flagData);
} catch (err) {
this.onError?.(new Error(`Failed to store in cache: ${err}`));
}
this.onLoad?.(this.featureFlags.length);
this.warnAboutExperienceContinuityFlags(flagData.flags);
break;
}
default:
return;
}
} catch (err) {
if (err instanceof ClientError) this.onError?.(err);
}
}
getPersonalApiKeyRequestOptions(method = 'GET', etag) {
const headers = {
...this.customHeaders,
'Content-Type': 'application/json',
Authorization: `Bearer ${this.personalApiKey}`
};
if (etag) headers['If-None-Match'] = etag;
return {
method,
headers
};
}
_requestFeatureFlagDefinitions() {
const url = `${this.host}/flags/definitions?token=${this.projectApiKey}&send_cohorts`;
const options = this.getPersonalApiKeyRequestOptions('GET', this.flagsEtag);
let abortTimeout = null;
if (this.timeout && 'number' == typeof this.timeout) {
const controller = new AbortController();
abortTimeout = safeSetTimeout(()=>{
controller.abort();
}, this.timeout);
options.signal = controller.signal;
}
try {
const fetch1 = this.fetch;
return fetch1(url, options);
} finally{
clearTimeout(abortTimeout);
}
}
async stopPoller(timeoutMs = 30000) {
clearTimeout(this.poller);
if (this.cacheProvider) try {
const shutdownResult = this.cacheProvider.shutdown();
if (shutdownResult instanceof Promise) await Promise.race([
shutdownResult,
new Promise((_, reject)=>setTimeout(()=>reject(new Error(`Cache shutdown timeout after ${timeoutMs}ms`)), timeoutMs))
]);
} catch (err) {
this.onError?.(new Error(`Error during cache shutdown: ${err}`));
}
}
}
async function _hash(key, bucketingValue, salt = '') {
const hashString = await hashSHA1(`${key}.${bucketingValue}${salt}`);
return parseInt(hashString.slice(0, 15), 16) / LONG_SCALE;
}
function matchProperty(property, propertyValues, warnFunction) {
const key = property.key;
const value = property.value;
const operator = property.operator || 'exact';
if (key in propertyValues) {
if ('is_not_set' === operator) return false;
} else {
if ('is_not_set' === operator) return true;
throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`);
}
const overrideValue = propertyValues[key];
if (null == overrideValue && !NULL_VALUES_ALLOWED_OPERATORS.includes(operator)) {
if (warnFunction) warnFunction(`Property ${key} cannot have a value of null/undefined with the ${operator} operator`);
return false;
}
function computeExactMatch(value, overrideValue) {
if (Array.isArray(value)) return value.map((val)=>String(val).toLowerCase()).includes(String(overrideValue).toLowerCase());
return String(value).toLowerCase() === String(overrideValue).toLowerCase();
}
function compare(lhs, rhs, operator) {
if ('gt' === operator) return lhs > rhs;
if ('gte' === operator) return lhs >= rhs;
if ('lt' === operator) return lhs < rhs;
if ('lte' === operator) return lhs <= rhs;
throw new Error(`Invalid operator: ${operator}`);
}
switch(operator){
case 'exact':
return computeExactMatch(value, overrideValue);
case 'is_not':
return !computeExactMatch(value, overrideValue);
case 'is_set':
return key in propertyValues;
case 'icontains':
return String(overrideValue).toLowerCase().includes(String(value).toLowerCase());
case 'not_icontains':
return !String(overrideValue).toLowerCase().includes(String(value).toLowerCase());
case 'regex':
return isValidRegex(String(value)) && null !== String(overrideValue).match(String(value));
case 'not_regex':
return isValidRegex(String(value)) && null === String(overrideValue).match(String(value));
case 'gt':
case 'gte':
case 'lt':
case 'lte':
{
const parsedValue = 'number' == typeof value ? value : parseFloat(String(value));
let parsedOverride;
parsedOverride = 'number' == typeof overrideValue ? overrideValue : null != overrideValue ? parseFloat(String(overrideValue)) : NaN;
if (Number.isFinite(parsedValue) && Number.isFinite(parsedOverride)) return compare(parsedOverride, parsedValue, operator);
return compare(String(overrideValue), String(value), operator);
}
case 'is_date_after':
case 'is_date_before':
{
if ('boolean' == typeof value) throw new InconclusiveMatchError("Date operations cannot be performed on boolean values");
let parsedDate = relativeDateParseForFeatureFlagMatching(String(value));
if (null == parsedDate) parsedDate = convertToDateTime(value);
if (null == parsedDate) throw new InconclusiveMatchError(`Invalid date: ${value}`);
const overrideDate = convertToDateTime(overrideValue);
if ([
'is_date_before'
].includes(operator)) return overrideDate < parsedDate;
return overrideDate > parsedDate;
}
case 'semver_eq':
{
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)));
return 0 === cmp;
}
case 'semver_neq':
{
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)));
return 0 !== cmp;
}
case 'semver_gt':
{
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)));
return cmp > 0;
}
case 'semver_gte':
{
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)));
return cmp >= 0;
}
case 'semver_lt':
{
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)));
return cmp < 0;
}
case 'semver_lte':
{
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)));
return cmp <= 0;
}
case 'semver_tilde':
{
const overrideParsed = parseSemver(String(overrideValue));
const { lower, upper } = computeTildeBounds(String(value));
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0;
}
case 'semver_caret':
{
const overrideParsed = parseSemver(String(overrideValue));
const { lower, upper } = computeCaretBounds(String(value));
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0;
}
case 'semver_wildcard':
{
const overrideParsed = parseSemver(String(overrideValue));
const { lower, upper } = computeWildcardBounds(String(value));
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0;
}
default:
throw new InconclusiveMatchError(`Unknown operator: ${operator}`);
}
}
function checkCohortExists(cohortId, cohortProperties) {
if (!(cohortId in cohortProperties)) throw new RequiresServerEvaluation(`cohort ${cohortId} not found in local cohorts - likely a static cohort that requires server evaluation`);
}
async function matchCohort(property, propertyValues, cohortProperties, debugMode = false, flagDependencyEvaluator) {
const cohortId = String(property.value);
checkCohortExists(cohortId, cohortProperties);
const propertyGroup = cohortProperties[cohortId];
return matchPropertyGroup(propertyGroup, propertyValues, cohortProperties, debugMode, flagDependencyEvaluator);
}
async function matchPropertyGroup(propertyGroup, propertyValues, cohortProperties, debugMode = false, flagDependencyEvaluator) {
if (!propertyGroup) return true;
const propertyGroupType = propertyGroup.type;
const properties = propertyGroup.values;
if (!properties || 0 === properties.length) return true;
let errorMatchingLocally = false;
if ('values' in properties[0]) {
for (const prop of properties)try {
const matches = await matchPropertyGroup(prop, propertyValues, cohortProperties, debugMode, flagDependencyEvaluator);
if ('AND' === propertyGroupType) {
if (!matches) return false;
} else if (matches) return true;
} catch (err) {
if (err instanceof RequiresServerEvaluation) throw err;
if (err instanceof InconclusiveMatchError) {
if (debugMode) console.debug(`Failed to compute property ${prop} locally: ${err}`);
errorMatchingLocally = true;
} else throw err;
}
if (errorMatchingLocally) throw new InconclusiveMatchError("Can't match cohort without a given cohort property value");
return 'AND' === propertyGroupType;
}
for (const prop of properties)try {
let matches;
if ('cohort' === prop.type) matches = await matchCohort(prop, propertyValues, cohortProperties, debugMode, flagDependencyEvaluator);
else if ('flag' === prop.type) {
if (!flagDependencyEvaluator) throw new InconclusiveMatchError(`Flag dependency '${prop.key || 'unknown'}' cannot be evaluated without a flag dependency evaluator`);
matches = await flagDependencyEvaluator(prop);
} else matches = matchProperty(prop, propertyValues);
const negation = prop.negation || false;
if ('AND' === propertyGroupType) {
if (!matches && !negation) return false;
if (matches && negation) return false;
} else {
if (matches && !negation) return true;
if (!matches && negation) return true;
}
} catch (err) {
if (err instanceof RequiresServerEvaluation) throw err;
if (err instanceof InconclusiveMatchError) {
if (debugMode) console.debug(`Failed to compute property ${prop} locally: ${err}`);
errorMatchingLocally = true;
} else throw err;
}
if (errorMatchingLocally) throw new InconclusiveMatchError("can't match cohort without a given cohort property value");
return 'AND' === propertyGroupType;
}
function isValidRegex(regex) {
try {
new RegExp(regex);
return true;
} catch (err) {
return false;
}
}
function parseSemverNumericIdentifier(part, raw) {
if (!/^\d+$/.test(part)) throw new InconclusiveMatchError(`Invalid semver: ${raw}`);
if (part.length > 1 && '0' === part[0]) throw new InconclusiveMatchError(`Invalid semver: ${raw}`);
return parseInt(part, 10);
}
function parseSemver(value) {
const text = String(value).trim().replace(/^[vV]/, '');
const baseVersion = text.split('-')[0].split('+')[0];
if (!baseVersion || baseVersion.startsWith('.')) throw new InconclusiveMatchError(`Invalid semver: ${value}`);
const parts = baseVersion.split('.');
const parsePart = (part)=>{
if (void 0 === part || '' === part) return 0;
return parseSemverNumericIdentifier(part, value);
};
const major = parsePart(parts[0]);
const minor = parsePart(parts[1]);
const patch = parsePart(parts[2]);
return [
major,
minor,
patch
];
}
function compareSemverTuples(a, b) {
for(let i = 0; i < 3; i++){
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
return 0;
}
function computeTildeBounds(value) {
const parsed = parseSemver(value);
const lower = [
parsed[0],
parsed[1],
parsed[2]
];
const upper = [
parsed[0],
parsed[1] + 1,
0
];
return {
lower,
upper
};
}
function computeCaretBounds(value) {
const parsed = parseSemver(value);
const [major, minor, patch] = parsed;
const lower = [
major,
minor,
patch
];
let upper;
upper = major > 0 ? [
major + 1,
0,
0
] : minor > 0 ? [
0,
minor + 1,
0
] : [
0,
0,
patch + 1
];
return {
lower,
upper
};
}
function computeWildcardBounds(value) {
const text = String(value).trim().replace(/^[vV]/, '');
const cleanedText = text.replace(/\.\*$/, '').replace(/\*$/, '');
if (!cleanedText) throw new InconclusiveMatchError(`Invalid wildcard semver: ${value}`);
const parts = cleanedText.split('.');
const parseWildcardPart = (part)=>{
try {
return parseSemverNumericIdentifier(part, value);
} catch {
throw new InconclusiveMatchError(`Invalid wildcard semver: ${value}`);
}
};
const major = parseWildcardPart(parts[0]);
let lower;
let upper;
if (1 === parts.length) {
lower = [
major,
0,
0
];
upper = [
major + 1,
0,
0
];
} else {
const minor = parseWildcardPart(parts[1]);
lower = [
major,
minor,
0
];
upper = [
major,
minor + 1,
0
];
}
return {
lower,
upper
};
}
function convertToDateTime(value) {
if (value instanceof Date) return value;
if ('string' == typeof value || 'number' == typeof value) {
const date = new Date(value);
if (!isNaN(date.valueOf())) return date;
throw new InconclusiveMatchError(`${value} is in an invalid date format`);
}
throw new InconclusiveMatchError(`The date provided ${value} must be a string, number, or date object`);
}
function relativeDateParseForFeatureFlagMatching(value) {
const regex = /^-?(?<number>[0-9]+)(?<interval>[a-z])$/;
const match = value.match(regex);
const parsedDt = new Date(new Date().toISOString());
if (!match) return null;
{
if (!match.groups) return null;
const number = parseInt(match.groups['number']);
if (number >= 10000) return null;
const interval = match.groups['interval'];
if ('h' == interval) parsedDt.setUTCHours(parsedDt.getUTCHours() - number);
else if ('d' == interval) parsedDt.setUTCDate(parsedDt.getUTCDate() - number);
else if ('w' == interval) parsedDt.setUTCDate(parsedDt.getUTCDate() - 7 * number);
else if ('m' == interval) parsedDt.setUTCMonth(parsedDt.getUTCMonth() - number);
else {
if ('y' != interval) return null;
parsedDt.setUTCFullYear(parsedDt.getUTCFullYear() - number);
}
return parsedDt;
}
}
export { ClientError, FeatureFlagsPoller, InconclusiveMatchError, RequiresServerEvaluation, matchProperty, parseSemver, relativeDateParseForFeatureFlagMatching };