better-auth-feature-flags
Version:
Ship features safely with feature flags, A/B testing, and progressive rollouts - Better Auth plugin for modern release management
437 lines (433 loc) • 12.1 kB
JavaScript
import {
__name
} from "./chunk-SHUYVCID.js";
// src/utils.ts
function generateId() {
return crypto.randomUUID();
}
__name(generateId, "generateId");
function parseJSON(value) {
if (typeof value !== "string") {
return value;
}
try {
return JSON.parse(value);
} catch {
return value;
}
}
__name(parseJSON, "parseJSON");
function calculateRollout(userId, percentage) {
if (percentage <= 0) return false;
if (percentage >= 100) return true;
const hashValue = userId.split("").reduce((acc, char) => {
return (acc << 5) - acc + char.charCodeAt(0) | 0;
}, 0);
return Math.abs(hashValue) % 100 < percentage;
}
__name(calculateRollout, "calculateRollout");
function evaluateCondition(value, operator, target) {
switch (operator) {
case "equals":
return value === target;
case "not_equals":
return value !== target;
case "contains":
return String(value).includes(String(target));
case "not_contains":
return !String(value).includes(String(target));
case "starts_with":
return String(value).startsWith(String(target));
case "ends_with":
return String(value).endsWith(String(target));
case "greater_than":
return Number(value) > Number(target);
case "less_than":
return Number(value) < Number(target);
case "greater_than_or_equal":
return Number(value) >= Number(target);
case "less_than_or_equal":
return Number(value) <= Number(target);
case "in":
return Array.isArray(target) ? target.includes(value) : false;
case "not_in":
return Array.isArray(target) ? !target.includes(value) : true;
case "regex":
try {
return new RegExp(String(target)).test(String(value));
} catch {
return false;
}
default:
return false;
}
}
__name(evaluateCondition, "evaluateCondition");
// src/evaluation.ts
async function evaluateFlags(flag, context, pluginContext, debug = false, environment) {
const startTime = debug ? Date.now() : 0;
const debugInfo = debug ? {
evaluationPath: [],
steps: []
} : null;
if (!flag.enabled) {
if (debug) {
debugInfo.evaluationPath.push("disabled");
debugInfo.steps.push({ step: "disabled", flagEnabled: false });
}
return {
value: flag.defaultValue,
reason: "disabled",
...debug && {
metadata: {
debug: {
...debugInfo,
processingTime: Date.now() - startTime,
flagId: flag.id,
environment
}
}
}
};
}
const override = await checkOverride(flag, context, pluginContext);
if (override) {
if (debug) {
debugInfo.evaluationPath.push("override");
debugInfo.steps.push({
step: "override",
matched: true,
overrideId: override.metadata?.overrideId,
userId: context.userId
});
return {
...override,
metadata: {
...override.metadata,
debug: {
...debugInfo,
processingTime: Date.now() - startTime,
flagId: flag.id,
environment
}
}
};
}
return override;
}
const ruleResult = await evaluateRules(flag, context, pluginContext);
if (ruleResult) {
if (debug) {
debugInfo.evaluationPath.push("rule");
debugInfo.steps.push({
step: "rule",
matched: true,
ruleId: ruleResult.metadata?.ruleId
});
return {
...ruleResult,
metadata: {
...ruleResult.metadata,
debug: {
...debugInfo,
processingTime: Date.now() - startTime,
flagId: flag.id,
environment
}
}
};
}
return ruleResult;
}
if (debug) {
debugInfo.steps.push({ step: "rules", matched: false });
}
const rolloutResult = checkRollout(flag, context);
if (rolloutResult) {
if (debug) {
debugInfo.evaluationPath.push("rollout");
debugInfo.steps.push({
step: "rollout",
matched: true,
rolloutPercentage: flag.rolloutPercentage
});
return {
...rolloutResult,
metadata: {
...rolloutResult.metadata,
debug: {
...debugInfo,
processingTime: Date.now() - startTime,
flagId: flag.id,
environment
}
}
};
}
return rolloutResult;
}
if (debug) {
debugInfo.steps.push({ step: "rollout", matched: false });
}
if (debug) {
debugInfo.evaluationPath.push("default");
debugInfo.steps.push({ step: "default", matched: true });
}
return {
value: flag.defaultValue,
reason: "default",
...debug && {
metadata: {
debug: {
...debugInfo,
processingTime: Date.now() - startTime,
flagId: flag.id,
environment
}
}
}
};
}
__name(evaluateFlags, "evaluateFlags");
async function checkOverride(flag, context, pluginContext) {
const { storage } = pluginContext;
try {
if (!context.userId) return null;
const override = await storage.getOverride(flag.id, context.userId);
if (override && override.enabled) {
return {
value: override.value,
variant: override.variant,
reason: "override",
metadata: {
overrideId: override.id
}
};
}
} catch (error) {
console.error(`[feature-flags] Error checking override: ${error}`);
}
return null;
}
__name(checkOverride, "checkOverride");
async function evaluateRules(flag, context, pluginContext) {
const { storage } = pluginContext;
try {
const rules = await storage.getRulesForFlag(flag.id);
for (const rule of rules) {
if (!rule.enabled) continue;
const matches = evaluateRuleConditions(rule, context);
if (matches) {
const variant = selectVariant(rule, flag, context);
return {
value: rule.value !== void 0 ? rule.value : flag.defaultValue,
variant,
reason: "rule_match",
metadata: {
ruleId: rule.id,
ruleName: rule.name,
priority: rule.priority
}
};
}
}
} catch (error) {
console.error(`[feature-flags] Error evaluating rules: ${error}`);
}
return null;
}
__name(evaluateRules, "evaluateRules");
function evaluateRuleConditions(rule, context) {
const { conditions } = rule;
if (!conditions) {
return true;
}
return evaluateConditionsRecursive(conditions, context);
}
__name(evaluateRuleConditions, "evaluateRuleConditions");
function evaluateConditionsRecursive(conditions, context) {
if (conditions.conditions && conditions.operator) {
const results = conditions.conditions.map((condition) => {
const attributeValue = getAttributeValue(context, condition.attribute);
return evaluateCondition(
attributeValue,
condition.operator,
condition.value
);
});
if (conditions.operator === "AND") {
return results.every(Boolean);
} else {
return results.some(Boolean);
}
}
let result = true;
if (conditions.all && conditions.all.length > 0) {
const allResults = conditions.all.map((condition) => {
const attributeValue = getAttributeValue(context, condition.attribute);
return evaluateCondition(
attributeValue,
condition.operator,
condition.value
);
});
result = result && allResults.every(Boolean);
}
if (conditions.any && conditions.any.length > 0) {
const anyResults = conditions.any.map((condition) => {
const attributeValue = getAttributeValue(context, condition.attribute);
return evaluateCondition(
attributeValue,
condition.operator,
condition.value
);
});
result = result && anyResults.some(Boolean);
}
if (conditions.not) {
const notResult = evaluateConditionsRecursive(conditions.not, context);
result = result && !notResult;
}
return result;
}
__name(evaluateConditionsRecursive, "evaluateConditionsRecursive");
function getAttributeValue(context, attribute) {
const parts = attribute.split(".");
let value = context;
for (const part of parts) {
if (value && typeof value === "object") {
value = value[part];
} else {
return void 0;
}
}
return value;
}
__name(getAttributeValue, "getAttributeValue");
function checkRollout(flag, context) {
if (flag.rolloutPercentage === void 0 || flag.rolloutPercentage === 100) {
return null;
}
if (flag.rolloutPercentage === 0) {
return {
value: flag.defaultValue,
reason: "percentage_rollout",
metadata: {
percentage: 0,
included: false
}
};
}
const hashInput = `${context.userId}:${flag.key}`;
const included = calculateRollout(hashInput, flag.rolloutPercentage);
if (included) {
const variant = selectVariantByWeight(flag, context);
return {
value: flag.defaultValue !== false ? flag.defaultValue : true,
variant,
reason: "percentage_rollout",
metadata: {
percentage: flag.rolloutPercentage,
included: true
}
};
}
return {
value: flag.type === "boolean" ? false : flag.defaultValue,
reason: "percentage_rollout",
metadata: {
percentage: flag.rolloutPercentage,
included: false
}
};
}
__name(checkRollout, "checkRollout");
function selectVariant(rule, flag, context) {
if (flag.variants && flag.variants.length > 0) {
return selectVariantByWeight(flag, context);
}
return void 0;
}
__name(selectVariant, "selectVariant");
function selectVariantByWeight(flag, context) {
if (!flag.variants || flag.variants.length === 0) {
return void 0;
}
const variants = flag.variants;
if (variants.length === 0) return void 0;
const hashInput = `${context.userId}:${flag.key}:variant`;
const hashValue = simpleHash(hashInput);
const hasWeights = variants.some((v) => typeof v.weight === "number");
if (hasWeights) {
const totalWeight = variants.reduce((sum, v) => sum + (v.weight || 0), 0);
if (totalWeight <= 0) {
const variantIndex = hashValue % variants.length;
return variants[variantIndex].key;
}
const targetWeight = hashValue % totalWeight;
let cumulativeWeight = 0;
for (const variant of variants) {
cumulativeWeight += variant.weight || 0;
if (targetWeight < cumulativeWeight) {
return variant.key;
}
}
return variants[variants.length - 1].key;
} else {
const variantIndex = hashValue % variants.length;
return variants[variantIndex].key;
}
}
__name(selectVariantByWeight, "selectVariantByWeight");
function simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
__name(simpleHash, "simpleHash");
async function evaluateFlagsBatch(keys, context, pluginContext, debug = false, environment) {
const { storage, config } = pluginContext;
const results = {};
const organizationId = config.multiTenant.enabled ? context.organizationId : void 0;
const flagPromises = keys.map((key) => storage.getFlag(key, organizationId));
const flags = await Promise.all(flagPromises);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const flag = flags[i];
if (!flag) {
results[key] = {
value: void 0,
reason: "not_found"
};
continue;
}
try {
results[key] = await evaluateFlags(
flag,
context,
pluginContext,
debug,
environment
);
} catch (error) {
console.error(`[feature-flags] Error evaluating flag ${key}:`, error);
results[key] = {
value: flag.defaultValue,
reason: "default",
metadata: { error: true }
};
}
}
return results;
}
__name(evaluateFlagsBatch, "evaluateFlagsBatch");
export {
generateId,
parseJSON,
evaluateFlags,
evaluateFlagsBatch
};