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
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { FeatureFlagsClient } from './shared/client';
|
|
2
|
+
export { InMemoryCache } from './shared/cache';
|
|
3
|
+
export { ConsoleLogger } from './shared/logger';
|
|
4
|
+
export { CircuitBreaker, CircuitOpenError } from './shared/circuit-breaker';
|
|
5
|
+
export { EventEmitter } from './shared/event-emitter';
|
|
6
|
+
export { withRetry } from './shared/retry';
|
|
7
|
+
export { EdgeEvaluator } from './shared/edge-evaluator';
|
|
8
|
+
export { FlagStreamClient } from './shared/streaming';
|
|
9
|
+
export { evaluateRules, evaluateRule } from './shared/targeting';
|
|
10
|
+
export { isInRollout, getHashBucket } from './shared/rollout';
|
|
11
|
+
export { assignVariation } from './shared/experiment';
|
|
12
|
+
export { ImpactMetrics } from './shared/metrics';
|
|
13
|
+
export type { FeatureFlag, WorkspaceFeatureFlag, FlagValue, FlagValueType, CreateFlagData, UpdateFlagData, SetWorkspaceFlagData, EvaluationContext, EvaluationReason, FeatureFlagEvaluation, BatchEvaluation, FeatureFlagStats, FeatureFlagsConfig, RetryConfig, CircuitBreakerConfig, LogLevel, ILogger, FeatureFlyEvent, EventHandler, EventPayloadMap, FlagEvaluatedPayload, FlagChangedPayload, RequestFailedPayload, CircuitStatePayload, TargetingOperator, TargetingCondition, TargetingRule, RolloutConfig, Variation, Experiment, ExperimentAssignment, TrackingCallback, StreamingConfig, FlagDocument, } from './shared/types';
|
|
14
|
+
export type { MetricsSnapshot, FlagMetric, ExperimentMetric, } from './shared/metrics';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// FeatureFly - Framework-Agnostic Feature Flags SDK
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
// Core client
|
|
5
|
+
export { FeatureFlagsClient } from './shared/client';
|
|
6
|
+
// Cache
|
|
7
|
+
export { InMemoryCache } from './shared/cache';
|
|
8
|
+
// Logger
|
|
9
|
+
export { ConsoleLogger } from './shared/logger';
|
|
10
|
+
// Circuit Breaker
|
|
11
|
+
export { CircuitBreaker, CircuitOpenError } from './shared/circuit-breaker';
|
|
12
|
+
// Event Emitter
|
|
13
|
+
export { EventEmitter } from './shared/event-emitter';
|
|
14
|
+
// Retry
|
|
15
|
+
export { withRetry } from './shared/retry';
|
|
16
|
+
// New Advanced Modules
|
|
17
|
+
export { EdgeEvaluator } from './shared/edge-evaluator';
|
|
18
|
+
export { FlagStreamClient } from './shared/streaming';
|
|
19
|
+
export { evaluateRules, evaluateRule } from './shared/targeting';
|
|
20
|
+
export { isInRollout, getHashBucket } from './shared/rollout';
|
|
21
|
+
export { assignVariation } from './shared/experiment';
|
|
22
|
+
export { ImpactMetrics } from './shared/metrics';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { FeatureFlagsClient } from '../shared/client';
|
|
3
|
+
import type { EvaluationContext, FlagValue } from '../shared/types';
|
|
4
|
+
/**
|
|
5
|
+
* Provider component that makes the FeatureFlagsClient available to all hooks.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <FeatureFlyProvider client={client}>
|
|
10
|
+
* <App />
|
|
11
|
+
* </FeatureFlyProvider>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare function FeatureFlyProvider({ client, children, }: {
|
|
15
|
+
client: FeatureFlagsClient;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}): import("react").FunctionComponentElement<import("react").ProviderProps<FeatureFlagsClient | null>>;
|
|
18
|
+
export interface UseFeatureFlagResult<T> {
|
|
19
|
+
value: T;
|
|
20
|
+
loading: boolean;
|
|
21
|
+
error: Error | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* React hook for evaluating a single feature flag.
|
|
25
|
+
* Automatically re-evaluates when the flag changes via streaming or cache invalidation.
|
|
26
|
+
*
|
|
27
|
+
* @param slug Flag slug identifier
|
|
28
|
+
* @param defaultValue Default value while loading
|
|
29
|
+
* @param context Optional evaluation context
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* const { value, loading } = useFeatureFlag('new-checkout', false);
|
|
34
|
+
*
|
|
35
|
+
* if (loading) return <Spinner />;
|
|
36
|
+
* return value ? <NewCheckout /> : <LegacyCheckout />;
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare function useFeatureFlag<T extends FlagValue = boolean>(slug: string, defaultValue: T, context?: EvaluationContext): UseFeatureFlagResult<T>;
|
|
40
|
+
export interface UseAllFlagsResult {
|
|
41
|
+
flags: Record<string, FlagValue>;
|
|
42
|
+
loading: boolean;
|
|
43
|
+
error: Error | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* React hook for batch-evaluating all feature flags.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* const { flags, loading } = useAllFlags({ workspaceId: 'ws-123' });
|
|
51
|
+
* if (flags['dark-mode']) { ... }
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function useAllFlags(context?: EvaluationContext): UseAllFlagsResult;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// FeatureFly — React Hooks
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Thin wrapper providing React hooks for FeatureFly SDK.
|
|
6
|
+
// Requires React 18+ (useSyncExternalStore).
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// import { FeatureFlyProvider, useFeatureFlag, useAllFlags } from 'featurefly/react';
|
|
10
|
+
//
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
12
|
+
import { createContext, useContext, useState, useEffect, useCallback, useMemo, createElement, } from 'react';
|
|
13
|
+
// ─── Context ────────────────────────────────────────────────────────────────────
|
|
14
|
+
const FeatureFlyContext = createContext(null);
|
|
15
|
+
/**
|
|
16
|
+
* Provider component that makes the FeatureFlagsClient available to all hooks.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <FeatureFlyProvider client={client}>
|
|
21
|
+
* <App />
|
|
22
|
+
* </FeatureFlyProvider>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function FeatureFlyProvider({ client, children, }) {
|
|
26
|
+
return createElement(FeatureFlyContext.Provider, { value: client }, children);
|
|
27
|
+
}
|
|
28
|
+
function useClient() {
|
|
29
|
+
const client = useContext(FeatureFlyContext);
|
|
30
|
+
if (!client) {
|
|
31
|
+
throw new Error('useFeatureFlag must be used within a <FeatureFlyProvider>. ' +
|
|
32
|
+
'Wrap your component tree with <FeatureFlyProvider client={client}>.');
|
|
33
|
+
}
|
|
34
|
+
return client;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* React hook for evaluating a single feature flag.
|
|
38
|
+
* Automatically re-evaluates when the flag changes via streaming or cache invalidation.
|
|
39
|
+
*
|
|
40
|
+
* @param slug Flag slug identifier
|
|
41
|
+
* @param defaultValue Default value while loading
|
|
42
|
+
* @param context Optional evaluation context
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```tsx
|
|
46
|
+
* const { value, loading } = useFeatureFlag('new-checkout', false);
|
|
47
|
+
*
|
|
48
|
+
* if (loading) return <Spinner />;
|
|
49
|
+
* return value ? <NewCheckout /> : <LegacyCheckout />;
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function useFeatureFlag(slug, defaultValue, context) {
|
|
53
|
+
const client = useClient();
|
|
54
|
+
const [value, setValue] = useState(defaultValue);
|
|
55
|
+
const [loading, setLoading] = useState(true);
|
|
56
|
+
const [error, setError] = useState(null);
|
|
57
|
+
// Memoize context to avoid infinite re-renders
|
|
58
|
+
const contextKey = useMemo(() => JSON.stringify(context ?? {}), [context?.userId, context?.workspaceId, context?.attributes]);
|
|
59
|
+
const evaluate = useCallback(async () => {
|
|
60
|
+
try {
|
|
61
|
+
setLoading(true);
|
|
62
|
+
const result = await client.evaluateFlag(slug, context);
|
|
63
|
+
setValue(result);
|
|
64
|
+
setError(null);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
}, [client, slug, contextKey]);
|
|
73
|
+
// Initial evaluation
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
evaluate();
|
|
76
|
+
}, [evaluate]);
|
|
77
|
+
// Re-evaluate on stream updates or flag changes
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const unsubs = [];
|
|
80
|
+
unsubs.push(client.on('flagsUpdated', () => evaluate()));
|
|
81
|
+
unsubs.push(client.on('flagChanged', (payload) => {
|
|
82
|
+
if (payload.slug === slug)
|
|
83
|
+
evaluate();
|
|
84
|
+
}));
|
|
85
|
+
return () => unsubs.forEach((u) => u());
|
|
86
|
+
}, [client, slug, evaluate]);
|
|
87
|
+
return { value, loading, error };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* React hook for batch-evaluating all feature flags.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```tsx
|
|
94
|
+
* const { flags, loading } = useAllFlags({ workspaceId: 'ws-123' });
|
|
95
|
+
* if (flags['dark-mode']) { ... }
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export function useAllFlags(context) {
|
|
99
|
+
const client = useClient();
|
|
100
|
+
const [flags, setFlags] = useState({});
|
|
101
|
+
const [loading, setLoading] = useState(true);
|
|
102
|
+
const [error, setError] = useState(null);
|
|
103
|
+
const contextKey = useMemo(() => JSON.stringify(context ?? {}), [context?.userId, context?.workspaceId, context?.attributes]);
|
|
104
|
+
const evaluate = useCallback(async () => {
|
|
105
|
+
try {
|
|
106
|
+
setLoading(true);
|
|
107
|
+
const result = await client.evaluateAllFlags(context);
|
|
108
|
+
setFlags(result);
|
|
109
|
+
setError(null);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
setLoading(false);
|
|
116
|
+
}
|
|
117
|
+
}, [client, contextKey]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
evaluate();
|
|
120
|
+
}, [evaluate]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const unsub = client.on('flagsUpdated', () => evaluate());
|
|
123
|
+
return () => unsub();
|
|
124
|
+
}, [client, evaluate]);
|
|
125
|
+
return { flags, loading, error };
|
|
126
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache with TTL expiration and automatic cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Uses a wrapper object `{ hit: true, value: T }` pattern internally
|
|
5
|
+
* to correctly handle falsy values (false, 0, '', null).
|
|
6
|
+
*/
|
|
7
|
+
export declare class InMemoryCache {
|
|
8
|
+
private readonly store;
|
|
9
|
+
private readonly ttlMs;
|
|
10
|
+
private cleanupTimer;
|
|
11
|
+
constructor(ttlMs?: number);
|
|
12
|
+
/**
|
|
13
|
+
* Get a cached value. Returns `{ hit: true, value }` if found and not expired,
|
|
14
|
+
* or `{ hit: false }` otherwise. This avoids ambiguity with falsy values.
|
|
15
|
+
*/
|
|
16
|
+
get<T>(key: string): {
|
|
17
|
+
hit: true;
|
|
18
|
+
value: T;
|
|
19
|
+
} | {
|
|
20
|
+
hit: false;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Check if a key exists AND is not expired.
|
|
24
|
+
*/
|
|
25
|
+
has(key: string): boolean;
|
|
26
|
+
set<T>(key: string, value: T): void;
|
|
27
|
+
delete(key: string): boolean;
|
|
28
|
+
clear(): void;
|
|
29
|
+
getStats(): {
|
|
30
|
+
size: number;
|
|
31
|
+
keys: string[];
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Release the cleanup timer. Call this when the client is being disposed
|
|
36
|
+
* to prevent memory leaks and dangling timers.
|
|
37
|
+
*/
|
|
38
|
+
destroy(): void;
|
|
39
|
+
private cleanup;
|
|
40
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache with TTL expiration and automatic cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Uses a wrapper object `{ hit: true, value: T }` pattern internally
|
|
5
|
+
* to correctly handle falsy values (false, 0, '', null).
|
|
6
|
+
*/
|
|
7
|
+
export class InMemoryCache {
|
|
8
|
+
constructor(ttlMs = 60000) {
|
|
9
|
+
this.store = new Map();
|
|
10
|
+
this.cleanupTimer = null;
|
|
11
|
+
this.ttlMs = ttlMs;
|
|
12
|
+
if (ttlMs > 0) {
|
|
13
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), Math.max(ttlMs, 30000));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// ─── Read ────────────────────────────────────────────────────────────────────
|
|
17
|
+
/**
|
|
18
|
+
* Get a cached value. Returns `{ hit: true, value }` if found and not expired,
|
|
19
|
+
* or `{ hit: false }` otherwise. This avoids ambiguity with falsy values.
|
|
20
|
+
*/
|
|
21
|
+
get(key) {
|
|
22
|
+
if (this.ttlMs <= 0)
|
|
23
|
+
return { hit: false };
|
|
24
|
+
const entry = this.store.get(key);
|
|
25
|
+
if (!entry)
|
|
26
|
+
return { hit: false };
|
|
27
|
+
if (Date.now() > entry.expiresAt) {
|
|
28
|
+
this.store.delete(key);
|
|
29
|
+
return { hit: false };
|
|
30
|
+
}
|
|
31
|
+
return { hit: true, value: entry.value };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check if a key exists AND is not expired.
|
|
35
|
+
*/
|
|
36
|
+
has(key) {
|
|
37
|
+
return this.get(key).hit;
|
|
38
|
+
}
|
|
39
|
+
// ─── Write ───────────────────────────────────────────────────────────────────
|
|
40
|
+
set(key, value) {
|
|
41
|
+
if (this.ttlMs <= 0)
|
|
42
|
+
return;
|
|
43
|
+
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
44
|
+
}
|
|
45
|
+
delete(key) {
|
|
46
|
+
return this.store.delete(key);
|
|
47
|
+
}
|
|
48
|
+
clear() {
|
|
49
|
+
this.store.clear();
|
|
50
|
+
}
|
|
51
|
+
// ─── Stats ───────────────────────────────────────────────────────────────────
|
|
52
|
+
getStats() {
|
|
53
|
+
return {
|
|
54
|
+
size: this.store.size,
|
|
55
|
+
keys: Array.from(this.store.keys()),
|
|
56
|
+
enabled: this.ttlMs > 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
|
60
|
+
/**
|
|
61
|
+
* Release the cleanup timer. Call this when the client is being disposed
|
|
62
|
+
* to prevent memory leaks and dangling timers.
|
|
63
|
+
*/
|
|
64
|
+
destroy() {
|
|
65
|
+
if (this.cleanupTimer) {
|
|
66
|
+
clearInterval(this.cleanupTimer);
|
|
67
|
+
this.cleanupTimer = null;
|
|
68
|
+
}
|
|
69
|
+
this.store.clear();
|
|
70
|
+
}
|
|
71
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
72
|
+
cleanup() {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
for (const [key, entry] of this.store.entries()) {
|
|
75
|
+
if (now > entry.expiresAt) {
|
|
76
|
+
this.store.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ILogger } from './types';
|
|
2
|
+
export type CircuitState = 'closed' | 'open' | 'half-open';
|
|
3
|
+
export interface CircuitBreakerOptions {
|
|
4
|
+
failureThreshold: number;
|
|
5
|
+
resetTimeoutMs: number;
|
|
6
|
+
logger: ILogger;
|
|
7
|
+
onStateChange?: (state: CircuitState, failures: number) => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Circuit breaker to prevent cascading failures when the feature flag API is down.
|
|
11
|
+
*
|
|
12
|
+
* States:
|
|
13
|
+
* - CLOSED: Requests pass through. Failures increment counter.
|
|
14
|
+
* - OPEN: Requests are rejected immediately. After resetTimeoutMs, transitions to HALF-OPEN.
|
|
15
|
+
* - HALF-OPEN: One probe request is allowed. Success → CLOSED, failure → OPEN.
|
|
16
|
+
*/
|
|
17
|
+
export declare class CircuitBreaker {
|
|
18
|
+
private state;
|
|
19
|
+
private failures;
|
|
20
|
+
private lastFailureTime;
|
|
21
|
+
private readonly options;
|
|
22
|
+
constructor(options: CircuitBreakerOptions);
|
|
23
|
+
getState(): CircuitState;
|
|
24
|
+
getFailures(): number;
|
|
25
|
+
/**
|
|
26
|
+
* Execute a function through the circuit breaker.
|
|
27
|
+
* If the circuit is open, throws immediately.
|
|
28
|
+
* If the circuit is half-open, allows one probe request.
|
|
29
|
+
*/
|
|
30
|
+
execute<T>(fn: () => Promise<T>): Promise<T>;
|
|
31
|
+
/**
|
|
32
|
+
* Manually reset the circuit to closed state.
|
|
33
|
+
*/
|
|
34
|
+
reset(): void;
|
|
35
|
+
private onSuccess;
|
|
36
|
+
private onFailure;
|
|
37
|
+
private shouldAttemptReset;
|
|
38
|
+
private transitionTo;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Error thrown when the circuit breaker is open and rejecting requests.
|
|
42
|
+
*/
|
|
43
|
+
export declare class CircuitOpenError extends Error {
|
|
44
|
+
readonly name = "CircuitOpenError";
|
|
45
|
+
constructor(message: string);
|
|
46
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker to prevent cascading failures when the feature flag API is down.
|
|
3
|
+
*
|
|
4
|
+
* States:
|
|
5
|
+
* - CLOSED: Requests pass through. Failures increment counter.
|
|
6
|
+
* - OPEN: Requests are rejected immediately. After resetTimeoutMs, transitions to HALF-OPEN.
|
|
7
|
+
* - HALF-OPEN: One probe request is allowed. Success → CLOSED, failure → OPEN.
|
|
8
|
+
*/
|
|
9
|
+
export class CircuitBreaker {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.state = 'closed';
|
|
12
|
+
this.failures = 0;
|
|
13
|
+
this.lastFailureTime = 0;
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
getState() {
|
|
17
|
+
return this.state;
|
|
18
|
+
}
|
|
19
|
+
getFailures() {
|
|
20
|
+
return this.failures;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Execute a function through the circuit breaker.
|
|
24
|
+
* If the circuit is open, throws immediately.
|
|
25
|
+
* If the circuit is half-open, allows one probe request.
|
|
26
|
+
*/
|
|
27
|
+
async execute(fn) {
|
|
28
|
+
if (this.state === 'open') {
|
|
29
|
+
if (this.shouldAttemptReset()) {
|
|
30
|
+
this.transitionTo('half-open');
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
throw new CircuitOpenError(`Circuit breaker is OPEN. ${this.failures} consecutive failures. ` +
|
|
34
|
+
`Will retry after ${new Date(this.lastFailureTime + this.options.resetTimeoutMs).toISOString()}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const result = await fn();
|
|
39
|
+
this.onSuccess();
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
this.onFailure();
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Manually reset the circuit to closed state.
|
|
49
|
+
*/
|
|
50
|
+
reset() {
|
|
51
|
+
this.transitionTo('closed');
|
|
52
|
+
this.failures = 0;
|
|
53
|
+
this.lastFailureTime = 0;
|
|
54
|
+
}
|
|
55
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
56
|
+
onSuccess() {
|
|
57
|
+
if (this.state === 'half-open' || this.failures > 0) {
|
|
58
|
+
this.failures = 0;
|
|
59
|
+
this.transitionTo('closed');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
onFailure() {
|
|
63
|
+
this.failures++;
|
|
64
|
+
this.lastFailureTime = Date.now();
|
|
65
|
+
if (this.failures >= this.options.failureThreshold) {
|
|
66
|
+
this.transitionTo('open');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
shouldAttemptReset() {
|
|
70
|
+
return Date.now() - this.lastFailureTime >= this.options.resetTimeoutMs;
|
|
71
|
+
}
|
|
72
|
+
transitionTo(newState) {
|
|
73
|
+
if (this.state === newState)
|
|
74
|
+
return;
|
|
75
|
+
const oldState = this.state;
|
|
76
|
+
this.state = newState;
|
|
77
|
+
this.options.logger.info(`Circuit breaker: ${oldState} → ${newState} (failures: ${this.failures})`);
|
|
78
|
+
this.options.onStateChange?.(newState, this.failures);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Error thrown when the circuit breaker is open and rejecting requests.
|
|
83
|
+
*/
|
|
84
|
+
export class CircuitOpenError extends Error {
|
|
85
|
+
constructor(message) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = 'CircuitOpenError';
|
|
88
|
+
Object.setPrototypeOf(this, CircuitOpenError.prototype);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { FeatureFlag, WorkspaceFeatureFlag, CreateFlagData, UpdateFlagData, FeatureFlagStats, FeatureFlagsConfig, FlagValue, EvaluationContext, FeatureFlyEvent, EventHandler } from './types';
|
|
2
|
+
import { MetricsSnapshot } from './metrics';
|
|
3
|
+
/**
|
|
4
|
+
* FeatureFly SDK Client
|
|
5
|
+
*
|
|
6
|
+
* Framework-agnostic feature flags client with:
|
|
7
|
+
* - In-memory caching with TTL
|
|
8
|
+
* - Retry with exponential backoff + jitter
|
|
9
|
+
* - Circuit breaker for resilience
|
|
10
|
+
* - Typed event system
|
|
11
|
+
* - Local overrides for dev/testing
|
|
12
|
+
* - Fallback defaults for graceful degradation
|
|
13
|
+
* - Multi-type flag values (boolean, string, number, JSON)
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const client = new FeatureFlagsClient({
|
|
18
|
+
* baseUrl: 'https://api.example.com',
|
|
19
|
+
* apiKey: 'your-key',
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* const isEnabled = await client.evaluateFlag('new-feature', { workspaceId: '123' });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare class FeatureFlagsClient {
|
|
26
|
+
private readonly http;
|
|
27
|
+
private readonly cache;
|
|
28
|
+
private readonly circuitBreaker;
|
|
29
|
+
private readonly events;
|
|
30
|
+
private readonly logger;
|
|
31
|
+
private readonly retryConfig;
|
|
32
|
+
private readonly localOverrides;
|
|
33
|
+
private readonly fallbackDefaults;
|
|
34
|
+
private readonly previousValues;
|
|
35
|
+
private streamClient?;
|
|
36
|
+
private edgeEvaluator?;
|
|
37
|
+
private readonly metrics;
|
|
38
|
+
private disposed;
|
|
39
|
+
constructor(config: FeatureFlagsConfig);
|
|
40
|
+
/**
|
|
41
|
+
* Start or resume the SSE streaming connection.
|
|
42
|
+
*/
|
|
43
|
+
startStreaming(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Stop the SSE streaming connection.
|
|
46
|
+
*/
|
|
47
|
+
stopStreaming(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Fetch a full FlagDocument from the API to initialize Edge Evaluation mode.
|
|
50
|
+
* If streaming is enabled, updates will auto-refresh the document.
|
|
51
|
+
*/
|
|
52
|
+
loadEdgeDocument(): Promise<void>;
|
|
53
|
+
private refreshEdgeDocument;
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe to SDK events.
|
|
56
|
+
* @returns Unsubscribe function
|
|
57
|
+
*/
|
|
58
|
+
on<E extends FeatureFlyEvent>(event: E, handler: EventHandler<E>): () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Subscribe to an event once.
|
|
61
|
+
*/
|
|
62
|
+
once<E extends FeatureFlyEvent>(event: E, handler: EventHandler<E>): () => void;
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate a single flag. Returns the flag value.
|
|
65
|
+
*
|
|
66
|
+
* Resolution order:
|
|
67
|
+
* 1. Local overrides (dev/testing)
|
|
68
|
+
* 2. Cache hit
|
|
69
|
+
* 3. Remote API call
|
|
70
|
+
* 4. Fallback defaults
|
|
71
|
+
*/
|
|
72
|
+
evaluateFlag<T extends FlagValue = boolean>(slug: string, context?: EvaluationContext): Promise<T>;
|
|
73
|
+
/**
|
|
74
|
+
* Evaluate all flags in a single batch request.
|
|
75
|
+
*/
|
|
76
|
+
evaluateAllFlags(context?: EvaluationContext): Promise<Record<string, FlagValue>>;
|
|
77
|
+
createFlag(data: CreateFlagData): Promise<FeatureFlag>;
|
|
78
|
+
getAllFlags(): Promise<FeatureFlag[]>;
|
|
79
|
+
getFlagById(id: string): Promise<FeatureFlag | null>;
|
|
80
|
+
getFlagBySlug(slug: string): Promise<FeatureFlag | null>;
|
|
81
|
+
updateFlag(id: string, data: UpdateFlagData): Promise<FeatureFlag>;
|
|
82
|
+
deleteFlag(id: string): Promise<void>;
|
|
83
|
+
setWorkspaceFlag(slug: string, workspaceId: string, value: FlagValue): Promise<WorkspaceFeatureFlag>;
|
|
84
|
+
removeWorkspaceFlag(slug: string, workspaceId: string): Promise<void>;
|
|
85
|
+
getWorkspaceFlags(workspaceId: string): Promise<WorkspaceFeatureFlag[]>;
|
|
86
|
+
getFlagStats(): Promise<FeatureFlagStats>;
|
|
87
|
+
getFlagsByCategory(category: 'frontend' | 'backend' | 'both'): Promise<FeatureFlag[]>;
|
|
88
|
+
getFlagsByTargetService(serviceName: string): Promise<FeatureFlag[]>;
|
|
89
|
+
/**
|
|
90
|
+
* Set a local override for a flag. Overrides skip HTTP entirely.
|
|
91
|
+
* Useful for development and testing.
|
|
92
|
+
*/
|
|
93
|
+
setLocalOverride(slug: string, value: FlagValue): void;
|
|
94
|
+
/**
|
|
95
|
+
* Remove a local override.
|
|
96
|
+
*/
|
|
97
|
+
removeLocalOverride(slug: string): void;
|
|
98
|
+
/**
|
|
99
|
+
* Get all local overrides.
|
|
100
|
+
*/
|
|
101
|
+
getLocalOverrides(): Record<string, FlagValue>;
|
|
102
|
+
/**
|
|
103
|
+
* Clear all local overrides.
|
|
104
|
+
*/
|
|
105
|
+
clearLocalOverrides(): void;
|
|
106
|
+
/**
|
|
107
|
+
* Clear all cached data.
|
|
108
|
+
*/
|
|
109
|
+
clearCache(): void;
|
|
110
|
+
/**
|
|
111
|
+
* Get cache statistics.
|
|
112
|
+
*/
|
|
113
|
+
getCacheStats(): {
|
|
114
|
+
size: number;
|
|
115
|
+
keys: string[];
|
|
116
|
+
enabled: boolean;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Get current circuit breaker state.
|
|
120
|
+
*/
|
|
121
|
+
getCircuitBreakerState(): {
|
|
122
|
+
state: string;
|
|
123
|
+
failures: number;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Reset the circuit breaker to closed state.
|
|
127
|
+
*/
|
|
128
|
+
resetCircuitBreaker(): void;
|
|
129
|
+
/**
|
|
130
|
+
* Check if the client has been disposed.
|
|
131
|
+
*/
|
|
132
|
+
isDisposed(): boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Get a snapshot of all collected impact metrics.
|
|
135
|
+
* Includes per-flag evaluation counts, cache hit rates, latency percentiles,
|
|
136
|
+
* and experiment exposure counts.
|
|
137
|
+
*/
|
|
138
|
+
getImpactMetrics(): MetricsSnapshot;
|
|
139
|
+
/**
|
|
140
|
+
* Reset all collected impact metrics counters.
|
|
141
|
+
*/
|
|
142
|
+
resetMetrics(): void;
|
|
143
|
+
/**
|
|
144
|
+
* Dispose the client, releasing all resources (timers, listeners, metrics).
|
|
145
|
+
* After calling dispose, the client cannot be used again.
|
|
146
|
+
*/
|
|
147
|
+
dispose(): void;
|
|
148
|
+
private fetchWithResiliency;
|
|
149
|
+
private buildCacheKey;
|
|
150
|
+
private detectChange;
|
|
151
|
+
private emitEvaluated;
|
|
152
|
+
private assertNotDisposed;
|
|
153
|
+
}
|