@lacspace/flags 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +14 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +14 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -12,8 +12,18 @@ function fnv1a(str) {
|
|
|
12
12
|
function bucket(key) {
|
|
13
13
|
return fnv1a(key) % 1e5 / 1e5;
|
|
14
14
|
}
|
|
15
|
-
function
|
|
16
|
-
return
|
|
15
|
+
function rolloutKey(flagKey, seed, ctx, salt = "") {
|
|
16
|
+
return `${flagKey}:${seed}:${salt}:${ctx.key}`;
|
|
17
|
+
}
|
|
18
|
+
function percentage(flag, ctx, seed = "", salt = "") {
|
|
19
|
+
return Math.floor(bucket(rolloutKey(flag, seed, ctx, salt)) * 100);
|
|
20
|
+
}
|
|
21
|
+
function safeRegexTest(pattern, value) {
|
|
22
|
+
try {
|
|
23
|
+
return new RegExp(pattern).test(value);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
17
27
|
}
|
|
18
28
|
function matchOperator(actual, op) {
|
|
19
29
|
if ("eq" in op && actual !== op.eq) return false;
|
|
@@ -25,7 +35,7 @@ function matchOperator(actual, op) {
|
|
|
25
35
|
if (typeof op.lt === "number" && !(typeof actual === "number" && actual < op.lt)) return false;
|
|
26
36
|
if (typeof op.lte === "number" && !(typeof actual === "number" && actual <= op.lte)) return false;
|
|
27
37
|
if (typeof op.contains === "string" && !(typeof actual === "string" && actual.includes(op.contains))) return false;
|
|
28
|
-
if (typeof op.regex === "string" && !(typeof actual === "string" &&
|
|
38
|
+
if (typeof op.regex === "string" && !(typeof actual === "string" && safeRegexTest(op.regex, actual))) return false;
|
|
29
39
|
return true;
|
|
30
40
|
}
|
|
31
41
|
function matchCondition(attrs, cond) {
|
|
@@ -42,7 +52,7 @@ function matchCondition(attrs, cond) {
|
|
|
42
52
|
function inRollout(flagKey, seed, ctx, rollout, salt = "") {
|
|
43
53
|
if (rollout >= 100) return true;
|
|
44
54
|
if (rollout <= 0) return false;
|
|
45
|
-
return bucket(
|
|
55
|
+
return bucket(rolloutKey(flagKey, seed, ctx, salt)) * 100 < rollout;
|
|
46
56
|
}
|
|
47
57
|
function pickVariant(flagKey, seed, ctx, variants) {
|
|
48
58
|
const total = variants.reduce((s, v) => s + (v.weight ?? 1), 0);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/flags\n *\n * Feature flags & A/B experiments with **no SaaS and no infrastructure**.\n * You own the config (a plain object — from JSON, env, a DB row, anywhere);\n * this evaluates it. The whole space is hosted vendors (LaunchDarkly, Optimizely,\n * Unleash) — this is the tiny, self-hosted primitive for the rest of us.\n *\n * - Deterministic bucketing — the same user ALWAYS gets the same result\n * (stable percentage rollouts & A/B assignment, no flicker, works offline)\n * - Targeting rules on user attributes (eq / in / gt / contains / regex …)\n * - Boolean flags AND weighted multivariate experiments\n * - Synchronous, zero-dependency, isomorphic — evaluate flags right in render\n */\n\nexport type AttrValue = string | number | boolean | null | undefined;\nexport type Attributes = Record<string, AttrValue>;\n\nexport interface Context {\n /** Stable identifier the bucketing is keyed on (user id, account id, device id). */\n key: string;\n /** Attributes used by targeting rules. */\n attributes?: Attributes;\n}\n\n/* ------------------------------ conditions ------------------------------ */\n\nexport interface Operator {\n eq?: AttrValue;\n ne?: AttrValue;\n in?: AttrValue[];\n nin?: AttrValue[];\n gt?: number;\n gte?: number;\n lt?: number;\n lte?: number;\n contains?: string;\n regex?: string;\n}\n\n/** A set of attribute conditions — ALL must match. */\nexport type Condition = Record<string, AttrValue | Operator>;\n\nexport interface Rule {\n /** Attribute conditions that must all match for this rule to apply. */\n when: Condition;\n /** Force this value (boolean flag) or variant key (variant flag). */\n value?: boolean | string;\n /** Or: percentage rollout within the matched segment (0–100). */\n rollout?: number;\n}\n\nexport interface Variant {\n key: string;\n /** Relative weight (default 1 → equal split). */\n weight?: number;\n}\n\nexport interface BooleanFlag {\n type?: \"boolean\";\n /** Master switch. Default true. */\n enabled?: boolean;\n /** Percentage rollout 0–100 when no rule matches. Default 100. */\n rollout?: number;\n rules?: Rule[];\n /** Value when disabled or outside the rollout. Default false. */\n default?: boolean;\n /** Salt for bucketing (change it to re-shuffle everyone). */\n seed?: string;\n}\n\nexport interface VariantFlag {\n type: \"variant\";\n enabled?: boolean;\n variants: Variant[];\n rules?: Rule[];\n /** Fallback variant key when disabled. Default: first variant. */\n default?: string;\n seed?: string;\n}\n\nexport type FlagDef = BooleanFlag | VariantFlag;\n\n/* ------------------------------ hashing ------------------------------ */\n\n/** FNV-1a 32-bit — fast, deterministic, well-distributed for bucketing. */\nfunction fnv1a(str: string): number {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;\n }\n return h >>> 0;\n}\n\n/** Stable bucket in [0, 1) for a key. */\nexport function bucket(key: string): number {\n return (fnv1a(key) % 100000) / 100000;\n}\n\n/** Stable 0–100 percentage for a (flag, context) pair — useful for debugging rollouts. */\nexport function percentage(flag: string, ctx: Context, seed = \"\"): number {\n return Math.floor(bucket(`${flag}:${seed}:${ctx.key}`) * 100);\n}\n\n/* ------------------------------ matching ------------------------------ */\n\nfunction matchOperator(actual: AttrValue, op: Operator): boolean {\n if (\"eq\" in op && actual !== op.eq) return false;\n if (\"ne\" in op && actual === op.ne) return false;\n if (op.in && !op.in.includes(actual)) return false;\n if (op.nin && op.nin.includes(actual)) return false;\n if (typeof op.gt === \"number\" && !(typeof actual === \"number\" && actual > op.gt)) return false;\n if (typeof op.gte === \"number\" && !(typeof actual === \"number\" && actual >= op.gte)) return false;\n if (typeof op.lt === \"number\" && !(typeof actual === \"number\" && actual < op.lt)) return false;\n if (typeof op.lte === \"number\" && !(typeof actual === \"number\" && actual <= op.lte)) return false;\n if (typeof op.contains === \"string\" && !(typeof actual === \"string\" && actual.includes(op.contains))) return false;\n if (typeof op.regex === \"string\" && !(typeof actual === \"string\" && new RegExp(op.regex).test(actual))) return false;\n return true;\n}\n\nfunction matchCondition(attrs: Attributes, cond: Condition): boolean {\n for (const [field, expected] of Object.entries(cond)) {\n const actual = attrs[field];\n if (expected !== null && typeof expected === \"object\" && !Array.isArray(expected)) {\n if (!matchOperator(actual, expected)) return false;\n } else if (actual !== expected) {\n return false;\n }\n }\n return true;\n}\n\n/* ------------------------------ evaluation ------------------------------ */\n\nfunction inRollout(flagKey: string, seed: string, ctx: Context, rollout: number, salt = \"\"): boolean {\n if (rollout >= 100) return true;\n if (rollout <= 0) return false;\n return bucket(`${flagKey}:${seed}:${salt}:${ctx.key}`) * 100 < rollout;\n}\n\nfunction pickVariant(flagKey: string, seed: string, ctx: Context, variants: Variant[]): string {\n const total = variants.reduce((s, v) => s + (v.weight ?? 1), 0);\n if (total <= 0) return variants[0]?.key ?? \"\";\n let point = bucket(`${flagKey}:${seed}:variant:${ctx.key}`) * total;\n for (const v of variants) {\n point -= v.weight ?? 1;\n if (point < 0) return v.key;\n }\n return variants[variants.length - 1]!.key;\n}\n\n/** Evaluate a boolean flag for a context. */\nexport function isEnabled(flagKey: string, def: BooleanFlag, ctx: Context): boolean {\n const fallback = def.default ?? false;\n if (def.enabled === false) return fallback;\n const seed = def.seed ?? \"\";\n\n for (const rule of def.rules ?? []) {\n if (matchCondition(ctx.attributes ?? {}, rule.when)) {\n if (typeof rule.value === \"boolean\") return rule.value;\n if (typeof rule.rollout === \"number\") {\n return inRollout(flagKey, seed, ctx, rule.rollout, ruleSalt(rule)) ? true : fallback;\n }\n return true;\n }\n }\n // Fallthrough rollout: default 100% when there are no rules, but 0% when rules\n // exist (rules define who gets it; set an explicit `rollout` to also include a\n // slice of everyone else).\n const rollout = def.rollout ?? (def.rules && def.rules.length ? 0 : 100);\n return inRollout(flagKey, seed, ctx, rollout) ? true : fallback;\n}\n\n/** Evaluate a variant/experiment flag for a context — returns the variant key. */\nexport function variant(flagKey: string, def: VariantFlag, ctx: Context): string {\n const fallback = def.default ?? def.variants[0]?.key ?? \"\";\n if (def.enabled === false) return fallback;\n const seed = def.seed ?? \"\";\n\n for (const rule of def.rules ?? []) {\n if (matchCondition(ctx.attributes ?? {}, rule.when)) {\n if (typeof rule.value === \"string\") return rule.value;\n break; // matched a rule with no explicit variant → fall through to weighted split\n }\n }\n return pickVariant(flagKey, seed, ctx, def.variants);\n}\n\nfunction ruleSalt(rule: Rule): string {\n return JSON.stringify(rule.when);\n}\n\nconst isVariantFlag = (d: FlagDef): d is VariantFlag => d.type === \"variant\";\n\n/* ------------------------------ the store ------------------------------ */\n\n/**\n * A flag set built from a plain config object. Evaluate synchronously anywhere.\n *\n * @example\n * const flags = new Flags({\n * \"new-dashboard\": { rollout: 25 }, // 25% of users\n * \"beta\": { rules: [{ when: { plan: \"pro\" }, value: true }] },\n * \"checkout-exp\": { type: \"variant\", variants: [\n * { key: \"control\", weight: 1 }, { key: \"one-click\", weight: 1 },\n * ]},\n * });\n * flags.isEnabled(\"new-dashboard\", { key: user.id }); // stable per user\n * flags.variant(\"checkout-exp\", { key: user.id }); // \"control\" | \"one-click\"\n */\nexport class Flags {\n private defs: Record<string, FlagDef>;\n\n constructor(defs: Record<string, FlagDef> = {}) {\n this.defs = { ...defs };\n }\n\n /** Replace / merge flag definitions at runtime (hot config reload). */\n update(defs: Record<string, FlagDef>): this {\n this.defs = { ...this.defs, ...defs };\n return this;\n }\n\n /** Set (or remove, with `undefined`) a single flag. */\n set(key: string, def: FlagDef | undefined): this {\n if (def === undefined) delete this.defs[key];\n else this.defs[key] = def;\n return this;\n }\n\n has(key: string): boolean {\n return key in this.defs;\n }\n\n /** Boolean evaluation. Unknown flag → false. */\n isEnabled(key: string, ctx: Context): boolean {\n const def = this.defs[key];\n if (!def || isVariantFlag(def)) return false;\n return isEnabled(key, def, ctx);\n }\n\n /** Variant evaluation. Unknown flag → \"\". */\n variant(key: string, ctx: Context): string {\n const def = this.defs[key];\n if (!def || !isVariantFlag(def)) return \"\";\n return variant(key, def, ctx);\n }\n\n /** Generic evaluation → boolean for boolean flags, variant key for variant flags. */\n evaluate(key: string, ctx: Context): boolean | string {\n const def = this.defs[key];\n if (!def) return false;\n return isVariantFlag(def) ? variant(key, def, ctx) : isEnabled(key, def, ctx);\n }\n\n /**\n * Evaluate every flag for a context — ideal for bootstrapping a client so the\n * browser never flickers or needs a round-trip.\n */\n all(ctx: Context): Record<string, boolean | string> {\n const out: Record<string, boolean | string> = {};\n for (const key of Object.keys(this.defs)) out[key] = this.evaluate(key, ctx);\n return out;\n }\n\n /** The current raw config. */\n toJSON(): Record<string, FlagDef> {\n return { ...this.defs };\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/flags\n *\n * Feature flags & A/B experiments with **no SaaS and no infrastructure**.\n * You own the config (a plain object — from JSON, env, a DB row, anywhere);\n * this evaluates it. The whole space is hosted vendors (LaunchDarkly, Optimizely,\n * Unleash) — this is the tiny, self-hosted primitive for the rest of us.\n *\n * - Deterministic bucketing — the same user ALWAYS gets the same result\n * (stable percentage rollouts & A/B assignment, no flicker, works offline)\n * - Targeting rules on user attributes (eq / in / gt / contains / regex …)\n * - Boolean flags AND weighted multivariate experiments\n * - Synchronous, zero-dependency, isomorphic — evaluate flags right in render\n */\n\nexport type AttrValue = string | number | boolean | null | undefined;\nexport type Attributes = Record<string, AttrValue>;\n\nexport interface Context {\n /** Stable identifier the bucketing is keyed on (user id, account id, device id). */\n key: string;\n /** Attributes used by targeting rules. */\n attributes?: Attributes;\n}\n\n/* ------------------------------ conditions ------------------------------ */\n\nexport interface Operator {\n eq?: AttrValue;\n ne?: AttrValue;\n in?: AttrValue[];\n nin?: AttrValue[];\n gt?: number;\n gte?: number;\n lt?: number;\n lte?: number;\n contains?: string;\n regex?: string;\n}\n\n/** A set of attribute conditions — ALL must match. */\nexport type Condition = Record<string, AttrValue | Operator>;\n\nexport interface Rule {\n /** Attribute conditions that must all match for this rule to apply. */\n when: Condition;\n /** Force this value (boolean flag) or variant key (variant flag). */\n value?: boolean | string;\n /** Or: percentage rollout within the matched segment (0–100). */\n rollout?: number;\n}\n\nexport interface Variant {\n key: string;\n /** Relative weight (default 1 → equal split). */\n weight?: number;\n}\n\nexport interface BooleanFlag {\n type?: \"boolean\";\n /** Master switch. Default true. */\n enabled?: boolean;\n /** Percentage rollout 0–100 when no rule matches. Default 100. */\n rollout?: number;\n rules?: Rule[];\n /** Value when disabled or outside the rollout. Default false. */\n default?: boolean;\n /** Salt for bucketing (change it to re-shuffle everyone). */\n seed?: string;\n}\n\nexport interface VariantFlag {\n type: \"variant\";\n enabled?: boolean;\n variants: Variant[];\n rules?: Rule[];\n /** Fallback variant key when disabled. Default: first variant. */\n default?: string;\n seed?: string;\n}\n\nexport type FlagDef = BooleanFlag | VariantFlag;\n\n/* ------------------------------ hashing ------------------------------ */\n\n/** FNV-1a 32-bit — fast, deterministic, well-distributed for bucketing. */\nfunction fnv1a(str: string): number {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;\n }\n return h >>> 0;\n}\n\n/** Stable bucket in [0, 1) for a key. */\nexport function bucket(key: string): number {\n return (fnv1a(key) % 100000) / 100000;\n}\n\n/** Canonical bucketing string shared by the rollout decision and the debug helper. */\nfunction rolloutKey(flagKey: string, seed: string, ctx: Context, salt = \"\"): string {\n return `${flagKey}:${seed}:${salt}:${ctx.key}`;\n}\n\n/**\n * Stable 0–100 percentage for a (flag, context) pair — useful for debugging rollouts.\n * Computes the exact same bucket the real rollout decision uses, so it truthfully\n * reports who is in the rollout. Pass the flag's own `seed` (and the rule `salt`,\n * if inspecting a rule rollout) to match a specific decision.\n */\nexport function percentage(flag: string, ctx: Context, seed = \"\", salt = \"\"): number {\n return Math.floor(bucket(rolloutKey(flag, seed, ctx, salt)) * 100);\n}\n\n/* ------------------------------ matching ------------------------------ */\n\n/** A malformed regex rule must not throw — treat it as \"no match\". */\nfunction safeRegexTest(pattern: string, value: string): boolean {\n try {\n return new RegExp(pattern).test(value);\n } catch {\n return false;\n }\n}\n\nfunction matchOperator(actual: AttrValue, op: Operator): boolean {\n if (\"eq\" in op && actual !== op.eq) return false;\n if (\"ne\" in op && actual === op.ne) return false;\n if (op.in && !op.in.includes(actual)) return false;\n if (op.nin && op.nin.includes(actual)) return false;\n if (typeof op.gt === \"number\" && !(typeof actual === \"number\" && actual > op.gt)) return false;\n if (typeof op.gte === \"number\" && !(typeof actual === \"number\" && actual >= op.gte)) return false;\n if (typeof op.lt === \"number\" && !(typeof actual === \"number\" && actual < op.lt)) return false;\n if (typeof op.lte === \"number\" && !(typeof actual === \"number\" && actual <= op.lte)) return false;\n if (typeof op.contains === \"string\" && !(typeof actual === \"string\" && actual.includes(op.contains))) return false;\n if (typeof op.regex === \"string\" && !(typeof actual === \"string\" && safeRegexTest(op.regex, actual))) return false;\n return true;\n}\n\nfunction matchCondition(attrs: Attributes, cond: Condition): boolean {\n for (const [field, expected] of Object.entries(cond)) {\n const actual = attrs[field];\n if (expected !== null && typeof expected === \"object\" && !Array.isArray(expected)) {\n if (!matchOperator(actual, expected)) return false;\n } else if (actual !== expected) {\n return false;\n }\n }\n return true;\n}\n\n/* ------------------------------ evaluation ------------------------------ */\n\nfunction inRollout(flagKey: string, seed: string, ctx: Context, rollout: number, salt = \"\"): boolean {\n if (rollout >= 100) return true;\n if (rollout <= 0) return false;\n return bucket(rolloutKey(flagKey, seed, ctx, salt)) * 100 < rollout;\n}\n\nfunction pickVariant(flagKey: string, seed: string, ctx: Context, variants: Variant[]): string {\n const total = variants.reduce((s, v) => s + (v.weight ?? 1), 0);\n if (total <= 0) return variants[0]?.key ?? \"\";\n let point = bucket(`${flagKey}:${seed}:variant:${ctx.key}`) * total;\n for (const v of variants) {\n point -= v.weight ?? 1;\n if (point < 0) return v.key;\n }\n return variants[variants.length - 1]!.key;\n}\n\n/** Evaluate a boolean flag for a context. */\nexport function isEnabled(flagKey: string, def: BooleanFlag, ctx: Context): boolean {\n const fallback = def.default ?? false;\n if (def.enabled === false) return fallback;\n const seed = def.seed ?? \"\";\n\n for (const rule of def.rules ?? []) {\n if (matchCondition(ctx.attributes ?? {}, rule.when)) {\n if (typeof rule.value === \"boolean\") return rule.value;\n if (typeof rule.rollout === \"number\") {\n return inRollout(flagKey, seed, ctx, rule.rollout, ruleSalt(rule)) ? true : fallback;\n }\n return true;\n }\n }\n // Fallthrough rollout: default 100% when there are no rules, but 0% when rules\n // exist (rules define who gets it; set an explicit `rollout` to also include a\n // slice of everyone else).\n const rollout = def.rollout ?? (def.rules && def.rules.length ? 0 : 100);\n return inRollout(flagKey, seed, ctx, rollout) ? true : fallback;\n}\n\n/** Evaluate a variant/experiment flag for a context — returns the variant key. */\nexport function variant(flagKey: string, def: VariantFlag, ctx: Context): string {\n const fallback = def.default ?? def.variants[0]?.key ?? \"\";\n if (def.enabled === false) return fallback;\n const seed = def.seed ?? \"\";\n\n for (const rule of def.rules ?? []) {\n if (matchCondition(ctx.attributes ?? {}, rule.when)) {\n if (typeof rule.value === \"string\") return rule.value;\n break; // matched a rule with no explicit variant → fall through to weighted split\n }\n }\n return pickVariant(flagKey, seed, ctx, def.variants);\n}\n\nfunction ruleSalt(rule: Rule): string {\n return JSON.stringify(rule.when);\n}\n\nconst isVariantFlag = (d: FlagDef): d is VariantFlag => d.type === \"variant\";\n\n/* ------------------------------ the store ------------------------------ */\n\n/**\n * A flag set built from a plain config object. Evaluate synchronously anywhere.\n *\n * @example\n * const flags = new Flags({\n * \"new-dashboard\": { rollout: 25 }, // 25% of users\n * \"beta\": { rules: [{ when: { plan: \"pro\" }, value: true }] },\n * \"checkout-exp\": { type: \"variant\", variants: [\n * { key: \"control\", weight: 1 }, { key: \"one-click\", weight: 1 },\n * ]},\n * });\n * flags.isEnabled(\"new-dashboard\", { key: user.id }); // stable per user\n * flags.variant(\"checkout-exp\", { key: user.id }); // \"control\" | \"one-click\"\n */\nexport class Flags {\n private defs: Record<string, FlagDef>;\n\n constructor(defs: Record<string, FlagDef> = {}) {\n this.defs = { ...defs };\n }\n\n /** Replace / merge flag definitions at runtime (hot config reload). */\n update(defs: Record<string, FlagDef>): this {\n this.defs = { ...this.defs, ...defs };\n return this;\n }\n\n /** Set (or remove, with `undefined`) a single flag. */\n set(key: string, def: FlagDef | undefined): this {\n if (def === undefined) delete this.defs[key];\n else this.defs[key] = def;\n return this;\n }\n\n has(key: string): boolean {\n return key in this.defs;\n }\n\n /** Boolean evaluation. Unknown flag → false. */\n isEnabled(key: string, ctx: Context): boolean {\n const def = this.defs[key];\n if (!def || isVariantFlag(def)) return false;\n return isEnabled(key, def, ctx);\n }\n\n /** Variant evaluation. Unknown flag → \"\". */\n variant(key: string, ctx: Context): string {\n const def = this.defs[key];\n if (!def || !isVariantFlag(def)) return \"\";\n return variant(key, def, ctx);\n }\n\n /** Generic evaluation → boolean for boolean flags, variant key for variant flags. */\n evaluate(key: string, ctx: Context): boolean | string {\n const def = this.defs[key];\n if (!def) return false;\n return isVariantFlag(def) ? variant(key, def, ctx) : isEnabled(key, def, ctx);\n }\n\n /**\n * Evaluate every flag for a context — ideal for bootstrapping a client so the\n * browser never flickers or needs a round-trip.\n */\n all(ctx: Context): Record<string, boolean | string> {\n const out: Record<string, boolean | string> = {};\n for (const key of Object.keys(this.defs)) out[key] = this.evaluate(key, ctx);\n return out;\n }\n\n /** The current raw config. */\n toJSON(): Record<string, FlagDef> {\n return { ...this.defs };\n }\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -71,8 +71,13 @@ interface VariantFlag {
|
|
|
71
71
|
type FlagDef = BooleanFlag | VariantFlag;
|
|
72
72
|
/** Stable bucket in [0, 1) for a key. */
|
|
73
73
|
declare function bucket(key: string): number;
|
|
74
|
-
/**
|
|
75
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Stable 0–100 percentage for a (flag, context) pair — useful for debugging rollouts.
|
|
76
|
+
* Computes the exact same bucket the real rollout decision uses, so it truthfully
|
|
77
|
+
* reports who is in the rollout. Pass the flag's own `seed` (and the rule `salt`,
|
|
78
|
+
* if inspecting a rule rollout) to match a specific decision.
|
|
79
|
+
*/
|
|
80
|
+
declare function percentage(flag: string, ctx: Context, seed?: string, salt?: string): number;
|
|
76
81
|
/** Evaluate a boolean flag for a context. */
|
|
77
82
|
declare function isEnabled(flagKey: string, def: BooleanFlag, ctx: Context): boolean;
|
|
78
83
|
/** Evaluate a variant/experiment flag for a context — returns the variant key. */
|
package/dist/index.d.ts
CHANGED
|
@@ -71,8 +71,13 @@ interface VariantFlag {
|
|
|
71
71
|
type FlagDef = BooleanFlag | VariantFlag;
|
|
72
72
|
/** Stable bucket in [0, 1) for a key. */
|
|
73
73
|
declare function bucket(key: string): number;
|
|
74
|
-
/**
|
|
75
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Stable 0–100 percentage for a (flag, context) pair — useful for debugging rollouts.
|
|
76
|
+
* Computes the exact same bucket the real rollout decision uses, so it truthfully
|
|
77
|
+
* reports who is in the rollout. Pass the flag's own `seed` (and the rule `salt`,
|
|
78
|
+
* if inspecting a rule rollout) to match a specific decision.
|
|
79
|
+
*/
|
|
80
|
+
declare function percentage(flag: string, ctx: Context, seed?: string, salt?: string): number;
|
|
76
81
|
/** Evaluate a boolean flag for a context. */
|
|
77
82
|
declare function isEnabled(flagKey: string, def: BooleanFlag, ctx: Context): boolean;
|
|
78
83
|
/** Evaluate a variant/experiment flag for a context — returns the variant key. */
|
package/dist/index.js
CHANGED
|
@@ -10,8 +10,18 @@ function fnv1a(str) {
|
|
|
10
10
|
function bucket(key) {
|
|
11
11
|
return fnv1a(key) % 1e5 / 1e5;
|
|
12
12
|
}
|
|
13
|
-
function
|
|
14
|
-
return
|
|
13
|
+
function rolloutKey(flagKey, seed, ctx, salt = "") {
|
|
14
|
+
return `${flagKey}:${seed}:${salt}:${ctx.key}`;
|
|
15
|
+
}
|
|
16
|
+
function percentage(flag, ctx, seed = "", salt = "") {
|
|
17
|
+
return Math.floor(bucket(rolloutKey(flag, seed, ctx, salt)) * 100);
|
|
18
|
+
}
|
|
19
|
+
function safeRegexTest(pattern, value) {
|
|
20
|
+
try {
|
|
21
|
+
return new RegExp(pattern).test(value);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
15
25
|
}
|
|
16
26
|
function matchOperator(actual, op) {
|
|
17
27
|
if ("eq" in op && actual !== op.eq) return false;
|
|
@@ -23,7 +33,7 @@ function matchOperator(actual, op) {
|
|
|
23
33
|
if (typeof op.lt === "number" && !(typeof actual === "number" && actual < op.lt)) return false;
|
|
24
34
|
if (typeof op.lte === "number" && !(typeof actual === "number" && actual <= op.lte)) return false;
|
|
25
35
|
if (typeof op.contains === "string" && !(typeof actual === "string" && actual.includes(op.contains))) return false;
|
|
26
|
-
if (typeof op.regex === "string" && !(typeof actual === "string" &&
|
|
36
|
+
if (typeof op.regex === "string" && !(typeof actual === "string" && safeRegexTest(op.regex, actual))) return false;
|
|
27
37
|
return true;
|
|
28
38
|
}
|
|
29
39
|
function matchCondition(attrs, cond) {
|
|
@@ -40,7 +50,7 @@ function matchCondition(attrs, cond) {
|
|
|
40
50
|
function inRollout(flagKey, seed, ctx, rollout, salt = "") {
|
|
41
51
|
if (rollout >= 100) return true;
|
|
42
52
|
if (rollout <= 0) return false;
|
|
43
|
-
return bucket(
|
|
53
|
+
return bucket(rolloutKey(flagKey, seed, ctx, salt)) * 100 < rollout;
|
|
44
54
|
}
|
|
45
55
|
function pickVariant(flagKey, seed, ctx, variants) {
|
|
46
56
|
const total = variants.reduce((s, v) => s + (v.weight ?? 1), 0);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @lacspace/flags\n *\n * Feature flags & A/B experiments with **no SaaS and no infrastructure**.\n * You own the config (a plain object — from JSON, env, a DB row, anywhere);\n * this evaluates it. The whole space is hosted vendors (LaunchDarkly, Optimizely,\n * Unleash) — this is the tiny, self-hosted primitive for the rest of us.\n *\n * - Deterministic bucketing — the same user ALWAYS gets the same result\n * (stable percentage rollouts & A/B assignment, no flicker, works offline)\n * - Targeting rules on user attributes (eq / in / gt / contains / regex …)\n * - Boolean flags AND weighted multivariate experiments\n * - Synchronous, zero-dependency, isomorphic — evaluate flags right in render\n */\n\nexport type AttrValue = string | number | boolean | null | undefined;\nexport type Attributes = Record<string, AttrValue>;\n\nexport interface Context {\n /** Stable identifier the bucketing is keyed on (user id, account id, device id). */\n key: string;\n /** Attributes used by targeting rules. */\n attributes?: Attributes;\n}\n\n/* ------------------------------ conditions ------------------------------ */\n\nexport interface Operator {\n eq?: AttrValue;\n ne?: AttrValue;\n in?: AttrValue[];\n nin?: AttrValue[];\n gt?: number;\n gte?: number;\n lt?: number;\n lte?: number;\n contains?: string;\n regex?: string;\n}\n\n/** A set of attribute conditions — ALL must match. */\nexport type Condition = Record<string, AttrValue | Operator>;\n\nexport interface Rule {\n /** Attribute conditions that must all match for this rule to apply. */\n when: Condition;\n /** Force this value (boolean flag) or variant key (variant flag). */\n value?: boolean | string;\n /** Or: percentage rollout within the matched segment (0–100). */\n rollout?: number;\n}\n\nexport interface Variant {\n key: string;\n /** Relative weight (default 1 → equal split). */\n weight?: number;\n}\n\nexport interface BooleanFlag {\n type?: \"boolean\";\n /** Master switch. Default true. */\n enabled?: boolean;\n /** Percentage rollout 0–100 when no rule matches. Default 100. */\n rollout?: number;\n rules?: Rule[];\n /** Value when disabled or outside the rollout. Default false. */\n default?: boolean;\n /** Salt for bucketing (change it to re-shuffle everyone). */\n seed?: string;\n}\n\nexport interface VariantFlag {\n type: \"variant\";\n enabled?: boolean;\n variants: Variant[];\n rules?: Rule[];\n /** Fallback variant key when disabled. Default: first variant. */\n default?: string;\n seed?: string;\n}\n\nexport type FlagDef = BooleanFlag | VariantFlag;\n\n/* ------------------------------ hashing ------------------------------ */\n\n/** FNV-1a 32-bit — fast, deterministic, well-distributed for bucketing. */\nfunction fnv1a(str: string): number {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;\n }\n return h >>> 0;\n}\n\n/** Stable bucket in [0, 1) for a key. */\nexport function bucket(key: string): number {\n return (fnv1a(key) % 100000) / 100000;\n}\n\n/** Stable 0–100 percentage for a (flag, context) pair — useful for debugging rollouts. */\nexport function percentage(flag: string, ctx: Context, seed = \"\"): number {\n return Math.floor(bucket(`${flag}:${seed}:${ctx.key}`) * 100);\n}\n\n/* ------------------------------ matching ------------------------------ */\n\nfunction matchOperator(actual: AttrValue, op: Operator): boolean {\n if (\"eq\" in op && actual !== op.eq) return false;\n if (\"ne\" in op && actual === op.ne) return false;\n if (op.in && !op.in.includes(actual)) return false;\n if (op.nin && op.nin.includes(actual)) return false;\n if (typeof op.gt === \"number\" && !(typeof actual === \"number\" && actual > op.gt)) return false;\n if (typeof op.gte === \"number\" && !(typeof actual === \"number\" && actual >= op.gte)) return false;\n if (typeof op.lt === \"number\" && !(typeof actual === \"number\" && actual < op.lt)) return false;\n if (typeof op.lte === \"number\" && !(typeof actual === \"number\" && actual <= op.lte)) return false;\n if (typeof op.contains === \"string\" && !(typeof actual === \"string\" && actual.includes(op.contains))) return false;\n if (typeof op.regex === \"string\" && !(typeof actual === \"string\" && new RegExp(op.regex).test(actual))) return false;\n return true;\n}\n\nfunction matchCondition(attrs: Attributes, cond: Condition): boolean {\n for (const [field, expected] of Object.entries(cond)) {\n const actual = attrs[field];\n if (expected !== null && typeof expected === \"object\" && !Array.isArray(expected)) {\n if (!matchOperator(actual, expected)) return false;\n } else if (actual !== expected) {\n return false;\n }\n }\n return true;\n}\n\n/* ------------------------------ evaluation ------------------------------ */\n\nfunction inRollout(flagKey: string, seed: string, ctx: Context, rollout: number, salt = \"\"): boolean {\n if (rollout >= 100) return true;\n if (rollout <= 0) return false;\n return bucket(`${flagKey}:${seed}:${salt}:${ctx.key}`) * 100 < rollout;\n}\n\nfunction pickVariant(flagKey: string, seed: string, ctx: Context, variants: Variant[]): string {\n const total = variants.reduce((s, v) => s + (v.weight ?? 1), 0);\n if (total <= 0) return variants[0]?.key ?? \"\";\n let point = bucket(`${flagKey}:${seed}:variant:${ctx.key}`) * total;\n for (const v of variants) {\n point -= v.weight ?? 1;\n if (point < 0) return v.key;\n }\n return variants[variants.length - 1]!.key;\n}\n\n/** Evaluate a boolean flag for a context. */\nexport function isEnabled(flagKey: string, def: BooleanFlag, ctx: Context): boolean {\n const fallback = def.default ?? false;\n if (def.enabled === false) return fallback;\n const seed = def.seed ?? \"\";\n\n for (const rule of def.rules ?? []) {\n if (matchCondition(ctx.attributes ?? {}, rule.when)) {\n if (typeof rule.value === \"boolean\") return rule.value;\n if (typeof rule.rollout === \"number\") {\n return inRollout(flagKey, seed, ctx, rule.rollout, ruleSalt(rule)) ? true : fallback;\n }\n return true;\n }\n }\n // Fallthrough rollout: default 100% when there are no rules, but 0% when rules\n // exist (rules define who gets it; set an explicit `rollout` to also include a\n // slice of everyone else).\n const rollout = def.rollout ?? (def.rules && def.rules.length ? 0 : 100);\n return inRollout(flagKey, seed, ctx, rollout) ? true : fallback;\n}\n\n/** Evaluate a variant/experiment flag for a context — returns the variant key. */\nexport function variant(flagKey: string, def: VariantFlag, ctx: Context): string {\n const fallback = def.default ?? def.variants[0]?.key ?? \"\";\n if (def.enabled === false) return fallback;\n const seed = def.seed ?? \"\";\n\n for (const rule of def.rules ?? []) {\n if (matchCondition(ctx.attributes ?? {}, rule.when)) {\n if (typeof rule.value === \"string\") return rule.value;\n break; // matched a rule with no explicit variant → fall through to weighted split\n }\n }\n return pickVariant(flagKey, seed, ctx, def.variants);\n}\n\nfunction ruleSalt(rule: Rule): string {\n return JSON.stringify(rule.when);\n}\n\nconst isVariantFlag = (d: FlagDef): d is VariantFlag => d.type === \"variant\";\n\n/* ------------------------------ the store ------------------------------ */\n\n/**\n * A flag set built from a plain config object. Evaluate synchronously anywhere.\n *\n * @example\n * const flags = new Flags({\n * \"new-dashboard\": { rollout: 25 }, // 25% of users\n * \"beta\": { rules: [{ when: { plan: \"pro\" }, value: true }] },\n * \"checkout-exp\": { type: \"variant\", variants: [\n * { key: \"control\", weight: 1 }, { key: \"one-click\", weight: 1 },\n * ]},\n * });\n * flags.isEnabled(\"new-dashboard\", { key: user.id }); // stable per user\n * flags.variant(\"checkout-exp\", { key: user.id }); // \"control\" | \"one-click\"\n */\nexport class Flags {\n private defs: Record<string, FlagDef>;\n\n constructor(defs: Record<string, FlagDef> = {}) {\n this.defs = { ...defs };\n }\n\n /** Replace / merge flag definitions at runtime (hot config reload). */\n update(defs: Record<string, FlagDef>): this {\n this.defs = { ...this.defs, ...defs };\n return this;\n }\n\n /** Set (or remove, with `undefined`) a single flag. */\n set(key: string, def: FlagDef | undefined): this {\n if (def === undefined) delete this.defs[key];\n else this.defs[key] = def;\n return this;\n }\n\n has(key: string): boolean {\n return key in this.defs;\n }\n\n /** Boolean evaluation. Unknown flag → false. */\n isEnabled(key: string, ctx: Context): boolean {\n const def = this.defs[key];\n if (!def || isVariantFlag(def)) return false;\n return isEnabled(key, def, ctx);\n }\n\n /** Variant evaluation. Unknown flag → \"\". */\n variant(key: string, ctx: Context): string {\n const def = this.defs[key];\n if (!def || !isVariantFlag(def)) return \"\";\n return variant(key, def, ctx);\n }\n\n /** Generic evaluation → boolean for boolean flags, variant key for variant flags. */\n evaluate(key: string, ctx: Context): boolean | string {\n const def = this.defs[key];\n if (!def) return false;\n return isVariantFlag(def) ? variant(key, def, ctx) : isEnabled(key, def, ctx);\n }\n\n /**\n * Evaluate every flag for a context — ideal for bootstrapping a client so the\n * browser never flickers or needs a round-trip.\n */\n all(ctx: Context): Record<string, boolean | string> {\n const out: Record<string, boolean | string> = {};\n for (const key of Object.keys(this.defs)) out[key] = this.evaluate(key, ctx);\n return out;\n }\n\n /** The current raw config. */\n toJSON(): Record<string, FlagDef> {\n return { ...this.defs };\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @lacspace/flags\n *\n * Feature flags & A/B experiments with **no SaaS and no infrastructure**.\n * You own the config (a plain object — from JSON, env, a DB row, anywhere);\n * this evaluates it. The whole space is hosted vendors (LaunchDarkly, Optimizely,\n * Unleash) — this is the tiny, self-hosted primitive for the rest of us.\n *\n * - Deterministic bucketing — the same user ALWAYS gets the same result\n * (stable percentage rollouts & A/B assignment, no flicker, works offline)\n * - Targeting rules on user attributes (eq / in / gt / contains / regex …)\n * - Boolean flags AND weighted multivariate experiments\n * - Synchronous, zero-dependency, isomorphic — evaluate flags right in render\n */\n\nexport type AttrValue = string | number | boolean | null | undefined;\nexport type Attributes = Record<string, AttrValue>;\n\nexport interface Context {\n /** Stable identifier the bucketing is keyed on (user id, account id, device id). */\n key: string;\n /** Attributes used by targeting rules. */\n attributes?: Attributes;\n}\n\n/* ------------------------------ conditions ------------------------------ */\n\nexport interface Operator {\n eq?: AttrValue;\n ne?: AttrValue;\n in?: AttrValue[];\n nin?: AttrValue[];\n gt?: number;\n gte?: number;\n lt?: number;\n lte?: number;\n contains?: string;\n regex?: string;\n}\n\n/** A set of attribute conditions — ALL must match. */\nexport type Condition = Record<string, AttrValue | Operator>;\n\nexport interface Rule {\n /** Attribute conditions that must all match for this rule to apply. */\n when: Condition;\n /** Force this value (boolean flag) or variant key (variant flag). */\n value?: boolean | string;\n /** Or: percentage rollout within the matched segment (0–100). */\n rollout?: number;\n}\n\nexport interface Variant {\n key: string;\n /** Relative weight (default 1 → equal split). */\n weight?: number;\n}\n\nexport interface BooleanFlag {\n type?: \"boolean\";\n /** Master switch. Default true. */\n enabled?: boolean;\n /** Percentage rollout 0–100 when no rule matches. Default 100. */\n rollout?: number;\n rules?: Rule[];\n /** Value when disabled or outside the rollout. Default false. */\n default?: boolean;\n /** Salt for bucketing (change it to re-shuffle everyone). */\n seed?: string;\n}\n\nexport interface VariantFlag {\n type: \"variant\";\n enabled?: boolean;\n variants: Variant[];\n rules?: Rule[];\n /** Fallback variant key when disabled. Default: first variant. */\n default?: string;\n seed?: string;\n}\n\nexport type FlagDef = BooleanFlag | VariantFlag;\n\n/* ------------------------------ hashing ------------------------------ */\n\n/** FNV-1a 32-bit — fast, deterministic, well-distributed for bucketing. */\nfunction fnv1a(str: string): number {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;\n }\n return h >>> 0;\n}\n\n/** Stable bucket in [0, 1) for a key. */\nexport function bucket(key: string): number {\n return (fnv1a(key) % 100000) / 100000;\n}\n\n/** Canonical bucketing string shared by the rollout decision and the debug helper. */\nfunction rolloutKey(flagKey: string, seed: string, ctx: Context, salt = \"\"): string {\n return `${flagKey}:${seed}:${salt}:${ctx.key}`;\n}\n\n/**\n * Stable 0–100 percentage for a (flag, context) pair — useful for debugging rollouts.\n * Computes the exact same bucket the real rollout decision uses, so it truthfully\n * reports who is in the rollout. Pass the flag's own `seed` (and the rule `salt`,\n * if inspecting a rule rollout) to match a specific decision.\n */\nexport function percentage(flag: string, ctx: Context, seed = \"\", salt = \"\"): number {\n return Math.floor(bucket(rolloutKey(flag, seed, ctx, salt)) * 100);\n}\n\n/* ------------------------------ matching ------------------------------ */\n\n/** A malformed regex rule must not throw — treat it as \"no match\". */\nfunction safeRegexTest(pattern: string, value: string): boolean {\n try {\n return new RegExp(pattern).test(value);\n } catch {\n return false;\n }\n}\n\nfunction matchOperator(actual: AttrValue, op: Operator): boolean {\n if (\"eq\" in op && actual !== op.eq) return false;\n if (\"ne\" in op && actual === op.ne) return false;\n if (op.in && !op.in.includes(actual)) return false;\n if (op.nin && op.nin.includes(actual)) return false;\n if (typeof op.gt === \"number\" && !(typeof actual === \"number\" && actual > op.gt)) return false;\n if (typeof op.gte === \"number\" && !(typeof actual === \"number\" && actual >= op.gte)) return false;\n if (typeof op.lt === \"number\" && !(typeof actual === \"number\" && actual < op.lt)) return false;\n if (typeof op.lte === \"number\" && !(typeof actual === \"number\" && actual <= op.lte)) return false;\n if (typeof op.contains === \"string\" && !(typeof actual === \"string\" && actual.includes(op.contains))) return false;\n if (typeof op.regex === \"string\" && !(typeof actual === \"string\" && safeRegexTest(op.regex, actual))) return false;\n return true;\n}\n\nfunction matchCondition(attrs: Attributes, cond: Condition): boolean {\n for (const [field, expected] of Object.entries(cond)) {\n const actual = attrs[field];\n if (expected !== null && typeof expected === \"object\" && !Array.isArray(expected)) {\n if (!matchOperator(actual, expected)) return false;\n } else if (actual !== expected) {\n return false;\n }\n }\n return true;\n}\n\n/* ------------------------------ evaluation ------------------------------ */\n\nfunction inRollout(flagKey: string, seed: string, ctx: Context, rollout: number, salt = \"\"): boolean {\n if (rollout >= 100) return true;\n if (rollout <= 0) return false;\n return bucket(rolloutKey(flagKey, seed, ctx, salt)) * 100 < rollout;\n}\n\nfunction pickVariant(flagKey: string, seed: string, ctx: Context, variants: Variant[]): string {\n const total = variants.reduce((s, v) => s + (v.weight ?? 1), 0);\n if (total <= 0) return variants[0]?.key ?? \"\";\n let point = bucket(`${flagKey}:${seed}:variant:${ctx.key}`) * total;\n for (const v of variants) {\n point -= v.weight ?? 1;\n if (point < 0) return v.key;\n }\n return variants[variants.length - 1]!.key;\n}\n\n/** Evaluate a boolean flag for a context. */\nexport function isEnabled(flagKey: string, def: BooleanFlag, ctx: Context): boolean {\n const fallback = def.default ?? false;\n if (def.enabled === false) return fallback;\n const seed = def.seed ?? \"\";\n\n for (const rule of def.rules ?? []) {\n if (matchCondition(ctx.attributes ?? {}, rule.when)) {\n if (typeof rule.value === \"boolean\") return rule.value;\n if (typeof rule.rollout === \"number\") {\n return inRollout(flagKey, seed, ctx, rule.rollout, ruleSalt(rule)) ? true : fallback;\n }\n return true;\n }\n }\n // Fallthrough rollout: default 100% when there are no rules, but 0% when rules\n // exist (rules define who gets it; set an explicit `rollout` to also include a\n // slice of everyone else).\n const rollout = def.rollout ?? (def.rules && def.rules.length ? 0 : 100);\n return inRollout(flagKey, seed, ctx, rollout) ? true : fallback;\n}\n\n/** Evaluate a variant/experiment flag for a context — returns the variant key. */\nexport function variant(flagKey: string, def: VariantFlag, ctx: Context): string {\n const fallback = def.default ?? def.variants[0]?.key ?? \"\";\n if (def.enabled === false) return fallback;\n const seed = def.seed ?? \"\";\n\n for (const rule of def.rules ?? []) {\n if (matchCondition(ctx.attributes ?? {}, rule.when)) {\n if (typeof rule.value === \"string\") return rule.value;\n break; // matched a rule with no explicit variant → fall through to weighted split\n }\n }\n return pickVariant(flagKey, seed, ctx, def.variants);\n}\n\nfunction ruleSalt(rule: Rule): string {\n return JSON.stringify(rule.when);\n}\n\nconst isVariantFlag = (d: FlagDef): d is VariantFlag => d.type === \"variant\";\n\n/* ------------------------------ the store ------------------------------ */\n\n/**\n * A flag set built from a plain config object. Evaluate synchronously anywhere.\n *\n * @example\n * const flags = new Flags({\n * \"new-dashboard\": { rollout: 25 }, // 25% of users\n * \"beta\": { rules: [{ when: { plan: \"pro\" }, value: true }] },\n * \"checkout-exp\": { type: \"variant\", variants: [\n * { key: \"control\", weight: 1 }, { key: \"one-click\", weight: 1 },\n * ]},\n * });\n * flags.isEnabled(\"new-dashboard\", { key: user.id }); // stable per user\n * flags.variant(\"checkout-exp\", { key: user.id }); // \"control\" | \"one-click\"\n */\nexport class Flags {\n private defs: Record<string, FlagDef>;\n\n constructor(defs: Record<string, FlagDef> = {}) {\n this.defs = { ...defs };\n }\n\n /** Replace / merge flag definitions at runtime (hot config reload). */\n update(defs: Record<string, FlagDef>): this {\n this.defs = { ...this.defs, ...defs };\n return this;\n }\n\n /** Set (or remove, with `undefined`) a single flag. */\n set(key: string, def: FlagDef | undefined): this {\n if (def === undefined) delete this.defs[key];\n else this.defs[key] = def;\n return this;\n }\n\n has(key: string): boolean {\n return key in this.defs;\n }\n\n /** Boolean evaluation. Unknown flag → false. */\n isEnabled(key: string, ctx: Context): boolean {\n const def = this.defs[key];\n if (!def || isVariantFlag(def)) return false;\n return isEnabled(key, def, ctx);\n }\n\n /** Variant evaluation. Unknown flag → \"\". */\n variant(key: string, ctx: Context): string {\n const def = this.defs[key];\n if (!def || !isVariantFlag(def)) return \"\";\n return variant(key, def, ctx);\n }\n\n /** Generic evaluation → boolean for boolean flags, variant key for variant flags. */\n evaluate(key: string, ctx: Context): boolean | string {\n const def = this.defs[key];\n if (!def) return false;\n return isVariantFlag(def) ? variant(key, def, ctx) : isEnabled(key, def, ctx);\n }\n\n /**\n * Evaluate every flag for a context — ideal for bootstrapping a client so the\n * browser never flickers or needs a round-trip.\n */\n all(ctx: Context): Record<string, boolean | string> {\n const out: Record<string, boolean | string> = {};\n for (const key of Object.keys(this.defs)) out[key] = this.evaluate(key, ctx);\n return out;\n }\n\n /** The current raw config. */\n toJSON(): Record<string, FlagDef> {\n return { ...this.defs };\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lacspace/flags",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Feature flags & A/B experiments with no SaaS and no infrastructure — deterministic bucketing (same user always gets the same result), targeting rules, percentage rollouts and weighted variants. Synchronous, zero-dependency, isomorphic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|