@flipfeatureflag/core 0.1.1

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.
@@ -0,0 +1,124 @@
1
+ type FlagValue = boolean | string | number | Record<string, unknown>;
2
+ type SdkReason = "rule_match" | "fallthrough" | "default" | "disabled";
3
+ interface SdkContext {
4
+ userId?: string;
5
+ attributes?: Record<string, unknown>;
6
+ }
7
+ interface SdkEvaluateRequest {
8
+ env?: string;
9
+ context: SdkContext;
10
+ flagKeys?: string[];
11
+ }
12
+ interface SdkFlagEvaluation {
13
+ value: FlagValue;
14
+ variationKey: string;
15
+ reason: SdkReason;
16
+ }
17
+ interface SdkEvaluateResponse {
18
+ flags: Record<string, SdkFlagEvaluation>;
19
+ evalId?: string;
20
+ updatedAt?: string;
21
+ }
22
+ interface SdkMetadataResponse {
23
+ items: Flag[];
24
+ updatedAt: string;
25
+ }
26
+ interface SdkMetricEvent {
27
+ flagKey: string;
28
+ variationKey: string;
29
+ reason: SdkReason | string;
30
+ ts: string;
31
+ context?: SdkContext;
32
+ }
33
+ interface SdkMetricsRequest {
34
+ env?: string;
35
+ events: SdkMetricEvent[];
36
+ }
37
+ interface Flag {
38
+ id: string;
39
+ projectId: string;
40
+ key: string;
41
+ name: string;
42
+ description?: string;
43
+ type: "boolean" | "string" | "number" | "json";
44
+ enabled: boolean;
45
+ defaultValue: FlagValue;
46
+ variations?: FlagVariation[];
47
+ targeting?: TargetingRules;
48
+ createdAt: string;
49
+ updatedAt: string;
50
+ }
51
+ interface FlagVariation {
52
+ key: string;
53
+ value: FlagValue;
54
+ description?: string;
55
+ weight?: number;
56
+ }
57
+ interface TargetingRules {
58
+ rules?: TargetingRule[];
59
+ fallthrough?: FlagValue;
60
+ }
61
+ interface TargetingRule {
62
+ conditions: Condition[];
63
+ variationKey: string;
64
+ rollout?: number;
65
+ }
66
+ type ConditionOperator = "eq" | "ne" | "in" | "not_in" | "gt" | "gte" | "lt" | "lte" | "contains";
67
+ interface Condition {
68
+ attribute: string;
69
+ operator: ConditionOperator;
70
+ value: string | number | boolean | string[];
71
+ }
72
+ interface FlipFlagClientOptions {
73
+ url: string;
74
+ sdkKey: string;
75
+ env?: string;
76
+ appName?: string;
77
+ refreshIntervalMs?: number;
78
+ metricsIntervalMs?: number;
79
+ disableRefresh?: boolean;
80
+ disableMetrics?: boolean;
81
+ context?: SdkContext;
82
+ fetch?: typeof fetch;
83
+ }
84
+ interface FlagsStatus {
85
+ ready: boolean;
86
+ error?: Error;
87
+ updatedAt?: string;
88
+ }
89
+ interface FlagEvaluationResult extends SdkFlagEvaluation {
90
+ flagKey: string;
91
+ }
92
+ type FlipFlagEvent = "ready" | "update" | "error" | "metrics";
93
+ type FlipFlagListener = (payload?: unknown) => void;
94
+
95
+ declare class FlipFlagClient {
96
+ private options;
97
+ private http;
98
+ private emitter;
99
+ private status;
100
+ private evaluations;
101
+ private refreshTimer?;
102
+ private metricsTimer?;
103
+ private metricsBuffer;
104
+ private context;
105
+ constructor(options: FlipFlagClientOptions);
106
+ start(): Promise<void>;
107
+ stop(): void;
108
+ on(event: FlipFlagEvent, listener: FlipFlagListener): void;
109
+ off(event: FlipFlagEvent, listener: FlipFlagListener): void;
110
+ getStatus(): FlagsStatus;
111
+ getContext(): SdkContext;
112
+ updateContext(partial: SdkContext): void;
113
+ getAllFlags(): Record<string, SdkFlagEvaluation>;
114
+ isEnabled(flagKey: string, defaultValue?: boolean): boolean;
115
+ getVariant(flagKey: string, defaultValue?: FlagValue): SdkFlagEvaluation;
116
+ private evaluate;
117
+ private recordEvaluation;
118
+ private refresh;
119
+ private applyEvaluate;
120
+ flushMetrics(): Promise<void>;
121
+ listFlagEvaluations(): FlagEvaluationResult[];
122
+ }
123
+
124
+ export { type Condition, type ConditionOperator, type Flag, type FlagEvaluationResult, type FlagValue, type FlagVariation, type FlagsStatus, FlipFlagClient, type FlipFlagClientOptions, type FlipFlagEvent, type FlipFlagListener, type SdkContext, type SdkEvaluateRequest, type SdkEvaluateResponse, type SdkFlagEvaluation, type SdkMetadataResponse, type SdkMetricEvent, type SdkMetricsRequest, type SdkReason, type TargetingRule, type TargetingRules };
@@ -0,0 +1,124 @@
1
+ type FlagValue = boolean | string | number | Record<string, unknown>;
2
+ type SdkReason = "rule_match" | "fallthrough" | "default" | "disabled";
3
+ interface SdkContext {
4
+ userId?: string;
5
+ attributes?: Record<string, unknown>;
6
+ }
7
+ interface SdkEvaluateRequest {
8
+ env?: string;
9
+ context: SdkContext;
10
+ flagKeys?: string[];
11
+ }
12
+ interface SdkFlagEvaluation {
13
+ value: FlagValue;
14
+ variationKey: string;
15
+ reason: SdkReason;
16
+ }
17
+ interface SdkEvaluateResponse {
18
+ flags: Record<string, SdkFlagEvaluation>;
19
+ evalId?: string;
20
+ updatedAt?: string;
21
+ }
22
+ interface SdkMetadataResponse {
23
+ items: Flag[];
24
+ updatedAt: string;
25
+ }
26
+ interface SdkMetricEvent {
27
+ flagKey: string;
28
+ variationKey: string;
29
+ reason: SdkReason | string;
30
+ ts: string;
31
+ context?: SdkContext;
32
+ }
33
+ interface SdkMetricsRequest {
34
+ env?: string;
35
+ events: SdkMetricEvent[];
36
+ }
37
+ interface Flag {
38
+ id: string;
39
+ projectId: string;
40
+ key: string;
41
+ name: string;
42
+ description?: string;
43
+ type: "boolean" | "string" | "number" | "json";
44
+ enabled: boolean;
45
+ defaultValue: FlagValue;
46
+ variations?: FlagVariation[];
47
+ targeting?: TargetingRules;
48
+ createdAt: string;
49
+ updatedAt: string;
50
+ }
51
+ interface FlagVariation {
52
+ key: string;
53
+ value: FlagValue;
54
+ description?: string;
55
+ weight?: number;
56
+ }
57
+ interface TargetingRules {
58
+ rules?: TargetingRule[];
59
+ fallthrough?: FlagValue;
60
+ }
61
+ interface TargetingRule {
62
+ conditions: Condition[];
63
+ variationKey: string;
64
+ rollout?: number;
65
+ }
66
+ type ConditionOperator = "eq" | "ne" | "in" | "not_in" | "gt" | "gte" | "lt" | "lte" | "contains";
67
+ interface Condition {
68
+ attribute: string;
69
+ operator: ConditionOperator;
70
+ value: string | number | boolean | string[];
71
+ }
72
+ interface FlipFlagClientOptions {
73
+ url: string;
74
+ sdkKey: string;
75
+ env?: string;
76
+ appName?: string;
77
+ refreshIntervalMs?: number;
78
+ metricsIntervalMs?: number;
79
+ disableRefresh?: boolean;
80
+ disableMetrics?: boolean;
81
+ context?: SdkContext;
82
+ fetch?: typeof fetch;
83
+ }
84
+ interface FlagsStatus {
85
+ ready: boolean;
86
+ error?: Error;
87
+ updatedAt?: string;
88
+ }
89
+ interface FlagEvaluationResult extends SdkFlagEvaluation {
90
+ flagKey: string;
91
+ }
92
+ type FlipFlagEvent = "ready" | "update" | "error" | "metrics";
93
+ type FlipFlagListener = (payload?: unknown) => void;
94
+
95
+ declare class FlipFlagClient {
96
+ private options;
97
+ private http;
98
+ private emitter;
99
+ private status;
100
+ private evaluations;
101
+ private refreshTimer?;
102
+ private metricsTimer?;
103
+ private metricsBuffer;
104
+ private context;
105
+ constructor(options: FlipFlagClientOptions);
106
+ start(): Promise<void>;
107
+ stop(): void;
108
+ on(event: FlipFlagEvent, listener: FlipFlagListener): void;
109
+ off(event: FlipFlagEvent, listener: FlipFlagListener): void;
110
+ getStatus(): FlagsStatus;
111
+ getContext(): SdkContext;
112
+ updateContext(partial: SdkContext): void;
113
+ getAllFlags(): Record<string, SdkFlagEvaluation>;
114
+ isEnabled(flagKey: string, defaultValue?: boolean): boolean;
115
+ getVariant(flagKey: string, defaultValue?: FlagValue): SdkFlagEvaluation;
116
+ private evaluate;
117
+ private recordEvaluation;
118
+ private refresh;
119
+ private applyEvaluate;
120
+ flushMetrics(): Promise<void>;
121
+ listFlagEvaluations(): FlagEvaluationResult[];
122
+ }
123
+
124
+ export { type Condition, type ConditionOperator, type Flag, type FlagEvaluationResult, type FlagValue, type FlagVariation, type FlagsStatus, FlipFlagClient, type FlipFlagClientOptions, type FlipFlagEvent, type FlipFlagListener, type SdkContext, type SdkEvaluateRequest, type SdkEvaluateResponse, type SdkFlagEvaluation, type SdkMetadataResponse, type SdkMetricEvent, type SdkMetricsRequest, type SdkReason, type TargetingRule, type TargetingRules };
package/dist/index.js ADDED
@@ -0,0 +1,289 @@
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 index_exports = {};
22
+ __export(index_exports, {
23
+ FlipFlagClient: () => FlipFlagClient
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/emitter.ts
28
+ var Emitter = class {
29
+ constructor() {
30
+ this.listeners = /* @__PURE__ */ new Map();
31
+ }
32
+ on(event, listener) {
33
+ const current = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
34
+ current.add(listener);
35
+ this.listeners.set(event, current);
36
+ }
37
+ off(event, listener) {
38
+ const current = this.listeners.get(event);
39
+ if (!current) {
40
+ return;
41
+ }
42
+ current.delete(listener);
43
+ }
44
+ emit(event, payload) {
45
+ const current = this.listeners.get(event);
46
+ if (!current) {
47
+ return;
48
+ }
49
+ for (const listener of current) {
50
+ listener(payload);
51
+ }
52
+ }
53
+ };
54
+
55
+ // src/http.ts
56
+ var HttpClient = class {
57
+ constructor(options) {
58
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
59
+ this.sdkKey = options.sdkKey;
60
+ this.fetchFn = options.fetchFn;
61
+ }
62
+ async evaluate(request) {
63
+ return this.post("/v1/sdk/flags/evaluate", request);
64
+ }
65
+ async metrics(payload) {
66
+ await this.post("/v1/sdk/metrics", payload);
67
+ }
68
+ async get(path) {
69
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
70
+ method: "GET",
71
+ headers: this.headers()
72
+ });
73
+ return this.parseJson(response);
74
+ }
75
+ async post(path, payload) {
76
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
77
+ method: "POST",
78
+ headers: {
79
+ ...this.headers(),
80
+ "content-type": "application/json"
81
+ },
82
+ body: JSON.stringify(payload)
83
+ });
84
+ if (response.status === 202 && !response.headers.get("content-type")) {
85
+ return void 0;
86
+ }
87
+ return this.parseJson(response);
88
+ }
89
+ headers() {
90
+ return {
91
+ "x-sdk-key": this.sdkKey
92
+ };
93
+ }
94
+ async parseJson(response) {
95
+ if (!response.ok) {
96
+ const message = await response.text();
97
+ throw new Error(`flipFeatureFlag SDK request failed: ${response.status} ${message}`);
98
+ }
99
+ return await response.json();
100
+ }
101
+ };
102
+
103
+ // src/metrics.ts
104
+ var MetricsBuffer = class {
105
+ constructor() {
106
+ this.events = [];
107
+ }
108
+ add(event) {
109
+ this.events.push(event);
110
+ }
111
+ drain() {
112
+ const drained = this.events;
113
+ this.events = [];
114
+ return drained;
115
+ }
116
+ };
117
+ function buildMetricEvent(flagKey, variationKey, reason, context) {
118
+ return {
119
+ flagKey,
120
+ variationKey,
121
+ reason,
122
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
123
+ context
124
+ };
125
+ }
126
+ function buildMetricsRequest(env, events) {
127
+ return {
128
+ env,
129
+ events
130
+ };
131
+ }
132
+
133
+ // src/client.ts
134
+ var DEFAULT_REFRESH_MS = 15e3;
135
+ var DEFAULT_METRICS_MS = 6e4;
136
+ var FlipFlagClient = class {
137
+ constructor(options) {
138
+ this.emitter = new Emitter();
139
+ this.status = { ready: false };
140
+ this.evaluations = /* @__PURE__ */ new Map();
141
+ this.metricsBuffer = new MetricsBuffer();
142
+ if (!options.url) {
143
+ throw new Error("flipFeatureFlag SDK requires url");
144
+ }
145
+ if (!options.sdkKey) {
146
+ throw new Error("flipFeatureFlag SDK requires sdkKey");
147
+ }
148
+ this.options = {
149
+ refreshIntervalMs: DEFAULT_REFRESH_MS,
150
+ metricsIntervalMs: DEFAULT_METRICS_MS,
151
+ ...options
152
+ };
153
+ this.context = options.context ?? {};
154
+ const fetchFn = options.fetch ?? fetch;
155
+ this.http = new HttpClient({
156
+ baseUrl: this.options.url,
157
+ sdkKey: this.options.sdkKey,
158
+ fetchFn
159
+ });
160
+ }
161
+ async start() {
162
+ await this.refresh();
163
+ if (!this.options.disableRefresh) {
164
+ this.refreshTimer = setInterval(() => {
165
+ void this.refresh();
166
+ }, this.options.refreshIntervalMs);
167
+ }
168
+ if (!this.options.disableMetrics) {
169
+ this.metricsTimer = setInterval(() => {
170
+ void this.flushMetrics();
171
+ }, this.options.metricsIntervalMs);
172
+ }
173
+ }
174
+ stop() {
175
+ if (this.refreshTimer) {
176
+ clearInterval(this.refreshTimer);
177
+ this.refreshTimer = void 0;
178
+ }
179
+ if (this.metricsTimer) {
180
+ clearInterval(this.metricsTimer);
181
+ this.metricsTimer = void 0;
182
+ }
183
+ }
184
+ on(event, listener) {
185
+ this.emitter.on(event, listener);
186
+ }
187
+ off(event, listener) {
188
+ this.emitter.off(event, listener);
189
+ }
190
+ getStatus() {
191
+ return { ...this.status };
192
+ }
193
+ getContext() {
194
+ return { ...this.context };
195
+ }
196
+ updateContext(partial) {
197
+ this.context = {
198
+ ...this.context,
199
+ ...partial,
200
+ attributes: {
201
+ ...this.context.attributes ?? {},
202
+ ...partial.attributes ?? {}
203
+ }
204
+ };
205
+ void this.refresh();
206
+ }
207
+ getAllFlags() {
208
+ const result = {};
209
+ for (const [key, evaluation] of this.evaluations.entries()) {
210
+ result[key] = { ...evaluation };
211
+ }
212
+ return result;
213
+ }
214
+ isEnabled(flagKey, defaultValue = false) {
215
+ const evaluation = this.evaluate(flagKey, defaultValue);
216
+ return Boolean(evaluation.value);
217
+ }
218
+ getVariant(flagKey, defaultValue = false) {
219
+ return this.evaluate(flagKey, defaultValue);
220
+ }
221
+ evaluate(flagKey, defaultValue) {
222
+ const cached = this.evaluations.get(flagKey);
223
+ if (cached) {
224
+ return this.recordEvaluation(flagKey, cached);
225
+ }
226
+ void this.refresh([flagKey]);
227
+ return this.recordEvaluation(flagKey, {
228
+ value: defaultValue,
229
+ variationKey: "default",
230
+ reason: "default"
231
+ });
232
+ }
233
+ recordEvaluation(flagKey, evaluation) {
234
+ this.evaluations.set(flagKey, evaluation);
235
+ this.metricsBuffer.add(
236
+ buildMetricEvent(flagKey, evaluation.variationKey, evaluation.reason, this.context)
237
+ );
238
+ return evaluation;
239
+ }
240
+ async refresh(flagKeys) {
241
+ try {
242
+ const response = await this.http.evaluate({
243
+ env: this.options.env,
244
+ context: this.context,
245
+ flagKeys
246
+ });
247
+ this.applyEvaluate(response);
248
+ if (!this.status.ready) {
249
+ this.status.ready = true;
250
+ this.emitter.emit("ready");
251
+ }
252
+ this.emitter.emit("update");
253
+ } catch (error) {
254
+ this.status.error = error;
255
+ this.emitter.emit("error", error);
256
+ }
257
+ }
258
+ applyEvaluate(response) {
259
+ this.evaluations.clear();
260
+ for (const [key, evaluation] of Object.entries(response.flags)) {
261
+ this.evaluations.set(key, evaluation);
262
+ }
263
+ this.status.updatedAt = response.updatedAt;
264
+ }
265
+ async flushMetrics() {
266
+ const events = this.metricsBuffer.drain();
267
+ if (events.length === 0) {
268
+ return;
269
+ }
270
+ try {
271
+ await this.http.metrics(buildMetricsRequest(this.options.env, events));
272
+ this.emitter.emit("metrics", events);
273
+ } catch (error) {
274
+ this.status.error = error;
275
+ this.emitter.emit("error", error);
276
+ }
277
+ }
278
+ listFlagEvaluations() {
279
+ const result = [];
280
+ for (const [flagKey, evaluation] of this.evaluations.entries()) {
281
+ result.push({ flagKey, ...evaluation });
282
+ }
283
+ return result;
284
+ }
285
+ };
286
+ // Annotate the CommonJS export names for ESM import in node:
287
+ 0 && (module.exports = {
288
+ FlipFlagClient
289
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,262 @@
1
+ // src/emitter.ts
2
+ var Emitter = class {
3
+ constructor() {
4
+ this.listeners = /* @__PURE__ */ new Map();
5
+ }
6
+ on(event, listener) {
7
+ const current = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
8
+ current.add(listener);
9
+ this.listeners.set(event, current);
10
+ }
11
+ off(event, listener) {
12
+ const current = this.listeners.get(event);
13
+ if (!current) {
14
+ return;
15
+ }
16
+ current.delete(listener);
17
+ }
18
+ emit(event, payload) {
19
+ const current = this.listeners.get(event);
20
+ if (!current) {
21
+ return;
22
+ }
23
+ for (const listener of current) {
24
+ listener(payload);
25
+ }
26
+ }
27
+ };
28
+
29
+ // src/http.ts
30
+ var HttpClient = class {
31
+ constructor(options) {
32
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
33
+ this.sdkKey = options.sdkKey;
34
+ this.fetchFn = options.fetchFn;
35
+ }
36
+ async evaluate(request) {
37
+ return this.post("/v1/sdk/flags/evaluate", request);
38
+ }
39
+ async metrics(payload) {
40
+ await this.post("/v1/sdk/metrics", payload);
41
+ }
42
+ async get(path) {
43
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
44
+ method: "GET",
45
+ headers: this.headers()
46
+ });
47
+ return this.parseJson(response);
48
+ }
49
+ async post(path, payload) {
50
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
51
+ method: "POST",
52
+ headers: {
53
+ ...this.headers(),
54
+ "content-type": "application/json"
55
+ },
56
+ body: JSON.stringify(payload)
57
+ });
58
+ if (response.status === 202 && !response.headers.get("content-type")) {
59
+ return void 0;
60
+ }
61
+ return this.parseJson(response);
62
+ }
63
+ headers() {
64
+ return {
65
+ "x-sdk-key": this.sdkKey
66
+ };
67
+ }
68
+ async parseJson(response) {
69
+ if (!response.ok) {
70
+ const message = await response.text();
71
+ throw new Error(`flipFeatureFlag SDK request failed: ${response.status} ${message}`);
72
+ }
73
+ return await response.json();
74
+ }
75
+ };
76
+
77
+ // src/metrics.ts
78
+ var MetricsBuffer = class {
79
+ constructor() {
80
+ this.events = [];
81
+ }
82
+ add(event) {
83
+ this.events.push(event);
84
+ }
85
+ drain() {
86
+ const drained = this.events;
87
+ this.events = [];
88
+ return drained;
89
+ }
90
+ };
91
+ function buildMetricEvent(flagKey, variationKey, reason, context) {
92
+ return {
93
+ flagKey,
94
+ variationKey,
95
+ reason,
96
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
97
+ context
98
+ };
99
+ }
100
+ function buildMetricsRequest(env, events) {
101
+ return {
102
+ env,
103
+ events
104
+ };
105
+ }
106
+
107
+ // src/client.ts
108
+ var DEFAULT_REFRESH_MS = 15e3;
109
+ var DEFAULT_METRICS_MS = 6e4;
110
+ var FlipFlagClient = class {
111
+ constructor(options) {
112
+ this.emitter = new Emitter();
113
+ this.status = { ready: false };
114
+ this.evaluations = /* @__PURE__ */ new Map();
115
+ this.metricsBuffer = new MetricsBuffer();
116
+ if (!options.url) {
117
+ throw new Error("flipFeatureFlag SDK requires url");
118
+ }
119
+ if (!options.sdkKey) {
120
+ throw new Error("flipFeatureFlag SDK requires sdkKey");
121
+ }
122
+ this.options = {
123
+ refreshIntervalMs: DEFAULT_REFRESH_MS,
124
+ metricsIntervalMs: DEFAULT_METRICS_MS,
125
+ ...options
126
+ };
127
+ this.context = options.context ?? {};
128
+ const fetchFn = options.fetch ?? fetch;
129
+ this.http = new HttpClient({
130
+ baseUrl: this.options.url,
131
+ sdkKey: this.options.sdkKey,
132
+ fetchFn
133
+ });
134
+ }
135
+ async start() {
136
+ await this.refresh();
137
+ if (!this.options.disableRefresh) {
138
+ this.refreshTimer = setInterval(() => {
139
+ void this.refresh();
140
+ }, this.options.refreshIntervalMs);
141
+ }
142
+ if (!this.options.disableMetrics) {
143
+ this.metricsTimer = setInterval(() => {
144
+ void this.flushMetrics();
145
+ }, this.options.metricsIntervalMs);
146
+ }
147
+ }
148
+ stop() {
149
+ if (this.refreshTimer) {
150
+ clearInterval(this.refreshTimer);
151
+ this.refreshTimer = void 0;
152
+ }
153
+ if (this.metricsTimer) {
154
+ clearInterval(this.metricsTimer);
155
+ this.metricsTimer = void 0;
156
+ }
157
+ }
158
+ on(event, listener) {
159
+ this.emitter.on(event, listener);
160
+ }
161
+ off(event, listener) {
162
+ this.emitter.off(event, listener);
163
+ }
164
+ getStatus() {
165
+ return { ...this.status };
166
+ }
167
+ getContext() {
168
+ return { ...this.context };
169
+ }
170
+ updateContext(partial) {
171
+ this.context = {
172
+ ...this.context,
173
+ ...partial,
174
+ attributes: {
175
+ ...this.context.attributes ?? {},
176
+ ...partial.attributes ?? {}
177
+ }
178
+ };
179
+ void this.refresh();
180
+ }
181
+ getAllFlags() {
182
+ const result = {};
183
+ for (const [key, evaluation] of this.evaluations.entries()) {
184
+ result[key] = { ...evaluation };
185
+ }
186
+ return result;
187
+ }
188
+ isEnabled(flagKey, defaultValue = false) {
189
+ const evaluation = this.evaluate(flagKey, defaultValue);
190
+ return Boolean(evaluation.value);
191
+ }
192
+ getVariant(flagKey, defaultValue = false) {
193
+ return this.evaluate(flagKey, defaultValue);
194
+ }
195
+ evaluate(flagKey, defaultValue) {
196
+ const cached = this.evaluations.get(flagKey);
197
+ if (cached) {
198
+ return this.recordEvaluation(flagKey, cached);
199
+ }
200
+ void this.refresh([flagKey]);
201
+ return this.recordEvaluation(flagKey, {
202
+ value: defaultValue,
203
+ variationKey: "default",
204
+ reason: "default"
205
+ });
206
+ }
207
+ recordEvaluation(flagKey, evaluation) {
208
+ this.evaluations.set(flagKey, evaluation);
209
+ this.metricsBuffer.add(
210
+ buildMetricEvent(flagKey, evaluation.variationKey, evaluation.reason, this.context)
211
+ );
212
+ return evaluation;
213
+ }
214
+ async refresh(flagKeys) {
215
+ try {
216
+ const response = await this.http.evaluate({
217
+ env: this.options.env,
218
+ context: this.context,
219
+ flagKeys
220
+ });
221
+ this.applyEvaluate(response);
222
+ if (!this.status.ready) {
223
+ this.status.ready = true;
224
+ this.emitter.emit("ready");
225
+ }
226
+ this.emitter.emit("update");
227
+ } catch (error) {
228
+ this.status.error = error;
229
+ this.emitter.emit("error", error);
230
+ }
231
+ }
232
+ applyEvaluate(response) {
233
+ this.evaluations.clear();
234
+ for (const [key, evaluation] of Object.entries(response.flags)) {
235
+ this.evaluations.set(key, evaluation);
236
+ }
237
+ this.status.updatedAt = response.updatedAt;
238
+ }
239
+ async flushMetrics() {
240
+ const events = this.metricsBuffer.drain();
241
+ if (events.length === 0) {
242
+ return;
243
+ }
244
+ try {
245
+ await this.http.metrics(buildMetricsRequest(this.options.env, events));
246
+ this.emitter.emit("metrics", events);
247
+ } catch (error) {
248
+ this.status.error = error;
249
+ this.emitter.emit("error", error);
250
+ }
251
+ }
252
+ listFlagEvaluations() {
253
+ const result = [];
254
+ for (const [flagKey, evaluation] of this.evaluations.entries()) {
255
+ result.push({ flagKey, ...evaluation });
256
+ }
257
+ return result;
258
+ }
259
+ };
260
+ export {
261
+ FlipFlagClient
262
+ };
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@flipfeatureflag/core",
3
+ "version": "0.1.1",
4
+ "description": "flipFeatureFlag core SDK",
5
+ "main": "dist/index.cjs",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.cjs"
13
+ }
14
+ },
15
+ "sideEffects": false,
16
+ "scripts": {
17
+ "build": "tsup src/index.ts --format esm,cjs --dts",
18
+ "test": "vitest run"
19
+ }
20
+ }
package/src/client.ts ADDED
@@ -0,0 +1,198 @@
1
+ import { Emitter } from "./emitter";
2
+ import { HttpClient } from "./http";
3
+ import { buildMetricEvent, buildMetricsRequest, MetricsBuffer } from "./metrics";
4
+ import {
5
+ FlagEvaluationResult,
6
+ FlagValue,
7
+ FlagsStatus,
8
+ FlipFlagClientOptions,
9
+ FlipFlagEvent,
10
+ FlipFlagListener,
11
+ SdkEvaluateResponse,
12
+ SdkFlagEvaluation,
13
+ SdkContext,
14
+ } from "./types";
15
+
16
+ const DEFAULT_REFRESH_MS = 15000;
17
+ const DEFAULT_METRICS_MS = 60000;
18
+
19
+ export class FlipFlagClient {
20
+ private options: Required<Pick<FlipFlagClientOptions, "url" | "sdkKey">> & FlipFlagClientOptions;
21
+ private http: HttpClient;
22
+ private emitter = new Emitter();
23
+ private status: FlagsStatus = { ready: false };
24
+ private evaluations = new Map<string, SdkFlagEvaluation>();
25
+ private refreshTimer?: ReturnType<typeof setInterval>;
26
+ private metricsTimer?: ReturnType<typeof setInterval>;
27
+ private metricsBuffer = new MetricsBuffer();
28
+ private context: SdkContext;
29
+
30
+ constructor(options: FlipFlagClientOptions) {
31
+ if (!options.url) {
32
+ throw new Error("flipFeatureFlag SDK requires url");
33
+ }
34
+ if (!options.sdkKey) {
35
+ throw new Error("flipFeatureFlag SDK requires sdkKey");
36
+ }
37
+
38
+ this.options = {
39
+ refreshIntervalMs: DEFAULT_REFRESH_MS,
40
+ metricsIntervalMs: DEFAULT_METRICS_MS,
41
+ ...options,
42
+ } as Required<Pick<FlipFlagClientOptions, "url" | "sdkKey">> & FlipFlagClientOptions;
43
+
44
+ this.context = options.context ?? {};
45
+
46
+ const fetchFn = options.fetch ?? fetch;
47
+ this.http = new HttpClient({
48
+ baseUrl: this.options.url,
49
+ sdkKey: this.options.sdkKey,
50
+ fetchFn,
51
+ });
52
+ }
53
+
54
+ async start(): Promise<void> {
55
+ await this.refresh();
56
+
57
+ if (!this.options.disableRefresh) {
58
+ this.refreshTimer = setInterval(() => {
59
+ void this.refresh();
60
+ }, this.options.refreshIntervalMs);
61
+ }
62
+
63
+ if (!this.options.disableMetrics) {
64
+ this.metricsTimer = setInterval(() => {
65
+ void this.flushMetrics();
66
+ }, this.options.metricsIntervalMs);
67
+ }
68
+ }
69
+
70
+ stop() {
71
+ if (this.refreshTimer) {
72
+ clearInterval(this.refreshTimer);
73
+ this.refreshTimer = undefined;
74
+ }
75
+ if (this.metricsTimer) {
76
+ clearInterval(this.metricsTimer);
77
+ this.metricsTimer = undefined;
78
+ }
79
+ }
80
+
81
+ on(event: FlipFlagEvent, listener: FlipFlagListener) {
82
+ this.emitter.on(event, listener);
83
+ }
84
+
85
+ off(event: FlipFlagEvent, listener: FlipFlagListener) {
86
+ this.emitter.off(event, listener);
87
+ }
88
+
89
+ getStatus(): FlagsStatus {
90
+ return { ...this.status };
91
+ }
92
+
93
+ getContext(): SdkContext {
94
+ return { ...this.context };
95
+ }
96
+
97
+ updateContext(partial: SdkContext) {
98
+ this.context = {
99
+ ...this.context,
100
+ ...partial,
101
+ attributes: {
102
+ ...(this.context.attributes ?? {}),
103
+ ...(partial.attributes ?? {}),
104
+ },
105
+ };
106
+ void this.refresh();
107
+ }
108
+
109
+ getAllFlags(): Record<string, SdkFlagEvaluation> {
110
+ const result: Record<string, SdkFlagEvaluation> = {};
111
+ for (const [key, evaluation] of this.evaluations.entries()) {
112
+ result[key] = { ...evaluation };
113
+ }
114
+ return result;
115
+ }
116
+
117
+ isEnabled(flagKey: string, defaultValue: boolean = false): boolean {
118
+ const evaluation = this.evaluate(flagKey, defaultValue);
119
+ return Boolean(evaluation.value);
120
+ }
121
+
122
+ getVariant(flagKey: string, defaultValue: FlagValue = false): SdkFlagEvaluation {
123
+ return this.evaluate(flagKey, defaultValue);
124
+ }
125
+
126
+ private evaluate(flagKey: string, defaultValue: FlagValue): SdkFlagEvaluation {
127
+ const cached = this.evaluations.get(flagKey);
128
+ if (cached) {
129
+ return this.recordEvaluation(flagKey, cached);
130
+ }
131
+
132
+ void this.refresh([flagKey]);
133
+ return this.recordEvaluation(flagKey, {
134
+ value: defaultValue,
135
+ variationKey: "default",
136
+ reason: "default",
137
+ });
138
+ }
139
+
140
+ private recordEvaluation(flagKey: string, evaluation: SdkFlagEvaluation): SdkFlagEvaluation {
141
+ this.evaluations.set(flagKey, evaluation);
142
+ this.metricsBuffer.add(
143
+ buildMetricEvent(flagKey, evaluation.variationKey, evaluation.reason, this.context),
144
+ );
145
+ return evaluation;
146
+ }
147
+
148
+ private async refresh(flagKeys?: string[]) {
149
+ try {
150
+ const response = await this.http.evaluate({
151
+ env: this.options.env,
152
+ context: this.context,
153
+ flagKeys,
154
+ });
155
+ this.applyEvaluate(response);
156
+
157
+ if (!this.status.ready) {
158
+ this.status.ready = true;
159
+ this.emitter.emit("ready");
160
+ }
161
+
162
+ this.emitter.emit("update");
163
+ } catch (error) {
164
+ this.status.error = error as Error;
165
+ this.emitter.emit("error", error);
166
+ }
167
+ }
168
+
169
+ private applyEvaluate(response: SdkEvaluateResponse) {
170
+ this.evaluations.clear();
171
+ for (const [key, evaluation] of Object.entries(response.flags)) {
172
+ this.evaluations.set(key, evaluation);
173
+ }
174
+ this.status.updatedAt = response.updatedAt;
175
+ }
176
+
177
+ async flushMetrics(): Promise<void> {
178
+ const events = this.metricsBuffer.drain();
179
+ if (events.length === 0) {
180
+ return;
181
+ }
182
+ try {
183
+ await this.http.metrics(buildMetricsRequest(this.options.env, events));
184
+ this.emitter.emit("metrics", events);
185
+ } catch (error) {
186
+ this.status.error = error as Error;
187
+ this.emitter.emit("error", error);
188
+ }
189
+ }
190
+
191
+ listFlagEvaluations(): FlagEvaluationResult[] {
192
+ const result: FlagEvaluationResult[] = [];
193
+ for (const [flagKey, evaluation] of this.evaluations.entries()) {
194
+ result.push({ flagKey, ...evaluation });
195
+ }
196
+ return result;
197
+ }
198
+ }
package/src/emitter.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { FlipFlagEvent, FlipFlagListener } from "./types";
2
+
3
+ export class Emitter {
4
+ private listeners = new Map<FlipFlagEvent, Set<FlipFlagListener>>();
5
+
6
+ on(event: FlipFlagEvent, listener: FlipFlagListener) {
7
+ const current = this.listeners.get(event) ?? new Set();
8
+ current.add(listener);
9
+ this.listeners.set(event, current);
10
+ }
11
+
12
+ off(event: FlipFlagEvent, listener: FlipFlagListener) {
13
+ const current = this.listeners.get(event);
14
+ if (!current) {
15
+ return;
16
+ }
17
+ current.delete(listener);
18
+ }
19
+
20
+ emit(event: FlipFlagEvent, payload?: unknown) {
21
+ const current = this.listeners.get(event);
22
+ if (!current) {
23
+ return;
24
+ }
25
+ for (const listener of current) {
26
+ listener(payload);
27
+ }
28
+ }
29
+ }
package/src/http.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { SdkEvaluateRequest, SdkEvaluateResponse, SdkMetricsRequest } from "./types";
2
+
3
+ export interface HttpClientOptions {
4
+ baseUrl: string;
5
+ sdkKey: string;
6
+ fetchFn: typeof fetch;
7
+ }
8
+
9
+ export class HttpClient {
10
+ private baseUrl: string;
11
+ private sdkKey: string;
12
+ private fetchFn: typeof fetch;
13
+
14
+ constructor(options: HttpClientOptions) {
15
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
16
+ this.sdkKey = options.sdkKey;
17
+ this.fetchFn = options.fetchFn;
18
+ }
19
+
20
+ async evaluate(request: SdkEvaluateRequest): Promise<SdkEvaluateResponse> {
21
+ return this.post("/v1/sdk/flags/evaluate", request);
22
+ }
23
+
24
+ async metrics(payload: SdkMetricsRequest): Promise<void> {
25
+ await this.post("/v1/sdk/metrics", payload);
26
+ }
27
+
28
+ private async get<T>(path: string): Promise<T> {
29
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
30
+ method: "GET",
31
+ headers: this.headers(),
32
+ });
33
+ return this.parseJson(response);
34
+ }
35
+
36
+ private async post<T>(path: string, payload: unknown): Promise<T> {
37
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
38
+ method: "POST",
39
+ headers: {
40
+ ...this.headers(),
41
+ "content-type": "application/json",
42
+ },
43
+ body: JSON.stringify(payload),
44
+ });
45
+ if (response.status === 202 && !response.headers.get("content-type")) {
46
+ return undefined as T;
47
+ }
48
+ return this.parseJson(response);
49
+ }
50
+
51
+ private headers() {
52
+ return {
53
+ "x-sdk-key": this.sdkKey,
54
+ };
55
+ }
56
+
57
+ private async parseJson<T>(response: Response): Promise<T> {
58
+ if (!response.ok) {
59
+ const message = await response.text();
60
+ throw new Error(`flipFeatureFlag SDK request failed: ${response.status} ${message}`);
61
+ }
62
+ return (await response.json()) as T;
63
+ }
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./types";
2
+ export { FlipFlagClient } from "./client";
package/src/metrics.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { SdkContext, SdkMetricEvent, SdkMetricsRequest } from "./types";
2
+
3
+ export class MetricsBuffer {
4
+ private events: SdkMetricEvent[] = [];
5
+
6
+ add(event: SdkMetricEvent) {
7
+ this.events.push(event);
8
+ }
9
+
10
+ drain(): SdkMetricEvent[] {
11
+ const drained = this.events;
12
+ this.events = [];
13
+ return drained;
14
+ }
15
+ }
16
+
17
+ export function buildMetricEvent(
18
+ flagKey: string,
19
+ variationKey: string,
20
+ reason: string,
21
+ context?: SdkContext,
22
+ ): SdkMetricEvent {
23
+ return {
24
+ flagKey,
25
+ variationKey,
26
+ reason,
27
+ ts: new Date().toISOString(),
28
+ context,
29
+ };
30
+ }
31
+
32
+ export function buildMetricsRequest(env: string | undefined, events: SdkMetricEvent[]): SdkMetricsRequest {
33
+ return {
34
+ env,
35
+ events,
36
+ };
37
+ }
package/src/types.ts ADDED
@@ -0,0 +1,121 @@
1
+ export type FlagValue = boolean | string | number | Record<string, unknown>;
2
+
3
+ export type SdkReason = "rule_match" | "fallthrough" | "default" | "disabled";
4
+
5
+ export interface SdkContext {
6
+ userId?: string;
7
+ attributes?: Record<string, unknown>;
8
+ }
9
+
10
+ export interface SdkEvaluateRequest {
11
+ env?: string;
12
+ context: SdkContext;
13
+ flagKeys?: string[];
14
+ }
15
+
16
+ export interface SdkFlagEvaluation {
17
+ value: FlagValue;
18
+ variationKey: string;
19
+ reason: SdkReason;
20
+ }
21
+
22
+ export interface SdkEvaluateResponse {
23
+ flags: Record<string, SdkFlagEvaluation>;
24
+ evalId?: string;
25
+ updatedAt?: string;
26
+ }
27
+
28
+ export interface SdkMetadataResponse {
29
+ items: Flag[];
30
+ updatedAt: string;
31
+ }
32
+
33
+ export interface SdkMetricEvent {
34
+ flagKey: string;
35
+ variationKey: string;
36
+ reason: SdkReason | string;
37
+ ts: string;
38
+ context?: SdkContext;
39
+ }
40
+
41
+ export interface SdkMetricsRequest {
42
+ env?: string;
43
+ events: SdkMetricEvent[];
44
+ }
45
+
46
+ export interface Flag {
47
+ id: string;
48
+ projectId: string;
49
+ key: string;
50
+ name: string;
51
+ description?: string;
52
+ type: "boolean" | "string" | "number" | "json";
53
+ enabled: boolean;
54
+ defaultValue: FlagValue;
55
+ variations?: FlagVariation[];
56
+ targeting?: TargetingRules;
57
+ createdAt: string;
58
+ updatedAt: string;
59
+ }
60
+
61
+ export interface FlagVariation {
62
+ key: string;
63
+ value: FlagValue;
64
+ description?: string;
65
+ weight?: number;
66
+ }
67
+
68
+ export interface TargetingRules {
69
+ rules?: TargetingRule[];
70
+ fallthrough?: FlagValue;
71
+ }
72
+
73
+ export interface TargetingRule {
74
+ conditions: Condition[];
75
+ variationKey: string;
76
+ rollout?: number;
77
+ }
78
+
79
+ export type ConditionOperator =
80
+ | "eq"
81
+ | "ne"
82
+ | "in"
83
+ | "not_in"
84
+ | "gt"
85
+ | "gte"
86
+ | "lt"
87
+ | "lte"
88
+ | "contains";
89
+
90
+ export interface Condition {
91
+ attribute: string;
92
+ operator: ConditionOperator;
93
+ value: string | number | boolean | string[];
94
+ }
95
+
96
+ export interface FlipFlagClientOptions {
97
+ url: string;
98
+ sdkKey: string;
99
+ env?: string;
100
+ appName?: string;
101
+ refreshIntervalMs?: number;
102
+ metricsIntervalMs?: number;
103
+ disableRefresh?: boolean;
104
+ disableMetrics?: boolean;
105
+ context?: SdkContext;
106
+ fetch?: typeof fetch;
107
+ }
108
+
109
+ export interface FlagsStatus {
110
+ ready: boolean;
111
+ error?: Error;
112
+ updatedAt?: string;
113
+ }
114
+
115
+ export interface FlagEvaluationResult extends SdkFlagEvaluation {
116
+ flagKey: string;
117
+ }
118
+
119
+ export type FlipFlagEvent = "ready" | "update" | "error" | "metrics";
120
+
121
+ export type FlipFlagListener = (payload?: unknown) => void;
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { FlipFlagClient } from "../src/client";
3
+
4
+ function createResponse(payload: unknown) {
5
+ return {
6
+ ok: true,
7
+ status: 200,
8
+ headers: new Headers({ "content-type": "application/json" }),
9
+ json: async () => payload,
10
+ text: async () => JSON.stringify(payload),
11
+ } as Response;
12
+ }
13
+
14
+ describe("FlipFlagClient (remote)", () => {
15
+ it("fetches evaluations on start", async () => {
16
+ const fetchMock = vi.fn(async () =>
17
+ createResponse({
18
+ flags: {
19
+ new_checkout: {
20
+ value: true,
21
+ variationKey: "on",
22
+ reason: "rule_match",
23
+ },
24
+ },
25
+ updatedAt: "2026-02-03T12:00:00Z",
26
+ }),
27
+ );
28
+
29
+ const client = new FlipFlagClient({
30
+ url: "https://api.example.com",
31
+ sdkKey: "test",
32
+ disableRefresh: true,
33
+ disableMetrics: true,
34
+ fetch: fetchMock as unknown as typeof fetch,
35
+ });
36
+
37
+ await client.start();
38
+ expect(client.isEnabled("new_checkout")).toBe(true);
39
+ expect(fetchMock).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ it("flushes metrics", async () => {
43
+ const fetchMock = vi
44
+ .fn()
45
+ .mockResolvedValueOnce(
46
+ createResponse({
47
+ flags: {
48
+ flag_a: { value: true, variationKey: "on", reason: "rule_match" },
49
+ },
50
+ }),
51
+ )
52
+ .mockResolvedValueOnce({
53
+ ok: true,
54
+ status: 202,
55
+ headers: new Headers(),
56
+ json: async () => ({}),
57
+ text: async () => "",
58
+ } as Response);
59
+
60
+ const client = new FlipFlagClient({
61
+ url: "https://api.example.com",
62
+ sdkKey: "test",
63
+ disableRefresh: true,
64
+ disableMetrics: true,
65
+ fetch: fetchMock as unknown as typeof fetch,
66
+ });
67
+
68
+ await client.start();
69
+ client.isEnabled("flag_a");
70
+ await client.flushMetrics();
71
+
72
+ expect(fetchMock).toHaveBeenCalledTimes(2);
73
+ });
74
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }