@nexussdk/flags 0.0.1 → 0.0.4
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 +210 -0
- package/dist/chunk-VC3UFQ47.mjs +767 -0
- package/dist/dev-server/cli.mjs +95 -0
- package/dist/dev-server/index.mjs +11 -0
- package/dist/index.cjs +1 -2
- package/dist/index.d.mts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.global.js +3 -4
- package/dist/index.mjs +1 -2
- package/package.json +57 -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/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';
|
package/src/sse-manager.ts
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Singleton SSE Manager with ref-counting and auto-reconnect.
|
|
3
|
-
* Ensures exactly one EventSource connection per API key across all flag subscribers.
|
|
4
|
-
* @module @nexus/sdk-flags/sse-manager
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { FlagStreamEvent } from '@nexussdk/contracts';
|
|
8
|
-
import { computeBackoffMs } from '@nexussdk/core';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Options for creating an SSE connection.
|
|
12
|
-
*/
|
|
13
|
-
export interface SSEManagerOptions {
|
|
14
|
-
/** Full SSE stream URL (e.g. "http://localhost:8080/api/v1/flags/stream"). */
|
|
15
|
-
url: string;
|
|
16
|
-
/** Public API key for authentication. */
|
|
17
|
-
apiKey: string;
|
|
18
|
-
/** Callback invoked for each received flag stream event. */
|
|
19
|
-
onEvent: (event: FlagStreamEvent) => void;
|
|
20
|
-
/** Callback invoked on connection state changes (optional). */
|
|
21
|
-
onStateChange?: (connected: boolean) => void;
|
|
22
|
-
/** Maximum reconnect attempts. Defaults to Infinity. */
|
|
23
|
-
maxReconnects?: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Ref-counted Singleton SSE Manager.
|
|
28
|
-
*
|
|
29
|
-
* Multiple consumers sharing the same `apiKey` receive updates
|
|
30
|
-
* from a single shared SSE connection. Connection is torn down when all
|
|
31
|
-
* consumers have called `disconnect()`.
|
|
32
|
-
*
|
|
33
|
-
* Connection lifecycle:
|
|
34
|
-
* 1. `connect()` → opens EventSource or streaming fetch
|
|
35
|
-
* 2. On disconnect: exponential backoff reconnect (max 30s)
|
|
36
|
-
* 3. `disconnect()` → decrements ref count; tears down on 0
|
|
37
|
-
*
|
|
38
|
-
* @example
|
|
39
|
-
* const manager = new SSEManager({
|
|
40
|
-
* url: 'http://localhost:8080/api/v1/flags/stream',
|
|
41
|
-
* apiKey: 'pk_live_...',
|
|
42
|
-
* onEvent: (event) => updateCache(event),
|
|
43
|
-
* });
|
|
44
|
-
* manager.connect();
|
|
45
|
-
* // Later:
|
|
46
|
-
* manager.disconnect();
|
|
47
|
-
*/
|
|
48
|
-
export class SSEManager {
|
|
49
|
-
private readonly options: SSEManagerOptions;
|
|
50
|
-
private eventSource: EventSource | null = null;
|
|
51
|
-
private connected = false;
|
|
52
|
-
private destroyed = false;
|
|
53
|
-
private reconnectAttempt = 0;
|
|
54
|
-
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
-
private readonly maxReconnects: number;
|
|
56
|
-
|
|
57
|
-
constructor(options: SSEManagerOptions) {
|
|
58
|
-
this.options = options;
|
|
59
|
-
this.maxReconnects = options.maxReconnects ?? Infinity;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Returns whether the SSE connection is currently active.
|
|
64
|
-
*
|
|
65
|
-
* @returns `true` if EventSource is open and receiving events.
|
|
66
|
-
*/
|
|
67
|
-
get isConnected(): boolean {
|
|
68
|
-
return this.connected;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Opens the SSE connection to the Go-Gin stream endpoint.
|
|
73
|
-
* Safe to call multiple times — no-op if already connected.
|
|
74
|
-
*
|
|
75
|
-
* @example
|
|
76
|
-
* manager.connect();
|
|
77
|
-
*/
|
|
78
|
-
connect(): void {
|
|
79
|
-
if (this.destroyed || this.connected || this.eventSource) return;
|
|
80
|
-
this.openConnection();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Closes the SSE connection and cancels any pending reconnect timers.
|
|
85
|
-
*
|
|
86
|
-
* @example
|
|
87
|
-
* manager.disconnect();
|
|
88
|
-
*/
|
|
89
|
-
disconnect(): void {
|
|
90
|
-
this.destroyed = true;
|
|
91
|
-
this.cleanup();
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
private openConnection(): void {
|
|
95
|
-
if (this.destroyed) return;
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
// Append API key as query parameter for SSE (EventSource doesn't support custom headers)
|
|
99
|
-
const url = new URL(this.options.url);
|
|
100
|
-
url.searchParams.set('apiKey', this.options.apiKey);
|
|
101
|
-
|
|
102
|
-
this.eventSource = new EventSource(url.toString());
|
|
103
|
-
|
|
104
|
-
this.eventSource.addEventListener('open', () => {
|
|
105
|
-
this.connected = true;
|
|
106
|
-
this.reconnectAttempt = 0;
|
|
107
|
-
this.options.onStateChange?.(true);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
this.eventSource.addEventListener('message', (evt: MessageEvent<string>) => {
|
|
111
|
-
this.handleMessage(evt.data);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Go-Gin sends events with event type 'message' by default
|
|
115
|
-
// Also listen for explicitly typed events
|
|
116
|
-
this.eventSource.addEventListener('flag_update', (evt: MessageEvent<string>) => {
|
|
117
|
-
this.handleMessage(evt.data);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
this.eventSource.addEventListener('error', () => {
|
|
121
|
-
this.connected = false;
|
|
122
|
-
this.options.onStateChange?.(false);
|
|
123
|
-
this.cleanup();
|
|
124
|
-
this.scheduleReconnect();
|
|
125
|
-
});
|
|
126
|
-
} catch {
|
|
127
|
-
this.scheduleReconnect();
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
private handleMessage(data: string): void {
|
|
132
|
-
try {
|
|
133
|
-
const event = JSON.parse(data) as FlagStreamEvent;
|
|
134
|
-
this.options.onEvent(event);
|
|
135
|
-
} catch {
|
|
136
|
-
// Ignore malformed SSE payloads — never crash the host application
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private cleanup(): void {
|
|
141
|
-
if (this.eventSource) {
|
|
142
|
-
this.eventSource.close();
|
|
143
|
-
this.eventSource = null;
|
|
144
|
-
}
|
|
145
|
-
if (this.reconnectTimer !== null) {
|
|
146
|
-
clearTimeout(this.reconnectTimer);
|
|
147
|
-
this.reconnectTimer = null;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private scheduleReconnect(): void {
|
|
152
|
-
if (this.destroyed) return;
|
|
153
|
-
if (this.reconnectAttempt >= this.maxReconnects) return;
|
|
154
|
-
|
|
155
|
-
const delay = computeBackoffMs(this.reconnectAttempt, 1000, 30_000);
|
|
156
|
-
this.reconnectAttempt++;
|
|
157
|
-
|
|
158
|
-
this.reconnectTimer = setTimeout(() => {
|
|
159
|
-
this.reconnectTimer = null;
|
|
160
|
-
if (!this.destroyed) {
|
|
161
|
-
this.openConnection();
|
|
162
|
-
}
|
|
163
|
-
}, delay);
|
|
164
|
-
}
|
|
165
|
-
}
|
package/src/storage.ts
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview In-memory flag storage with LocalStorage sync and incognito fallback.
|
|
3
|
-
* @module @nexus/sdk-flags/storage
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { FlagEvaluationResult } from '@nexussdk/contracts';
|
|
7
|
-
|
|
8
|
-
const STORAGE_KEY_PREFIX = 'nexus_flags_';
|
|
9
|
-
const ANON_ID_KEY = 'nexus_anon_id';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* In-memory flag cache with optional LocalStorage persistence.
|
|
13
|
-
* Falls back to pure in-memory storage in SSR / Incognito / Worker contexts.
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* const store = new FlagStorage('pk_live_abc123');
|
|
17
|
-
* store.set('checkout_v2', { key: 'checkout_v2', enabled: true, variants: {}, reason: 'ROLLOUT_MATCH', version: 3 });
|
|
18
|
-
* const result = store.get('checkout_v2');
|
|
19
|
-
*/
|
|
20
|
-
export class FlagStorage {
|
|
21
|
-
private readonly memory = new Map<string, FlagEvaluationResult>();
|
|
22
|
-
private readonly storageKey: string;
|
|
23
|
-
private readonly localStorageAvailable: boolean;
|
|
24
|
-
private broadcastChannel: BroadcastChannel | null = null;
|
|
25
|
-
|
|
26
|
-
constructor(apiKeyPrefix: string) {
|
|
27
|
-
this.storageKey = `${STORAGE_KEY_PREFIX}${apiKeyPrefix}`;
|
|
28
|
-
this.localStorageAvailable = this.testLocalStorage();
|
|
29
|
-
this.hydrate();
|
|
30
|
-
this.setupBroadcastChannel();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Stores a flag evaluation result in memory (and localStorage if available).
|
|
35
|
-
*
|
|
36
|
-
* @param key - Flag programmatic key.
|
|
37
|
-
* @param result - Evaluation result to persist.
|
|
38
|
-
*/
|
|
39
|
-
set(key: string, result: FlagEvaluationResult): void {
|
|
40
|
-
this.memory.set(key, result);
|
|
41
|
-
this.persist();
|
|
42
|
-
this.broadcastUpdate(key, result);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Retrieves a flag evaluation result by key.
|
|
47
|
-
*
|
|
48
|
-
* @param key - Flag programmatic key.
|
|
49
|
-
* @returns The cached evaluation result or `undefined`.
|
|
50
|
-
*/
|
|
51
|
-
get(key: string): FlagEvaluationResult | undefined {
|
|
52
|
-
return this.memory.get(key);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Bulk-sets multiple flag results (e.g. after a batch fetch).
|
|
57
|
-
*
|
|
58
|
-
* @param flags - Record of flag key → evaluation result.
|
|
59
|
-
*/
|
|
60
|
-
setAll(flags: Record<string, FlagEvaluationResult>): void {
|
|
61
|
-
for (const [key, value] of Object.entries(flags)) {
|
|
62
|
-
this.memory.set(key, value);
|
|
63
|
-
}
|
|
64
|
-
this.persist();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Removes a single flag from storage (e.g. on FLAG_DELETE SSE event).
|
|
69
|
-
*
|
|
70
|
-
* @param key - Flag programmatic key.
|
|
71
|
-
*/
|
|
72
|
-
delete(key: string): void {
|
|
73
|
-
this.memory.delete(key);
|
|
74
|
-
this.persist();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Returns all cached flag results.
|
|
79
|
-
*
|
|
80
|
-
* @returns All stored flag results as a record.
|
|
81
|
-
*/
|
|
82
|
-
getAll(): Record<string, FlagEvaluationResult> {
|
|
83
|
-
return Object.fromEntries(this.memory.entries());
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Clears all cached flags from memory and localStorage.
|
|
88
|
-
*/
|
|
89
|
-
clear(): void {
|
|
90
|
-
this.memory.clear();
|
|
91
|
-
if (this.localStorageAvailable) {
|
|
92
|
-
try {
|
|
93
|
-
window.localStorage.removeItem(this.storageKey);
|
|
94
|
-
} catch {
|
|
95
|
-
// Storage access denied
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
this.broadcastChannel?.close();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Retrieves or creates a persistent anonymous user ID.
|
|
103
|
-
*
|
|
104
|
-
* @returns Anonymous ID string (e.g. "anon_xyz123abc").
|
|
105
|
-
*/
|
|
106
|
-
getOrCreateAnonymousId(): string {
|
|
107
|
-
if (typeof window === 'undefined') return 'anon-ssr-node';
|
|
108
|
-
|
|
109
|
-
if (this.localStorageAvailable) {
|
|
110
|
-
try {
|
|
111
|
-
let id = window.localStorage.getItem(ANON_ID_KEY);
|
|
112
|
-
if (!id) {
|
|
113
|
-
id = `anon_${Math.random().toString(36).substring(2, 11)}`;
|
|
114
|
-
window.localStorage.setItem(ANON_ID_KEY, id);
|
|
115
|
-
}
|
|
116
|
-
return id;
|
|
117
|
-
} catch {
|
|
118
|
-
// Incognito / locked storage
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return `anon_${Math.random().toString(36).substring(2, 11)}`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
private testLocalStorage(): boolean {
|
|
126
|
-
try {
|
|
127
|
-
if (typeof window === 'undefined') return false;
|
|
128
|
-
const testKey = '__nexus_test__';
|
|
129
|
-
window.localStorage.setItem(testKey, '1');
|
|
130
|
-
window.localStorage.removeItem(testKey);
|
|
131
|
-
return true;
|
|
132
|
-
} catch {
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private hydrate(): void {
|
|
138
|
-
if (!this.localStorageAvailable) return;
|
|
139
|
-
try {
|
|
140
|
-
const raw = window.localStorage.getItem(this.storageKey);
|
|
141
|
-
if (raw) {
|
|
142
|
-
const parsed = JSON.parse(raw) as Record<string, FlagEvaluationResult>;
|
|
143
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
144
|
-
this.memory.set(key, value);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
} catch {
|
|
148
|
-
// Corrupt storage — start fresh
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
private persist(): void {
|
|
153
|
-
if (!this.localStorageAvailable) return;
|
|
154
|
-
try {
|
|
155
|
-
window.localStorage.setItem(
|
|
156
|
-
this.storageKey,
|
|
157
|
-
JSON.stringify(Object.fromEntries(this.memory.entries())),
|
|
158
|
-
);
|
|
159
|
-
} catch {
|
|
160
|
-
// Storage quota exceeded — in-memory only
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private setupBroadcastChannel(): void {
|
|
165
|
-
try {
|
|
166
|
-
if (typeof BroadcastChannel !== 'undefined') {
|
|
167
|
-
this.broadcastChannel = new BroadcastChannel(`nexus_flags_${this.storageKey}`);
|
|
168
|
-
this.broadcastChannel.onmessage = (evt: MessageEvent<{ key: string; result: FlagEvaluationResult }>) => {
|
|
169
|
-
// Sync updates from other tabs into local memory cache
|
|
170
|
-
if (evt.data?.key && evt.data?.result) {
|
|
171
|
-
this.memory.set(evt.data.key, evt.data.result);
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
} catch {
|
|
176
|
-
// BroadcastChannel unavailable (e.g. Worker context)
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
private broadcastUpdate(key: string, result: FlagEvaluationResult): void {
|
|
181
|
-
try {
|
|
182
|
-
this.broadcastChannel?.postMessage({ key, result });
|
|
183
|
-
} catch {
|
|
184
|
-
// Broadcast failed — no-op
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
package/tsconfig.json
DELETED
package/tsup.config.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'tsup';
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
entry: ['src/index.ts'],
|
|
5
|
-
format: ['esm', 'cjs', 'iife'],
|
|
6
|
-
globalName: 'NexusFlags',
|
|
7
|
-
dts: true,
|
|
8
|
-
splitting: false,
|
|
9
|
-
sourcemap: true,
|
|
10
|
-
clean: true,
|
|
11
|
-
minify: true,
|
|
12
|
-
treeshake: true,
|
|
13
|
-
target: 'es2022',
|
|
14
|
-
outExtension({ format }) {
|
|
15
|
-
return {
|
|
16
|
-
js: format === 'esm' ? '.mjs' : format === 'cjs' ? '.cjs' : '.global.js',
|
|
17
|
-
};
|
|
18
|
-
},
|
|
19
|
-
});
|