1
2interface Condition {
3 attribute: string;
4 operator: "eq" | "neq" | "in" | "contains" | "starts_with";
5 values: string[];
6}
7
8interface Rule {
9 conditions: Condition[];
10 variant: string;
11 priority: number;
12}
13
14export interface FlagConfig {
15 key: string;
16 enabled: boolean;
17 percentage: number;
18 rules: Rule[];
19 defaultVariant: string;
20}
21
22export interface EvalContext {
23 userId: string;
24 email?: string;
25 plan?: string;
26 country?: string;
27 properties?: Record<string, string>;
28}
29
30export interface EvalResult {
31 enabled: boolean;
32 variant: string;
33 reason: string;
34}
35
36export class FlagEvaluator {
37 private flags = new Map<string, FlagConfig>();
38
39 update(configs: FlagConfig[]): void {
40 const newFlags = new Map<string, FlagConfig>();
41 for (const config of configs) {
42 newFlags.set(config.key, config);
43 }
44 this.flags = newFlags;
45 }
46
47 evaluate(flagKey: string, context: EvalContext): EvalResult {
48 const flag = this.flags.get(flagKey);
49 if (!flag || !flag.enabled) {
50 return { enabled: false, variant: "", reason: "disabled" };
51 }
52
53 const sortedRules = [...flag.rules].sort((a, b) => b.priority - a.priority);
54 for (const rule of sortedRules) {
55 if (rule.conditions.every(c => this.matchCondition(c, context))) {
56 return { enabled: true, variant: rule.variant, reason: "rule_match" };
57 }
58 }
59
60 if (flag.percentage > 0 && flag.percentage < 100) {
61 const bucket = this.hashBucket(flagKey, context.userId);
62 if (bucket < flag.percentage) {
63 return { enabled: true, variant: flag.defaultVariant, reason: "percentage" };
64 }
65 return { enabled: false, variant: "", reason: "percentage_excluded" };
66 }
67
68 return { enabled: true, variant: flag.defaultVariant, reason: "default" };
69 }
70
71 isEnabled(flagKey: string, context: EvalContext): boolean {
72 return this.evaluate(flagKey, context).enabled;
73 }
74
75 getAllFlags(): Map<string, FlagConfig> {
76 return this.flags;
77 }
78
79 private async hashBucket(flagKey: string, userId: string): Promise<number> {
80
81 const encoder = new TextEncoder();
82 const data = encoder.encode(`${flagKey}:${userId}`);
83 const hashBuffer = await crypto.subtle.digest("SHA-256", data);
84 const view = new DataView(hashBuffer);
85 return (view.getUint32(0) / 0xffffffff) * 100;
86 }
87
88
89 private hashBucketSync(flagKey: string, userId: string): number {
90 let hash = 0;
91 const str = `${flagKey}:${userId}`;
92 for (let i = 0; i < str.length; i++) {
93 const char = str.charCodeAt(i);
94 hash = ((hash << 5) - hash) + char;
95 hash = hash & hash;
96 }
97 return Math.abs(hash % 100);
98 }
99
100 private matchCondition(cond: Condition, ctx: EvalContext): boolean {
101 const value = this.getAttribute(cond.attribute, ctx);
102 switch (cond.operator) {
103 case "eq": return cond.values[0] === value;
104 case "neq": return cond.values[0] !== value;
105 case "in": return cond.values.includes(value);
106 case "contains": return value.includes(cond.values[0]);
107 case "starts_with": return value.startsWith(cond.values[0]);
108 default: return false;
109 }
110 }
111
112 private getAttribute(attr: string, ctx: EvalContext): string {
113 const map: Record<string, string | undefined> = {
114 user_id: ctx.userId, email: ctx.email, plan: ctx.plan, country: ctx.country,
115 };
116 return map[attr] ?? ctx.properties?.[attr] ?? "";
117 }
118}
119