@lacspace/flags 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,51 @@
1
+ Lacspace Free Licence
2
+ Version 1.0, August 2026
3
+
4
+ Copyright (c) 2026 Lacspace
5
+
6
+ PREAMBLE
7
+
8
+ This software is published by Lacspace under the Lacspace Free Licence β€” a free,
9
+ permissive licence that lets you use this software for any purpose, including in
10
+ commercial products and services, at no cost. It grants the same freedoms as
11
+ common permissive open-source licences; the only condition is that this notice
12
+ travels with the software. The canonical, always-current text of this licence is
13
+ maintained at https://lacspace.com/licenses/lacspace-free-1.0
14
+
15
+ GRANT OF RIGHTS
16
+
17
+ Permission is hereby granted, free of charge, to any person or organisation
18
+ obtaining a copy of this software and its associated documentation and data files
19
+ (the "Software"), to deal in the Software without restriction, including without
20
+ limitation the rights to use, copy, modify, merge, publish, distribute,
21
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the
22
+ Software is furnished to do so, subject to the conditions below. These rights are
23
+ granted for any purpose, personal or commercial, and are perpetual, worldwide,
24
+ non-exclusive, and royalty-free.
25
+
26
+ CONDITIONS
27
+
28
+ The above copyright notice, this permission notice, and the name of this licence
29
+ ("Lacspace Free Licence") shall be included in all copies or substantial portions
30
+ of the Software.
31
+
32
+ TRADEMARKS
33
+
34
+ This licence does not grant permission to use the trade names, trademarks, service
35
+ marks, logos, or product names of Lacspace, except as required to reproduce the
36
+ notice above or to describe the origin of the Software in a truthful manner.
37
+
38
+ DISCLAIMER OF WARRANTY AND LIMITATION OF LIABILITY
39
+
40
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
42
+ FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
43
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
44
+ AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
45
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
46
+
47
+ ---
48
+
49
+ The Lacspace Free Licence is a source-available, permissive licence and is not (as
50
+ of this version) an OSI-approved licence. In substance it grants the same freedoms
51
+ as the MIT Licence. Learn more at https://lacspace.com/licenses
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ <div align="center">
2
+
3
+ # @lacspace/flags
4
+
5
+ **Feature flags & A/B experiments with no SaaS and no infrastructure β€” deterministic, synchronous, zero-dependency.**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/@lacspace/flags?color=%2316a34a&label=npm)](https://www.npmjs.com/package/@lacspace/flags)
8
+ [![install size](https://packagephobia.com/badge?p=@lacspace/flags)](https://packagephobia.com/result?p=@lacspace/flags)
9
+ [![minzipped](https://img.shields.io/bundlephobia/minzip/@lacspace/flags?label=minzip)](https://bundlephobia.com/package/@lacspace/flags)
10
+ [![types](https://img.shields.io/badge/types-included-blue)](https://www.npmjs.com/package/@lacspace/flags)
11
+ [![license](https://img.shields.io/npm/l/@lacspace/flags?color=green)](https://github.com/lacspace/npm-packages/blob/main/LICENSE)
12
+
13
+ </div>
14
+
15
+ > Feature flagging is dominated by hosted vendors (LaunchDarkly, Optimizely, Unleash). But you don't always want a SaaS, a network call, or a monthly bill. **You** own the config β€” a plain object from JSON, env or a DB row β€” and this evaluates it: stable rollouts, targeting and A/B tests, synchronously, offline.
16
+
17
+ - 🎯 **Deterministic** β€” the same user always gets the same result (no flicker, no round-trip, works offline)
18
+ - πŸ“Š Percentage rollouts + **weighted A/B / multivariate experiments**
19
+ - 🧩 Targeting rules on attributes β€” `eq` / `in` / `gt` / `contains` / `regex` …
20
+ - ⚑ **Synchronous** (evaluate right in render) · zero dependencies · isomorphic
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @lacspace/flags # or pnpm add / yarn add / bun add
26
+ ```
27
+
28
+ ## Define once, evaluate anywhere
29
+
30
+ ```ts
31
+ import { Flags } from "@lacspace/flags";
32
+
33
+ // Config β€” load from JSON / env / DB, hot-swap with flags.update(...)
34
+ export const flags = new Flags({
35
+ "new-dashboard": { rollout: 25 }, // 25% of users
36
+ "beta": { rules: [{ when: { plan: "pro" }, value: true }] }, // pro users only
37
+ "eu-feature": { rules: [{ when: { country: { in: ["DE", "FR"] } }, value: true }] },
38
+ "checkout-exp": { // A/B test
39
+ type: "variant",
40
+ variants: [{ key: "control", weight: 1 }, { key: "one-click", weight: 1 }],
41
+ },
42
+ });
43
+
44
+ // Evaluate β€” synchronous, stable per user
45
+ flags.isEnabled("new-dashboard", { key: user.id }); // boolean
46
+ flags.isEnabled("beta", { key: user.id, attributes: { plan: user.plan } });
47
+ flags.variant("checkout-exp", { key: user.id }); // "control" | "one-click"
48
+ ```
49
+
50
+ The same `key` always buckets the same way β€” a user in the 25% rollout stays in it across reloads and devices, and always sees the same experiment variant.
51
+
52
+ ## Targeting rules
53
+
54
+ ```ts
55
+ new Flags({
56
+ "premium-banner": {
57
+ rollout: 0, // off for everyone by default…
58
+ rules: [
59
+ { when: { plan: "pro" }, value: true }, // …except pro users
60
+ { when: { signupDays: { gte: 30 } }, rollout: 50 }, // …and 50% of 30-day-olds
61
+ { when: { email: { regex: "@lacspace\\.com$" } }, value: true }, // …and staff
62
+ ],
63
+ },
64
+ });
65
+ ```
66
+
67
+ Operators: `eq`, `ne`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `contains`, `regex` (a bare value means equality). Rules are evaluated top-to-bottom; the first match wins.
68
+
69
+ ## Bootstrap a client (no flicker)
70
+
71
+ ```ts
72
+ // server: evaluate everything once, ship it with the page
73
+ const initial = flags.all({ key: user.id, attributes });
74
+ // β†’ { "new-dashboard": true, "beta": false, "checkout-exp": "one-click", … }
75
+ ```
76
+
77
+ ## API
78
+
79
+ | Export | Description |
80
+ | --- | --- |
81
+ | `new Flags(config)` | build a flag set from a config object |
82
+ | `.isEnabled(key, ctx)` | boolean flag β†’ `boolean` |
83
+ | `.variant(key, ctx)` | variant flag β†’ variant key |
84
+ | `.evaluate(key, ctx)` Β· `.all(ctx)` | generic / evaluate-all |
85
+ | `.update(config)` Β· `.set(key, def)` | hot-reload config |
86
+ | `isEnabled(key, def, ctx)` Β· `variant(key, def, ctx)` | functional (no store) |
87
+ | `bucket(key)` Β· `percentage(flag, ctx)` | inspect the deterministic bucketing |
88
+
89
+ Context is `{ key, attributes? }`. `key` drives bucketing; `attributes` drive targeting. Set a flag's `seed` to re-shuffle everyone. No rules β†’ `rollout` defaults to 100%; with rules, unmatched users default to off unless you set an explicit `rollout`.
90
+
91
+ ## Licensing
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.
94
+
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
+
97
+ ---
98
+
99
+ <div align="center">
100
+
101
+ **Part of the Lacspace ecosystem β€” zero-dependency, isomorphic TypeScript packages.**
102
+
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)
104
+
105
+ </div>
106
+
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 ADDED
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ // src/index.ts
4
+ function fnv1a(str) {
5
+ let h = 2166136261;
6
+ for (let i = 0; i < str.length; i++) {
7
+ h ^= str.charCodeAt(i);
8
+ h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
9
+ }
10
+ return h >>> 0;
11
+ }
12
+ function bucket(key) {
13
+ return fnv1a(key) % 1e5 / 1e5;
14
+ }
15
+ function percentage(flag, ctx, seed = "") {
16
+ return Math.floor(bucket(`${flag}:${seed}:${ctx.key}`) * 100);
17
+ }
18
+ function matchOperator(actual, op) {
19
+ if ("eq" in op && actual !== op.eq) return false;
20
+ if ("ne" in op && actual === op.ne) return false;
21
+ if (op.in && !op.in.includes(actual)) return false;
22
+ if (op.nin && op.nin.includes(actual)) return false;
23
+ if (typeof op.gt === "number" && !(typeof actual === "number" && actual > op.gt)) return false;
24
+ if (typeof op.gte === "number" && !(typeof actual === "number" && actual >= op.gte)) return false;
25
+ if (typeof op.lt === "number" && !(typeof actual === "number" && actual < op.lt)) return false;
26
+ if (typeof op.lte === "number" && !(typeof actual === "number" && actual <= op.lte)) return false;
27
+ 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;
29
+ return true;
30
+ }
31
+ function matchCondition(attrs, cond) {
32
+ for (const [field, expected] of Object.entries(cond)) {
33
+ const actual = attrs[field];
34
+ if (expected !== null && typeof expected === "object" && !Array.isArray(expected)) {
35
+ if (!matchOperator(actual, expected)) return false;
36
+ } else if (actual !== expected) {
37
+ return false;
38
+ }
39
+ }
40
+ return true;
41
+ }
42
+ function inRollout(flagKey, seed, ctx, rollout, salt = "") {
43
+ if (rollout >= 100) return true;
44
+ if (rollout <= 0) return false;
45
+ return bucket(`${flagKey}:${seed}:${salt}:${ctx.key}`) * 100 < rollout;
46
+ }
47
+ function pickVariant(flagKey, seed, ctx, variants) {
48
+ const total = variants.reduce((s, v) => s + (v.weight ?? 1), 0);
49
+ if (total <= 0) return variants[0]?.key ?? "";
50
+ let point = bucket(`${flagKey}:${seed}:variant:${ctx.key}`) * total;
51
+ for (const v of variants) {
52
+ point -= v.weight ?? 1;
53
+ if (point < 0) return v.key;
54
+ }
55
+ return variants[variants.length - 1].key;
56
+ }
57
+ function isEnabled(flagKey, def, ctx) {
58
+ const fallback = def.default ?? false;
59
+ if (def.enabled === false) return fallback;
60
+ const seed = def.seed ?? "";
61
+ for (const rule of def.rules ?? []) {
62
+ if (matchCondition(ctx.attributes ?? {}, rule.when)) {
63
+ if (typeof rule.value === "boolean") return rule.value;
64
+ if (typeof rule.rollout === "number") {
65
+ return inRollout(flagKey, seed, ctx, rule.rollout, ruleSalt(rule)) ? true : fallback;
66
+ }
67
+ return true;
68
+ }
69
+ }
70
+ const rollout = def.rollout ?? (def.rules && def.rules.length ? 0 : 100);
71
+ return inRollout(flagKey, seed, ctx, rollout) ? true : fallback;
72
+ }
73
+ function variant(flagKey, def, ctx) {
74
+ const fallback = def.default ?? def.variants[0]?.key ?? "";
75
+ if (def.enabled === false) return fallback;
76
+ const seed = def.seed ?? "";
77
+ for (const rule of def.rules ?? []) {
78
+ if (matchCondition(ctx.attributes ?? {}, rule.when)) {
79
+ if (typeof rule.value === "string") return rule.value;
80
+ break;
81
+ }
82
+ }
83
+ return pickVariant(flagKey, seed, ctx, def.variants);
84
+ }
85
+ function ruleSalt(rule) {
86
+ return JSON.stringify(rule.when);
87
+ }
88
+ var isVariantFlag = (d) => d.type === "variant";
89
+ var Flags = class {
90
+ constructor(defs = {}) {
91
+ this.defs = { ...defs };
92
+ }
93
+ /** Replace / merge flag definitions at runtime (hot config reload). */
94
+ update(defs) {
95
+ this.defs = { ...this.defs, ...defs };
96
+ return this;
97
+ }
98
+ /** Set (or remove, with `undefined`) a single flag. */
99
+ set(key, def) {
100
+ if (def === void 0) delete this.defs[key];
101
+ else this.defs[key] = def;
102
+ return this;
103
+ }
104
+ has(key) {
105
+ return key in this.defs;
106
+ }
107
+ /** Boolean evaluation. Unknown flag β†’ false. */
108
+ isEnabled(key, ctx) {
109
+ const def = this.defs[key];
110
+ if (!def || isVariantFlag(def)) return false;
111
+ return isEnabled(key, def, ctx);
112
+ }
113
+ /** Variant evaluation. Unknown flag β†’ "". */
114
+ variant(key, ctx) {
115
+ const def = this.defs[key];
116
+ if (!def || !isVariantFlag(def)) return "";
117
+ return variant(key, def, ctx);
118
+ }
119
+ /** Generic evaluation β†’ boolean for boolean flags, variant key for variant flags. */
120
+ evaluate(key, ctx) {
121
+ const def = this.defs[key];
122
+ if (!def) return false;
123
+ return isVariantFlag(def) ? variant(key, def, ctx) : isEnabled(key, def, ctx);
124
+ }
125
+ /**
126
+ * Evaluate every flag for a context β€” ideal for bootstrapping a client so the
127
+ * browser never flickers or needs a round-trip.
128
+ */
129
+ all(ctx) {
130
+ const out = {};
131
+ for (const key of Object.keys(this.defs)) out[key] = this.evaluate(key, ctx);
132
+ return out;
133
+ }
134
+ /** The current raw config. */
135
+ toJSON() {
136
+ return { ...this.defs };
137
+ }
138
+ };
139
+
140
+ exports.Flags = Flags;
141
+ exports.bucket = bucket;
142
+ exports.isEnabled = isEnabled;
143
+ exports.percentage = percentage;
144
+ exports.variant = variant;
145
+ //# sourceMappingURL=index.cjs.map
146
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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"]}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @lacspace/flags
3
+ *
4
+ * Feature flags & A/B experiments with **no SaaS and no infrastructure**.
5
+ * You own the config (a plain object β€” from JSON, env, a DB row, anywhere);
6
+ * this evaluates it. The whole space is hosted vendors (LaunchDarkly, Optimizely,
7
+ * Unleash) β€” this is the tiny, self-hosted primitive for the rest of us.
8
+ *
9
+ * - Deterministic bucketing β€” the same user ALWAYS gets the same result
10
+ * (stable percentage rollouts & A/B assignment, no flicker, works offline)
11
+ * - Targeting rules on user attributes (eq / in / gt / contains / regex …)
12
+ * - Boolean flags AND weighted multivariate experiments
13
+ * - Synchronous, zero-dependency, isomorphic β€” evaluate flags right in render
14
+ */
15
+ type AttrValue = string | number | boolean | null | undefined;
16
+ type Attributes = Record<string, AttrValue>;
17
+ interface Context {
18
+ /** Stable identifier the bucketing is keyed on (user id, account id, device id). */
19
+ key: string;
20
+ /** Attributes used by targeting rules. */
21
+ attributes?: Attributes;
22
+ }
23
+ interface Operator {
24
+ eq?: AttrValue;
25
+ ne?: AttrValue;
26
+ in?: AttrValue[];
27
+ nin?: AttrValue[];
28
+ gt?: number;
29
+ gte?: number;
30
+ lt?: number;
31
+ lte?: number;
32
+ contains?: string;
33
+ regex?: string;
34
+ }
35
+ /** A set of attribute conditions β€” ALL must match. */
36
+ type Condition = Record<string, AttrValue | Operator>;
37
+ interface Rule {
38
+ /** Attribute conditions that must all match for this rule to apply. */
39
+ when: Condition;
40
+ /** Force this value (boolean flag) or variant key (variant flag). */
41
+ value?: boolean | string;
42
+ /** Or: percentage rollout within the matched segment (0–100). */
43
+ rollout?: number;
44
+ }
45
+ interface Variant {
46
+ key: string;
47
+ /** Relative weight (default 1 β†’ equal split). */
48
+ weight?: number;
49
+ }
50
+ interface BooleanFlag {
51
+ type?: "boolean";
52
+ /** Master switch. Default true. */
53
+ enabled?: boolean;
54
+ /** Percentage rollout 0–100 when no rule matches. Default 100. */
55
+ rollout?: number;
56
+ rules?: Rule[];
57
+ /** Value when disabled or outside the rollout. Default false. */
58
+ default?: boolean;
59
+ /** Salt for bucketing (change it to re-shuffle everyone). */
60
+ seed?: string;
61
+ }
62
+ interface VariantFlag {
63
+ type: "variant";
64
+ enabled?: boolean;
65
+ variants: Variant[];
66
+ rules?: Rule[];
67
+ /** Fallback variant key when disabled. Default: first variant. */
68
+ default?: string;
69
+ seed?: string;
70
+ }
71
+ type FlagDef = BooleanFlag | VariantFlag;
72
+ /** Stable bucket in [0, 1) for a key. */
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;
76
+ /** Evaluate a boolean flag for a context. */
77
+ declare function isEnabled(flagKey: string, def: BooleanFlag, ctx: Context): boolean;
78
+ /** Evaluate a variant/experiment flag for a context β€” returns the variant key. */
79
+ declare function variant(flagKey: string, def: VariantFlag, ctx: Context): string;
80
+ /**
81
+ * A flag set built from a plain config object. Evaluate synchronously anywhere.
82
+ *
83
+ * @example
84
+ * const flags = new Flags({
85
+ * "new-dashboard": { rollout: 25 }, // 25% of users
86
+ * "beta": { rules: [{ when: { plan: "pro" }, value: true }] },
87
+ * "checkout-exp": { type: "variant", variants: [
88
+ * { key: "control", weight: 1 }, { key: "one-click", weight: 1 },
89
+ * ]},
90
+ * });
91
+ * flags.isEnabled("new-dashboard", { key: user.id }); // stable per user
92
+ * flags.variant("checkout-exp", { key: user.id }); // "control" | "one-click"
93
+ */
94
+ declare class Flags {
95
+ private defs;
96
+ constructor(defs?: Record<string, FlagDef>);
97
+ /** Replace / merge flag definitions at runtime (hot config reload). */
98
+ update(defs: Record<string, FlagDef>): this;
99
+ /** Set (or remove, with `undefined`) a single flag. */
100
+ set(key: string, def: FlagDef | undefined): this;
101
+ has(key: string): boolean;
102
+ /** Boolean evaluation. Unknown flag β†’ false. */
103
+ isEnabled(key: string, ctx: Context): boolean;
104
+ /** Variant evaluation. Unknown flag β†’ "". */
105
+ variant(key: string, ctx: Context): string;
106
+ /** Generic evaluation β†’ boolean for boolean flags, variant key for variant flags. */
107
+ evaluate(key: string, ctx: Context): boolean | string;
108
+ /**
109
+ * Evaluate every flag for a context β€” ideal for bootstrapping a client so the
110
+ * browser never flickers or needs a round-trip.
111
+ */
112
+ all(ctx: Context): Record<string, boolean | string>;
113
+ /** The current raw config. */
114
+ toJSON(): Record<string, FlagDef>;
115
+ }
116
+
117
+ export { type AttrValue, type Attributes, type BooleanFlag, type Condition, type Context, type FlagDef, Flags, type Operator, type Rule, type Variant, type VariantFlag, bucket, isEnabled, percentage, variant };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @lacspace/flags
3
+ *
4
+ * Feature flags & A/B experiments with **no SaaS and no infrastructure**.
5
+ * You own the config (a plain object β€” from JSON, env, a DB row, anywhere);
6
+ * this evaluates it. The whole space is hosted vendors (LaunchDarkly, Optimizely,
7
+ * Unleash) β€” this is the tiny, self-hosted primitive for the rest of us.
8
+ *
9
+ * - Deterministic bucketing β€” the same user ALWAYS gets the same result
10
+ * (stable percentage rollouts & A/B assignment, no flicker, works offline)
11
+ * - Targeting rules on user attributes (eq / in / gt / contains / regex …)
12
+ * - Boolean flags AND weighted multivariate experiments
13
+ * - Synchronous, zero-dependency, isomorphic β€” evaluate flags right in render
14
+ */
15
+ type AttrValue = string | number | boolean | null | undefined;
16
+ type Attributes = Record<string, AttrValue>;
17
+ interface Context {
18
+ /** Stable identifier the bucketing is keyed on (user id, account id, device id). */
19
+ key: string;
20
+ /** Attributes used by targeting rules. */
21
+ attributes?: Attributes;
22
+ }
23
+ interface Operator {
24
+ eq?: AttrValue;
25
+ ne?: AttrValue;
26
+ in?: AttrValue[];
27
+ nin?: AttrValue[];
28
+ gt?: number;
29
+ gte?: number;
30
+ lt?: number;
31
+ lte?: number;
32
+ contains?: string;
33
+ regex?: string;
34
+ }
35
+ /** A set of attribute conditions β€” ALL must match. */
36
+ type Condition = Record<string, AttrValue | Operator>;
37
+ interface Rule {
38
+ /** Attribute conditions that must all match for this rule to apply. */
39
+ when: Condition;
40
+ /** Force this value (boolean flag) or variant key (variant flag). */
41
+ value?: boolean | string;
42
+ /** Or: percentage rollout within the matched segment (0–100). */
43
+ rollout?: number;
44
+ }
45
+ interface Variant {
46
+ key: string;
47
+ /** Relative weight (default 1 β†’ equal split). */
48
+ weight?: number;
49
+ }
50
+ interface BooleanFlag {
51
+ type?: "boolean";
52
+ /** Master switch. Default true. */
53
+ enabled?: boolean;
54
+ /** Percentage rollout 0–100 when no rule matches. Default 100. */
55
+ rollout?: number;
56
+ rules?: Rule[];
57
+ /** Value when disabled or outside the rollout. Default false. */
58
+ default?: boolean;
59
+ /** Salt for bucketing (change it to re-shuffle everyone). */
60
+ seed?: string;
61
+ }
62
+ interface VariantFlag {
63
+ type: "variant";
64
+ enabled?: boolean;
65
+ variants: Variant[];
66
+ rules?: Rule[];
67
+ /** Fallback variant key when disabled. Default: first variant. */
68
+ default?: string;
69
+ seed?: string;
70
+ }
71
+ type FlagDef = BooleanFlag | VariantFlag;
72
+ /** Stable bucket in [0, 1) for a key. */
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;
76
+ /** Evaluate a boolean flag for a context. */
77
+ declare function isEnabled(flagKey: string, def: BooleanFlag, ctx: Context): boolean;
78
+ /** Evaluate a variant/experiment flag for a context β€” returns the variant key. */
79
+ declare function variant(flagKey: string, def: VariantFlag, ctx: Context): string;
80
+ /**
81
+ * A flag set built from a plain config object. Evaluate synchronously anywhere.
82
+ *
83
+ * @example
84
+ * const flags = new Flags({
85
+ * "new-dashboard": { rollout: 25 }, // 25% of users
86
+ * "beta": { rules: [{ when: { plan: "pro" }, value: true }] },
87
+ * "checkout-exp": { type: "variant", variants: [
88
+ * { key: "control", weight: 1 }, { key: "one-click", weight: 1 },
89
+ * ]},
90
+ * });
91
+ * flags.isEnabled("new-dashboard", { key: user.id }); // stable per user
92
+ * flags.variant("checkout-exp", { key: user.id }); // "control" | "one-click"
93
+ */
94
+ declare class Flags {
95
+ private defs;
96
+ constructor(defs?: Record<string, FlagDef>);
97
+ /** Replace / merge flag definitions at runtime (hot config reload). */
98
+ update(defs: Record<string, FlagDef>): this;
99
+ /** Set (or remove, with `undefined`) a single flag. */
100
+ set(key: string, def: FlagDef | undefined): this;
101
+ has(key: string): boolean;
102
+ /** Boolean evaluation. Unknown flag β†’ false. */
103
+ isEnabled(key: string, ctx: Context): boolean;
104
+ /** Variant evaluation. Unknown flag β†’ "". */
105
+ variant(key: string, ctx: Context): string;
106
+ /** Generic evaluation β†’ boolean for boolean flags, variant key for variant flags. */
107
+ evaluate(key: string, ctx: Context): boolean | string;
108
+ /**
109
+ * Evaluate every flag for a context β€” ideal for bootstrapping a client so the
110
+ * browser never flickers or needs a round-trip.
111
+ */
112
+ all(ctx: Context): Record<string, boolean | string>;
113
+ /** The current raw config. */
114
+ toJSON(): Record<string, FlagDef>;
115
+ }
116
+
117
+ export { type AttrValue, type Attributes, type BooleanFlag, type Condition, type Context, type FlagDef, Flags, type Operator, type Rule, type Variant, type VariantFlag, bucket, isEnabled, percentage, variant };
package/dist/index.js ADDED
@@ -0,0 +1,140 @@
1
+ // src/index.ts
2
+ function fnv1a(str) {
3
+ let h = 2166136261;
4
+ for (let i = 0; i < str.length; i++) {
5
+ h ^= str.charCodeAt(i);
6
+ h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
7
+ }
8
+ return h >>> 0;
9
+ }
10
+ function bucket(key) {
11
+ return fnv1a(key) % 1e5 / 1e5;
12
+ }
13
+ function percentage(flag, ctx, seed = "") {
14
+ return Math.floor(bucket(`${flag}:${seed}:${ctx.key}`) * 100);
15
+ }
16
+ function matchOperator(actual, op) {
17
+ if ("eq" in op && actual !== op.eq) return false;
18
+ if ("ne" in op && actual === op.ne) return false;
19
+ if (op.in && !op.in.includes(actual)) return false;
20
+ if (op.nin && op.nin.includes(actual)) return false;
21
+ if (typeof op.gt === "number" && !(typeof actual === "number" && actual > op.gt)) return false;
22
+ if (typeof op.gte === "number" && !(typeof actual === "number" && actual >= op.gte)) return false;
23
+ if (typeof op.lt === "number" && !(typeof actual === "number" && actual < op.lt)) return false;
24
+ if (typeof op.lte === "number" && !(typeof actual === "number" && actual <= op.lte)) return false;
25
+ 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;
27
+ return true;
28
+ }
29
+ function matchCondition(attrs, cond) {
30
+ for (const [field, expected] of Object.entries(cond)) {
31
+ const actual = attrs[field];
32
+ if (expected !== null && typeof expected === "object" && !Array.isArray(expected)) {
33
+ if (!matchOperator(actual, expected)) return false;
34
+ } else if (actual !== expected) {
35
+ return false;
36
+ }
37
+ }
38
+ return true;
39
+ }
40
+ function inRollout(flagKey, seed, ctx, rollout, salt = "") {
41
+ if (rollout >= 100) return true;
42
+ if (rollout <= 0) return false;
43
+ return bucket(`${flagKey}:${seed}:${salt}:${ctx.key}`) * 100 < rollout;
44
+ }
45
+ function pickVariant(flagKey, seed, ctx, variants) {
46
+ const total = variants.reduce((s, v) => s + (v.weight ?? 1), 0);
47
+ if (total <= 0) return variants[0]?.key ?? "";
48
+ let point = bucket(`${flagKey}:${seed}:variant:${ctx.key}`) * total;
49
+ for (const v of variants) {
50
+ point -= v.weight ?? 1;
51
+ if (point < 0) return v.key;
52
+ }
53
+ return variants[variants.length - 1].key;
54
+ }
55
+ function isEnabled(flagKey, def, ctx) {
56
+ const fallback = def.default ?? false;
57
+ if (def.enabled === false) return fallback;
58
+ const seed = def.seed ?? "";
59
+ for (const rule of def.rules ?? []) {
60
+ if (matchCondition(ctx.attributes ?? {}, rule.when)) {
61
+ if (typeof rule.value === "boolean") return rule.value;
62
+ if (typeof rule.rollout === "number") {
63
+ return inRollout(flagKey, seed, ctx, rule.rollout, ruleSalt(rule)) ? true : fallback;
64
+ }
65
+ return true;
66
+ }
67
+ }
68
+ const rollout = def.rollout ?? (def.rules && def.rules.length ? 0 : 100);
69
+ return inRollout(flagKey, seed, ctx, rollout) ? true : fallback;
70
+ }
71
+ function variant(flagKey, def, ctx) {
72
+ const fallback = def.default ?? def.variants[0]?.key ?? "";
73
+ if (def.enabled === false) return fallback;
74
+ const seed = def.seed ?? "";
75
+ for (const rule of def.rules ?? []) {
76
+ if (matchCondition(ctx.attributes ?? {}, rule.when)) {
77
+ if (typeof rule.value === "string") return rule.value;
78
+ break;
79
+ }
80
+ }
81
+ return pickVariant(flagKey, seed, ctx, def.variants);
82
+ }
83
+ function ruleSalt(rule) {
84
+ return JSON.stringify(rule.when);
85
+ }
86
+ var isVariantFlag = (d) => d.type === "variant";
87
+ var Flags = class {
88
+ constructor(defs = {}) {
89
+ this.defs = { ...defs };
90
+ }
91
+ /** Replace / merge flag definitions at runtime (hot config reload). */
92
+ update(defs) {
93
+ this.defs = { ...this.defs, ...defs };
94
+ return this;
95
+ }
96
+ /** Set (or remove, with `undefined`) a single flag. */
97
+ set(key, def) {
98
+ if (def === void 0) delete this.defs[key];
99
+ else this.defs[key] = def;
100
+ return this;
101
+ }
102
+ has(key) {
103
+ return key in this.defs;
104
+ }
105
+ /** Boolean evaluation. Unknown flag β†’ false. */
106
+ isEnabled(key, ctx) {
107
+ const def = this.defs[key];
108
+ if (!def || isVariantFlag(def)) return false;
109
+ return isEnabled(key, def, ctx);
110
+ }
111
+ /** Variant evaluation. Unknown flag β†’ "". */
112
+ variant(key, ctx) {
113
+ const def = this.defs[key];
114
+ if (!def || !isVariantFlag(def)) return "";
115
+ return variant(key, def, ctx);
116
+ }
117
+ /** Generic evaluation β†’ boolean for boolean flags, variant key for variant flags. */
118
+ evaluate(key, ctx) {
119
+ const def = this.defs[key];
120
+ if (!def) return false;
121
+ return isVariantFlag(def) ? variant(key, def, ctx) : isEnabled(key, def, ctx);
122
+ }
123
+ /**
124
+ * Evaluate every flag for a context β€” ideal for bootstrapping a client so the
125
+ * browser never flickers or needs a round-trip.
126
+ */
127
+ all(ctx) {
128
+ const out = {};
129
+ for (const key of Object.keys(this.defs)) out[key] = this.evaluate(key, ctx);
130
+ return out;
131
+ }
132
+ /** The current raw config. */
133
+ toJSON() {
134
+ return { ...this.defs };
135
+ }
136
+ };
137
+
138
+ export { Flags, bucket, isEnabled, percentage, variant };
139
+ //# sourceMappingURL=index.js.map
140
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@lacspace/flags",
3
+ "version": "1.0.0",
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
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "sideEffects": false,
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "keywords": [
30
+ "feature-flags",
31
+ "feature-toggle",
32
+ "feature-flag",
33
+ "ab-testing",
34
+ "a-b-testing",
35
+ "experiments",
36
+ "rollout",
37
+ "percentage-rollout",
38
+ "targeting",
39
+ "launchdarkly-alternative",
40
+ "unleash-alternative",
41
+ "self-hosted",
42
+ "deterministic",
43
+ "isomorphic",
44
+ "typescript"
45
+ ],
46
+ "author": "Lacspace <contact@lacspace.com>",
47
+ "license": "SEE LICENSE IN LICENSE",
48
+ "homepage": "https://lacspace.com/packages",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/lacspace/npm-packages.git",
52
+ "directory": "flags"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/lacspace/npm-packages/issues"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }