@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.
- package/dist/index.d.mts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +289 -0
- package/dist/index.mjs +262 -0
- package/package.json +20 -0
- package/src/client.ts +198 -0
- package/src/emitter.ts +29 -0
- package/src/http.ts +64 -0
- package/src/index.ts +2 -0
- package/src/metrics.ts +37 -0
- package/src/types.ts +121 -0
- package/test/client.test.ts +74 -0
- package/tsconfig.json +4 -0
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
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