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

281 lines (277 loc) 7.91 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) { if (!flag.enabled) { return { value: flag.defaultValue, reason: "disabled" }; } const override = await checkOverride(flag, context, pluginContext); if (override) { return override; } const ruleResult = await evaluateRules(flag, context, pluginContext); if (ruleResult) { return ruleResult; } const rolloutResult = checkRollout(flag, context); if (rolloutResult) { return rolloutResult; } return { value: flag.defaultValue, variant: flag.defaultVariant, reason: "default" }; } __name(evaluateFlags, "evaluateFlags"); async function checkOverride(flag, context, pluginContext) { const { storage } = pluginContext; try { 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: variant || rule.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 || conditions.conditions.length === 0) { return true; } 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((r) => r === true); } else { return results.some((r) => r === true); } } __name(evaluateRuleConditions, "evaluateRuleConditions"); 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 = selectVariantByPercentage(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 (rule.variant) { return rule.variant; } if (flag.variants && Object.keys(flag.variants).length > 0) { return selectVariantByPercentage(flag, context); } return void 0; } __name(selectVariant, "selectVariant"); function selectVariantByPercentage(flag, context) { if (!flag.variants || Object.keys(flag.variants).length === 0) { return void 0; } const variants = Object.keys(flag.variants); if (variants.length === 0) return void 0; const hashInput = `${context.userId}:${flag.key}:variant`; const hashValue = simpleHash(hashInput); const variantIndex = hashValue % variants.length; return variants[variantIndex]; } __name(selectVariantByPercentage, "selectVariantByPercentage"); 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) { 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); } catch (error) { console.error(`[feature-flags] Error evaluating flag ${key}:`, error); results[key] = { value: flag.defaultValue, reason: "error" }; } } return results; } __name(evaluateFlagsBatch, "evaluateFlagsBatch"); export { generateId, parseJSON, evaluateFlags, evaluateFlagsBatch };