@nexussdk/flags 0.0.1 → 0.0.3
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 +31 -0
- package/dist/index.cjs +1 -2
- package/dist/index.global.js +1 -2
- package/dist/index.mjs +1 -2
- package/package.json +31 -3
- package/.turbo/turbo-build.log +0 -26
- package/dist/index.cjs.map +0 -1
- package/dist/index.global.js.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/src/client.ts +0 -273
- package/src/evaluator.ts +0 -268
- package/src/index.ts +0 -14
- package/src/sse-manager.ts +0 -165
- package/src/storage.ts +0 -187
- package/tsconfig.json +0 -8
- package/tsup.config.ts +0 -19
package/src/client.ts
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview NexusFlagsClient — Full-featured feature flags SDK client.
|
|
3
|
-
* In-memory evaluation, SSE real-time sync, ABAC targeting, and MurmurHash3 rollout.
|
|
4
|
-
* @module @nexus/sdk-flags/client
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type {
|
|
8
|
-
FlagEvaluationResult,
|
|
9
|
-
FlagVariants,
|
|
10
|
-
FeatureFlag,
|
|
11
|
-
UserContext,
|
|
12
|
-
} from '@nexussdk/contracts';
|
|
13
|
-
import { resolveApiKey, resolveBaseUrl, fetchWithRetry } from '@nexussdk/core';
|
|
14
|
-
import { evaluateFlag } from './evaluator.js';
|
|
15
|
-
import { SSEManager } from './sse-manager.js';
|
|
16
|
-
import { FlagStorage } from './storage.js';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Options for initializing the NexusFlagsClient.
|
|
20
|
-
*
|
|
21
|
-
* @example
|
|
22
|
-
* const client = new NexusFlagsClient({
|
|
23
|
-
* apiKey: 'pk_live_...',
|
|
24
|
-
* baseUrl: 'http://localhost:8080',
|
|
25
|
-
* user: { id: 'usr_12345', country: 'VN' },
|
|
26
|
-
* realtime: true,
|
|
27
|
-
* });
|
|
28
|
-
*/
|
|
29
|
-
export interface NexusFlagsOptions {
|
|
30
|
-
/**
|
|
31
|
-
* Public API Key ('pk_live_...' or 'pk_test_...').
|
|
32
|
-
* If omitted, resolved automatically via env variables.
|
|
33
|
-
*/
|
|
34
|
-
apiKey?: string;
|
|
35
|
-
/**
|
|
36
|
-
* Base ingestion URL. Defaults to 'https://api.nexus.dev'.
|
|
37
|
-
*/
|
|
38
|
-
baseUrl?: string;
|
|
39
|
-
/**
|
|
40
|
-
* Initial user identity context for targeting and percentage rollouts.
|
|
41
|
-
*/
|
|
42
|
-
user?: UserContext;
|
|
43
|
-
/**
|
|
44
|
-
* Pre-hydrated flags evaluated on the server (SSR) to prevent client-side UI flicker.
|
|
45
|
-
*/
|
|
46
|
-
bootstrap?: Record<string, FlagEvaluationResult>;
|
|
47
|
-
/**
|
|
48
|
-
* Toggle real-time SSE updates. Defaults to true.
|
|
49
|
-
*/
|
|
50
|
-
realtime?: boolean;
|
|
51
|
-
/**
|
|
52
|
-
* Network timeout in milliseconds for evaluation fetch. Defaults to 3000ms.
|
|
53
|
-
*/
|
|
54
|
-
timeoutMs?: number;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Public interface for the NexusFlagsClient.
|
|
59
|
-
*/
|
|
60
|
-
export interface INexusFlagsClient {
|
|
61
|
-
/**
|
|
62
|
-
* Synchronously checks if a flag is active for the current user.
|
|
63
|
-
*
|
|
64
|
-
* @param key - Unique flag identifier.
|
|
65
|
-
* @param defaultValue - Fallback returned if flag is absent or evaluating.
|
|
66
|
-
* @returns `true` if the flag is enabled.
|
|
67
|
-
*
|
|
68
|
-
* @example
|
|
69
|
-
* const showBanner = client.isEnabled('promo_banner_v2', false);
|
|
70
|
-
*/
|
|
71
|
-
isEnabled(key: string, defaultValue?: boolean): boolean;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Synchronously retrieves a specific dynamic configuration variant.
|
|
75
|
-
*
|
|
76
|
-
* @param key - Unique flag identifier.
|
|
77
|
-
* @param variantKey - Property inside the variant object.
|
|
78
|
-
* @param defaultValue - Fallback returned if flag/variant is missing.
|
|
79
|
-
* @returns The variant value cast to type T.
|
|
80
|
-
*
|
|
81
|
-
* @example
|
|
82
|
-
* const rate = client.getVariant<number>('promo_banner_v2', 'discount_rate', 10);
|
|
83
|
-
*/
|
|
84
|
-
getVariant<T = unknown>(key: string, variantKey: string, defaultValue?: T): T;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Updates current user context and re-evaluates all flags.
|
|
88
|
-
*
|
|
89
|
-
* @param user - New user context to apply.
|
|
90
|
-
* @returns Promise that resolves after flags are refreshed.
|
|
91
|
-
*
|
|
92
|
-
* @example
|
|
93
|
-
* await client.identify({ id: 'usr_99', country: 'SG' });
|
|
94
|
-
*/
|
|
95
|
-
identify(user: UserContext): Promise<void>;
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Resets user context to an anonymous persistent device identifier.
|
|
99
|
-
*
|
|
100
|
-
* @example
|
|
101
|
-
* client.reset(); // called on logout
|
|
102
|
-
*/
|
|
103
|
-
reset(): void;
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Subscribes to runtime flag changes triggered by SSE updates.
|
|
107
|
-
*
|
|
108
|
-
* @param key - Flag key to observe.
|
|
109
|
-
* @param callback - Called with new evaluation result when the flag changes.
|
|
110
|
-
* @returns Unsubscribe function — call to remove the listener.
|
|
111
|
-
*
|
|
112
|
-
* @example
|
|
113
|
-
* const unsub = client.onFlagChange('checkout_v2', (result) => {
|
|
114
|
-
* setCheckoutEnabled(result.enabled);
|
|
115
|
-
* });
|
|
116
|
-
* // On component unmount:
|
|
117
|
-
* unsub();
|
|
118
|
-
*/
|
|
119
|
-
onFlagChange(key: string, callback: (result: FlagEvaluationResult) => void): () => void;
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Gracefully shuts down active SSE connections and observers.
|
|
123
|
-
*
|
|
124
|
-
* @example
|
|
125
|
-
* client.destroy();
|
|
126
|
-
*/
|
|
127
|
-
destroy(): void;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Feature Flags Client — the primary SDK entry point for flag evaluation.
|
|
132
|
-
*
|
|
133
|
-
* @implements {INexusFlagsClient}
|
|
134
|
-
*
|
|
135
|
-
* @example
|
|
136
|
-
* const client = new NexusFlagsClient({ apiKey: 'pk_live_...' });
|
|
137
|
-
* const enabled = client.isEnabled('new_checkout', false);
|
|
138
|
-
*/
|
|
139
|
-
export class NexusFlagsClient implements INexusFlagsClient {
|
|
140
|
-
private readonly apiKey: string;
|
|
141
|
-
private readonly baseUrl: string;
|
|
142
|
-
private readonly timeoutMs: number;
|
|
143
|
-
private user: UserContext;
|
|
144
|
-
private readonly storage: FlagStorage;
|
|
145
|
-
private readonly listeners = new Map<string, Set<(res: FlagEvaluationResult) => void>>();
|
|
146
|
-
private sseManager?: SSEManager;
|
|
147
|
-
private flagDefinitions = new Map<string, FeatureFlag>();
|
|
148
|
-
|
|
149
|
-
constructor(options: NexusFlagsOptions = {}) {
|
|
150
|
-
this.apiKey = resolveApiKey(options.apiKey);
|
|
151
|
-
this.baseUrl = resolveBaseUrl(options.baseUrl);
|
|
152
|
-
this.timeoutMs = options.timeoutMs ?? 3_000;
|
|
153
|
-
this.storage = new FlagStorage(this.apiKey.substring(0, 16));
|
|
154
|
-
this.user = options.user ?? { id: this.storage.getOrCreateAnonymousId() };
|
|
155
|
-
|
|
156
|
-
// Hydrate bootstrap flags (SSR pre-evaluation)
|
|
157
|
-
if (options.bootstrap) {
|
|
158
|
-
this.storage.setAll(options.bootstrap);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (options.realtime !== false) {
|
|
162
|
-
this.initRealtimeSync();
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Prefetch flags asynchronously on init
|
|
166
|
-
void this.refreshFlags();
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Evaluates whether a given feature flag is enabled for the current context.
|
|
171
|
-
*/
|
|
172
|
-
public isEnabled(key: string, defaultValue = false): boolean {
|
|
173
|
-
const cached = this.storage.get(key);
|
|
174
|
-
return cached !== undefined ? cached.enabled : defaultValue;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Retrieves a typed variant configuration for an active feature flag.
|
|
179
|
-
*/
|
|
180
|
-
public getVariant<T = unknown>(key: string, variantKey: string, defaultValue?: T): T {
|
|
181
|
-
const cached = this.storage.get(key);
|
|
182
|
-
if (!cached?.enabled || !cached.variants) {
|
|
183
|
-
return defaultValue as T;
|
|
184
|
-
}
|
|
185
|
-
const value = (cached.variants as FlagVariants)[variantKey];
|
|
186
|
-
return value !== undefined ? (value as T) : (defaultValue as T);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Identifies an authenticated user and triggers a flag evaluation refresh.
|
|
191
|
-
*/
|
|
192
|
-
public async identify(user: UserContext): Promise<void> {
|
|
193
|
-
this.user = { ...this.user, ...user };
|
|
194
|
-
await this.refreshFlags();
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Resets the active user context to a new anonymous identifier.
|
|
199
|
-
*/
|
|
200
|
-
public reset(): void {
|
|
201
|
-
this.user = { id: this.storage.getOrCreateAnonymousId() };
|
|
202
|
-
void this.refreshFlags();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Registers a listener callback invoked when a flag's evaluation state changes.
|
|
207
|
-
*/
|
|
208
|
-
public onFlagChange(key: string, callback: (result: FlagEvaluationResult) => void): () => void {
|
|
209
|
-
if (!this.listeners.has(key)) {
|
|
210
|
-
this.listeners.set(key, new Set());
|
|
211
|
-
}
|
|
212
|
-
this.listeners.get(key)!.add(callback);
|
|
213
|
-
return () => this.listeners.get(key)?.delete(callback);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Destroys the client, terminating SSE connections and clearing listeners.
|
|
218
|
-
*/
|
|
219
|
-
public destroy(): void {
|
|
220
|
-
this.sseManager?.disconnect();
|
|
221
|
-
this.listeners.clear();
|
|
222
|
-
this.storage.clear();
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
private initRealtimeSync(): void {
|
|
226
|
-
this.sseManager = new SSEManager({
|
|
227
|
-
url: `${this.baseUrl}/api/v1/flags/stream`,
|
|
228
|
-
apiKey: this.apiKey,
|
|
229
|
-
onEvent: (event) => {
|
|
230
|
-
if (event.type === 'FLAG_UPDATE' && event.data) {
|
|
231
|
-
// Use locally evaluated result if we have the flag definition
|
|
232
|
-
const flagDef = this.flagDefinitions.get(event.key);
|
|
233
|
-
const result = flagDef
|
|
234
|
-
? evaluateFlag(flagDef, this.user)
|
|
235
|
-
: event.data;
|
|
236
|
-
|
|
237
|
-
this.storage.set(event.key, result);
|
|
238
|
-
this.listeners.get(event.key)?.forEach((cb) => cb(result));
|
|
239
|
-
} else if (event.type === 'FLAG_DELETE') {
|
|
240
|
-
this.storage.delete(event.key);
|
|
241
|
-
this.flagDefinitions.delete(event.key);
|
|
242
|
-
}
|
|
243
|
-
},
|
|
244
|
-
});
|
|
245
|
-
this.sseManager.connect();
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private async refreshFlags(): Promise<void> {
|
|
249
|
-
try {
|
|
250
|
-
const result = await fetchWithRetry<Record<string, FeatureFlag>>({
|
|
251
|
-
url: `${this.baseUrl}/api/v1/flags/eval`,
|
|
252
|
-
method: 'GET',
|
|
253
|
-
headers: {
|
|
254
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
255
|
-
'X-Nexus-User-Id': this.user.id ?? 'anon',
|
|
256
|
-
'X-Nexus-Country': this.user.country ?? '',
|
|
257
|
-
},
|
|
258
|
-
timeoutMs: this.timeoutMs,
|
|
259
|
-
maxRetries: 2,
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
for (const [key, flag] of Object.entries(result.data)) {
|
|
263
|
-
this.flagDefinitions.set(key, flag);
|
|
264
|
-
const evaluated = evaluateFlag(flag, this.user);
|
|
265
|
-
this.storage.set(key, evaluated);
|
|
266
|
-
this.listeners.get(key)?.forEach((cb) => cb(evaluated));
|
|
267
|
-
}
|
|
268
|
-
} catch {
|
|
269
|
-
// Offline fallback: retain existing in-memory cache silently
|
|
270
|
-
// Never throw from a background refresh — host app must not be affected
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
package/src/evaluator.ts
DELETED
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview MurmurHash3 32-bit implementation and ABAC rule evaluator.
|
|
3
|
-
* Pure TypeScript — no external dependencies. Implements deterministic rollout bucketing.
|
|
4
|
-
* @module @nexus/sdk-flags/evaluator
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { FeatureFlag, FlagEvaluationResult, TargetingRule, UserContext } from '@nexussdk/contracts';
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// MurmurHash3 32-bit Implementation (Pure TypeScript)
|
|
11
|
-
// Used for deterministic user-to-bucket mapping without server round-trips.
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Converts a string to a Uint32 MurmurHash3 hash.
|
|
16
|
-
* Implements the MurmurHash3 32-bit algorithm (x86 variant).
|
|
17
|
-
*
|
|
18
|
-
* @param key - Input string to hash.
|
|
19
|
-
* @param seed - Optional seed value. Defaults to 0.
|
|
20
|
-
* @returns Unsigned 32-bit integer hash value.
|
|
21
|
-
*
|
|
22
|
-
* @example
|
|
23
|
-
* const hash = murmur3('user123:checkout_v2', 0);
|
|
24
|
-
* const bucket = hash % 100; // 0-99 deterministic bucket
|
|
25
|
-
*/
|
|
26
|
-
export function murmur3(key: string, seed = 0): number {
|
|
27
|
-
let h = seed >>> 0;
|
|
28
|
-
const c1 = 0xcc9e2d51;
|
|
29
|
-
const c2 = 0x1b873593;
|
|
30
|
-
|
|
31
|
-
let i = 0;
|
|
32
|
-
// Process 4-byte chunks
|
|
33
|
-
const length4 = Math.floor(key.length / 4) * 4;
|
|
34
|
-
while (i < length4) {
|
|
35
|
-
let k =
|
|
36
|
-
((key.charCodeAt(i) & 0xff)) |
|
|
37
|
-
((key.charCodeAt(i + 1) & 0xff) << 8) |
|
|
38
|
-
((key.charCodeAt(i + 2) & 0xff) << 16) |
|
|
39
|
-
((key.charCodeAt(i + 3) & 0xff) << 24);
|
|
40
|
-
|
|
41
|
-
k = Math.imul(k, c1);
|
|
42
|
-
k = (k << 15) | (k >>> 17);
|
|
43
|
-
k = Math.imul(k, c2);
|
|
44
|
-
|
|
45
|
-
h ^= k;
|
|
46
|
-
h = (h << 13) | (h >>> 19);
|
|
47
|
-
h = (Math.imul(h, 5) + 0xe6546b64) >>> 0;
|
|
48
|
-
i += 4;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Process remaining bytes
|
|
52
|
-
let k2 = 0;
|
|
53
|
-
const rem = key.length & 3;
|
|
54
|
-
if (rem >= 3) k2 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
|
|
55
|
-
if (rem >= 2) k2 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
|
|
56
|
-
if (rem >= 1) {
|
|
57
|
-
k2 ^= key.charCodeAt(i) & 0xff;
|
|
58
|
-
k2 = Math.imul(k2, c1);
|
|
59
|
-
k2 = (k2 << 15) | (k2 >>> 17);
|
|
60
|
-
k2 = Math.imul(k2, c2);
|
|
61
|
-
h ^= k2;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Finalization mix
|
|
65
|
-
h ^= key.length;
|
|
66
|
-
h ^= h >>> 16;
|
|
67
|
-
h = Math.imul(h, 0x85ebca6b);
|
|
68
|
-
h ^= h >>> 13;
|
|
69
|
-
h = Math.imul(h, 0xc2b2ae35);
|
|
70
|
-
h ^= h >>> 16;
|
|
71
|
-
|
|
72
|
-
return h >>> 0; // Ensure unsigned
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Computes the deterministic rollout bucket (0-99) for a given user+flag combination.
|
|
77
|
-
* Uses MurmurHash3 for even distribution without server state.
|
|
78
|
-
*
|
|
79
|
-
* @param userId - User's unique identifier (anonymous ID if not authenticated).
|
|
80
|
-
* @param flagKey - Flag programmatic key.
|
|
81
|
-
* @returns Bucket value between 0 and 99 (inclusive).
|
|
82
|
-
*
|
|
83
|
-
* @example
|
|
84
|
-
* const bucket = computeRolloutBucket('usr_12345', 'checkout_v2');
|
|
85
|
-
* // isEnabled = bucket < flag.rolloutPercentage
|
|
86
|
-
*/
|
|
87
|
-
export function computeRolloutBucket(userId: string, flagKey: string): number {
|
|
88
|
-
const hashInput = `${userId}:${flagKey}`;
|
|
89
|
-
return murmur3(hashInput) % 100;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ---------------------------------------------------------------------------
|
|
93
|
-
// ABAC Rule Evaluator
|
|
94
|
-
// ---------------------------------------------------------------------------
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Performs a simple semantic version comparison.
|
|
98
|
-
* Compares two semver strings in "MAJOR.MINOR.PATCH" format.
|
|
99
|
-
*
|
|
100
|
-
* @param a - First version string.
|
|
101
|
-
* @param b - Second version string.
|
|
102
|
-
* @returns Positive if a > b, negative if a < b, 0 if equal.
|
|
103
|
-
*/
|
|
104
|
-
function compareSemver(a: string, b: string): number {
|
|
105
|
-
const partsA = a.replace(/^v/, '').split('.').map(Number);
|
|
106
|
-
const partsB = b.replace(/^v/, '').split('.').map(Number);
|
|
107
|
-
for (let i = 0; i < 3; i++) {
|
|
108
|
-
const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
|
|
109
|
-
if (diff !== 0) return diff;
|
|
110
|
-
}
|
|
111
|
-
return 0;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Extracts a nested attribute value from the UserContext by dot-notation path.
|
|
116
|
-
*
|
|
117
|
-
* @param ctx - UserContext object.
|
|
118
|
-
* @param attributePath - Dot-separated path (e.g. "custom.tier", "country").
|
|
119
|
-
* @returns The attribute value or `undefined` if not found.
|
|
120
|
-
*/
|
|
121
|
-
function resolveAttribute(ctx: UserContext, attributePath: string): unknown {
|
|
122
|
-
const parts = attributePath.split('.');
|
|
123
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
124
|
-
let current: any = ctx;
|
|
125
|
-
for (const part of parts) {
|
|
126
|
-
if (current == null || typeof current !== 'object') return undefined;
|
|
127
|
-
current = current[part];
|
|
128
|
-
}
|
|
129
|
-
return current;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Evaluates a single targeting rule against the user context.
|
|
134
|
-
*
|
|
135
|
-
* @param rule - The ABAC targeting rule to evaluate.
|
|
136
|
-
* @param userCtx - The current user context.
|
|
137
|
-
* @returns `true` if the rule passes for this user.
|
|
138
|
-
*/
|
|
139
|
-
function evaluateSingleRule(rule: TargetingRule, userCtx: UserContext): boolean {
|
|
140
|
-
const userValue = resolveAttribute(userCtx, rule.attribute);
|
|
141
|
-
const targets = rule.values;
|
|
142
|
-
|
|
143
|
-
switch (rule.operator) {
|
|
144
|
-
case 'EQUALS':
|
|
145
|
-
return userValue === targets[0];
|
|
146
|
-
case 'NOT_EQUALS':
|
|
147
|
-
return userValue !== targets[0];
|
|
148
|
-
case 'IN':
|
|
149
|
-
return targets.includes(userValue as string | number | boolean);
|
|
150
|
-
case 'NOT_IN':
|
|
151
|
-
return !targets.includes(userValue as string | number | boolean);
|
|
152
|
-
case 'CONTAINS':
|
|
153
|
-
return typeof userValue === 'string' && userValue.includes(String(targets[0]));
|
|
154
|
-
case 'NOT_CONTAINS':
|
|
155
|
-
return typeof userValue === 'string' && !userValue.includes(String(targets[0]));
|
|
156
|
-
case 'STARTS_WITH':
|
|
157
|
-
return typeof userValue === 'string' && userValue.startsWith(String(targets[0]));
|
|
158
|
-
case 'ENDS_WITH':
|
|
159
|
-
return typeof userValue === 'string' && userValue.endsWith(String(targets[0]));
|
|
160
|
-
case 'GREATER_THAN':
|
|
161
|
-
return typeof userValue === 'number' && userValue > Number(targets[0]);
|
|
162
|
-
case 'LESS_THAN':
|
|
163
|
-
return typeof userValue === 'number' && userValue < Number(targets[0]);
|
|
164
|
-
case 'SEMVER_GTE':
|
|
165
|
-
return (
|
|
166
|
-
typeof userValue === 'string' &&
|
|
167
|
-
typeof targets[0] === 'string' &&
|
|
168
|
-
compareSemver(userValue, String(targets[0])) >= 0
|
|
169
|
-
);
|
|
170
|
-
case 'SEMVER_LTE':
|
|
171
|
-
return (
|
|
172
|
-
typeof userValue === 'string' &&
|
|
173
|
-
typeof targets[0] === 'string' &&
|
|
174
|
-
compareSemver(userValue, String(targets[0])) <= 0
|
|
175
|
-
);
|
|
176
|
-
default:
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Evaluates a FeatureFlag against a UserContext using the full ABAC + rollout engine.
|
|
183
|
-
*
|
|
184
|
-
* Evaluation Order:
|
|
185
|
-
* 1. Kill-switch check (`isEnabled === false` → KILL_SWITCH)
|
|
186
|
-
* 2. All targeting rules must pass (ABAC evaluation)
|
|
187
|
-
* 3. Percentage rollout via MurmurHash3 bucketing
|
|
188
|
-
* 4. Default enabled state
|
|
189
|
-
*
|
|
190
|
-
* @param flag - The feature flag definition from the cache.
|
|
191
|
-
* @param userCtx - Current user context for targeting evaluation.
|
|
192
|
-
* @returns Full {@link FlagEvaluationResult} with reason explanation.
|
|
193
|
-
*
|
|
194
|
-
* @example
|
|
195
|
-
* const result = evaluateFlag(flag, { id: 'usr_12345', country: 'VN' });
|
|
196
|
-
* if (result.enabled) {
|
|
197
|
-
* console.log(result.variants.discount_rate); // 20
|
|
198
|
-
* }
|
|
199
|
-
*/
|
|
200
|
-
export function evaluateFlag(flag: FeatureFlag, userCtx: UserContext): FlagEvaluationResult {
|
|
201
|
-
// Step 1: Kill-switch
|
|
202
|
-
if (!flag.isEnabled) {
|
|
203
|
-
return {
|
|
204
|
-
key: flag.key,
|
|
205
|
-
enabled: false,
|
|
206
|
-
variants: {},
|
|
207
|
-
reason: 'KILL_SWITCH',
|
|
208
|
-
version: flag.version,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Step 2: ABAC targeting rules — all must pass (AND logic)
|
|
213
|
-
if (flag.targetingRules.length > 0) {
|
|
214
|
-
const allRulesPass = flag.targetingRules.every((rule) =>
|
|
215
|
-
evaluateSingleRule(rule, userCtx),
|
|
216
|
-
);
|
|
217
|
-
if (!allRulesPass) {
|
|
218
|
-
return {
|
|
219
|
-
key: flag.key,
|
|
220
|
-
enabled: false,
|
|
221
|
-
variants: {},
|
|
222
|
-
reason: 'FALLBACK',
|
|
223
|
-
version: flag.version,
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
// All targeting rules matched
|
|
227
|
-
if (flag.rolloutPercentage >= 100) {
|
|
228
|
-
return {
|
|
229
|
-
key: flag.key,
|
|
230
|
-
enabled: true,
|
|
231
|
-
variants: flag.variants,
|
|
232
|
-
reason: 'TARGETING_MATCH',
|
|
233
|
-
version: flag.version,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Step 3: Percentage rollout (MurmurHash3)
|
|
239
|
-
if (flag.rolloutPercentage > 0) {
|
|
240
|
-
const userId = userCtx.id ?? 'anon';
|
|
241
|
-
const bucket = computeRolloutBucket(userId, flag.key);
|
|
242
|
-
if (bucket < flag.rolloutPercentage) {
|
|
243
|
-
return {
|
|
244
|
-
key: flag.key,
|
|
245
|
-
enabled: true,
|
|
246
|
-
variants: flag.variants,
|
|
247
|
-
reason: flag.targetingRules.length > 0 ? 'TARGETING_MATCH' : 'ROLLOUT_MATCH',
|
|
248
|
-
version: flag.version,
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
return {
|
|
252
|
-
key: flag.key,
|
|
253
|
-
enabled: false,
|
|
254
|
-
variants: {},
|
|
255
|
-
reason: 'FALLBACK',
|
|
256
|
-
version: flag.version,
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Step 4: Full kill-switch off (rolloutPercentage === 0 with no rules)
|
|
261
|
-
return {
|
|
262
|
-
key: flag.key,
|
|
263
|
-
enabled: false,
|
|
264
|
-
variants: {},
|
|
265
|
-
reason: 'KILL_SWITCH',
|
|
266
|
-
version: flag.version,
|
|
267
|
-
};
|
|
268
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Public export interface for @nexussdk/flags.
|
|
3
|
-
*
|
|
4
|
-
* @example
|
|
5
|
-
* import { NexusFlagsClient } from '@nexussdk/flags';
|
|
6
|
-
* const client = new NexusFlagsClient({ apiKey: 'pk_live_...' });
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export { NexusFlagsClient } from './client.js';
|
|
10
|
-
export type { NexusFlagsOptions, INexusFlagsClient } from './client.js';
|
|
11
|
-
export { evaluateFlag, murmur3, computeRolloutBucket } from './evaluator.js';
|
|
12
|
-
export { SSEManager } from './sse-manager.js';
|
|
13
|
-
export type { SSEManagerOptions } from './sse-manager.js';
|
|
14
|
-
export { FlagStorage } from './storage.js';
|