featurefly 0.1.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/LICENSE +21 -0
- package/README.md +687 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +22 -0
- package/dist/react/index.d.ts +54 -0
- package/dist/react/index.js +126 -0
- package/dist/shared/cache.d.ts +40 -0
- package/dist/shared/cache.js +80 -0
- package/dist/shared/circuit-breaker.d.ts +46 -0
- package/dist/shared/circuit-breaker.js +90 -0
- package/dist/shared/client.d.ts +153 -0
- package/dist/shared/client.js +560 -0
- package/dist/shared/edge-evaluator.d.ts +35 -0
- package/dist/shared/edge-evaluator.js +127 -0
- package/dist/shared/event-emitter.d.ts +29 -0
- package/dist/shared/event-emitter.js +68 -0
- package/dist/shared/experiment.d.ts +9 -0
- package/dist/shared/experiment.js +51 -0
- package/dist/shared/index.d.ts +7 -0
- package/dist/shared/index.js +7 -0
- package/dist/shared/logger.d.ts +14 -0
- package/dist/shared/logger.js +37 -0
- package/dist/shared/metrics.d.ts +79 -0
- package/dist/shared/metrics.js +147 -0
- package/dist/shared/retry.d.ts +10 -0
- package/dist/shared/retry.js +39 -0
- package/dist/shared/rollout.d.ts +14 -0
- package/dist/shared/rollout.js +77 -0
- package/dist/shared/streaming.d.ts +35 -0
- package/dist/shared/streaming.js +117 -0
- package/dist/shared/targeting.d.ts +10 -0
- package/dist/shared/targeting.js +133 -0
- package/dist/shared/types.d.ts +248 -0
- package/dist/shared/types.js +4 -0
- package/dist/vue/index.d.ts +60 -0
- package/dist/vue/index.js +136 -0
- package/package.json +97 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { InMemoryCache } from './cache';
|
|
3
|
+
import { ConsoleLogger } from './logger';
|
|
4
|
+
import { CircuitBreaker } from './circuit-breaker';
|
|
5
|
+
import { EventEmitter } from './event-emitter';
|
|
6
|
+
import { withRetry } from './retry';
|
|
7
|
+
import { FlagStreamClient } from './streaming';
|
|
8
|
+
import { EdgeEvaluator } from './edge-evaluator';
|
|
9
|
+
import { ImpactMetrics } from './metrics';
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
11
|
+
// DEFAULTS
|
|
12
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
13
|
+
const DEFAULT_TIMEOUT = 10000;
|
|
14
|
+
const DEFAULT_CACHE_TTL = 60000;
|
|
15
|
+
const DEFAULT_RETRY = {
|
|
16
|
+
maxAttempts: 3,
|
|
17
|
+
baseDelayMs: 1000,
|
|
18
|
+
maxDelayMs: 10000,
|
|
19
|
+
};
|
|
20
|
+
const DEFAULT_CIRCUIT_BREAKER = {
|
|
21
|
+
failureThreshold: 5,
|
|
22
|
+
resetTimeoutMs: 30000,
|
|
23
|
+
};
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
25
|
+
// CLIENT
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
27
|
+
/**
|
|
28
|
+
* FeatureFly SDK Client
|
|
29
|
+
*
|
|
30
|
+
* Framework-agnostic feature flags client with:
|
|
31
|
+
* - In-memory caching with TTL
|
|
32
|
+
* - Retry with exponential backoff + jitter
|
|
33
|
+
* - Circuit breaker for resilience
|
|
34
|
+
* - Typed event system
|
|
35
|
+
* - Local overrides for dev/testing
|
|
36
|
+
* - Fallback defaults for graceful degradation
|
|
37
|
+
* - Multi-type flag values (boolean, string, number, JSON)
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const client = new FeatureFlagsClient({
|
|
42
|
+
* baseUrl: 'https://api.example.com',
|
|
43
|
+
* apiKey: 'your-key',
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* const isEnabled = await client.evaluateFlag('new-feature', { workspaceId: '123' });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class FeatureFlagsClient {
|
|
50
|
+
constructor(config) {
|
|
51
|
+
this.previousValues = new Map();
|
|
52
|
+
this.disposed = false;
|
|
53
|
+
// Logger
|
|
54
|
+
this.logger = config.logger ?? new ConsoleLogger(config.logLevel ?? 'warn');
|
|
55
|
+
// Cache
|
|
56
|
+
const cacheTtl = config.cacheEnabled === false ? 0 : (config.cacheTtlMs ?? DEFAULT_CACHE_TTL);
|
|
57
|
+
this.cache = new InMemoryCache(cacheTtl);
|
|
58
|
+
// Retry
|
|
59
|
+
this.retryConfig = { ...DEFAULT_RETRY, ...config.retry };
|
|
60
|
+
// Event emitter
|
|
61
|
+
this.events = new EventEmitter();
|
|
62
|
+
// Circuit breaker
|
|
63
|
+
const cbConfig = { ...DEFAULT_CIRCUIT_BREAKER, ...config.circuitBreaker };
|
|
64
|
+
this.circuitBreaker = new CircuitBreaker({
|
|
65
|
+
...cbConfig,
|
|
66
|
+
logger: this.logger,
|
|
67
|
+
onStateChange: (state, failures) => {
|
|
68
|
+
const eventMap = {
|
|
69
|
+
'open': 'circuitOpen',
|
|
70
|
+
'closed': 'circuitClosed',
|
|
71
|
+
'half-open': 'circuitHalfOpen',
|
|
72
|
+
};
|
|
73
|
+
const event = eventMap[state];
|
|
74
|
+
if (event) {
|
|
75
|
+
this.events.emit(event, { state, failures });
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
// Local overrides & fallbacks
|
|
80
|
+
this.localOverrides = { ...config.localOverrides };
|
|
81
|
+
this.fallbackDefaults = { ...config.fallbackDefaults };
|
|
82
|
+
// HTTP client
|
|
83
|
+
this.http = axios.create({
|
|
84
|
+
baseURL: config.baseUrl,
|
|
85
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
...(config.apiKey && { Authorization: `Bearer ${config.apiKey}` }),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
// Edge Evaluator Initialization
|
|
92
|
+
if (config.edgeDocument) {
|
|
93
|
+
this.edgeEvaluator = new EdgeEvaluator(config.edgeDocument, this.fallbackDefaults, config.trackingCallback);
|
|
94
|
+
this.logger.info('Edge evaluator initialized from provided document');
|
|
95
|
+
}
|
|
96
|
+
// Streaming Initialization
|
|
97
|
+
if (config.streaming) {
|
|
98
|
+
const streamConfig = typeof config.streaming === 'object' ? config.streaming : {};
|
|
99
|
+
this.streamClient = new FlagStreamClient(config.baseUrl, config.apiKey, streamConfig, this.logger, this.events);
|
|
100
|
+
// Auto-connect stream on boot
|
|
101
|
+
this.streamClient.connect();
|
|
102
|
+
// When stream notifies of updates, we should refresh the edge document if in edge mode
|
|
103
|
+
// or simply clear the cache if in remote mode
|
|
104
|
+
this.events.on('flagsUpdated', () => {
|
|
105
|
+
if (this.edgeEvaluator) {
|
|
106
|
+
this.refreshEdgeDocument().catch(e => this.logger.error('Failed to refresh edge doc on stream update', e));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
this.cache.clear();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Impact Metrics
|
|
114
|
+
this.metrics = new ImpactMetrics(this.events);
|
|
115
|
+
this.logger.debug(`Initialized with baseUrl=${config.baseUrl}, cache=${cacheTtl}ms, retry=${this.retryConfig.maxAttempts}`);
|
|
116
|
+
}
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
118
|
+
// STREAMING & EDGE MANAGERS
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
/**
|
|
121
|
+
* Start or resume the SSE streaming connection.
|
|
122
|
+
*/
|
|
123
|
+
startStreaming() {
|
|
124
|
+
this.assertNotDisposed();
|
|
125
|
+
if (!this.streamClient) {
|
|
126
|
+
this.logger.warn('Streaming was not configured. Use startStreaming(config) to enable it.');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.streamClient.connect();
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Stop the SSE streaming connection.
|
|
133
|
+
*/
|
|
134
|
+
stopStreaming() {
|
|
135
|
+
this.streamClient?.disconnect();
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Fetch a full FlagDocument from the API to initialize Edge Evaluation mode.
|
|
139
|
+
* If streaming is enabled, updates will auto-refresh the document.
|
|
140
|
+
*/
|
|
141
|
+
async loadEdgeDocument() {
|
|
142
|
+
this.assertNotDisposed();
|
|
143
|
+
const doc = await this.fetchWithResiliency(async () => {
|
|
144
|
+
const response = await this.http.get('/feature-flags/document');
|
|
145
|
+
return response.data;
|
|
146
|
+
});
|
|
147
|
+
if (this.edgeEvaluator) {
|
|
148
|
+
this.edgeEvaluator.updateDocument(doc);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.edgeEvaluator = new EdgeEvaluator(doc, this.fallbackDefaults);
|
|
152
|
+
}
|
|
153
|
+
this.logger.info('Edge document loaded. Client is now in offline evaluation mode.');
|
|
154
|
+
}
|
|
155
|
+
async refreshEdgeDocument() {
|
|
156
|
+
if (!this.edgeEvaluator)
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
const response = await this.http.get('/feature-flags/document');
|
|
160
|
+
this.edgeEvaluator.updateDocument(response.data);
|
|
161
|
+
this.logger.debug('Edge document refreshed from stream trigger');
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
this.logger.error('Failed to refresh edge document', e);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
168
|
+
// EVENT SYSTEM
|
|
169
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
170
|
+
/**
|
|
171
|
+
* Subscribe to SDK events.
|
|
172
|
+
* @returns Unsubscribe function
|
|
173
|
+
*/
|
|
174
|
+
on(event, handler) {
|
|
175
|
+
return this.events.on(event, handler);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Subscribe to an event once.
|
|
179
|
+
*/
|
|
180
|
+
once(event, handler) {
|
|
181
|
+
return this.events.once(event, handler);
|
|
182
|
+
}
|
|
183
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
184
|
+
// FLAG EVALUATION
|
|
185
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
186
|
+
/**
|
|
187
|
+
* Evaluate a single flag. Returns the flag value.
|
|
188
|
+
*
|
|
189
|
+
* Resolution order:
|
|
190
|
+
* 1. Local overrides (dev/testing)
|
|
191
|
+
* 2. Cache hit
|
|
192
|
+
* 3. Remote API call
|
|
193
|
+
* 4. Fallback defaults
|
|
194
|
+
*/
|
|
195
|
+
async evaluateFlag(slug, context) {
|
|
196
|
+
this.assertNotDisposed();
|
|
197
|
+
const start = Date.now();
|
|
198
|
+
// 1. Local overrides always skip HTTP & Edge processing
|
|
199
|
+
if (slug in this.localOverrides) {
|
|
200
|
+
const value = this.localOverrides[slug];
|
|
201
|
+
this.logger.debug(`Flag "${slug}" resolved from local override: ${String(value)}`);
|
|
202
|
+
this.emitEvaluated(slug, value, 'LOCAL_OVERRIDE', start);
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
// 2. Edge Evaluation (zero HTTP, done purely in memory)
|
|
206
|
+
if (this.edgeEvaluator) {
|
|
207
|
+
const { value, reason } = this.edgeEvaluator.evaluate(slug, context || {}, this.localOverrides);
|
|
208
|
+
this.detectChange(slug, value);
|
|
209
|
+
this.emitEvaluated(slug, value, reason, start);
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
// 3. Cache hit (Remote Evaluation mode)
|
|
213
|
+
const cacheKey = this.buildCacheKey('evaluate', slug, context);
|
|
214
|
+
const cached = this.cache.get(cacheKey);
|
|
215
|
+
if (cached.hit) {
|
|
216
|
+
this.logger.debug(`Flag "${slug}" resolved from cache: ${String(cached.value)}`);
|
|
217
|
+
this.events.emit('cacheHit', { key: cacheKey });
|
|
218
|
+
this.emitEvaluated(slug, cached.value, 'CACHE_HIT', start);
|
|
219
|
+
return cached.value;
|
|
220
|
+
}
|
|
221
|
+
this.events.emit('cacheMiss', { key: cacheKey });
|
|
222
|
+
// 4. Remote call
|
|
223
|
+
try {
|
|
224
|
+
const value = await this.fetchWithResiliency(async () => {
|
|
225
|
+
const params = context ?? {};
|
|
226
|
+
const response = await this.http.get(`/feature-flags/${slug}/evaluate`, { params });
|
|
227
|
+
return response.data.value;
|
|
228
|
+
});
|
|
229
|
+
this.cache.set(cacheKey, value);
|
|
230
|
+
this.detectChange(slug, value);
|
|
231
|
+
this.emitEvaluated(slug, value, 'DEFAULT', start);
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
// 5. Fallback
|
|
236
|
+
if (slug in this.fallbackDefaults) {
|
|
237
|
+
const value = this.fallbackDefaults[slug];
|
|
238
|
+
this.logger.warn(`Flag "${slug}" using fallback default: ${String(value)}`);
|
|
239
|
+
this.emitEvaluated(slug, value, 'FALLBACK', start);
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
this.logger.error(`Flag "${slug}" evaluation failed with no fallback`, error);
|
|
243
|
+
this.emitEvaluated(slug, false, 'ERROR', start);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Evaluate all flags in a single batch request.
|
|
249
|
+
*/
|
|
250
|
+
async evaluateAllFlags(context) {
|
|
251
|
+
this.assertNotDisposed();
|
|
252
|
+
// 1. Edge Evaluation Batch
|
|
253
|
+
if (this.edgeEvaluator) {
|
|
254
|
+
return this.edgeEvaluator.evaluateAll(context || {}, this.localOverrides);
|
|
255
|
+
}
|
|
256
|
+
// 2. Remote Evaluation
|
|
257
|
+
const cacheKey = this.buildCacheKey('batch-evaluate', undefined, context);
|
|
258
|
+
const cached = this.cache.get(cacheKey);
|
|
259
|
+
if (cached.hit) {
|
|
260
|
+
this.events.emit('cacheHit', { key: cacheKey });
|
|
261
|
+
return cached.value;
|
|
262
|
+
}
|
|
263
|
+
this.events.emit('cacheMiss', { key: cacheKey });
|
|
264
|
+
try {
|
|
265
|
+
const result = await this.fetchWithResiliency(async () => {
|
|
266
|
+
const params = context ?? {};
|
|
267
|
+
const response = await this.http.get('/feature-flags/batch/evaluate', { params });
|
|
268
|
+
return response.data;
|
|
269
|
+
});
|
|
270
|
+
// Merge local overrides on top
|
|
271
|
+
const merged = { ...result, ...this.localOverrides };
|
|
272
|
+
this.cache.set(cacheKey, merged);
|
|
273
|
+
return merged;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
this.logger.error('Batch evaluation failed, returning fallback defaults', error);
|
|
277
|
+
return { ...this.fallbackDefaults, ...this.localOverrides };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
281
|
+
// FLAG MANAGEMENT (CRUD)
|
|
282
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
283
|
+
async createFlag(data) {
|
|
284
|
+
this.assertNotDisposed();
|
|
285
|
+
const response = await this.fetchWithResiliency(() => this.http.post('/feature-flags', data));
|
|
286
|
+
this.cache.clear();
|
|
287
|
+
this.events.emit('cacheCleared', undefined);
|
|
288
|
+
return response.data;
|
|
289
|
+
}
|
|
290
|
+
async getAllFlags() {
|
|
291
|
+
this.assertNotDisposed();
|
|
292
|
+
const cacheKey = 'all-flags';
|
|
293
|
+
const cached = this.cache.get(cacheKey);
|
|
294
|
+
if (cached.hit)
|
|
295
|
+
return cached.value;
|
|
296
|
+
const response = await this.fetchWithResiliency(() => this.http.get('/feature-flags'));
|
|
297
|
+
this.cache.set(cacheKey, response.data);
|
|
298
|
+
return response.data;
|
|
299
|
+
}
|
|
300
|
+
async getFlagById(id) {
|
|
301
|
+
this.assertNotDisposed();
|
|
302
|
+
const cacheKey = `flag-${id}`;
|
|
303
|
+
const cached = this.cache.get(cacheKey);
|
|
304
|
+
if (cached.hit)
|
|
305
|
+
return cached.value;
|
|
306
|
+
try {
|
|
307
|
+
const response = await this.fetchWithResiliency(() => this.http.get(`/feature-flags/${id}`));
|
|
308
|
+
this.cache.set(cacheKey, response.data);
|
|
309
|
+
return response.data;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (axios.isAxiosError(error) && error.response?.status === 404)
|
|
313
|
+
return null;
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async getFlagBySlug(slug) {
|
|
318
|
+
this.assertNotDisposed();
|
|
319
|
+
const cacheKey = `flag-slug-${slug}`;
|
|
320
|
+
const cached = this.cache.get(cacheKey);
|
|
321
|
+
if (cached.hit)
|
|
322
|
+
return cached.value;
|
|
323
|
+
try {
|
|
324
|
+
const response = await this.fetchWithResiliency(() => this.http.get(`/feature-flags/slug/${slug}`));
|
|
325
|
+
this.cache.set(cacheKey, response.data);
|
|
326
|
+
return response.data;
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (axios.isAxiosError(error) && error.response?.status === 404)
|
|
330
|
+
return null;
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async updateFlag(id, data) {
|
|
335
|
+
this.assertNotDisposed();
|
|
336
|
+
const response = await this.fetchWithResiliency(() => this.http.patch(`/feature-flags/${id}`, data));
|
|
337
|
+
this.cache.clear();
|
|
338
|
+
this.events.emit('cacheCleared', undefined);
|
|
339
|
+
return response.data;
|
|
340
|
+
}
|
|
341
|
+
async deleteFlag(id) {
|
|
342
|
+
this.assertNotDisposed();
|
|
343
|
+
await this.fetchWithResiliency(() => this.http.delete(`/feature-flags/${id}`));
|
|
344
|
+
this.cache.clear();
|
|
345
|
+
this.events.emit('cacheCleared', undefined);
|
|
346
|
+
}
|
|
347
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
348
|
+
// WORKSPACE FLAGS
|
|
349
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
350
|
+
async setWorkspaceFlag(slug, workspaceId, value) {
|
|
351
|
+
this.assertNotDisposed();
|
|
352
|
+
const data = { value };
|
|
353
|
+
const response = await this.fetchWithResiliency(() => this.http.post(`/feature-flags/${slug}/workspaces/${workspaceId}`, data));
|
|
354
|
+
// Invalidate relevant cache entries
|
|
355
|
+
this.cache.delete(this.buildCacheKey('evaluate', slug, { workspaceId }));
|
|
356
|
+
this.cache.delete(this.buildCacheKey('batch-evaluate', undefined, { workspaceId }));
|
|
357
|
+
this.cache.delete(`workspace-flags-${workspaceId}`);
|
|
358
|
+
return response.data;
|
|
359
|
+
}
|
|
360
|
+
async removeWorkspaceFlag(slug, workspaceId) {
|
|
361
|
+
this.assertNotDisposed();
|
|
362
|
+
await this.fetchWithResiliency(() => this.http.delete(`/feature-flags/${slug}/workspaces/${workspaceId}`));
|
|
363
|
+
this.cache.delete(this.buildCacheKey('evaluate', slug, { workspaceId }));
|
|
364
|
+
this.cache.delete(this.buildCacheKey('batch-evaluate', undefined, { workspaceId }));
|
|
365
|
+
this.cache.delete(`workspace-flags-${workspaceId}`);
|
|
366
|
+
}
|
|
367
|
+
async getWorkspaceFlags(workspaceId) {
|
|
368
|
+
this.assertNotDisposed();
|
|
369
|
+
const cacheKey = `workspace-flags-${workspaceId}`;
|
|
370
|
+
const cached = this.cache.get(cacheKey);
|
|
371
|
+
if (cached.hit)
|
|
372
|
+
return cached.value;
|
|
373
|
+
const response = await this.fetchWithResiliency(() => this.http.get(`/feature-flags/workspaces/${workspaceId}/flags`));
|
|
374
|
+
this.cache.set(cacheKey, response.data);
|
|
375
|
+
return response.data;
|
|
376
|
+
}
|
|
377
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
378
|
+
// ANALYTICS
|
|
379
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
380
|
+
async getFlagStats() {
|
|
381
|
+
this.assertNotDisposed();
|
|
382
|
+
const cacheKey = 'flag-stats';
|
|
383
|
+
const cached = this.cache.get(cacheKey);
|
|
384
|
+
if (cached.hit)
|
|
385
|
+
return cached.value;
|
|
386
|
+
const response = await this.fetchWithResiliency(() => this.http.get('/feature-flags/stats/overview'));
|
|
387
|
+
this.cache.set(cacheKey, response.data);
|
|
388
|
+
return response.data;
|
|
389
|
+
}
|
|
390
|
+
async getFlagsByCategory(category) {
|
|
391
|
+
this.assertNotDisposed();
|
|
392
|
+
const cacheKey = `flags-by-category-${category}`;
|
|
393
|
+
const cached = this.cache.get(cacheKey);
|
|
394
|
+
if (cached.hit)
|
|
395
|
+
return cached.value;
|
|
396
|
+
const response = await this.fetchWithResiliency(() => this.http.get(`/feature-flags/category/${category}`));
|
|
397
|
+
this.cache.set(cacheKey, response.data);
|
|
398
|
+
return response.data;
|
|
399
|
+
}
|
|
400
|
+
async getFlagsByTargetService(serviceName) {
|
|
401
|
+
this.assertNotDisposed();
|
|
402
|
+
const cacheKey = `flags-by-service-${serviceName}`;
|
|
403
|
+
const cached = this.cache.get(cacheKey);
|
|
404
|
+
if (cached.hit)
|
|
405
|
+
return cached.value;
|
|
406
|
+
const response = await this.fetchWithResiliency(() => this.http.get(`/feature-flags/service/${serviceName}`));
|
|
407
|
+
this.cache.set(cacheKey, response.data);
|
|
408
|
+
return response.data;
|
|
409
|
+
}
|
|
410
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
411
|
+
// LOCAL OVERRIDES (dev/testing)
|
|
412
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
413
|
+
/**
|
|
414
|
+
* Set a local override for a flag. Overrides skip HTTP entirely.
|
|
415
|
+
* Useful for development and testing.
|
|
416
|
+
*/
|
|
417
|
+
setLocalOverride(slug, value) {
|
|
418
|
+
this.localOverrides[slug] = value;
|
|
419
|
+
this.logger.debug(`Local override set: "${slug}" = ${String(value)}`);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Remove a local override.
|
|
423
|
+
*/
|
|
424
|
+
removeLocalOverride(slug) {
|
|
425
|
+
delete this.localOverrides[slug];
|
|
426
|
+
this.logger.debug(`Local override removed: "${slug}"`);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Get all local overrides.
|
|
430
|
+
*/
|
|
431
|
+
getLocalOverrides() {
|
|
432
|
+
return { ...this.localOverrides };
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Clear all local overrides.
|
|
436
|
+
*/
|
|
437
|
+
clearLocalOverrides() {
|
|
438
|
+
Object.keys(this.localOverrides).forEach((key) => delete this.localOverrides[key]);
|
|
439
|
+
this.logger.debug('All local overrides cleared');
|
|
440
|
+
}
|
|
441
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
442
|
+
// UTILITY
|
|
443
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
444
|
+
/**
|
|
445
|
+
* Clear all cached data.
|
|
446
|
+
*/
|
|
447
|
+
clearCache() {
|
|
448
|
+
this.cache.clear();
|
|
449
|
+
this.events.emit('cacheCleared', undefined);
|
|
450
|
+
this.logger.debug('Cache cleared');
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Get cache statistics.
|
|
454
|
+
*/
|
|
455
|
+
getCacheStats() {
|
|
456
|
+
return this.cache.getStats();
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Get current circuit breaker state.
|
|
460
|
+
*/
|
|
461
|
+
getCircuitBreakerState() {
|
|
462
|
+
return {
|
|
463
|
+
state: this.circuitBreaker.getState(),
|
|
464
|
+
failures: this.circuitBreaker.getFailures(),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Reset the circuit breaker to closed state.
|
|
469
|
+
*/
|
|
470
|
+
resetCircuitBreaker() {
|
|
471
|
+
this.circuitBreaker.reset();
|
|
472
|
+
this.logger.info('Circuit breaker manually reset');
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Check if the client has been disposed.
|
|
476
|
+
*/
|
|
477
|
+
isDisposed() {
|
|
478
|
+
return this.disposed;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Get a snapshot of all collected impact metrics.
|
|
482
|
+
* Includes per-flag evaluation counts, cache hit rates, latency percentiles,
|
|
483
|
+
* and experiment exposure counts.
|
|
484
|
+
*/
|
|
485
|
+
getImpactMetrics() {
|
|
486
|
+
return this.metrics.getSnapshot();
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Reset all collected impact metrics counters.
|
|
490
|
+
*/
|
|
491
|
+
resetMetrics() {
|
|
492
|
+
this.metrics.reset();
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Dispose the client, releasing all resources (timers, listeners, metrics).
|
|
496
|
+
* After calling dispose, the client cannot be used again.
|
|
497
|
+
*/
|
|
498
|
+
dispose() {
|
|
499
|
+
this.disposed = true;
|
|
500
|
+
this.cache.destroy();
|
|
501
|
+
this.metrics.destroy();
|
|
502
|
+
this.streamClient?.dispose();
|
|
503
|
+
this.events.removeAllListeners();
|
|
504
|
+
this.previousValues.clear();
|
|
505
|
+
this.logger.debug('Client disposed');
|
|
506
|
+
}
|
|
507
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
508
|
+
// INTERNALS
|
|
509
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
510
|
+
async fetchWithResiliency(fn) {
|
|
511
|
+
return this.circuitBreaker.execute(() => withRetry(fn, this.retryConfig, this.logger, (attempt, error) => {
|
|
512
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
513
|
+
this.events.emit('requestFailed', {
|
|
514
|
+
endpoint: 'unknown',
|
|
515
|
+
error: errorMessage,
|
|
516
|
+
attempt,
|
|
517
|
+
});
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
buildCacheKey(prefix, slug, context) {
|
|
521
|
+
const parts = [prefix];
|
|
522
|
+
if (slug)
|
|
523
|
+
parts.push(slug);
|
|
524
|
+
if (context?.workspaceId)
|
|
525
|
+
parts.push(`w:${context.workspaceId}`);
|
|
526
|
+
if (context?.userId)
|
|
527
|
+
parts.push(`u:${context.userId}`);
|
|
528
|
+
if (context?.attributes) {
|
|
529
|
+
const sorted = Object.keys(context.attributes).sort();
|
|
530
|
+
for (const k of sorted) {
|
|
531
|
+
parts.push(`${k}:${context.attributes[k]}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return parts.join(':');
|
|
535
|
+
}
|
|
536
|
+
detectChange(slug, newValue) {
|
|
537
|
+
const previousValue = this.previousValues.get(slug);
|
|
538
|
+
if (previousValue !== undefined && previousValue !== newValue) {
|
|
539
|
+
this.events.emit('flagChanged', {
|
|
540
|
+
slug,
|
|
541
|
+
previousValue,
|
|
542
|
+
newValue,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
this.previousValues.set(slug, newValue);
|
|
546
|
+
}
|
|
547
|
+
emitEvaluated(slug, value, reason, startTime) {
|
|
548
|
+
this.events.emit('flagEvaluated', {
|
|
549
|
+
slug,
|
|
550
|
+
value,
|
|
551
|
+
reason: reason,
|
|
552
|
+
durationMs: Date.now() - startTime,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
assertNotDisposed() {
|
|
556
|
+
if (this.disposed) {
|
|
557
|
+
throw new Error('FeatureFlagsClient has been disposed. Create a new instance.');
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { FlagDocument, EvaluationContext, FlagValue, ExperimentAssignment, TrackingCallback } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Result of an edge evaluation for a single flag.
|
|
4
|
+
*/
|
|
5
|
+
export interface EdgeEvaluationResult {
|
|
6
|
+
value: FlagValue;
|
|
7
|
+
reason: 'TARGETING_MATCH' | 'PERCENTAGE_ROLLOUT' | 'EXPERIMENT_ASSIGNMENT' | 'DEFAULT' | 'LOCAL_OVERRIDE';
|
|
8
|
+
assignment?: ExperimentAssignment;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Offline / Local Evaluator Engine.
|
|
12
|
+
* Takes a pre-fetched `FlagDocument` and evaluates flags entirely in-memory
|
|
13
|
+
* without making any HTTP calls. Perfect for edge workers or serverless.
|
|
14
|
+
*/
|
|
15
|
+
export declare class EdgeEvaluator {
|
|
16
|
+
private document;
|
|
17
|
+
private flagIndex;
|
|
18
|
+
private readonly fallbackDefaults;
|
|
19
|
+
private readonly trackingCallback?;
|
|
20
|
+
constructor(document: FlagDocument, fallbackDefaults?: Record<string, FlagValue>, trackingCallback?: TrackingCallback);
|
|
21
|
+
/**
|
|
22
|
+
* Update the internal document with a fresh one.
|
|
23
|
+
*/
|
|
24
|
+
updateDocument(document: FlagDocument): void;
|
|
25
|
+
private rebuildIndex;
|
|
26
|
+
/**
|
|
27
|
+
* Evaluate a single flag against a context.
|
|
28
|
+
*/
|
|
29
|
+
evaluate<T extends FlagValue = boolean>(slug: string, context: EvaluationContext, localOverrides?: Record<string, FlagValue>): EdgeEvaluationResult;
|
|
30
|
+
/**
|
|
31
|
+
* Evaluate all flags in the document at once.
|
|
32
|
+
*/
|
|
33
|
+
evaluateAll(context: EvaluationContext, localOverrides?: Record<string, FlagValue>): Record<string, FlagValue>;
|
|
34
|
+
private evaluateInner;
|
|
35
|
+
}
|