@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.mjs
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// src/api/httpClient.ts
|
|
2
|
+
function createHttpClient(config) {
|
|
3
|
+
const baseUrl = config.options?.apiUrl ?? (typeof process !== "undefined" ? process.env.FLAGIFY_API_URL : void 0) ?? "https://api.flagify.dev";
|
|
4
|
+
const headers = {
|
|
5
|
+
"Content-Type": "application/json",
|
|
6
|
+
"x-api-key": config.publicKey
|
|
7
|
+
};
|
|
8
|
+
return {
|
|
9
|
+
baseUrl,
|
|
10
|
+
headers,
|
|
11
|
+
get: async (path) => {
|
|
12
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
13
|
+
method: "GET",
|
|
14
|
+
headers
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
throw new Error(`[HTTP GET] ${res.status} ${res.statusText}`);
|
|
18
|
+
}
|
|
19
|
+
return res.json();
|
|
20
|
+
},
|
|
21
|
+
post: async (path, body) => {
|
|
22
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers,
|
|
25
|
+
body: JSON.stringify(body)
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
throw new Error(`[HTTP POST] ${res.status} ${res.statusText}`);
|
|
29
|
+
}
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/realtime.ts
|
|
36
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
37
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
38
|
+
var RealtimeListener = class {
|
|
39
|
+
constructor(httpClient, events) {
|
|
40
|
+
this.httpClient = httpClient;
|
|
41
|
+
this.events = events;
|
|
42
|
+
this.controller = null;
|
|
43
|
+
this.reconnectAttempts = 0;
|
|
44
|
+
this.reconnectTimer = null;
|
|
45
|
+
this.hasConnectedBefore = false;
|
|
46
|
+
}
|
|
47
|
+
connect() {
|
|
48
|
+
this.disconnect();
|
|
49
|
+
this.controller = new AbortController();
|
|
50
|
+
this.stream(this.controller.signal);
|
|
51
|
+
}
|
|
52
|
+
disconnect() {
|
|
53
|
+
if (this.reconnectTimer) {
|
|
54
|
+
clearTimeout(this.reconnectTimer);
|
|
55
|
+
this.reconnectTimer = null;
|
|
56
|
+
}
|
|
57
|
+
if (this.controller) {
|
|
58
|
+
this.controller.abort();
|
|
59
|
+
this.controller = null;
|
|
60
|
+
}
|
|
61
|
+
this.reconnectAttempts = 0;
|
|
62
|
+
}
|
|
63
|
+
async stream(signal) {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(
|
|
66
|
+
`${this.httpClient.baseUrl}/v1/eval/flags/stream`,
|
|
67
|
+
{
|
|
68
|
+
method: "GET",
|
|
69
|
+
headers: this.httpClient.headers,
|
|
70
|
+
signal
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new Error(`SSE connection failed: ${res.status} ${res.statusText}`);
|
|
75
|
+
}
|
|
76
|
+
if (!res.body) {
|
|
77
|
+
throw new Error("SSE response has no body");
|
|
78
|
+
}
|
|
79
|
+
this.reconnectAttempts = 0;
|
|
80
|
+
const reader = res.body.getReader();
|
|
81
|
+
const decoder = new TextDecoder();
|
|
82
|
+
let buffer = "";
|
|
83
|
+
while (true) {
|
|
84
|
+
const { done, value } = await reader.read();
|
|
85
|
+
if (done) break;
|
|
86
|
+
buffer += decoder.decode(value, { stream: true });
|
|
87
|
+
const parts = buffer.split("\n\n");
|
|
88
|
+
buffer = parts.pop() ?? "";
|
|
89
|
+
for (const part of parts) {
|
|
90
|
+
this.parseSSEFrame(part);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!signal.aborted) {
|
|
94
|
+
this.scheduleReconnect();
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (signal.aborted) return;
|
|
98
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
99
|
+
this.events.onError(error);
|
|
100
|
+
this.scheduleReconnect();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
parseSSEFrame(frame) {
|
|
104
|
+
let eventType = "";
|
|
105
|
+
let data = "";
|
|
106
|
+
for (const line of frame.split("\n")) {
|
|
107
|
+
if (line.startsWith("event: ")) {
|
|
108
|
+
eventType = line.slice(7).trim();
|
|
109
|
+
} else if (line.startsWith("data: ")) {
|
|
110
|
+
data = line.slice(6).trim();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (eventType === "connected") {
|
|
114
|
+
if (this.hasConnectedBefore) {
|
|
115
|
+
this.events.onReconnected();
|
|
116
|
+
} else {
|
|
117
|
+
this.hasConnectedBefore = true;
|
|
118
|
+
this.events.onConnected();
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (eventType === "flag_change" && data) {
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(data);
|
|
125
|
+
this.events.onFlagChange(parsed);
|
|
126
|
+
} catch {
|
|
127
|
+
console.warn("[Flagify] Failed to parse SSE event:", data);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
scheduleReconnect() {
|
|
132
|
+
const delay = Math.min(
|
|
133
|
+
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts),
|
|
134
|
+
RECONNECT_MAX_MS
|
|
135
|
+
);
|
|
136
|
+
this.reconnectAttempts++;
|
|
137
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// src/client.ts
|
|
142
|
+
var Flagify = class {
|
|
143
|
+
constructor(config) {
|
|
144
|
+
this.config = config;
|
|
145
|
+
this.flagCache = /* @__PURE__ */ new Map();
|
|
146
|
+
this.realtime = null;
|
|
147
|
+
this.pollTimer = null;
|
|
148
|
+
/** Called when a flag changes via SSE. Useful for triggering React re-renders. */
|
|
149
|
+
this.onFlagChange = null;
|
|
150
|
+
this.validateConfig();
|
|
151
|
+
this.httpClient = createHttpClient(config);
|
|
152
|
+
this.readyPromise = this.syncFlags();
|
|
153
|
+
if (this.config.options?.realtime) {
|
|
154
|
+
this.setupRealtimeListener();
|
|
155
|
+
}
|
|
156
|
+
if (this.config.options?.pollIntervalMs) {
|
|
157
|
+
this.setupPolling();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Resolves when the initial flag sync is complete. */
|
|
161
|
+
ready() {
|
|
162
|
+
return this.readyPromise;
|
|
163
|
+
}
|
|
164
|
+
getValue(flagKey, fallback) {
|
|
165
|
+
const cached = this.flagCache.get(flagKey);
|
|
166
|
+
if (!cached) return fallback;
|
|
167
|
+
if (this.isStale(cached)) {
|
|
168
|
+
this.refetchFlag(flagKey);
|
|
169
|
+
}
|
|
170
|
+
if (!cached.flag.enabled) return cached.flag.offValue;
|
|
171
|
+
return cached.flag.value ?? fallback;
|
|
172
|
+
}
|
|
173
|
+
isEnabled(flagKey) {
|
|
174
|
+
const cached = this.flagCache.get(flagKey);
|
|
175
|
+
if (!cached) return false;
|
|
176
|
+
if (this.isStale(cached)) {
|
|
177
|
+
this.refetchFlag(flagKey);
|
|
178
|
+
}
|
|
179
|
+
if (cached.flag.type !== "boolean") return false;
|
|
180
|
+
if (!cached.flag.enabled) return cached.flag.offValue === true;
|
|
181
|
+
return cached.flag.value === true;
|
|
182
|
+
}
|
|
183
|
+
getVariant(flagKey, fallback) {
|
|
184
|
+
const cached = this.flagCache.get(flagKey);
|
|
185
|
+
if (!cached || !cached.flag.enabled) return fallback;
|
|
186
|
+
const variants = cached.flag.variants;
|
|
187
|
+
if (!variants || variants.length === 0) return fallback;
|
|
188
|
+
let best = variants[0];
|
|
189
|
+
for (let i = 1; i < variants.length; i++) {
|
|
190
|
+
if (variants[i].weight > best.weight) {
|
|
191
|
+
best = variants[i];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return best.key;
|
|
195
|
+
}
|
|
196
|
+
async evaluate(flagKey, user) {
|
|
197
|
+
return this.httpClient.post(
|
|
198
|
+
`/v1/eval/flags/${flagKey}/evaluate`,
|
|
199
|
+
{ userId: user.id, attributes: user }
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Disconnects the realtime listener and cleans up resources.
|
|
204
|
+
*/
|
|
205
|
+
destroy() {
|
|
206
|
+
if (this.realtime) {
|
|
207
|
+
this.realtime.disconnect();
|
|
208
|
+
this.realtime = null;
|
|
209
|
+
}
|
|
210
|
+
if (this.pollTimer) {
|
|
211
|
+
clearInterval(this.pollTimer);
|
|
212
|
+
this.pollTimer = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
isStale(cached) {
|
|
216
|
+
const staleTime = this.config.options?.staleTimeMs;
|
|
217
|
+
if (typeof staleTime !== "number") {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return Date.now() - cached.lastFetchedAt > staleTime;
|
|
221
|
+
}
|
|
222
|
+
async refetchFlag(flagKey) {
|
|
223
|
+
try {
|
|
224
|
+
const fresh = await this.httpClient.get(
|
|
225
|
+
`/v1/eval/flags/${flagKey}`
|
|
226
|
+
);
|
|
227
|
+
this.flagCache.set(flagKey, {
|
|
228
|
+
flag: fresh,
|
|
229
|
+
lastFetchedAt: Date.now()
|
|
230
|
+
});
|
|
231
|
+
const user = this.config.options?.user;
|
|
232
|
+
if (user) {
|
|
233
|
+
const result = await this.httpClient.post(`/v1/eval/flags/${flagKey}/evaluate`, {
|
|
234
|
+
userId: user.id,
|
|
235
|
+
attributes: user
|
|
236
|
+
});
|
|
237
|
+
const cached = this.flagCache.get(flagKey);
|
|
238
|
+
if (cached) {
|
|
239
|
+
this.flagCache.set(flagKey, {
|
|
240
|
+
flag: { ...cached.flag, value: result.value },
|
|
241
|
+
lastFetchedAt: cached.lastFetchedAt
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
this.onFlagChange?.({
|
|
246
|
+
environmentId: "",
|
|
247
|
+
flagKey,
|
|
248
|
+
action: "updated"
|
|
249
|
+
});
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.warn(`[Flagify] Failed to refetch flag "${flagKey}":`, err);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async syncFlags() {
|
|
255
|
+
try {
|
|
256
|
+
const flags = await this.httpClient.get(`/v1/eval/flags`);
|
|
257
|
+
for (const flag of flags) {
|
|
258
|
+
this.flagCache.set(flag.key, {
|
|
259
|
+
flag,
|
|
260
|
+
lastFetchedAt: Date.now()
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const user = this.config.options?.user;
|
|
264
|
+
if (user) {
|
|
265
|
+
await this.evaluateWithUser(user);
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.warn(`[Flagify] Failed to sync flags: ${err}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async evaluateWithUser(user) {
|
|
272
|
+
try {
|
|
273
|
+
const results = await this.httpClient.post(`/v1/eval/flags/evaluate`, { userId: user.id, attributes: user });
|
|
274
|
+
for (const result of results) {
|
|
275
|
+
const cached = this.flagCache.get(result.key);
|
|
276
|
+
if (cached) {
|
|
277
|
+
this.flagCache.set(result.key, {
|
|
278
|
+
flag: { ...cached.flag, value: result.value },
|
|
279
|
+
lastFetchedAt: cached.lastFetchedAt
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.warn(`[Flagify] Failed to evaluate flags for user: ${err}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
validateConfig() {
|
|
288
|
+
const missing = [];
|
|
289
|
+
if (!this.config.publicKey) {
|
|
290
|
+
missing.push("publicKey");
|
|
291
|
+
}
|
|
292
|
+
if (!this.config.projectKey) {
|
|
293
|
+
missing.push("projectKey");
|
|
294
|
+
}
|
|
295
|
+
if (missing.length > 0) {
|
|
296
|
+
console.error(
|
|
297
|
+
`[Flagify] Missing required config keys: ${missing.join(", ")}`,
|
|
298
|
+
"All feature flags will be disabled."
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
setupPolling() {
|
|
303
|
+
const interval = this.config.options.pollIntervalMs;
|
|
304
|
+
this.pollTimer = setInterval(async () => {
|
|
305
|
+
await this.syncFlags();
|
|
306
|
+
this.onFlagChange?.({ environmentId: "", flagKey: "*", action: "updated" });
|
|
307
|
+
}, interval);
|
|
308
|
+
}
|
|
309
|
+
setupRealtimeListener() {
|
|
310
|
+
this.realtime = new RealtimeListener(this.httpClient, {
|
|
311
|
+
onConnected: () => {
|
|
312
|
+
console.info("[Flagify] Realtime connected");
|
|
313
|
+
},
|
|
314
|
+
onReconnected: () => {
|
|
315
|
+
console.info("[Flagify] Realtime reconnected \u2014 resyncing all flags");
|
|
316
|
+
this.syncFlags();
|
|
317
|
+
},
|
|
318
|
+
onFlagChange: (event) => {
|
|
319
|
+
console.debug(`[Flagify] Flag changed: ${event.flagKey} (${event.action})`);
|
|
320
|
+
this.refetchFlag(event.flagKey);
|
|
321
|
+
},
|
|
322
|
+
onError: (error) => {
|
|
323
|
+
console.warn("[Flagify] Realtime error (will reconnect):", error.message);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
this.realtime.connect();
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
export {
|
|
330
|
+
Flagify,
|
|
331
|
+
RealtimeListener
|
|
332
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flagify/node",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Official Flagify SDK for feature flag evaluation. TypeScript-first with local caching.",
|
|
5
|
+
"author": "Mario Campbell R <mario@mariocampbellr.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"module": "dist/index.mjs",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.mjs",
|
|
14
|
+
"require": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"dotenv": "^16.5.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.15.0"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"feature-flags",
|
|
28
|
+
"flagify",
|
|
29
|
+
"feature-toggle",
|
|
30
|
+
"sdk"
|
|
31
|
+
],
|
|
32
|
+
"homepage": "https://flagify.dev",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/flagifyhq/javascript",
|
|
36
|
+
"directory": "packages/node"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/flagifyhq/javascript/issues"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"generate": "barrelsby -c ./barrelsby.json",
|
|
46
|
+
"build": "pnpm run generate && tsup src/index.ts --format esm,cjs --dts",
|
|
47
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"lint": "tsc --noEmit",
|
|
50
|
+
"clean": "rm -rf dist"
|
|
51
|
+
}
|
|
52
|
+
}
|