UNPKG

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