@lacspace/flags 1.0.0 → 1.0.3

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/README.md CHANGED
@@ -90,18 +90,24 @@ Context is `{ key, attributes? }`. `key` drives bucketing; `attributes` drive ta
90
90
 
91
91
  ## Licensing
92
92
 
93
- This package is **free** under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — MIT-equivalent freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
93
+ This package is **free** under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — permissive freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
94
94
 
95
95
  Not every Lacspace package is free. We also offer **Commercial** (paid), **Client-specific**, and **Private** (proprietary) packages under separate terms. See the full **[Lacspace Licence Centre](https://lacspace.com/licenses)**.
96
96
 
97
+ <!-- LACSPACE-DEV-PLATFORM -->
98
+
97
99
  ---
98
100
 
99
- <div align="center">
101
+ ## The Lacspace Developer Platform
100
102
 
101
- **Part of the Lacspace ecosystem zero-dependency, isomorphic TypeScript packages.**
103
+ `@lacspace/flags` is part of **63 zero-dependency, isomorphic TypeScript packages** — one standard library for the modern web. Explore the ecosystem:
102
104
 
103
- [All packages ↗](https://lacspace.com/packages) · [npm org ↗](https://www.npmjs.com/org/lacspace) · [Licence Centre ↗](https://lacspace.com/licenses) · [GitHub ↗](https://github.com/lacspace/npm-packages)
105
+ - 📦 **This package, documented** https://developer.lacspace.com/packages/flags
106
+ - 🗂️ **All 63 packages** — https://developer.lacspace.com/packages
107
+ - 🧭 **Developer handbook** — guides & runnable recipes — https://developer.lacspace.com/handbook
108
+ - 🧪 **Live playground** — run any package in your browser — https://developer.lacspace.com/playground
109
+ - 🖥️ **Finished app templates** — https://templates.lacspace.com
110
+ - 🚀 **Scaffold a full app** — `npm create lacspace-app@latest`
104
111
 
105
- </div>
112
+ Free under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — a permissive, free-to-use licence.
106
113
 
107
- <div align="center"><sub>Built with care by <a href="https://lacspace.com">Lacspace</a> · Lacspace Free Licence · <a href="https://github.com/lacspace/npm-packages">source</a></sub></div>
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 percentage(flag, ctx, seed = "") {
16
- return Math.floor(bucket(`${flag}:${seed}:${ctx.key}`) * 100);
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" && new RegExp(op.regex).test(actual))) return false;
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(`${flagKey}:${seed}:${salt}:${ctx.key}`) * 100 < rollout;
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);
@@ -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
- /** Stable 0–100 percentage for a (flag, context) pair — useful for debugging rollouts. */
75
- declare function percentage(flag: string, ctx: Context, seed?: string): number;
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
- /** Stable 0–100 percentage for a (flag, context) pair — useful for debugging rollouts. */
75
- declare function percentage(flag: string, ctx: Context, seed?: string): number;
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 percentage(flag, ctx, seed = "") {
14
- return Math.floor(bucket(`${flag}:${seed}:${ctx.key}`) * 100);
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" && new RegExp(op.regex).test(actual))) return false;
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(`${flagKey}:${seed}:${salt}:${ctx.key}`) * 100 < rollout;
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.0",
3
+ "version": "1.0.3",
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",
@@ -45,7 +45,7 @@
45
45
  ],
46
46
  "author": "Lacspace <contact@lacspace.com>",
47
47
  "license": "SEE LICENSE IN LICENSE",
48
- "homepage": "https://lacspace.com/packages",
48
+ "homepage": "https://developer.lacspace.com/packages/flags",
49
49
  "repository": {
50
50
  "type": "git",
51
51
  "url": "git+https://github.com/lacspace/npm-packages.git",