@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 +51 -0
- package/README.md +107 -0
- package/dist/index.cjs +146 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +117 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +140 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
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
|
+
[](https://www.npmjs.com/package/@lacspace/flags)
|
|
8
|
+
[](https://packagephobia.com/result?p=@lacspace/flags)
|
|
9
|
+
[](https://bundlephobia.com/package/@lacspace/flags)
|
|
10
|
+
[](https://www.npmjs.com/package/@lacspace/flags)
|
|
11
|
+
[](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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|