@useorbits/flags 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # `@useorbits/flags`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Feature flags for apps on [Orbits](https://orbits.africa). Ship code switched off, then turn it on for your team, a
4
+ share of visitors, or everyone, from your project's **Flags** tab, without a redeploy.
5
+
6
+ Flags are checked in your app, not over the network. The SDK fetches your environment's flags once, answers every
7
+ check from memory, and refreshes them in the background every few seconds, so a change in the Flags tab reaches your
8
+ app within seconds.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install @useorbits/flags
14
+ ```
15
+
16
+ Create a flag in your project's **Flags** tab. Orbits gives every deployment its environment's key as `ORBITS_FLAGS`,
17
+ so there's nothing to configure.
18
+
19
+ ## Check a flag
20
+
21
+ In server code (a Server Component, a route handler, an API, a worker):
22
+
23
+ ```tsx
24
+ import { flag } from "@useorbits/flags";
25
+
26
+ export default async function Checkout() {
27
+ const user = await currentUser();
28
+ if (await flag("new-checkout", { id: user.id, email: user.email })) {
29
+ return <OnePageCheckout />;
30
+ }
31
+ return <ClassicCheckout />;
32
+ }
33
+ ```
34
+
35
+ String and number flags return their value. Pass a default for when the flag doesn't exist yet, or Orbits can't be
36
+ reached:
37
+
38
+ ```ts
39
+ const backend = await flag("search-backend", {}, "typesense");
40
+ const pageSize = await flag("page-size", {}, 20);
41
+ ```
42
+
43
+ A boolean flag's default is `false`.
44
+
45
+ ## Who gets what
46
+
47
+ What you pass as the second argument is the **context**. Rules in the Flags tab read it, and rollouts use its `id`.
48
+
49
+ | In the Flags tab | Reads |
50
+ | --- | --- |
51
+ | A rule, such as "Email ends with @kudi.ng" | The context's attribute: `email`, `plan`, `country`, or a path like `user.plan`. Comparisons ignore case. |
52
+ | Rollout to a share of visitors | `id`. The same id always gets the same answer, and stays in as the share grows. Without an id, only a 100% rollout is on. |
53
+
54
+ For each check, the first matching rule decides. Otherwise a boolean flag is off when it's switched off, and on for
55
+ its rollout's share of ids when it's on. A string or number flag serves its value.
56
+
57
+ To get every flag at once, such as to pass them all to a page:
58
+
59
+ ```ts
60
+ import { flags } from "@useorbits/flags";
61
+
62
+ const all = await flags({ id: user.id });
63
+ // { "new-checkout": true, "search-backend": "typesense" }
64
+ ```
65
+
66
+ ## Environments
67
+
68
+ Each deployment reads its own environment: production deployments get Production's flags, previews get Preview's.
69
+
70
+ To use Development's flags on your machine, copy its key from the Flags tab into `.env.local`:
71
+
72
+ ```sh
73
+ ORBITS_FLAGS=ofl_…
74
+ ```
75
+
76
+ Without a key, every flag gets its default, and the SDK says so once in the console.
77
+
78
+ ## Options
79
+
80
+ The shared client reads `ORBITS_FLAGS` and `ORBITS_API_URL`. To set them yourself, call `configure` before the first
81
+ check:
82
+
83
+ ```ts
84
+ import { configure } from "@useorbits/flags";
85
+
86
+ configure({ key: process.env.MY_FLAGS_KEY, refreshMs: 10_000 });
87
+ ```
88
+
89
+ | Option | Default | |
90
+ | --- | --- | --- |
91
+ | `key` | `ORBITS_FLAGS` | The environment's key. |
92
+ | `apiUrl` | `ORBITS_API_URL` | Orbits' API. |
93
+ | `refreshMs` | `5000` | How old the flags may get before they're fetched again in the background. |
94
+ | `report` | `true` | Report which flags were checked, for the Flags tab's "Seen in the last 24 hours". |
95
+ | `debug` | `false` | Log fetches and reports. |
96
+
97
+ For more than one key in a process, make a client of your own: `new FlagsClient({ key })`, then `client.flag(...)`.
98
+
99
+ ## What's reported
100
+
101
+ About once a minute while flags are being checked, the SDK tells Orbits how often each flag was checked and for which
102
+ ids. Each id goes as the first 16 characters of its SHA-256, never as it was. Turn it off with
103
+ `configure({ report: false })`.
104
+
105
+ ## License
106
+
107
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ FlagsClient: () => FlagsClient,
24
+ bucket: () => bucket,
25
+ configure: () => configure,
26
+ evaluate: () => evaluate,
27
+ flag: () => flag,
28
+ flags: () => flags,
29
+ matches: () => matches
30
+ });
31
+ module.exports = __toCommonJS(src_exports);
32
+
33
+ // src/evaluate.ts
34
+ function read(context, attribute) {
35
+ let at = context;
36
+ for (const part of attribute.split(".")) {
37
+ if (at === null || typeof at !== "object") return void 0;
38
+ at = at[part];
39
+ }
40
+ return at;
41
+ }
42
+ function matches(rule, context) {
43
+ const raw = read(context, rule.attribute);
44
+ if (raw === void 0 || raw === null || typeof raw === "object") return false;
45
+ const value = String(raw).toLowerCase();
46
+ const values = rule.values.map((v) => v.toLowerCase());
47
+ const first = values[0] ?? "";
48
+ switch (rule.operator) {
49
+ case "equals":
50
+ return value === first;
51
+ case "not_equals":
52
+ return value !== first;
53
+ case "one_of":
54
+ return values.includes(value);
55
+ case "ends_with":
56
+ return value.endsWith(first);
57
+ case "starts_with":
58
+ return value.startsWith(first);
59
+ case "contains":
60
+ return value.includes(first);
61
+ }
62
+ }
63
+ function bucket(key, id) {
64
+ let hash = 2166136261;
65
+ for (const ch of `${key}:${id}`) {
66
+ hash ^= ch.codePointAt(0);
67
+ hash = Math.imul(hash, 16777619) >>> 0;
68
+ }
69
+ return hash % 100;
70
+ }
71
+ function evaluate(key, def, context = {}) {
72
+ for (const rule of def.rules) if (matches(rule, context)) return rule.serve;
73
+ if (def.type !== "boolean") return def.value ?? (def.type === "number" ? 0 : "");
74
+ if (!def.on) return false;
75
+ if (def.rollout >= 100) return true;
76
+ const id = context.id;
77
+ if (id === void 0 || id === null || id === "") return false;
78
+ return bucket(key, String(id)) < def.rollout;
79
+ }
80
+
81
+ // src/client.ts
82
+ var DEFAULT_API = "https://api.theorbits.app";
83
+ var REPORT_EVERY = 6e4;
84
+ var IDS_PER_FLAG = 500;
85
+ var TIMEOUT = 3e3;
86
+ var env = (name) => typeof process === "undefined" ? void 0 : process.env[name];
87
+ async function hashId(id) {
88
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id));
89
+ return [...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
90
+ }
91
+ var FlagsClient = class {
92
+ constructor(options = {}) {
93
+ this.current = null;
94
+ this.fetchedAt = 0;
95
+ this.loading = null;
96
+ this.warned = false;
97
+ this.checks = /* @__PURE__ */ new Map();
98
+ this.timer = null;
99
+ this.key = options.key ?? env("ORBITS_FLAGS");
100
+ this.api = (options.apiUrl ?? env("ORBITS_API_URL") ?? DEFAULT_API).replace(/\/+$/, "");
101
+ this.refreshMs = options.refreshMs ?? 5e3;
102
+ this.report = options.report ?? true;
103
+ this.debug = options.debug ?? false;
104
+ this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
105
+ }
106
+ log(...args) {
107
+ if (this.debug) console.info("[Orbits Flags]", ...args);
108
+ }
109
+ /** Fetches the flags, or confirms the ones held are current; keeps them when Orbits can't be reached. */
110
+ load() {
111
+ this.loading ?? (this.loading = (async () => {
112
+ try {
113
+ const res = await this.fetch(`${this.api}/v1/flags/definitions`, {
114
+ headers: { authorization: `Bearer ${this.key}`, ...this.current ? { "if-none-match": `"${this.current.version}"` } : {} },
115
+ cache: "no-store",
116
+ signal: AbortSignal.timeout(TIMEOUT)
117
+ });
118
+ if (res.status === 304) this.log("unchanged");
119
+ else if (res.ok) {
120
+ this.current = await res.json();
121
+ this.log(`fetched ${Object.keys(this.current.flags).length} flags for ${this.current.environment}`);
122
+ } else if (!this.warned) {
123
+ this.warned = true;
124
+ console.warn(`[Orbits Flags] Orbits answered ${res.status} for the flags; checks get their defaults until it doesn't.`);
125
+ }
126
+ this.fetchedAt = Date.now();
127
+ } catch (error) {
128
+ this.log("couldn't reach Orbits", error);
129
+ this.fetchedAt = Date.now();
130
+ } finally {
131
+ this.loading = null;
132
+ }
133
+ })());
134
+ return this.loading;
135
+ }
136
+ /** The environment's flags: fetched the first time, then served from memory and refreshed behind the scenes. */
137
+ async definitions() {
138
+ if (!this.key) {
139
+ if (!this.warned) {
140
+ this.warned = true;
141
+ console.warn("[Orbits Flags] ORBITS_FLAGS isn't set, so every flag gets its default. Orbits sets it on deployments; locally, copy Development's key from the Flags page into .env.local.");
142
+ }
143
+ return null;
144
+ }
145
+ if (!this.current) await this.load();
146
+ else if (Date.now() - this.fetchedAt > this.refreshMs) void this.load();
147
+ return this.current;
148
+ }
149
+ /** A flag's value for this context, or the default when Orbits has no such flag (or couldn't be reached). */
150
+ async flag(key, context = {}, defaultValue) {
151
+ const fallback = defaultValue ?? false;
152
+ const defs = await this.definitions();
153
+ const def = defs?.flags[key];
154
+ if (!def) return fallback;
155
+ this.count(key, context.id);
156
+ return evaluate(key, def, context);
157
+ }
158
+ /** Every flag's value for this context, such as to hand a page all it needs at once. */
159
+ async all(context = {}) {
160
+ const defs = await this.definitions();
161
+ if (!defs) return {};
162
+ return Object.fromEntries(Object.entries(defs.flags).map(([key, def]) => [key, evaluate(key, def, context)]));
163
+ }
164
+ count(key, id) {
165
+ if (!this.report) return;
166
+ const c = this.checks.get(key) ?? { count: 0, ids: /* @__PURE__ */ new Set() };
167
+ c.count++;
168
+ if (id !== void 0 && id !== null && id !== "" && c.ids.size < IDS_PER_FLAG) c.ids.add(String(id));
169
+ this.checks.set(key, c);
170
+ if (!this.timer) {
171
+ this.timer = setInterval(() => void this.flush(), REPORT_EVERY);
172
+ this.timer.unref?.();
173
+ }
174
+ }
175
+ /** Sends what was checked since the last report. Called every minute while flags are checked. */
176
+ async flush() {
177
+ if (!this.key || !this.checks.size) return;
178
+ const batch = [...this.checks].slice(0, 200);
179
+ for (const [key] of batch) this.checks.delete(key);
180
+ const flags2 = await Promise.all(batch.map(async ([key, c]) => ({ key, checks: c.count, ids: await Promise.all([...c.ids].map(hashId)) })));
181
+ try {
182
+ await this.fetch(`${this.api}/v1/flags/exposures`, {
183
+ method: "POST",
184
+ headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
185
+ body: JSON.stringify({ flags: flags2 }),
186
+ signal: AbortSignal.timeout(TIMEOUT)
187
+ });
188
+ this.log(`reported ${flags2.length} flags`);
189
+ } catch (error) {
190
+ this.log("couldn't report checks", error);
191
+ }
192
+ }
193
+ };
194
+
195
+ // src/index.ts
196
+ var shared = null;
197
+ var client = () => shared ?? (shared = new FlagsClient());
198
+ function flag(key, context, defaultValue) {
199
+ return client().flag(key, context, defaultValue);
200
+ }
201
+ function flags(context) {
202
+ return client().all(context);
203
+ }
204
+ function configure(options) {
205
+ shared = new FlagsClient(options);
206
+ }
207
+ // Annotate the CommonJS export names for ESM import in node:
208
+ 0 && (module.exports = {
209
+ FlagsClient,
210
+ bucket,
211
+ configure,
212
+ evaluate,
213
+ flag,
214
+ flags,
215
+ matches
216
+ });
@@ -0,0 +1,88 @@
1
+ type FlagValue = boolean | string | number;
2
+ /** What a rule can read: an id for rollouts, and anything else to target on (email, plan, user.country). */
3
+ type FlagContext = {
4
+ id?: string | number | null;
5
+ [attribute: string]: unknown;
6
+ };
7
+ type RuleOperator = "equals" | "not_equals" | "one_of" | "ends_with" | "starts_with" | "contains";
8
+ interface Rule {
9
+ attribute: string;
10
+ operator: RuleOperator;
11
+ values: string[];
12
+ serve: FlagValue;
13
+ }
14
+ interface Definition {
15
+ type: "boolean" | "string" | "number";
16
+ on: boolean;
17
+ rollout: number;
18
+ value: string | number | null;
19
+ rules: Rule[];
20
+ }
21
+ /** Comparisons ignore case: an email's domain is the same whatever the visitor typed. */
22
+ declare function matches(rule: Rule, context: FlagContext): boolean;
23
+ /** 0–99, the same for a flag and an id every time (32-bit FNV-1a), so a visitor stays in or out as a rollout grows. */
24
+ declare function bucket(key: string, id: string): number;
25
+ declare function evaluate(key: string, def: Definition, context?: FlagContext): FlagValue;
26
+
27
+ interface FlagsOptions {
28
+ /** The environment's key: ORBITS_FLAGS, which Orbits sets on every deployment. */
29
+ key?: string;
30
+ /** Orbits' API: ORBITS_API_URL, set beside it. */
31
+ apiUrl?: string;
32
+ /** How old the flags may get before they're fetched again in the background, in ms (5 seconds). */
33
+ refreshMs?: number;
34
+ /** Report which flags were checked, for the flag's "Seen in the last 24 hours" (on unless false). */
35
+ report?: boolean;
36
+ /** Log fetches and reports. */
37
+ debug?: boolean;
38
+ fetch?: typeof fetch;
39
+ }
40
+ type Definitions = {
41
+ environment: string;
42
+ version: string;
43
+ flags: Record<string, Definition>;
44
+ };
45
+ declare class FlagsClient {
46
+ private readonly key;
47
+ private readonly api;
48
+ private readonly refreshMs;
49
+ private readonly report;
50
+ private readonly debug;
51
+ private readonly fetch;
52
+ private current;
53
+ private fetchedAt;
54
+ private loading;
55
+ private warned;
56
+ private readonly checks;
57
+ private timer;
58
+ constructor(options?: FlagsOptions);
59
+ private log;
60
+ /** Fetches the flags, or confirms the ones held are current; keeps them when Orbits can't be reached. */
61
+ private load;
62
+ /** The environment's flags: fetched the first time, then served from memory and refreshed behind the scenes. */
63
+ definitions(): Promise<Definitions | null>;
64
+ /** A flag's value for this context, or the default when Orbits has no such flag (or couldn't be reached). */
65
+ flag<T extends FlagValue = boolean>(key: string, context?: FlagContext, defaultValue?: T): Promise<T>;
66
+ /** Every flag's value for this context, such as to hand a page all it needs at once. */
67
+ all(context?: FlagContext): Promise<Record<string, FlagValue>>;
68
+ private count;
69
+ /** Sends what was checked since the last report. Called every minute while flags are checked. */
70
+ flush(): Promise<void>;
71
+ }
72
+
73
+ /**
74
+ * A flag's value, in server code: on, off or its value in this deployment's environment, for this context. Rules
75
+ * read the context (`email`, `plan`, `user.country`), rollouts its `id`. Without such a flag, or before Orbits can be
76
+ * reached, it's the default: false unless you pass one.
77
+ *
78
+ * if (await flag("new-checkout", { id: user.id, email: user.email })) return <OnePageCheckout />;
79
+ * const backend = await flag("search-backend", {}, "typesense");
80
+ */
81
+ declare function flag<T extends FlagValue = boolean>(key: string, context?: FlagContext, defaultValue?: T): Promise<T>;
82
+ /** Every flag's value for this context at once. */
83
+ declare function flags(context?: FlagContext): Promise<Record<string, FlagValue>>;
84
+ /** Sets how the shared client reads flags; call before the first flag(). Without it, it reads ORBITS_FLAGS. */
85
+ declare function configure(options: FlagsOptions): void;
86
+
87
+ export { FlagsClient, bucket, configure, evaluate, flag, flags, matches };
88
+ export type { Definition, FlagContext, FlagValue, FlagsOptions, Rule, RuleOperator };
@@ -0,0 +1,88 @@
1
+ type FlagValue = boolean | string | number;
2
+ /** What a rule can read: an id for rollouts, and anything else to target on (email, plan, user.country). */
3
+ type FlagContext = {
4
+ id?: string | number | null;
5
+ [attribute: string]: unknown;
6
+ };
7
+ type RuleOperator = "equals" | "not_equals" | "one_of" | "ends_with" | "starts_with" | "contains";
8
+ interface Rule {
9
+ attribute: string;
10
+ operator: RuleOperator;
11
+ values: string[];
12
+ serve: FlagValue;
13
+ }
14
+ interface Definition {
15
+ type: "boolean" | "string" | "number";
16
+ on: boolean;
17
+ rollout: number;
18
+ value: string | number | null;
19
+ rules: Rule[];
20
+ }
21
+ /** Comparisons ignore case: an email's domain is the same whatever the visitor typed. */
22
+ declare function matches(rule: Rule, context: FlagContext): boolean;
23
+ /** 0–99, the same for a flag and an id every time (32-bit FNV-1a), so a visitor stays in or out as a rollout grows. */
24
+ declare function bucket(key: string, id: string): number;
25
+ declare function evaluate(key: string, def: Definition, context?: FlagContext): FlagValue;
26
+
27
+ interface FlagsOptions {
28
+ /** The environment's key: ORBITS_FLAGS, which Orbits sets on every deployment. */
29
+ key?: string;
30
+ /** Orbits' API: ORBITS_API_URL, set beside it. */
31
+ apiUrl?: string;
32
+ /** How old the flags may get before they're fetched again in the background, in ms (5 seconds). */
33
+ refreshMs?: number;
34
+ /** Report which flags were checked, for the flag's "Seen in the last 24 hours" (on unless false). */
35
+ report?: boolean;
36
+ /** Log fetches and reports. */
37
+ debug?: boolean;
38
+ fetch?: typeof fetch;
39
+ }
40
+ type Definitions = {
41
+ environment: string;
42
+ version: string;
43
+ flags: Record<string, Definition>;
44
+ };
45
+ declare class FlagsClient {
46
+ private readonly key;
47
+ private readonly api;
48
+ private readonly refreshMs;
49
+ private readonly report;
50
+ private readonly debug;
51
+ private readonly fetch;
52
+ private current;
53
+ private fetchedAt;
54
+ private loading;
55
+ private warned;
56
+ private readonly checks;
57
+ private timer;
58
+ constructor(options?: FlagsOptions);
59
+ private log;
60
+ /** Fetches the flags, or confirms the ones held are current; keeps them when Orbits can't be reached. */
61
+ private load;
62
+ /** The environment's flags: fetched the first time, then served from memory and refreshed behind the scenes. */
63
+ definitions(): Promise<Definitions | null>;
64
+ /** A flag's value for this context, or the default when Orbits has no such flag (or couldn't be reached). */
65
+ flag<T extends FlagValue = boolean>(key: string, context?: FlagContext, defaultValue?: T): Promise<T>;
66
+ /** Every flag's value for this context, such as to hand a page all it needs at once. */
67
+ all(context?: FlagContext): Promise<Record<string, FlagValue>>;
68
+ private count;
69
+ /** Sends what was checked since the last report. Called every minute while flags are checked. */
70
+ flush(): Promise<void>;
71
+ }
72
+
73
+ /**
74
+ * A flag's value, in server code: on, off or its value in this deployment's environment, for this context. Rules
75
+ * read the context (`email`, `plan`, `user.country`), rollouts its `id`. Without such a flag, or before Orbits can be
76
+ * reached, it's the default: false unless you pass one.
77
+ *
78
+ * if (await flag("new-checkout", { id: user.id, email: user.email })) return <OnePageCheckout />;
79
+ * const backend = await flag("search-backend", {}, "typesense");
80
+ */
81
+ declare function flag<T extends FlagValue = boolean>(key: string, context?: FlagContext, defaultValue?: T): Promise<T>;
82
+ /** Every flag's value for this context at once. */
83
+ declare function flags(context?: FlagContext): Promise<Record<string, FlagValue>>;
84
+ /** Sets how the shared client reads flags; call before the first flag(). Without it, it reads ORBITS_FLAGS. */
85
+ declare function configure(options: FlagsOptions): void;
86
+
87
+ export { FlagsClient, bucket, configure, evaluate, flag, flags, matches };
88
+ export type { Definition, FlagContext, FlagValue, FlagsOptions, Rule, RuleOperator };
package/dist/index.js ADDED
@@ -0,0 +1,183 @@
1
+ // src/evaluate.ts
2
+ function read(context, attribute) {
3
+ let at = context;
4
+ for (const part of attribute.split(".")) {
5
+ if (at === null || typeof at !== "object") return void 0;
6
+ at = at[part];
7
+ }
8
+ return at;
9
+ }
10
+ function matches(rule, context) {
11
+ const raw = read(context, rule.attribute);
12
+ if (raw === void 0 || raw === null || typeof raw === "object") return false;
13
+ const value = String(raw).toLowerCase();
14
+ const values = rule.values.map((v) => v.toLowerCase());
15
+ const first = values[0] ?? "";
16
+ switch (rule.operator) {
17
+ case "equals":
18
+ return value === first;
19
+ case "not_equals":
20
+ return value !== first;
21
+ case "one_of":
22
+ return values.includes(value);
23
+ case "ends_with":
24
+ return value.endsWith(first);
25
+ case "starts_with":
26
+ return value.startsWith(first);
27
+ case "contains":
28
+ return value.includes(first);
29
+ }
30
+ }
31
+ function bucket(key, id) {
32
+ let hash = 2166136261;
33
+ for (const ch of `${key}:${id}`) {
34
+ hash ^= ch.codePointAt(0);
35
+ hash = Math.imul(hash, 16777619) >>> 0;
36
+ }
37
+ return hash % 100;
38
+ }
39
+ function evaluate(key, def, context = {}) {
40
+ for (const rule of def.rules) if (matches(rule, context)) return rule.serve;
41
+ if (def.type !== "boolean") return def.value ?? (def.type === "number" ? 0 : "");
42
+ if (!def.on) return false;
43
+ if (def.rollout >= 100) return true;
44
+ const id = context.id;
45
+ if (id === void 0 || id === null || id === "") return false;
46
+ return bucket(key, String(id)) < def.rollout;
47
+ }
48
+
49
+ // src/client.ts
50
+ var DEFAULT_API = "https://api.theorbits.app";
51
+ var REPORT_EVERY = 6e4;
52
+ var IDS_PER_FLAG = 500;
53
+ var TIMEOUT = 3e3;
54
+ var env = (name) => typeof process === "undefined" ? void 0 : process.env[name];
55
+ async function hashId(id) {
56
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id));
57
+ return [...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
58
+ }
59
+ var FlagsClient = class {
60
+ constructor(options = {}) {
61
+ this.current = null;
62
+ this.fetchedAt = 0;
63
+ this.loading = null;
64
+ this.warned = false;
65
+ this.checks = /* @__PURE__ */ new Map();
66
+ this.timer = null;
67
+ this.key = options.key ?? env("ORBITS_FLAGS");
68
+ this.api = (options.apiUrl ?? env("ORBITS_API_URL") ?? DEFAULT_API).replace(/\/+$/, "");
69
+ this.refreshMs = options.refreshMs ?? 5e3;
70
+ this.report = options.report ?? true;
71
+ this.debug = options.debug ?? false;
72
+ this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
73
+ }
74
+ log(...args) {
75
+ if (this.debug) console.info("[Orbits Flags]", ...args);
76
+ }
77
+ /** Fetches the flags, or confirms the ones held are current; keeps them when Orbits can't be reached. */
78
+ load() {
79
+ this.loading ?? (this.loading = (async () => {
80
+ try {
81
+ const res = await this.fetch(`${this.api}/v1/flags/definitions`, {
82
+ headers: { authorization: `Bearer ${this.key}`, ...this.current ? { "if-none-match": `"${this.current.version}"` } : {} },
83
+ cache: "no-store",
84
+ signal: AbortSignal.timeout(TIMEOUT)
85
+ });
86
+ if (res.status === 304) this.log("unchanged");
87
+ else if (res.ok) {
88
+ this.current = await res.json();
89
+ this.log(`fetched ${Object.keys(this.current.flags).length} flags for ${this.current.environment}`);
90
+ } else if (!this.warned) {
91
+ this.warned = true;
92
+ console.warn(`[Orbits Flags] Orbits answered ${res.status} for the flags; checks get their defaults until it doesn't.`);
93
+ }
94
+ this.fetchedAt = Date.now();
95
+ } catch (error) {
96
+ this.log("couldn't reach Orbits", error);
97
+ this.fetchedAt = Date.now();
98
+ } finally {
99
+ this.loading = null;
100
+ }
101
+ })());
102
+ return this.loading;
103
+ }
104
+ /** The environment's flags: fetched the first time, then served from memory and refreshed behind the scenes. */
105
+ async definitions() {
106
+ if (!this.key) {
107
+ if (!this.warned) {
108
+ this.warned = true;
109
+ console.warn("[Orbits Flags] ORBITS_FLAGS isn't set, so every flag gets its default. Orbits sets it on deployments; locally, copy Development's key from the Flags page into .env.local.");
110
+ }
111
+ return null;
112
+ }
113
+ if (!this.current) await this.load();
114
+ else if (Date.now() - this.fetchedAt > this.refreshMs) void this.load();
115
+ return this.current;
116
+ }
117
+ /** A flag's value for this context, or the default when Orbits has no such flag (or couldn't be reached). */
118
+ async flag(key, context = {}, defaultValue) {
119
+ const fallback = defaultValue ?? false;
120
+ const defs = await this.definitions();
121
+ const def = defs?.flags[key];
122
+ if (!def) return fallback;
123
+ this.count(key, context.id);
124
+ return evaluate(key, def, context);
125
+ }
126
+ /** Every flag's value for this context, such as to hand a page all it needs at once. */
127
+ async all(context = {}) {
128
+ const defs = await this.definitions();
129
+ if (!defs) return {};
130
+ return Object.fromEntries(Object.entries(defs.flags).map(([key, def]) => [key, evaluate(key, def, context)]));
131
+ }
132
+ count(key, id) {
133
+ if (!this.report) return;
134
+ const c = this.checks.get(key) ?? { count: 0, ids: /* @__PURE__ */ new Set() };
135
+ c.count++;
136
+ if (id !== void 0 && id !== null && id !== "" && c.ids.size < IDS_PER_FLAG) c.ids.add(String(id));
137
+ this.checks.set(key, c);
138
+ if (!this.timer) {
139
+ this.timer = setInterval(() => void this.flush(), REPORT_EVERY);
140
+ this.timer.unref?.();
141
+ }
142
+ }
143
+ /** Sends what was checked since the last report. Called every minute while flags are checked. */
144
+ async flush() {
145
+ if (!this.key || !this.checks.size) return;
146
+ const batch = [...this.checks].slice(0, 200);
147
+ for (const [key] of batch) this.checks.delete(key);
148
+ const flags2 = await Promise.all(batch.map(async ([key, c]) => ({ key, checks: c.count, ids: await Promise.all([...c.ids].map(hashId)) })));
149
+ try {
150
+ await this.fetch(`${this.api}/v1/flags/exposures`, {
151
+ method: "POST",
152
+ headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
153
+ body: JSON.stringify({ flags: flags2 }),
154
+ signal: AbortSignal.timeout(TIMEOUT)
155
+ });
156
+ this.log(`reported ${flags2.length} flags`);
157
+ } catch (error) {
158
+ this.log("couldn't report checks", error);
159
+ }
160
+ }
161
+ };
162
+
163
+ // src/index.ts
164
+ var shared = null;
165
+ var client = () => shared ?? (shared = new FlagsClient());
166
+ function flag(key, context, defaultValue) {
167
+ return client().flag(key, context, defaultValue);
168
+ }
169
+ function flags(context) {
170
+ return client().all(context);
171
+ }
172
+ function configure(options) {
173
+ shared = new FlagsClient(options);
174
+ }
175
+ export {
176
+ FlagsClient,
177
+ bucket,
178
+ configure,
179
+ evaluate,
180
+ flag,
181
+ flags,
182
+ matches
183
+ };
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@useorbits/flags",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Feature flags for apps on Orbits: switch code on for your team, a share of visitors or everyone, without a redeploy.",
5
+ "license": "MIT",
6
+ "repository": { "type": "git", "url": "git+https://github.com/The-Orbits/insights.git", "directory": "packages/flags" },
7
+ "homepage": "https://orbits.africa",
8
+ "keywords": ["feature flags", "flags", "rollout", "orbits"],
9
+ "type": "module",
10
+ "sideEffects": false,
11
+ "files": ["dist"],
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "typecheck": "tsc -p tsconfig.json"
22
+ },
23
+ "publishConfig": { "access": "public", "provenance": true }
24
+ }