@featureflip/browser 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/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @featureflip/browser-sdk
2
+
3
+ Framework-agnostic browser SDK for evaluating Featureflip feature flags.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @featureflip/browser-sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```ts
14
+ import { FeatureflipClient } from '@featureflip/browser-sdk';
15
+
16
+ const client = new FeatureflipClient({
17
+ clientKey: 'your-client-sdk-key',
18
+ });
19
+
20
+ await client.initialize();
21
+
22
+ const showBanner = client.boolVariation('show-banner', false);
23
+ ```
24
+
25
+ ## API Reference
26
+
27
+ ### Constructor
28
+
29
+ ```ts
30
+ new FeatureflipClient(config: FeatureflipClientConfig)
31
+ ```
32
+
33
+ ### Configuration Options
34
+
35
+ | Option | Type | Default | Description |
36
+ |---|---|---|---|
37
+ | `clientKey` | `string` | **(required)** | Client SDK key from your project settings |
38
+ | `baseUrl` | `string` | `https://eval.featureflip.io` | Evaluation API base URL |
39
+ | `context` | `Record<string, unknown>` | `{}` | Initial evaluation context (user attributes) |
40
+ | `streaming` | `boolean` | `true` | Enable SSE streaming for real-time updates |
41
+ | `initTimeout` | `number` | `10000` | Timeout in ms for the initial evaluate request |
42
+
43
+ ### Methods
44
+
45
+ #### `initialize(): Promise<void>`
46
+
47
+ Fetches all flag values from the server. Must be called before reading variations. Opens an SSE streaming connection if `streaming` is enabled.
48
+
49
+ #### `boolVariation(key: string, defaultValue: boolean): boolean`
50
+
51
+ Returns a boolean flag value, or `defaultValue` if the flag is missing or not a boolean.
52
+
53
+ #### `stringVariation(key: string, defaultValue: string): string`
54
+
55
+ Returns a string flag value, or `defaultValue` if the flag is missing or not a string.
56
+
57
+ #### `numberVariation(key: string, defaultValue: number): number`
58
+
59
+ Returns a number flag value, or `defaultValue` if the flag is missing or not a number.
60
+
61
+ #### `jsonVariation<T>(key: string, defaultValue: T): T`
62
+
63
+ Returns a flag value cast to `T`, or `defaultValue` if the flag is missing.
64
+
65
+ #### `identify(context: Record<string, unknown>): Promise<void>`
66
+
67
+ Re-evaluates all flags with a new context (e.g., after login). Emits `change` events for any flags whose values changed.
68
+
69
+ ```ts
70
+ await client.identify({ user_id: '123', plan: 'pro' });
71
+ ```
72
+
73
+ #### `on(event: EventType, handler: EventHandler): void`
74
+
75
+ Subscribe to events.
76
+
77
+ - `'ready'` -- fired after `initialize()` completes
78
+ - `'change'` -- fired when flag values change (receives a `FlagChanges` object)
79
+ - `'error'` -- fired on streaming or network errors
80
+
81
+ ```ts
82
+ client.on('change', (changes) => {
83
+ console.log('Flags changed:', changes);
84
+ });
85
+ ```
86
+
87
+ #### `off(event: EventType, handler: EventHandler): void`
88
+
89
+ Unsubscribe from events.
90
+
91
+ #### `close(): void`
92
+
93
+ Closes the SSE streaming connection and cleans up resources.
94
+
95
+ ### Testing
96
+
97
+ Use `FeatureflipClient.forTesting()` to create a client with predetermined flag values -- no network calls.
98
+
99
+ ```ts
100
+ const client = FeatureflipClient.forTesting({
101
+ 'show-banner': true,
102
+ 'button-color': 'blue',
103
+ });
104
+
105
+ client.boolVariation('show-banner', false); // true
106
+ client.stringVariation('button-color', 'red'); // 'blue'
107
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=class{handlers=new Map;on(e,t){this.handlers.has(e)||this.handlers.set(e,new Set),this.handlers.get(e).add(t)}off(e,t){this.handlers.get(e)?.delete(t)}emit(e,...t){this.handlers.get(e)?.forEach(e=>{try{e(...t)}catch{}})}};async function t(e,t,n,r){let i=await fetch(`${e}/v1/client/evaluate`,{method:`POST`,headers:{"Content-Type":`application/json`,Authorization:t},body:JSON.stringify({context:n}),signal:r});if(!i.ok)throw Error(`Evaluate request failed with status ${i.status}`);return i.json()}async function n(e,t,n,r){let i={"Content-Type":`application/json`,Authorization:t};r&&(i[`X-Connection-Id`]=r);let a=await fetch(`${e}/v1/client/identify`,{method:`POST`,headers:i,body:JSON.stringify({context:n})});if(!a.ok)throw Error(`Identify request failed with status ${a.status}`);return a.json()}var r=3e4,i=1e3,a=class{eventSource=null;options;backoffMs=i;reconnectTimer=null;closed=!1;_connectionId=null;get connectionId(){return this._connectionId}constructor(e){this.options=e,this.connect()}connect(){if(this.closed)return;this._connectionId=null;let{baseUrl:e,clientKey:t,context:n}=this.options,r=new TextEncoder().encode(JSON.stringify(n)),a=``;for(let e=0;e<r.length;e++)a+=String.fromCharCode(r[e]);let o=btoa(a),s=`${e}/v1/client/stream?authorization=${encodeURIComponent(t)}&context=${encodeURIComponent(o)}`;this.eventSource=new EventSource(s),this.eventSource.addEventListener(`flags-updated`,e=>{try{let t=JSON.parse(e.data);this.backoffMs=i,this.options.onChange(t.flags??t)}catch{}}),this.eventSource.addEventListener(`connection-ready`,e=>{try{let t=JSON.parse(e.data);t.connectionId&&(this._connectionId=t.connectionId)}catch{}}),this.eventSource.onerror=()=>{this.eventSource?.close(),this.eventSource=null,this.closed||(this.options.onError?.(Error(`SSE connection error`)),this.scheduleReconnect())}}scheduleReconnect(){this.closed||this.reconnectTimer!==null||(this.reconnectTimer=setTimeout(()=>{this.reconnectTimer=null,this.connect()},this.backoffMs),this.backoffMs=Math.min(this.backoffMs*2,r))}close(){this.closed=!0,this.eventSource?.close(),this.eventSource=null,this.reconnectTimer!==null&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null)}},o=class r{config;flags=new Map;emitter=new e;stream=null;initialized=!1;initPromise=null;closed=!1;constructor(e){if(!e.clientKey)throw Error(`clientKey is required`);this.config={clientKey:e.clientKey,baseUrl:e.baseUrl??`https://eval.featureflip.io`,context:e.context??{},streaming:e.streaming??!0,initTimeout:e.initTimeout??1e4}}initialize(){return this.initialized?Promise.resolve():(this.initPromise||=this.doInitialize(),this.initPromise)}async doInitialize(){let e=new AbortController,n=setTimeout(()=>e.abort(),this.config.initTimeout),r;try{r=await t(this.config.baseUrl,this.config.clientKey,this.config.context,e.signal)}finally{clearTimeout(n)}this.closed||(this.setFlags(r.flags),this.initialized=!0,this.config.streaming&&(this.stream=this.createStream()),this.emitter.emit(`ready`))}boolVariation(e,t){let n=this.flags.get(e);return n===void 0?t:typeof n.value==`boolean`?n.value:t}stringVariation(e,t){let n=this.flags.get(e);return n===void 0?t:typeof n.value==`string`?n.value:t}numberVariation(e,t){let n=this.flags.get(e);return n===void 0?t:typeof n.value==`number`?n.value:t}jsonVariation(e,t){let n=this.flags.get(e);return n===void 0?t:n.value}async identify(e){let t=this.config.context;this.config.context=e,this.stream&&=(this.stream.close(),null);let r;try{r=await n(this.config.baseUrl,this.config.clientKey,e)}catch(e){throw this.config.context=t,this.emitter.emit(`error`,e instanceof Error?e:Error(String(e))),this.config.streaming&&(this.stream=this.createStream()),e}let i=this.computeChanges(r.flags);this.setFlags(r.flags),Object.keys(i).length>0&&this.emitter.emit(`change`,i),this.config.streaming&&(this.stream=this.createStream())}on(e,t){this.emitter.on(e,t)}off(e,t){this.emitter.off(e,t)}close(){this.closed=!0,this.stream?.close(),this.stream=null}static forTesting(t){let n=Object.create(r.prototype);n.config={clientKey:`test-key`,baseUrl:`http://localhost`,context:{},streaming:!1,initTimeout:1e4},n.flags=new Map,n.emitter=new e,n.stream=null,n.initialized=!0,n.closed=!1;for(let[e,r]of Object.entries(t))n.flags.set(e,{value:r,variation:`test`,reason:`test`});return n}createStream(){return new a({baseUrl:this.config.baseUrl,clientKey:this.config.clientKey,context:this.config.context,onChange:e=>this.handleFlagUpdate(e),onError:e=>this.emitter.emit(`error`,e)})}setFlags(e){this.flags.clear();for(let[t,n]of Object.entries(e))this.flags.set(t,n)}computeChanges(e){let t={};for(let[n,r]of Object.entries(e)){let e=this.flags.get(n)?.value;e!==r.value&&(t[n]={oldValue:e,newValue:r.value})}for(let[n]of this.flags)n in e||(t[n]={oldValue:this.flags.get(n).value,newValue:void 0});return t}handleFlagUpdate(e){let t={};for(let[n,r]of Object.entries(e))if(r.reason===`FLAG_REMOVED`&&r.value===null){let e=this.flags.get(n);e!==void 0&&(t[n]={oldValue:e.value,newValue:void 0},this.flags.delete(n))}else{let e=this.flags.get(n);(e===void 0||e.value!==r.value)&&(t[n]={oldValue:e?.value,newValue:r.value}),this.flags.set(n,r)}Object.keys(t).length>0&&this.emitter.emit(`change`,t)}};exports.FeatureflipClient=o;
@@ -0,0 +1,52 @@
1
+ export declare type EventHandler = (...args: unknown[]) => void;
2
+
3
+ export declare type EventType = 'ready' | 'change' | 'error';
4
+
5
+ export declare class FeatureflipClient {
6
+ private config;
7
+ private flags;
8
+ private emitter;
9
+ private stream;
10
+ private initialized;
11
+ private initPromise;
12
+ private closed;
13
+ constructor(config: FeatureflipClientConfig);
14
+ initialize(): Promise<void>;
15
+ private doInitialize;
16
+ boolVariation(key: string, defaultValue: boolean): boolean;
17
+ stringVariation(key: string, defaultValue: string): string;
18
+ numberVariation(key: string, defaultValue: number): number;
19
+ jsonVariation<T>(key: string, defaultValue: T): T;
20
+ identify(context: Record<string, unknown>): Promise<void>;
21
+ on(event: EventType, handler: EventHandler): void;
22
+ off(event: EventType, handler: EventHandler): void;
23
+ close(): void;
24
+ static forTesting(flags: Record<string, unknown>): FeatureflipClient;
25
+ private createStream;
26
+ private setFlags;
27
+ private computeChanges;
28
+ private handleFlagUpdate;
29
+ }
30
+
31
+ export declare interface FeatureflipClientConfig {
32
+ clientKey: string;
33
+ baseUrl?: string;
34
+ context?: Record<string, unknown>;
35
+ streaming?: boolean;
36
+ initTimeout?: number;
37
+ }
38
+
39
+ export declare interface FlagChanges {
40
+ [flagKey: string]: {
41
+ oldValue: unknown;
42
+ newValue: unknown;
43
+ };
44
+ }
45
+
46
+ export declare interface FlagValue {
47
+ value: unknown;
48
+ variation: string;
49
+ reason: string;
50
+ }
51
+
52
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,220 @@
1
+ //#region src/events.ts
2
+ var e = class {
3
+ handlers = /* @__PURE__ */ new Map();
4
+ on(e, t) {
5
+ this.handlers.has(e) || this.handlers.set(e, /* @__PURE__ */ new Set()), this.handlers.get(e).add(t);
6
+ }
7
+ off(e, t) {
8
+ this.handlers.get(e)?.delete(t);
9
+ }
10
+ emit(e, ...t) {
11
+ this.handlers.get(e)?.forEach((e) => {
12
+ try {
13
+ e(...t);
14
+ } catch {}
15
+ });
16
+ }
17
+ };
18
+ //#endregion
19
+ //#region src/http.ts
20
+ async function t(e, t, n, r) {
21
+ let i = await fetch(`${e}/v1/client/evaluate`, {
22
+ method: "POST",
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ Authorization: t
26
+ },
27
+ body: JSON.stringify({ context: n }),
28
+ signal: r
29
+ });
30
+ if (!i.ok) throw Error(`Evaluate request failed with status ${i.status}`);
31
+ return i.json();
32
+ }
33
+ async function n(e, t, n, r) {
34
+ let i = {
35
+ "Content-Type": "application/json",
36
+ Authorization: t
37
+ };
38
+ r && (i["X-Connection-Id"] = r);
39
+ let a = await fetch(`${e}/v1/client/identify`, {
40
+ method: "POST",
41
+ headers: i,
42
+ body: JSON.stringify({ context: n })
43
+ });
44
+ if (!a.ok) throw Error(`Identify request failed with status ${a.status}`);
45
+ return a.json();
46
+ }
47
+ //#endregion
48
+ //#region src/streaming.ts
49
+ var r = 3e4, i = 1e3, a = class {
50
+ eventSource = null;
51
+ options;
52
+ backoffMs = i;
53
+ reconnectTimer = null;
54
+ closed = !1;
55
+ _connectionId = null;
56
+ get connectionId() {
57
+ return this._connectionId;
58
+ }
59
+ constructor(e) {
60
+ this.options = e, this.connect();
61
+ }
62
+ connect() {
63
+ if (this.closed) return;
64
+ this._connectionId = null;
65
+ let { baseUrl: e, clientKey: t, context: n } = this.options, r = new TextEncoder().encode(JSON.stringify(n)), a = "";
66
+ for (let e = 0; e < r.length; e++) a += String.fromCharCode(r[e]);
67
+ let o = btoa(a), s = `${e}/v1/client/stream?authorization=${encodeURIComponent(t)}&context=${encodeURIComponent(o)}`;
68
+ this.eventSource = new EventSource(s), this.eventSource.addEventListener("flags-updated", (e) => {
69
+ try {
70
+ let t = JSON.parse(e.data);
71
+ this.backoffMs = i, this.options.onChange(t.flags ?? t);
72
+ } catch {}
73
+ }), this.eventSource.addEventListener("connection-ready", (e) => {
74
+ try {
75
+ let t = JSON.parse(e.data);
76
+ t.connectionId && (this._connectionId = t.connectionId);
77
+ } catch {}
78
+ }), this.eventSource.onerror = () => {
79
+ this.eventSource?.close(), this.eventSource = null, this.closed || (this.options.onError?.(/* @__PURE__ */ Error("SSE connection error")), this.scheduleReconnect());
80
+ };
81
+ }
82
+ scheduleReconnect() {
83
+ this.closed || this.reconnectTimer !== null || (this.reconnectTimer = setTimeout(() => {
84
+ this.reconnectTimer = null, this.connect();
85
+ }, this.backoffMs), this.backoffMs = Math.min(this.backoffMs * 2, r));
86
+ }
87
+ close() {
88
+ this.closed = !0, this.eventSource?.close(), this.eventSource = null, this.reconnectTimer !== null && (clearTimeout(this.reconnectTimer), this.reconnectTimer = null);
89
+ }
90
+ }, o = class r {
91
+ config;
92
+ flags = /* @__PURE__ */ new Map();
93
+ emitter = new e();
94
+ stream = null;
95
+ initialized = !1;
96
+ initPromise = null;
97
+ closed = !1;
98
+ constructor(e) {
99
+ if (!e.clientKey) throw Error("clientKey is required");
100
+ this.config = {
101
+ clientKey: e.clientKey,
102
+ baseUrl: e.baseUrl ?? "https://eval.featureflip.io",
103
+ context: e.context ?? {},
104
+ streaming: e.streaming ?? !0,
105
+ initTimeout: e.initTimeout ?? 1e4
106
+ };
107
+ }
108
+ initialize() {
109
+ return this.initialized ? Promise.resolve() : (this.initPromise ||= this.doInitialize(), this.initPromise);
110
+ }
111
+ async doInitialize() {
112
+ let e = new AbortController(), n = setTimeout(() => e.abort(), this.config.initTimeout), r;
113
+ try {
114
+ r = await t(this.config.baseUrl, this.config.clientKey, this.config.context, e.signal);
115
+ } finally {
116
+ clearTimeout(n);
117
+ }
118
+ this.closed || (this.setFlags(r.flags), this.initialized = !0, this.config.streaming && (this.stream = this.createStream()), this.emitter.emit("ready"));
119
+ }
120
+ boolVariation(e, t) {
121
+ let n = this.flags.get(e);
122
+ return n === void 0 ? t : typeof n.value == "boolean" ? n.value : t;
123
+ }
124
+ stringVariation(e, t) {
125
+ let n = this.flags.get(e);
126
+ return n === void 0 ? t : typeof n.value == "string" ? n.value : t;
127
+ }
128
+ numberVariation(e, t) {
129
+ let n = this.flags.get(e);
130
+ return n === void 0 ? t : typeof n.value == "number" ? n.value : t;
131
+ }
132
+ jsonVariation(e, t) {
133
+ let n = this.flags.get(e);
134
+ return n === void 0 ? t : n.value;
135
+ }
136
+ async identify(e) {
137
+ let t = this.config.context;
138
+ this.config.context = e, this.stream &&= (this.stream.close(), null);
139
+ let r;
140
+ try {
141
+ r = await n(this.config.baseUrl, this.config.clientKey, e);
142
+ } catch (e) {
143
+ throw this.config.context = t, this.emitter.emit("error", e instanceof Error ? e : Error(String(e))), this.config.streaming && (this.stream = this.createStream()), e;
144
+ }
145
+ let i = this.computeChanges(r.flags);
146
+ this.setFlags(r.flags), Object.keys(i).length > 0 && this.emitter.emit("change", i), this.config.streaming && (this.stream = this.createStream());
147
+ }
148
+ on(e, t) {
149
+ this.emitter.on(e, t);
150
+ }
151
+ off(e, t) {
152
+ this.emitter.off(e, t);
153
+ }
154
+ close() {
155
+ this.closed = !0, this.stream?.close(), this.stream = null;
156
+ }
157
+ static forTesting(t) {
158
+ let n = Object.create(r.prototype);
159
+ n.config = {
160
+ clientKey: "test-key",
161
+ baseUrl: "http://localhost",
162
+ context: {},
163
+ streaming: !1,
164
+ initTimeout: 1e4
165
+ }, n.flags = /* @__PURE__ */ new Map(), n.emitter = new e(), n.stream = null, n.initialized = !0, n.closed = !1;
166
+ for (let [e, r] of Object.entries(t)) n.flags.set(e, {
167
+ value: r,
168
+ variation: "test",
169
+ reason: "test"
170
+ });
171
+ return n;
172
+ }
173
+ createStream() {
174
+ return new a({
175
+ baseUrl: this.config.baseUrl,
176
+ clientKey: this.config.clientKey,
177
+ context: this.config.context,
178
+ onChange: (e) => this.handleFlagUpdate(e),
179
+ onError: (e) => this.emitter.emit("error", e)
180
+ });
181
+ }
182
+ setFlags(e) {
183
+ this.flags.clear();
184
+ for (let [t, n] of Object.entries(e)) this.flags.set(t, n);
185
+ }
186
+ computeChanges(e) {
187
+ let t = {};
188
+ for (let [n, r] of Object.entries(e)) {
189
+ let e = this.flags.get(n)?.value;
190
+ e !== r.value && (t[n] = {
191
+ oldValue: e,
192
+ newValue: r.value
193
+ });
194
+ }
195
+ for (let [n] of this.flags) n in e || (t[n] = {
196
+ oldValue: this.flags.get(n).value,
197
+ newValue: void 0
198
+ });
199
+ return t;
200
+ }
201
+ handleFlagUpdate(e) {
202
+ let t = {};
203
+ for (let [n, r] of Object.entries(e)) if (r.reason === "FLAG_REMOVED" && r.value === null) {
204
+ let e = this.flags.get(n);
205
+ e !== void 0 && (t[n] = {
206
+ oldValue: e.value,
207
+ newValue: void 0
208
+ }, this.flags.delete(n));
209
+ } else {
210
+ let e = this.flags.get(n);
211
+ (e === void 0 || e.value !== r.value) && (t[n] = {
212
+ oldValue: e?.value,
213
+ newValue: r.value
214
+ }), this.flags.set(n, r);
215
+ }
216
+ Object.keys(t).length > 0 && this.emitter.emit("change", t);
217
+ }
218
+ };
219
+ //#endregion
220
+ export { o as FeatureflipClient };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@featureflip/browser",
3
+ "version": "1.0.0",
4
+ "description": "Browser SDK for Featureflip - framework-agnostic feature flag client",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "vite build",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "engines": {
25
+ "node": ">=20.19.0"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "homepage": "https://featureflip.io/docs",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "devDependencies": {
33
+ "jsdom": "^29.0.1",
34
+ "typescript": "^6.0.2",
35
+ "vite": "^8.0.3",
36
+ "vite-plugin-dts": "^4.0.0",
37
+ "vitest": "^4.1.2"
38
+ }
39
+ }