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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministically checks if a given key falls within a rollout percentage.
|
|
3
|
+
*
|
|
4
|
+
* @param key The stickiness key (e.g. userId)
|
|
5
|
+
* @param config Rollout configuration including percentage, salt, and bucket max
|
|
6
|
+
* @returns true if the key hashes to a bucket < percentage
|
|
7
|
+
*/
|
|
8
|
+
export function isInRollout(key, config) {
|
|
9
|
+
if (!config)
|
|
10
|
+
return false;
|
|
11
|
+
if (config.percentage <= 0)
|
|
12
|
+
return false;
|
|
13
|
+
if (config.percentage >= 100)
|
|
14
|
+
return true;
|
|
15
|
+
if (!key)
|
|
16
|
+
return false; // Anonymous users cannot be deterministically bucketed
|
|
17
|
+
const bucket = getHashBucket(key, config.salt, config.buckets);
|
|
18
|
+
// Example: percentage 20 means buckets 0-19 are true, 20-99 are false
|
|
19
|
+
// For buckets=1000, percentage 20.5 means buckets 0-204 are true
|
|
20
|
+
const maxBucket = (config.percentage / 100) * (config.buckets || 100);
|
|
21
|
+
return bucket < maxBucket;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Returns a deterministic bucket number (default 0-99) for a given key and salt.
|
|
25
|
+
* Uses MurmurHash3 (32-bit).
|
|
26
|
+
*/
|
|
27
|
+
export function getHashBucket(key, salt = '', buckets = 100) {
|
|
28
|
+
const hash = murmurhash3(`${salt}:${key}`);
|
|
29
|
+
return hash % buckets;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fast, pure-TypeScript implementation of MurmurHash3 (32-bit).
|
|
33
|
+
* Standard algorithm for deterministic feature flag bucket distribution.
|
|
34
|
+
*/
|
|
35
|
+
function murmurhash3(key, seed = 0) {
|
|
36
|
+
let h1b, k1;
|
|
37
|
+
let h1;
|
|
38
|
+
const remainder = key.length & 3;
|
|
39
|
+
const bytes = key.length - remainder;
|
|
40
|
+
h1 = seed;
|
|
41
|
+
const c1 = 0xcc9e2d51;
|
|
42
|
+
const c2 = 0x1b873593;
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < bytes) {
|
|
45
|
+
k1 =
|
|
46
|
+
((key.charCodeAt(i) & 0xff)) |
|
|
47
|
+
((key.charCodeAt(++i) & 0xff) << 8) |
|
|
48
|
+
((key.charCodeAt(++i) & 0xff) << 16) |
|
|
49
|
+
((key.charCodeAt(++i) & 0xff) << 24);
|
|
50
|
+
++i;
|
|
51
|
+
k1 = ((((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16))) & 0xffffffff;
|
|
52
|
+
k1 = (k1 << 15) | (k1 >>> 17);
|
|
53
|
+
k1 = ((((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16))) & 0xffffffff;
|
|
54
|
+
h1 ^= k1;
|
|
55
|
+
h1 = (h1 << 13) | (h1 >>> 19);
|
|
56
|
+
h1b = ((((h1 & 0xffff) * 5) + ((((h1 >>> 16) * 5) & 0xffff) << 16))) & 0xffffffff;
|
|
57
|
+
h1 = (((h1b & 0xffff) + 0x6b64) + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16));
|
|
58
|
+
}
|
|
59
|
+
k1 = 0;
|
|
60
|
+
switch (remainder) {
|
|
61
|
+
case 3: k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
|
|
62
|
+
case 2: k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
|
|
63
|
+
case 1:
|
|
64
|
+
k1 ^= (key.charCodeAt(i) & 0xff);
|
|
65
|
+
k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff;
|
|
66
|
+
k1 = (k1 << 15) | (k1 >>> 17);
|
|
67
|
+
k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff;
|
|
68
|
+
h1 ^= k1;
|
|
69
|
+
}
|
|
70
|
+
h1 ^= key.length;
|
|
71
|
+
h1 ^= h1 >>> 16;
|
|
72
|
+
h1 = (((h1 & 0xffff) * 0x85ebca6b) + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff;
|
|
73
|
+
h1 ^= h1 >>> 13;
|
|
74
|
+
h1 = (((h1 & 0xffff) * 0xc2b2ae35) + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16)) & 0xffffffff;
|
|
75
|
+
h1 ^= h1 >>> 16;
|
|
76
|
+
return h1 >>> 0;
|
|
77
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { StreamingConfig, ILogger } from './types';
|
|
2
|
+
import { EventEmitter } from './event-emitter';
|
|
3
|
+
/**
|
|
4
|
+
* Server-Sent Events (SSE) client for real-time feature flag updates.
|
|
5
|
+
*/
|
|
6
|
+
export declare class FlagStreamClient {
|
|
7
|
+
private readonly baseUrl;
|
|
8
|
+
private readonly apiKey;
|
|
9
|
+
private eventSource;
|
|
10
|
+
private readonly config;
|
|
11
|
+
private readonly logger;
|
|
12
|
+
private readonly events;
|
|
13
|
+
private reconnectAttempts;
|
|
14
|
+
private reconnectTimer;
|
|
15
|
+
private disposed;
|
|
16
|
+
constructor(baseUrl: string, apiKey: string | undefined, config: StreamingConfig, logger: ILogger, events: EventEmitter);
|
|
17
|
+
/**
|
|
18
|
+
* Connect to the SSE endpoint.
|
|
19
|
+
*/
|
|
20
|
+
connect(): void;
|
|
21
|
+
private handleUpdateEvent;
|
|
22
|
+
private scheduleReconnect;
|
|
23
|
+
/**
|
|
24
|
+
* Disconnect the stream. Automatically reconnects won't trigger unless connect() is called again.
|
|
25
|
+
*/
|
|
26
|
+
disconnect(): void;
|
|
27
|
+
/**
|
|
28
|
+
* Checks if the stream is currently connected.
|
|
29
|
+
*/
|
|
30
|
+
isConnected(): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Permanently dispose of the stream client.
|
|
33
|
+
*/
|
|
34
|
+
dispose(): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events (SSE) client for real-time feature flag updates.
|
|
3
|
+
*/
|
|
4
|
+
export class FlagStreamClient {
|
|
5
|
+
constructor(baseUrl, apiKey, config, logger, events) {
|
|
6
|
+
this.baseUrl = baseUrl;
|
|
7
|
+
this.apiKey = apiKey;
|
|
8
|
+
this.eventSource = null;
|
|
9
|
+
this.reconnectAttempts = 0;
|
|
10
|
+
this.reconnectTimer = null;
|
|
11
|
+
this.disposed = false;
|
|
12
|
+
this.handleUpdateEvent = (event) => {
|
|
13
|
+
try {
|
|
14
|
+
const data = JSON.parse(event.data);
|
|
15
|
+
this.logger.debug('Received stream update', data);
|
|
16
|
+
// Invalidate cache natively inside the client via events
|
|
17
|
+
this.events.emit('flagsUpdated', { source: 'stream', count: 1 });
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
this.logger.error('Failed to parse SSE message', event.data);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
this.config = {
|
|
24
|
+
reconnectDelayMs: 1000,
|
|
25
|
+
maxReconnectDelayMs: 30000,
|
|
26
|
+
...config,
|
|
27
|
+
};
|
|
28
|
+
this.logger = logger;
|
|
29
|
+
this.events = events;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Connect to the SSE endpoint.
|
|
33
|
+
*/
|
|
34
|
+
connect() {
|
|
35
|
+
if (this.disposed)
|
|
36
|
+
return;
|
|
37
|
+
if (this.eventSource)
|
|
38
|
+
return; // Already connected
|
|
39
|
+
const url = this.config.url || `${this.baseUrl}/feature-flags/stream`;
|
|
40
|
+
try {
|
|
41
|
+
// In browser or Node with EventSource polyfill
|
|
42
|
+
if (typeof EventSource !== 'undefined') {
|
|
43
|
+
const urlWithAuth = this.apiKey ? `${url}?apiKey=${this.apiKey}` : url;
|
|
44
|
+
this.eventSource = new EventSource(urlWithAuth);
|
|
45
|
+
this.eventSource.onopen = () => {
|
|
46
|
+
this.logger.info(`Stream connected to ${url}`);
|
|
47
|
+
this.reconnectAttempts = 0; // Reset backoff
|
|
48
|
+
this.events.emit('streamConnected', undefined);
|
|
49
|
+
};
|
|
50
|
+
this.eventSource.onerror = (err) => {
|
|
51
|
+
this.logger.warn(`Stream error, reconnecting...`, err);
|
|
52
|
+
this.scheduleReconnect();
|
|
53
|
+
};
|
|
54
|
+
// Listen for specific SSE events (requires type assertion due to DOM lib typings missing in some envs)
|
|
55
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
56
|
+
this.eventSource.addEventListener('flag.updated', this.handleUpdateEvent);
|
|
57
|
+
this.eventSource.addEventListener('flag.created', this.handleUpdateEvent);
|
|
58
|
+
this.eventSource.addEventListener('flag.deleted', this.handleUpdateEvent);
|
|
59
|
+
this.eventSource.addEventListener('message', this.handleUpdateEvent); // Fallback generic message
|
|
60
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
this.logger.warn('EventSource is not available in this environment, streaming disabled.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
this.logger.error('Failed to initialize EventSource:', error);
|
|
68
|
+
this.scheduleReconnect();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
scheduleReconnect() {
|
|
72
|
+
this.disconnect();
|
|
73
|
+
if (this.disposed)
|
|
74
|
+
return;
|
|
75
|
+
this.reconnectAttempts++;
|
|
76
|
+
const baseDelay = this.config.reconnectDelayMs || 1000;
|
|
77
|
+
const maxDelay = this.config.maxReconnectDelayMs || 30000;
|
|
78
|
+
// Exponential backoff with jitter
|
|
79
|
+
const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), maxDelay);
|
|
80
|
+
const jitter = Math.random() * 1000;
|
|
81
|
+
const actualDelay = delay + jitter;
|
|
82
|
+
this.logger.info(`Scheduling stream reconnect in ${Math.round(actualDelay)}ms (attempt ${this.reconnectAttempts})`);
|
|
83
|
+
this.reconnectTimer = setTimeout(() => {
|
|
84
|
+
this.connect();
|
|
85
|
+
}, actualDelay);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Disconnect the stream. Automatically reconnects won't trigger unless connect() is called again.
|
|
89
|
+
*/
|
|
90
|
+
disconnect() {
|
|
91
|
+
if (this.eventSource) {
|
|
92
|
+
this.eventSource.close();
|
|
93
|
+
this.eventSource = null;
|
|
94
|
+
this.events.emit('streamDisconnected', { error: undefined });
|
|
95
|
+
this.logger.info('Stream disconnected');
|
|
96
|
+
}
|
|
97
|
+
if (this.reconnectTimer) {
|
|
98
|
+
clearTimeout(this.reconnectTimer);
|
|
99
|
+
this.reconnectTimer = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Checks if the stream is currently connected.
|
|
104
|
+
*/
|
|
105
|
+
isConnected() {
|
|
106
|
+
if (typeof EventSource === 'undefined')
|
|
107
|
+
return false;
|
|
108
|
+
return this.eventSource?.readyState === EventSource.OPEN;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Permanently dispose of the stream client.
|
|
112
|
+
*/
|
|
113
|
+
dispose() {
|
|
114
|
+
this.disposed = true;
|
|
115
|
+
this.disconnect();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { EvaluationContext, FlagValue, TargetingRule } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Evaluates a set of targeting rules against an evaluation context.
|
|
4
|
+
* Returns the value of the first matching rule, or null if no rules match.
|
|
5
|
+
*/
|
|
6
|
+
export declare function evaluateRules(rules: TargetingRule[] | undefined, context: EvaluationContext | undefined): FlagValue | null;
|
|
7
|
+
/**
|
|
8
|
+
* Evaluates a single rule. A rule matches if ALL its conditions match (AND logic).
|
|
9
|
+
*/
|
|
10
|
+
export declare function evaluateRule(rule: TargetingRule, context: EvaluationContext | undefined): boolean;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluates a set of targeting rules against an evaluation context.
|
|
3
|
+
* Returns the value of the first matching rule, or null if no rules match.
|
|
4
|
+
*/
|
|
5
|
+
export function evaluateRules(rules, context) {
|
|
6
|
+
if (!rules || rules.length === 0)
|
|
7
|
+
return null;
|
|
8
|
+
// Sort by priority (lower number = higher priority / evaluated first)
|
|
9
|
+
const sortedRules = [...rules].sort((a, b) => a.priority - b.priority);
|
|
10
|
+
for (const rule of sortedRules) {
|
|
11
|
+
if (evaluateRule(rule, context)) {
|
|
12
|
+
// NOTE: Rollout percentage within a rule is evaluated separately by the rollout engine.
|
|
13
|
+
// If a rule matches, its value is the candidate.
|
|
14
|
+
return rule.value;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Evaluates a single rule. A rule matches if ALL its conditions match (AND logic).
|
|
21
|
+
*/
|
|
22
|
+
export function evaluateRule(rule, context) {
|
|
23
|
+
if (!rule.conditions || rule.conditions.length === 0) {
|
|
24
|
+
return true; // Empty conditions match everyone
|
|
25
|
+
}
|
|
26
|
+
for (const condition of rule.conditions) {
|
|
27
|
+
if (!evaluateCondition(condition, context)) {
|
|
28
|
+
return false; // ANY condition fails -> rule fails
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return true; // ALL conditions matched
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Evaluates a single condition against the context.
|
|
35
|
+
*/
|
|
36
|
+
function evaluateCondition(condition, context) {
|
|
37
|
+
const { attribute, operator, value: targetValue } = condition;
|
|
38
|
+
const contextValue = getContextAttribute(attribute, context);
|
|
39
|
+
if (contextValue === undefined && operator !== 'not_equals' && operator !== 'not_contains' && operator !== 'not_in') {
|
|
40
|
+
return false; // Missing attribute fails most checks
|
|
41
|
+
}
|
|
42
|
+
switch (operator) {
|
|
43
|
+
case 'equals':
|
|
44
|
+
return String(contextValue) === String(targetValue);
|
|
45
|
+
case 'not_equals':
|
|
46
|
+
return String(contextValue) !== String(targetValue);
|
|
47
|
+
case 'contains':
|
|
48
|
+
return typeof contextValue === 'string' && String(targetValue) !== '' && contextValue.includes(String(targetValue));
|
|
49
|
+
case 'not_contains':
|
|
50
|
+
return typeof contextValue !== 'string' || String(targetValue) === '' || !contextValue.includes(String(targetValue));
|
|
51
|
+
case 'starts_with':
|
|
52
|
+
return typeof contextValue === 'string' && String(targetValue) !== '' && contextValue.startsWith(String(targetValue));
|
|
53
|
+
case 'ends_with':
|
|
54
|
+
return typeof contextValue === 'string' && String(targetValue) !== '' && contextValue.endsWith(String(targetValue));
|
|
55
|
+
case 'in':
|
|
56
|
+
return Array.isArray(targetValue) && targetValue.map(String).includes(String(contextValue));
|
|
57
|
+
case 'not_in':
|
|
58
|
+
return !Array.isArray(targetValue) || !targetValue.map(String).includes(String(contextValue));
|
|
59
|
+
case 'gt':
|
|
60
|
+
return isNumeric(contextValue) && isNumeric(targetValue) && Number(contextValue) > Number(targetValue);
|
|
61
|
+
case 'gte':
|
|
62
|
+
return isNumeric(contextValue) && isNumeric(targetValue) && Number(contextValue) >= Number(targetValue);
|
|
63
|
+
case 'lt':
|
|
64
|
+
return isNumeric(contextValue) && isNumeric(targetValue) && Number(contextValue) < Number(targetValue);
|
|
65
|
+
case 'lte':
|
|
66
|
+
return isNumeric(contextValue) && isNumeric(targetValue) && Number(contextValue) <= Number(targetValue);
|
|
67
|
+
case 'regex':
|
|
68
|
+
try {
|
|
69
|
+
const regex = new RegExp(String(targetValue));
|
|
70
|
+
return typeof contextValue === 'string' && regex.test(contextValue);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
return false; // Invalid regex fails
|
|
74
|
+
}
|
|
75
|
+
case 'semver_eq':
|
|
76
|
+
case 'semver_gt':
|
|
77
|
+
case 'semver_lt':
|
|
78
|
+
return compareSemver(String(contextValue), String(targetValue), operator);
|
|
79
|
+
default:
|
|
80
|
+
return false; // Unknown operator fails
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Extracts an attribute from the context.
|
|
85
|
+
* Supports special top-level attributes like 'userId' and 'workspaceId'.
|
|
86
|
+
*/
|
|
87
|
+
function getContextAttribute(attribute, context) {
|
|
88
|
+
if (!context)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (attribute === 'userId')
|
|
91
|
+
return context.userId;
|
|
92
|
+
if (attribute === 'workspaceId')
|
|
93
|
+
return context.workspaceId;
|
|
94
|
+
return context.attributes?.[attribute];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Checks if a value is numeric.
|
|
98
|
+
*/
|
|
99
|
+
function isNumeric(value) {
|
|
100
|
+
if (typeof value === 'number')
|
|
101
|
+
return true;
|
|
102
|
+
if (typeof value === 'string')
|
|
103
|
+
return value.trim() !== '' && !Number.isNaN(Number(value));
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Basic semantic version comparison.
|
|
108
|
+
* Note: Assumes standard x.y.z format without prerelease tags for simplicity in this lightweight implementation.
|
|
109
|
+
*/
|
|
110
|
+
function compareSemver(v1, v2, operator) {
|
|
111
|
+
if (typeof v1 !== 'string' || typeof v2 !== 'string')
|
|
112
|
+
return false;
|
|
113
|
+
const parts1 = v1.split('.').map(Number);
|
|
114
|
+
const parts2 = v2.split('.').map(Number);
|
|
115
|
+
// Pad arrays to same length
|
|
116
|
+
const maxLength = Math.max(parts1.length, parts2.length);
|
|
117
|
+
for (let i = 0; i < maxLength; i++) {
|
|
118
|
+
const p1 = parts1[i] || 0;
|
|
119
|
+
const p2 = parts2[i] || 0;
|
|
120
|
+
if (p1 > p2)
|
|
121
|
+
return operator === 'semver_gt';
|
|
122
|
+
if (p1 < p2)
|
|
123
|
+
return operator === 'semver_lt';
|
|
124
|
+
}
|
|
125
|
+
// All parts equal numerically up to maxLength
|
|
126
|
+
// For exact match, strictly require identical structures or 0-padding equivalence
|
|
127
|
+
// We'll consider 2.0.0 and 2.0 NOT equal for strictness in rule targeting,
|
|
128
|
+
// users should specify full versions.
|
|
129
|
+
if (operator === 'semver_eq') {
|
|
130
|
+
return parts1.length === parts2.length;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported flag value types for multi-variant flags
|
|
3
|
+
*/
|
|
4
|
+
export type FlagValue = boolean | string | number | Record<string, unknown>;
|
|
5
|
+
/**
|
|
6
|
+
* Feature flag definition
|
|
7
|
+
*/
|
|
8
|
+
export interface FeatureFlag {
|
|
9
|
+
id: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
category: 'frontend' | 'backend' | 'both';
|
|
14
|
+
defaultValue: FlagValue;
|
|
15
|
+
valueType: FlagValueType;
|
|
16
|
+
targetServices?: string[];
|
|
17
|
+
tags?: string[];
|
|
18
|
+
version: number;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
targetingRules?: TargetingRule[];
|
|
22
|
+
rollout?: RolloutConfig;
|
|
23
|
+
experiment?: Experiment;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Allowed value types for a flag
|
|
27
|
+
*/
|
|
28
|
+
export type FlagValueType = 'boolean' | 'string' | 'number' | 'json';
|
|
29
|
+
/**
|
|
30
|
+
* Workspace-level override for a flag
|
|
31
|
+
*/
|
|
32
|
+
export interface WorkspaceFeatureFlag {
|
|
33
|
+
id: string;
|
|
34
|
+
workspaceId: string;
|
|
35
|
+
flagId: string;
|
|
36
|
+
value: FlagValue;
|
|
37
|
+
version: number;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
flag?: FeatureFlag;
|
|
41
|
+
}
|
|
42
|
+
export type TargetingOperator = 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'in' | 'not_in' | 'gt' | 'gte' | 'lt' | 'lte' | 'regex' | 'semver_gt' | 'semver_lt' | 'semver_eq';
|
|
43
|
+
export interface TargetingCondition {
|
|
44
|
+
attribute: string;
|
|
45
|
+
operator: TargetingOperator;
|
|
46
|
+
value: string | number | boolean | string[];
|
|
47
|
+
}
|
|
48
|
+
export interface TargetingRule {
|
|
49
|
+
id: string;
|
|
50
|
+
priority: number;
|
|
51
|
+
conditions: TargetingCondition[];
|
|
52
|
+
value: FlagValue;
|
|
53
|
+
rolloutPercentage?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface RolloutConfig {
|
|
56
|
+
percentage: number;
|
|
57
|
+
stickinessKey?: string;
|
|
58
|
+
salt?: string;
|
|
59
|
+
buckets?: number;
|
|
60
|
+
}
|
|
61
|
+
export interface Variation {
|
|
62
|
+
id: string;
|
|
63
|
+
value: FlagValue;
|
|
64
|
+
weight: number;
|
|
65
|
+
}
|
|
66
|
+
export interface Experiment {
|
|
67
|
+
id: string;
|
|
68
|
+
name?: string;
|
|
69
|
+
variations: Variation[];
|
|
70
|
+
stickinessKey?: string;
|
|
71
|
+
salt?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface ExperimentAssignment {
|
|
74
|
+
experimentId: string;
|
|
75
|
+
variationId: string;
|
|
76
|
+
value: FlagValue;
|
|
77
|
+
context: EvaluationContext;
|
|
78
|
+
}
|
|
79
|
+
export type TrackingCallback = (assignment: ExperimentAssignment) => void;
|
|
80
|
+
export interface StreamingConfig {
|
|
81
|
+
url?: string;
|
|
82
|
+
reconnectDelayMs?: number;
|
|
83
|
+
maxReconnectDelayMs?: number;
|
|
84
|
+
}
|
|
85
|
+
export interface FlagDocument {
|
|
86
|
+
flags: FeatureFlag[];
|
|
87
|
+
version: number;
|
|
88
|
+
fetchedAt: string;
|
|
89
|
+
}
|
|
90
|
+
export interface CreateFlagData {
|
|
91
|
+
slug: string;
|
|
92
|
+
name: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
category: 'frontend' | 'backend' | 'both';
|
|
95
|
+
valueType?: FlagValueType;
|
|
96
|
+
defaultValue?: FlagValue;
|
|
97
|
+
targetServices?: string[];
|
|
98
|
+
tags?: string[];
|
|
99
|
+
}
|
|
100
|
+
export interface UpdateFlagData {
|
|
101
|
+
name?: string;
|
|
102
|
+
description?: string;
|
|
103
|
+
category?: 'frontend' | 'backend' | 'both';
|
|
104
|
+
defaultValue?: FlagValue;
|
|
105
|
+
targetServices?: string[];
|
|
106
|
+
tags?: string[];
|
|
107
|
+
}
|
|
108
|
+
export interface SetWorkspaceFlagData {
|
|
109
|
+
value: FlagValue;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Context passed during flag evaluation for targeting/segmentation
|
|
113
|
+
*/
|
|
114
|
+
export interface EvaluationContext {
|
|
115
|
+
workspaceId?: string;
|
|
116
|
+
userId?: string;
|
|
117
|
+
attributes?: Record<string, string | number | boolean>;
|
|
118
|
+
}
|
|
119
|
+
export interface FeatureFlagEvaluation {
|
|
120
|
+
slug: string;
|
|
121
|
+
value: FlagValue;
|
|
122
|
+
reason: EvaluationReason;
|
|
123
|
+
context?: EvaluationContext;
|
|
124
|
+
evaluatedAt: string;
|
|
125
|
+
}
|
|
126
|
+
export type EvaluationReason = 'DEFAULT' | 'WORKSPACE_OVERRIDE' | 'TARGETING_MATCH' | 'PERCENTAGE_ROLLOUT' | 'EXPERIMENT_ASSIGNMENT' | 'FALLBACK' | 'ERROR' | 'LOCAL_OVERRIDE' | 'CACHE_HIT';
|
|
127
|
+
export interface BatchEvaluation {
|
|
128
|
+
flags: Record<string, FlagValue>;
|
|
129
|
+
context?: EvaluationContext;
|
|
130
|
+
evaluatedAt: string;
|
|
131
|
+
}
|
|
132
|
+
export interface FeatureFlagStats {
|
|
133
|
+
total: number;
|
|
134
|
+
byCategory: Record<string, number>;
|
|
135
|
+
byTargetService: Record<string, number>;
|
|
136
|
+
byValueType: Record<string, number>;
|
|
137
|
+
activeWorkspaces: number;
|
|
138
|
+
}
|
|
139
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
140
|
+
/**
|
|
141
|
+
* Injectable logger interface. Users can provide their own logger (e.g. pino, winston).
|
|
142
|
+
* Defaults to console-based logging.
|
|
143
|
+
*/
|
|
144
|
+
export interface ILogger {
|
|
145
|
+
debug(message: string, ...args: unknown[]): void;
|
|
146
|
+
info(message: string, ...args: unknown[]): void;
|
|
147
|
+
warn(message: string, ...args: unknown[]): void;
|
|
148
|
+
error(message: string, ...args: unknown[]): void;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Circuit breaker configuration
|
|
152
|
+
*/
|
|
153
|
+
export interface CircuitBreakerConfig {
|
|
154
|
+
/** Number of consecutive failures before opening the circuit (default: 5) */
|
|
155
|
+
failureThreshold: number;
|
|
156
|
+
/** Time in ms to wait before attempting a request after circuit opens (default: 30000) */
|
|
157
|
+
resetTimeoutMs: number;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Retry configuration
|
|
161
|
+
*/
|
|
162
|
+
export interface RetryConfig {
|
|
163
|
+
/** Max number of retry attempts (default: 3) */
|
|
164
|
+
maxAttempts: number;
|
|
165
|
+
/** Base delay in ms between retries, doubles each attempt (default: 1000) */
|
|
166
|
+
baseDelayMs: number;
|
|
167
|
+
/** Max delay cap in ms (default: 10000) */
|
|
168
|
+
maxDelayMs: number;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Main SDK configuration
|
|
172
|
+
*/
|
|
173
|
+
export interface FeatureFlagsConfig {
|
|
174
|
+
/** Base URL of the feature flags API */
|
|
175
|
+
baseUrl: string;
|
|
176
|
+
/** Optional API key for authentication */
|
|
177
|
+
apiKey?: string;
|
|
178
|
+
/** HTTP request timeout in ms (default: 10000) */
|
|
179
|
+
timeout?: number;
|
|
180
|
+
/** Enable/disable in-memory cache (default: true) */
|
|
181
|
+
cacheEnabled?: boolean;
|
|
182
|
+
/** Cache TTL in ms (default: 60000) */
|
|
183
|
+
cacheTtlMs?: number;
|
|
184
|
+
/** Retry configuration */
|
|
185
|
+
retry?: Partial<RetryConfig>;
|
|
186
|
+
/** Circuit breaker configuration */
|
|
187
|
+
circuitBreaker?: Partial<CircuitBreakerConfig>;
|
|
188
|
+
/** Log level (default: 'warn') */
|
|
189
|
+
logLevel?: LogLevel;
|
|
190
|
+
/** Custom logger implementation */
|
|
191
|
+
logger?: ILogger;
|
|
192
|
+
/** Local flag overrides — useful for development/testing. These skip HTTP entirely. */
|
|
193
|
+
localOverrides?: Record<string, FlagValue>;
|
|
194
|
+
/** Default values when the server is unreachable and no cache exists */
|
|
195
|
+
fallbackDefaults?: Record<string, FlagValue>;
|
|
196
|
+
/** Configure SSE streaming for real-time updates */
|
|
197
|
+
streaming?: boolean | StreamingConfig;
|
|
198
|
+
/** Pass a flag document to enable Edge mode (offline local evaluation) */
|
|
199
|
+
edgeDocument?: FlagDocument;
|
|
200
|
+
/** Hook for A/B testing variable assignments */
|
|
201
|
+
trackingCallback?: TrackingCallback;
|
|
202
|
+
}
|
|
203
|
+
export type FeatureFlyEvent = 'flagEvaluated' | 'flagChanged' | 'cacheHit' | 'cacheMiss' | 'cacheCleared' | 'requestFailed' | 'circuitOpen' | 'circuitClosed' | 'circuitHalfOpen' | 'flagsUpdated' | 'streamConnected' | 'streamDisconnected' | 'experimentAssigned';
|
|
204
|
+
export interface FlagEvaluatedPayload {
|
|
205
|
+
slug: string;
|
|
206
|
+
value: FlagValue;
|
|
207
|
+
reason: EvaluationReason;
|
|
208
|
+
durationMs: number;
|
|
209
|
+
}
|
|
210
|
+
export interface FlagChangedPayload {
|
|
211
|
+
slug: string;
|
|
212
|
+
previousValue: FlagValue;
|
|
213
|
+
newValue: FlagValue;
|
|
214
|
+
}
|
|
215
|
+
export interface RequestFailedPayload {
|
|
216
|
+
endpoint: string;
|
|
217
|
+
error: string;
|
|
218
|
+
attempt: number;
|
|
219
|
+
}
|
|
220
|
+
export interface CircuitStatePayload {
|
|
221
|
+
state: 'open' | 'closed' | 'half-open';
|
|
222
|
+
failures: number;
|
|
223
|
+
}
|
|
224
|
+
export type EventPayloadMap = {
|
|
225
|
+
flagEvaluated: FlagEvaluatedPayload;
|
|
226
|
+
flagChanged: FlagChangedPayload;
|
|
227
|
+
cacheHit: {
|
|
228
|
+
key: string;
|
|
229
|
+
};
|
|
230
|
+
cacheMiss: {
|
|
231
|
+
key: string;
|
|
232
|
+
};
|
|
233
|
+
cacheCleared: void;
|
|
234
|
+
requestFailed: RequestFailedPayload;
|
|
235
|
+
circuitOpen: CircuitStatePayload;
|
|
236
|
+
circuitClosed: CircuitStatePayload;
|
|
237
|
+
circuitHalfOpen: CircuitStatePayload;
|
|
238
|
+
flagsUpdated: {
|
|
239
|
+
source: 'stream' | 'fetch';
|
|
240
|
+
count: number;
|
|
241
|
+
};
|
|
242
|
+
streamConnected: void;
|
|
243
|
+
streamDisconnected: {
|
|
244
|
+
error?: Error;
|
|
245
|
+
};
|
|
246
|
+
experimentAssigned: ExperimentAssignment;
|
|
247
|
+
};
|
|
248
|
+
export type EventHandler<E extends FeatureFlyEvent> = (payload: EventPayloadMap[E]) => void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Ref, type App } from 'vue';
|
|
2
|
+
import type { FeatureFlagsClient } from '../shared/client';
|
|
3
|
+
import type { EvaluationContext, FlagValue } from '../shared/types';
|
|
4
|
+
/**
|
|
5
|
+
* Vue Plugin that provides the FeatureFlagsClient to the entire app.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { createApp } from 'vue';
|
|
10
|
+
* import { FeatureFlyPlugin } from 'featurefly/vue';
|
|
11
|
+
*
|
|
12
|
+
* const app = createApp(App);
|
|
13
|
+
* app.use(FeatureFlyPlugin, { client: featureFlyClient });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare const FeatureFlyPlugin: {
|
|
17
|
+
install(app: App, options: {
|
|
18
|
+
client: FeatureFlagsClient;
|
|
19
|
+
}): void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Vue composable for evaluating a single feature flag.
|
|
23
|
+
* Returns a reactive `Ref` that auto-updates on flag changes.
|
|
24
|
+
*
|
|
25
|
+
* @param slug Flag slug identifier
|
|
26
|
+
* @param defaultValue Default value while loading
|
|
27
|
+
* @param context Optional reactive evaluation context
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```vue
|
|
31
|
+
* <script setup>
|
|
32
|
+
* import { useFeatureFlag } from 'featurefly/vue';
|
|
33
|
+
*
|
|
34
|
+
* const darkMode = useFeatureFlag('dark-mode', false);
|
|
35
|
+
* </script>
|
|
36
|
+
*
|
|
37
|
+
* <template>
|
|
38
|
+
* <div :class="{ dark: darkMode.value }">...</div>
|
|
39
|
+
* </template>
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function useFeatureFlag<T extends FlagValue = boolean>(slug: string, defaultValue: T, context?: Ref<EvaluationContext> | EvaluationContext): Ref<T>;
|
|
43
|
+
/**
|
|
44
|
+
* Vue composable for batch-evaluating all feature flags.
|
|
45
|
+
* Returns a reactive `Ref<Record<string, FlagValue>>` that auto-updates.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```vue
|
|
49
|
+
* <script setup>
|
|
50
|
+
* import { useAllFlags } from 'featurefly/vue';
|
|
51
|
+
*
|
|
52
|
+
* const flags = useAllFlags({ workspaceId: 'ws-123' });
|
|
53
|
+
* </script>
|
|
54
|
+
*
|
|
55
|
+
* <template>
|
|
56
|
+
* <NewFeature v-if="flags['new-feature']" />
|
|
57
|
+
* </template>
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function useAllFlags(context?: Ref<EvaluationContext> | EvaluationContext): Ref<Record<string, FlagValue>>;
|