@flagify/node 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 +246 -0
- package/dist/index.d.mts +275 -0
- package/dist/index.d.ts +275 -0
- package/dist/index.js +360 -0
- package/dist/index.mjs +332 -0
- package/package.json +52 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a user context object used for feature flag targeting.
|
|
3
|
+
*
|
|
4
|
+
* You can define standard properties like `id`, `email`, `role`, and `group`,
|
|
5
|
+
* as well as any number of custom attributes for segmentation purposes.
|
|
6
|
+
*/
|
|
7
|
+
interface FlagifyUser {
|
|
8
|
+
/**
|
|
9
|
+
* Unique identifier for the user.
|
|
10
|
+
* This is typically required for targeting, experimentation, or auditing.
|
|
11
|
+
*/
|
|
12
|
+
id: string;
|
|
13
|
+
/**
|
|
14
|
+
* Optional email address of the user.
|
|
15
|
+
*/
|
|
16
|
+
email?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Optional role or access level of the user (e.g., "admin", "editor", "viewer").
|
|
19
|
+
*/
|
|
20
|
+
role?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Optional group or organization to which the user belongs.
|
|
23
|
+
*/
|
|
24
|
+
group?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Optional geolocation details used for region-based targeting.
|
|
27
|
+
*/
|
|
28
|
+
geolocation?: {
|
|
29
|
+
/**
|
|
30
|
+
* ISO country code (e.g., "US", "MX", "DE").
|
|
31
|
+
*/
|
|
32
|
+
country?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Optional region or state within the country.
|
|
35
|
+
*/
|
|
36
|
+
region?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Optional city or locality.
|
|
39
|
+
*/
|
|
40
|
+
city?: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Any other custom attributes for advanced targeting rules.
|
|
44
|
+
*/
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Configuration options required to initialize the Flagify client.
|
|
50
|
+
*/
|
|
51
|
+
interface FlagifyOptions {
|
|
52
|
+
/**
|
|
53
|
+
* The key identifying the project within your Flagify workspace.
|
|
54
|
+
*/
|
|
55
|
+
projectKey: string;
|
|
56
|
+
/**
|
|
57
|
+
* Public API key used to identify the client or application.
|
|
58
|
+
* This is safe to expose in client-side environments (e.g., browser, mobile).
|
|
59
|
+
*/
|
|
60
|
+
publicKey: string;
|
|
61
|
+
/**
|
|
62
|
+
* Optional private key for secure server-side communication.
|
|
63
|
+
* Never expose this in frontend environments.
|
|
64
|
+
*/
|
|
65
|
+
secretKey?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Additional optional configuration for advanced use cases.
|
|
68
|
+
*/
|
|
69
|
+
options?: {
|
|
70
|
+
/**
|
|
71
|
+
* Contextual user data for targeting rules and segmentation.
|
|
72
|
+
* You may provide built-in fields like `id`, `email`, `role`, or custom traits.
|
|
73
|
+
*/
|
|
74
|
+
user?: FlagifyUser;
|
|
75
|
+
/**
|
|
76
|
+
* Custom base URL for the Flagify API (e.g., for self-hosted instances or testing).
|
|
77
|
+
* Defaults to "https://api.flagify.app" if not provided.
|
|
78
|
+
*/
|
|
79
|
+
apiUrl?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Optional cache stale time in milliseconds for local flag resolution.
|
|
82
|
+
* Defaults to 5 minutes.
|
|
83
|
+
*/
|
|
84
|
+
staleTimeMs?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Enables real-time flag updates via Server-Sent Events.
|
|
87
|
+
*/
|
|
88
|
+
realtime?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Interval in milliseconds to periodically re-sync all flags.
|
|
91
|
+
* Useful as a fallback when realtime is unavailable.
|
|
92
|
+
* Example: 30000 (every 30 seconds).
|
|
93
|
+
*/
|
|
94
|
+
pollIntervalMs?: number;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface FlagifyHttpClient {
|
|
99
|
+
get<T = unknown>(path: string): Promise<T>;
|
|
100
|
+
post<T = unknown, B = unknown>(path: string, body: B): Promise<T>;
|
|
101
|
+
baseUrl: string;
|
|
102
|
+
headers: Record<string, string>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface RealtimeEvents {
|
|
106
|
+
onFlagChange: (event: FlagChangeEvent) => void;
|
|
107
|
+
onConnected: () => void;
|
|
108
|
+
onReconnected: () => void;
|
|
109
|
+
onError: (error: Error) => void;
|
|
110
|
+
}
|
|
111
|
+
interface FlagChangeEvent {
|
|
112
|
+
environmentId: string;
|
|
113
|
+
flagKey: string;
|
|
114
|
+
action: "updated" | "created" | "archived";
|
|
115
|
+
}
|
|
116
|
+
declare class RealtimeListener {
|
|
117
|
+
private readonly httpClient;
|
|
118
|
+
private readonly events;
|
|
119
|
+
private controller;
|
|
120
|
+
private reconnectAttempts;
|
|
121
|
+
private reconnectTimer;
|
|
122
|
+
private hasConnectedBefore;
|
|
123
|
+
constructor(httpClient: FlagifyHttpClient, events: RealtimeEvents);
|
|
124
|
+
connect(): void;
|
|
125
|
+
disconnect(): void;
|
|
126
|
+
private stream;
|
|
127
|
+
private parseSSEFrame;
|
|
128
|
+
private scheduleReconnect;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Interface defining the core methods for the Flaggy client.
|
|
133
|
+
*/
|
|
134
|
+
interface IFlagifyClient {
|
|
135
|
+
/**
|
|
136
|
+
* Retrieves the resolved value of a feature flag.
|
|
137
|
+
* Falls back to defaultValue if evaluation fails or flag is not found.
|
|
138
|
+
*
|
|
139
|
+
* @param flagKey - The key of the feature flag to retrieve.
|
|
140
|
+
* @returns The resolved value of the feature flag.
|
|
141
|
+
*/
|
|
142
|
+
getValue<T>(flagKey: string, fallback: T): T;
|
|
143
|
+
/**
|
|
144
|
+
* Checks if a boolean feature flag is enabled.
|
|
145
|
+
* Returns false if flag is not found or default is false.
|
|
146
|
+
*
|
|
147
|
+
* @param flagKey - The key of the feature flag to check.
|
|
148
|
+
* @returns True if the flag is enabled, false otherwise.
|
|
149
|
+
*/
|
|
150
|
+
isEnabled(flagKey: string): boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Returns the variant key with the highest weight for a multivariate flag.
|
|
153
|
+
* Returns fallback if the flag is missing, disabled, or has no variants.
|
|
154
|
+
*
|
|
155
|
+
* @param flagKey - The key of the feature flag.
|
|
156
|
+
* @param fallback - The default variant key.
|
|
157
|
+
* @returns The winning variant key.
|
|
158
|
+
*/
|
|
159
|
+
getVariant(flagKey: string, fallback: string): string;
|
|
160
|
+
/**
|
|
161
|
+
* Evaluates a flag with user context for targeting rules.
|
|
162
|
+
* Calls the API's evaluate endpoint directly (not cached).
|
|
163
|
+
*/
|
|
164
|
+
evaluate(flagKey: string, user: FlagifyUser): Promise<EvaluateResult>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface EvaluateResult {
|
|
168
|
+
key: string;
|
|
169
|
+
value: unknown;
|
|
170
|
+
reason: "targeting_rule" | "rollout" | "default" | "disabled";
|
|
171
|
+
}
|
|
172
|
+
declare class Flagify implements IFlagifyClient {
|
|
173
|
+
private readonly config;
|
|
174
|
+
private flagCache;
|
|
175
|
+
private httpClient;
|
|
176
|
+
private realtime;
|
|
177
|
+
private readyPromise;
|
|
178
|
+
private pollTimer;
|
|
179
|
+
/** Called when a flag changes via SSE. Useful for triggering React re-renders. */
|
|
180
|
+
onFlagChange: ((event: FlagChangeEvent) => void) | null;
|
|
181
|
+
constructor(config: FlagifyOptions);
|
|
182
|
+
/** Resolves when the initial flag sync is complete. */
|
|
183
|
+
ready(): Promise<void>;
|
|
184
|
+
getValue<T>(flagKey: string, fallback: T): T;
|
|
185
|
+
isEnabled(flagKey: string): boolean;
|
|
186
|
+
getVariant(flagKey: string, fallback: string): string;
|
|
187
|
+
evaluate(flagKey: string, user: FlagifyUser): Promise<EvaluateResult>;
|
|
188
|
+
/**
|
|
189
|
+
* Disconnects the realtime listener and cleans up resources.
|
|
190
|
+
*/
|
|
191
|
+
destroy(): void;
|
|
192
|
+
private isStale;
|
|
193
|
+
private refetchFlag;
|
|
194
|
+
private syncFlags;
|
|
195
|
+
private evaluateWithUser;
|
|
196
|
+
private validateConfig;
|
|
197
|
+
private setupPolling;
|
|
198
|
+
private setupRealtimeListener;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Represents a feature flag within the Flagify system.
|
|
203
|
+
*/
|
|
204
|
+
interface FlagifyFlaggy {
|
|
205
|
+
/**
|
|
206
|
+
* Unique identifier for the flag (e.g., "new-dashboard").
|
|
207
|
+
*/
|
|
208
|
+
key: string;
|
|
209
|
+
/**
|
|
210
|
+
* Human-readable name of the flag.
|
|
211
|
+
*/
|
|
212
|
+
name: string;
|
|
213
|
+
/**
|
|
214
|
+
* The current value of the flag, which can be a boolean, string, number, or JSON object.
|
|
215
|
+
* This is the value that will be returned when the flag is evaluated.
|
|
216
|
+
*/
|
|
217
|
+
value: boolean | string | number | Record<string, unknown>;
|
|
218
|
+
/**
|
|
219
|
+
* Detailed description of the flag's purpose.
|
|
220
|
+
*/
|
|
221
|
+
description?: string;
|
|
222
|
+
/**
|
|
223
|
+
* Data type of the flag's value (e.g., "boolean", "string", "number", "json").
|
|
224
|
+
*/
|
|
225
|
+
type: 'boolean' | 'string' | 'number' | 'json';
|
|
226
|
+
/**
|
|
227
|
+
* Default value returned when the flag is enabled but no targeting rules match.
|
|
228
|
+
*/
|
|
229
|
+
defaultValue: boolean | string | number | Record<string, unknown>;
|
|
230
|
+
/**
|
|
231
|
+
* Value returned when the flag is disabled (enabled = false).
|
|
232
|
+
*/
|
|
233
|
+
offValue: boolean | string | number | Record<string, unknown>;
|
|
234
|
+
/**
|
|
235
|
+
* Indicates whether the flag is currently active.
|
|
236
|
+
*/
|
|
237
|
+
enabled: boolean;
|
|
238
|
+
/**
|
|
239
|
+
* Optional rollout percentage (0 to 100) for gradual feature releases.
|
|
240
|
+
*/
|
|
241
|
+
rolloutPercentage?: number;
|
|
242
|
+
/**
|
|
243
|
+
* Optional targeting rules for user segmentation.
|
|
244
|
+
*/
|
|
245
|
+
targetingRules?: Array<{
|
|
246
|
+
priority: number;
|
|
247
|
+
segmentId?: string;
|
|
248
|
+
valueOverride?: unknown;
|
|
249
|
+
rolloutPercentage?: number;
|
|
250
|
+
enabled: boolean;
|
|
251
|
+
conditions?: Array<{
|
|
252
|
+
attribute: string;
|
|
253
|
+
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'in' | 'not_in' | 'gt' | 'lt';
|
|
254
|
+
value: unknown;
|
|
255
|
+
}>;
|
|
256
|
+
}>;
|
|
257
|
+
/**
|
|
258
|
+
* Optional multivariate variants for A/B testing.
|
|
259
|
+
*/
|
|
260
|
+
variants?: Array<{
|
|
261
|
+
key: string;
|
|
262
|
+
value: boolean | string | number | Record<string, unknown>;
|
|
263
|
+
weight: number;
|
|
264
|
+
}>;
|
|
265
|
+
/**
|
|
266
|
+
* Timestamp of when the flag was created.
|
|
267
|
+
*/
|
|
268
|
+
createdAt: string;
|
|
269
|
+
/**
|
|
270
|
+
* Timestamp of the last update to the flag.
|
|
271
|
+
*/
|
|
272
|
+
updatedAt: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export { type EvaluateResult, type FlagChangeEvent, Flagify, type FlagifyFlaggy, type FlagifyOptions, type FlagifyUser, type IFlagifyClient, type RealtimeEvents, RealtimeListener };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
Flagify: () => Flagify,
|
|
24
|
+
RealtimeListener: () => RealtimeListener
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/api/httpClient.ts
|
|
29
|
+
function createHttpClient(config) {
|
|
30
|
+
const baseUrl = config.options?.apiUrl ?? (typeof process !== "undefined" ? process.env.FLAGIFY_API_URL : void 0) ?? "https://api.flagify.dev";
|
|
31
|
+
const headers = {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"x-api-key": config.publicKey
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
baseUrl,
|
|
37
|
+
headers,
|
|
38
|
+
get: async (path) => {
|
|
39
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
40
|
+
method: "GET",
|
|
41
|
+
headers
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new Error(`[HTTP GET] ${res.status} ${res.statusText}`);
|
|
45
|
+
}
|
|
46
|
+
return res.json();
|
|
47
|
+
},
|
|
48
|
+
post: async (path, body) => {
|
|
49
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers,
|
|
52
|
+
body: JSON.stringify(body)
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new Error(`[HTTP POST] ${res.status} ${res.statusText}`);
|
|
56
|
+
}
|
|
57
|
+
return res.json();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/realtime.ts
|
|
63
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
64
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
65
|
+
var RealtimeListener = class {
|
|
66
|
+
constructor(httpClient, events) {
|
|
67
|
+
this.httpClient = httpClient;
|
|
68
|
+
this.events = events;
|
|
69
|
+
this.controller = null;
|
|
70
|
+
this.reconnectAttempts = 0;
|
|
71
|
+
this.reconnectTimer = null;
|
|
72
|
+
this.hasConnectedBefore = false;
|
|
73
|
+
}
|
|
74
|
+
connect() {
|
|
75
|
+
this.disconnect();
|
|
76
|
+
this.controller = new AbortController();
|
|
77
|
+
this.stream(this.controller.signal);
|
|
78
|
+
}
|
|
79
|
+
disconnect() {
|
|
80
|
+
if (this.reconnectTimer) {
|
|
81
|
+
clearTimeout(this.reconnectTimer);
|
|
82
|
+
this.reconnectTimer = null;
|
|
83
|
+
}
|
|
84
|
+
if (this.controller) {
|
|
85
|
+
this.controller.abort();
|
|
86
|
+
this.controller = null;
|
|
87
|
+
}
|
|
88
|
+
this.reconnectAttempts = 0;
|
|
89
|
+
}
|
|
90
|
+
async stream(signal) {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(
|
|
93
|
+
`${this.httpClient.baseUrl}/v1/eval/flags/stream`,
|
|
94
|
+
{
|
|
95
|
+
method: "GET",
|
|
96
|
+
headers: this.httpClient.headers,
|
|
97
|
+
signal
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
throw new Error(`SSE connection failed: ${res.status} ${res.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
if (!res.body) {
|
|
104
|
+
throw new Error("SSE response has no body");
|
|
105
|
+
}
|
|
106
|
+
this.reconnectAttempts = 0;
|
|
107
|
+
const reader = res.body.getReader();
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
let buffer = "";
|
|
110
|
+
while (true) {
|
|
111
|
+
const { done, value } = await reader.read();
|
|
112
|
+
if (done) break;
|
|
113
|
+
buffer += decoder.decode(value, { stream: true });
|
|
114
|
+
const parts = buffer.split("\n\n");
|
|
115
|
+
buffer = parts.pop() ?? "";
|
|
116
|
+
for (const part of parts) {
|
|
117
|
+
this.parseSSEFrame(part);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!signal.aborted) {
|
|
121
|
+
this.scheduleReconnect();
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (signal.aborted) return;
|
|
125
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
126
|
+
this.events.onError(error);
|
|
127
|
+
this.scheduleReconnect();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
parseSSEFrame(frame) {
|
|
131
|
+
let eventType = "";
|
|
132
|
+
let data = "";
|
|
133
|
+
for (const line of frame.split("\n")) {
|
|
134
|
+
if (line.startsWith("event: ")) {
|
|
135
|
+
eventType = line.slice(7).trim();
|
|
136
|
+
} else if (line.startsWith("data: ")) {
|
|
137
|
+
data = line.slice(6).trim();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (eventType === "connected") {
|
|
141
|
+
if (this.hasConnectedBefore) {
|
|
142
|
+
this.events.onReconnected();
|
|
143
|
+
} else {
|
|
144
|
+
this.hasConnectedBefore = true;
|
|
145
|
+
this.events.onConnected();
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (eventType === "flag_change" && data) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(data);
|
|
152
|
+
this.events.onFlagChange(parsed);
|
|
153
|
+
} catch {
|
|
154
|
+
console.warn("[Flagify] Failed to parse SSE event:", data);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
scheduleReconnect() {
|
|
159
|
+
const delay = Math.min(
|
|
160
|
+
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts),
|
|
161
|
+
RECONNECT_MAX_MS
|
|
162
|
+
);
|
|
163
|
+
this.reconnectAttempts++;
|
|
164
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// src/client.ts
|
|
169
|
+
var Flagify = class {
|
|
170
|
+
constructor(config) {
|
|
171
|
+
this.config = config;
|
|
172
|
+
this.flagCache = /* @__PURE__ */ new Map();
|
|
173
|
+
this.realtime = null;
|
|
174
|
+
this.pollTimer = null;
|
|
175
|
+
/** Called when a flag changes via SSE. Useful for triggering React re-renders. */
|
|
176
|
+
this.onFlagChange = null;
|
|
177
|
+
this.validateConfig();
|
|
178
|
+
this.httpClient = createHttpClient(config);
|
|
179
|
+
this.readyPromise = this.syncFlags();
|
|
180
|
+
if (this.config.options?.realtime) {
|
|
181
|
+
this.setupRealtimeListener();
|
|
182
|
+
}
|
|
183
|
+
if (this.config.options?.pollIntervalMs) {
|
|
184
|
+
this.setupPolling();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Resolves when the initial flag sync is complete. */
|
|
188
|
+
ready() {
|
|
189
|
+
return this.readyPromise;
|
|
190
|
+
}
|
|
191
|
+
getValue(flagKey, fallback) {
|
|
192
|
+
const cached = this.flagCache.get(flagKey);
|
|
193
|
+
if (!cached) return fallback;
|
|
194
|
+
if (this.isStale(cached)) {
|
|
195
|
+
this.refetchFlag(flagKey);
|
|
196
|
+
}
|
|
197
|
+
if (!cached.flag.enabled) return cached.flag.offValue;
|
|
198
|
+
return cached.flag.value ?? fallback;
|
|
199
|
+
}
|
|
200
|
+
isEnabled(flagKey) {
|
|
201
|
+
const cached = this.flagCache.get(flagKey);
|
|
202
|
+
if (!cached) return false;
|
|
203
|
+
if (this.isStale(cached)) {
|
|
204
|
+
this.refetchFlag(flagKey);
|
|
205
|
+
}
|
|
206
|
+
if (cached.flag.type !== "boolean") return false;
|
|
207
|
+
if (!cached.flag.enabled) return cached.flag.offValue === true;
|
|
208
|
+
return cached.flag.value === true;
|
|
209
|
+
}
|
|
210
|
+
getVariant(flagKey, fallback) {
|
|
211
|
+
const cached = this.flagCache.get(flagKey);
|
|
212
|
+
if (!cached || !cached.flag.enabled) return fallback;
|
|
213
|
+
const variants = cached.flag.variants;
|
|
214
|
+
if (!variants || variants.length === 0) return fallback;
|
|
215
|
+
let best = variants[0];
|
|
216
|
+
for (let i = 1; i < variants.length; i++) {
|
|
217
|
+
if (variants[i].weight > best.weight) {
|
|
218
|
+
best = variants[i];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return best.key;
|
|
222
|
+
}
|
|
223
|
+
async evaluate(flagKey, user) {
|
|
224
|
+
return this.httpClient.post(
|
|
225
|
+
`/v1/eval/flags/${flagKey}/evaluate`,
|
|
226
|
+
{ userId: user.id, attributes: user }
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Disconnects the realtime listener and cleans up resources.
|
|
231
|
+
*/
|
|
232
|
+
destroy() {
|
|
233
|
+
if (this.realtime) {
|
|
234
|
+
this.realtime.disconnect();
|
|
235
|
+
this.realtime = null;
|
|
236
|
+
}
|
|
237
|
+
if (this.pollTimer) {
|
|
238
|
+
clearInterval(this.pollTimer);
|
|
239
|
+
this.pollTimer = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
isStale(cached) {
|
|
243
|
+
const staleTime = this.config.options?.staleTimeMs;
|
|
244
|
+
if (typeof staleTime !== "number") {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return Date.now() - cached.lastFetchedAt > staleTime;
|
|
248
|
+
}
|
|
249
|
+
async refetchFlag(flagKey) {
|
|
250
|
+
try {
|
|
251
|
+
const fresh = await this.httpClient.get(
|
|
252
|
+
`/v1/eval/flags/${flagKey}`
|
|
253
|
+
);
|
|
254
|
+
this.flagCache.set(flagKey, {
|
|
255
|
+
flag: fresh,
|
|
256
|
+
lastFetchedAt: Date.now()
|
|
257
|
+
});
|
|
258
|
+
const user = this.config.options?.user;
|
|
259
|
+
if (user) {
|
|
260
|
+
const result = await this.httpClient.post(`/v1/eval/flags/${flagKey}/evaluate`, {
|
|
261
|
+
userId: user.id,
|
|
262
|
+
attributes: user
|
|
263
|
+
});
|
|
264
|
+
const cached = this.flagCache.get(flagKey);
|
|
265
|
+
if (cached) {
|
|
266
|
+
this.flagCache.set(flagKey, {
|
|
267
|
+
flag: { ...cached.flag, value: result.value },
|
|
268
|
+
lastFetchedAt: cached.lastFetchedAt
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
this.onFlagChange?.({
|
|
273
|
+
environmentId: "",
|
|
274
|
+
flagKey,
|
|
275
|
+
action: "updated"
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.warn(`[Flagify] Failed to refetch flag "${flagKey}":`, err);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async syncFlags() {
|
|
282
|
+
try {
|
|
283
|
+
const flags = await this.httpClient.get(`/v1/eval/flags`);
|
|
284
|
+
for (const flag of flags) {
|
|
285
|
+
this.flagCache.set(flag.key, {
|
|
286
|
+
flag,
|
|
287
|
+
lastFetchedAt: Date.now()
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const user = this.config.options?.user;
|
|
291
|
+
if (user) {
|
|
292
|
+
await this.evaluateWithUser(user);
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.warn(`[Flagify] Failed to sync flags: ${err}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async evaluateWithUser(user) {
|
|
299
|
+
try {
|
|
300
|
+
const results = await this.httpClient.post(`/v1/eval/flags/evaluate`, { userId: user.id, attributes: user });
|
|
301
|
+
for (const result of results) {
|
|
302
|
+
const cached = this.flagCache.get(result.key);
|
|
303
|
+
if (cached) {
|
|
304
|
+
this.flagCache.set(result.key, {
|
|
305
|
+
flag: { ...cached.flag, value: result.value },
|
|
306
|
+
lastFetchedAt: cached.lastFetchedAt
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.warn(`[Flagify] Failed to evaluate flags for user: ${err}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
validateConfig() {
|
|
315
|
+
const missing = [];
|
|
316
|
+
if (!this.config.publicKey) {
|
|
317
|
+
missing.push("publicKey");
|
|
318
|
+
}
|
|
319
|
+
if (!this.config.projectKey) {
|
|
320
|
+
missing.push("projectKey");
|
|
321
|
+
}
|
|
322
|
+
if (missing.length > 0) {
|
|
323
|
+
console.error(
|
|
324
|
+
`[Flagify] Missing required config keys: ${missing.join(", ")}`,
|
|
325
|
+
"All feature flags will be disabled."
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
setupPolling() {
|
|
330
|
+
const interval = this.config.options.pollIntervalMs;
|
|
331
|
+
this.pollTimer = setInterval(async () => {
|
|
332
|
+
await this.syncFlags();
|
|
333
|
+
this.onFlagChange?.({ environmentId: "", flagKey: "*", action: "updated" });
|
|
334
|
+
}, interval);
|
|
335
|
+
}
|
|
336
|
+
setupRealtimeListener() {
|
|
337
|
+
this.realtime = new RealtimeListener(this.httpClient, {
|
|
338
|
+
onConnected: () => {
|
|
339
|
+
console.info("[Flagify] Realtime connected");
|
|
340
|
+
},
|
|
341
|
+
onReconnected: () => {
|
|
342
|
+
console.info("[Flagify] Realtime reconnected \u2014 resyncing all flags");
|
|
343
|
+
this.syncFlags();
|
|
344
|
+
},
|
|
345
|
+
onFlagChange: (event) => {
|
|
346
|
+
console.debug(`[Flagify] Flag changed: ${event.flagKey} (${event.action})`);
|
|
347
|
+
this.refetchFlag(event.flagKey);
|
|
348
|
+
},
|
|
349
|
+
onError: (error) => {
|
|
350
|
+
console.warn("[Flagify] Realtime error (will reconnect):", error.message);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
this.realtime.connect();
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
357
|
+
0 && (module.exports = {
|
|
358
|
+
Flagify,
|
|
359
|
+
RealtimeListener
|
|
360
|
+
});
|