Skip to content

Commit 4e879d8

Browse files
committed
feat(sdk): improve SDK reliability and add identity management
1 parent 6eb39cb commit 4e879d8

6 files changed

Lines changed: 174 additions & 94 deletions

File tree

.changeset/busy-clubs-invite.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@flagix/evaluation-core": minor
3+
"@flagix/js-sdk": minor
4+
"@flagix/react": minor
5+
---
6+
7+
Improved SDK stability and lifecycle management. Added identify method for explicit user identity switching, fixed race conditions during initialization and SSE setup, and ensured feature flags gracefully fallback to 'off' variations when disabled. Fixed potential memory leaks in React hooks and added support for runtime API key changes.

packages/evaluation-core/src/evaluator.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ export function evaluateFlag(
202202
context: EvaluationContext
203203
): FlagVariation | null {
204204
if (!config.enabled) {
205-
return config.defaultVariation;
205+
const offVariation = config.variations.find(
206+
(v) => v.name.toLowerCase() === "off"
207+
);
208+
209+
return offVariation ?? config.defaultVariation;
206210
}
207211

208212
for (const rule of config.rules) {

sdk/javascript/src/client.ts

Lines changed: 115 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class FlagixClient {
3737
private readonly maxReconnectAttempts = Number.POSITIVE_INFINITY;
3838
private readonly baseReconnectDelay = 1000;
3939
private readonly maxReconnectDelay = 30_000;
40+
private isConnectingSSE = false;
4041

4142
constructor(options: FlagixClientOptions) {
4243
this.apiKey = options.apiKey;
@@ -49,21 +50,25 @@ export class FlagixClient {
4950
/**
5051
* Subscribes a listener to a flag update event.
5152
*/
52-
on(
53+
on = (
5354
event: typeof FLAG_UPDATE_EVENT,
5455
listener: (flagKey: string) => void
55-
): void {
56+
) => {
5657
this.emitter.on(event, listener);
57-
}
58+
};
5859

5960
/**
6061
* Unsubscribes a listener from a flag update event.
6162
*/
62-
off(
63+
off = (
6364
event: typeof FLAG_UPDATE_EVENT,
6465
listener: (flagKey: string) => void
65-
): void {
66+
) => {
6667
this.emitter.off(event, listener);
68+
};
69+
70+
getApiKey(): string {
71+
return this.apiKey;
6772
}
6873

6974
/**
@@ -125,6 +130,15 @@ export class FlagixClient {
125130
return (result?.value as T) ?? null;
126131
}
127132

133+
/**
134+
* Replaces the global evaluation context.
135+
*/
136+
identify(newContext: EvaluationContext): void {
137+
this.context = newContext;
138+
log("info", "[Flagix SDK] Context replaced");
139+
this.refreshAllFlags();
140+
}
141+
128142
/**
129143
* Sets or updates the global evaluation context.
130144
* @param newContext New context attributes to merge or replace.
@@ -135,7 +149,13 @@ export class FlagixClient {
135149
"info",
136150
"[Flagix SDK] Context updated. Evaluations will use the new context."
137151
);
152+
this.refreshAllFlags();
153+
}
138154

155+
/**
156+
* Helper to refresh all flags by emitting update events for each cached flag.
157+
*/
158+
private refreshAllFlags(): void {
139159
for (const flagKey of this.localCache.keys()) {
140160
this.emitter.emit(FLAG_UPDATE_EVENT, flagKey);
141161
}
@@ -172,6 +192,10 @@ export class FlagixClient {
172192
}
173193

174194
private async setupSSEListener(): Promise<void> {
195+
if (this.isConnectingSSE) {
196+
return;
197+
}
198+
175199
if (this.sseConnection) {
176200
try {
177201
this.sseConnection.close();
@@ -185,89 +209,102 @@ export class FlagixClient {
185209
this.sseConnection = null;
186210
}
187211

212+
this.isConnectingSSE = true;
188213
const url = `${this.apiBaseUrl}/api/sse/stream`;
189214

190-
const source = await createEventSource(url, this.apiKey);
191-
if (!source) {
192-
log("warn", "[Flagix SDK] Failed to create EventSource. Retrying...");
193-
this.scheduleReconnect();
194-
return;
195-
}
196-
197-
this.sseConnection = source;
198-
199-
source.onopen = () => {
200-
this.reconnectAttempts = 0;
201-
this.isReconnecting = false;
202-
if (this.reconnectTimeoutId) {
203-
clearTimeout(this.reconnectTimeoutId);
204-
this.reconnectTimeoutId = null;
215+
try {
216+
const source = await createEventSource(url, this.apiKey);
217+
if (!source) {
218+
log("warn", "[Flagix SDK] Failed to create EventSource. Retrying...");
219+
this.scheduleReconnect();
220+
return;
205221
}
206222

207-
// If this is a reconnection and not the first connection, refresh the cache
208-
// this ensures we have the latest flag values that may have changed while disconnected
209-
if (this.hasEstablishedConnection && this.isInitialized) {
210-
log(
211-
"info",
212-
"[Flagix SDK] SSE reconnected. Refreshing cache to sync with server..."
213-
);
214-
this.fetchInitialConfig().catch((error) => {
215-
log(
216-
"error",
217-
"[Flagix SDK] Failed to refresh cache after reconnection",
218-
error
219-
);
220-
});
221-
} else {
222-
this.hasEstablishedConnection = true;
223+
if (!this.isInitialized && !this.isReconnecting) {
224+
source.close();
225+
return;
223226
}
224227

225-
log("info", "[Flagix SDK] SSE connection established.");
226-
};
227-
228-
source.onerror = (error) => {
229-
const eventSource = error.target as EventSource;
230-
const readyState = eventSource?.readyState;
228+
this.sseConnection = source;
231229

232-
// EventSource.readyState: 0 = CONNECTING, 1 = OPEN, 2 = CLOSED
233-
if (readyState === 2) {
234-
log(
235-
"warn",
236-
"[Flagix SDK] SSE connection closed. Attempting to reconnect..."
237-
);
238-
this.handleReconnect();
239-
} else if (readyState === 0) {
240-
log(
241-
"warn",
242-
"[Flagix SDK] SSE connection error (connecting state)",
243-
error
244-
);
245-
} else {
246-
log("error", "[Flagix SDK] SSE error", error);
247-
this.handleReconnect();
248-
}
249-
};
230+
source.onopen = () => {
231+
this.reconnectAttempts = 0;
232+
this.isReconnecting = false;
233+
if (this.reconnectTimeoutId) {
234+
clearTimeout(this.reconnectTimeoutId);
235+
this.reconnectTimeoutId = null;
236+
}
250237

251-
// Listen for the "connected" event from the server
252-
source.addEventListener("connected", () => {
253-
log("info", "[Flagix SDK] SSE connection confirmed by server.");
254-
});
238+
// If this is a reconnection and not the first connection, refresh the cache
239+
// this ensures we have the latest flag values that may have changed while disconnected
240+
if (this.hasEstablishedConnection && this.isInitialized) {
241+
log(
242+
"info",
243+
"[Flagix SDK] SSE reconnected. Refreshing cache to sync with server..."
244+
);
245+
this.fetchInitialConfig().catch((error) => {
246+
log(
247+
"error",
248+
"[Flagix SDK] Failed to refresh cache after reconnection",
249+
error
250+
);
251+
});
252+
} else {
253+
this.hasEstablishedConnection = true;
254+
}
255+
256+
log("info", "[Flagix SDK] SSE connection established.");
257+
};
258+
259+
source.onerror = (error) => {
260+
const eventSource = error.target as EventSource;
261+
const readyState = eventSource?.readyState;
262+
263+
// EventSource.readyState: 0 = CONNECTING, 1 = OPEN, 2 = CLOSED
264+
if (readyState === 2) {
265+
log(
266+
"warn",
267+
"[Flagix SDK] SSE connection closed. Attempting to reconnect..."
268+
);
269+
this.handleReconnect();
270+
} else if (readyState === 0) {
271+
log(
272+
"warn",
273+
"[Flagix SDK] SSE connection error (connecting state)",
274+
error
275+
);
276+
} else {
277+
log("error", "[Flagix SDK] SSE error", error);
278+
this.handleReconnect();
279+
}
280+
};
281+
282+
// Listen for the "connected" event from the server
283+
source.addEventListener("connected", () => {
284+
log("info", "[Flagix SDK] SSE connection confirmed by server.");
285+
});
255286

256-
source.addEventListener(EVENT_TO_LISTEN, (event) => {
257-
try {
258-
const data = JSON.parse(event.data);
259-
const { flagKey, type } = data as {
260-
flagKey: string;
261-
type: FlagUpdateType;
262-
};
287+
source.addEventListener(EVENT_TO_LISTEN, (event) => {
288+
try {
289+
const data = JSON.parse(event.data);
290+
const { flagKey, type } = data as {
291+
flagKey: string;
292+
type: FlagUpdateType;
293+
};
263294

264-
log("info", `[Flagix SDK] Received update for ${flagKey} (${type}).`);
295+
log("info", `[Flagix SDK] Received update for ${flagKey} (${type}).`);
265296

266-
this.fetchSingleFlagConfig(flagKey, type);
267-
} catch (error) {
268-
log("error", "[Flagix SDK] Failed to parse SSE event data.", error);
269-
}
270-
});
297+
this.fetchSingleFlagConfig(flagKey, type);
298+
} catch (error) {
299+
log("error", "[Flagix SDK] Failed to parse SSE event data.", error);
300+
}
301+
});
302+
} catch (error) {
303+
log("error", "[Flagix SDK] Failed during SSE setup", error);
304+
this.handleReconnect();
305+
} finally {
306+
this.isConnectingSSE = false;
307+
}
271308
}
272309

273310
private handleReconnect(): void {
@@ -324,7 +361,7 @@ export class FlagixClient {
324361
): Promise<void> {
325362
const url = `${this.apiBaseUrl}/api/flag-config/${flagKey}`;
326363

327-
if (type === "FLAG_DELETED" || type === "RULE_DELETED") {
364+
if (type === "FLAG_DELETED") {
328365
this.localCache.delete(flagKey);
329366
log("info", `[Flagix SDK] Flag ${flagKey} deleted from cache.`);
330367
this.emitter.emit(FLAG_UPDATE_EVENT, flagKey);

sdk/javascript/src/index.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,24 @@ export const Flagix = {
1717
* Initializes the Flagix SDK, fetches all flags, and sets up an SSE connection.
1818
*/
1919
async initialize(options: FlagixClientOptions): Promise<void> {
20+
// this check ensures that we are able to watch for api key changes and re-initialize accordingly
21+
// this ensures we dont use stale clients across different api keys
2022
if (clientInstance) {
21-
log("warn", "Flagix SDK already initialized. Ignoring subsequent call.");
22-
return;
23+
if (clientInstance.getApiKey() === options.apiKey) {
24+
if (clientInstance.getIsInitialized()) {
25+
return;
26+
}
27+
28+
if (isInitializing && initializationPromise) {
29+
return initializationPromise;
30+
}
31+
} else {
32+
log(
33+
"info",
34+
"[Flagix SDK] API Key change detected. Resetting client..."
35+
);
36+
this.close();
37+
}
2338
}
2439

2540
if (isInitializing && initializationPromise) {
@@ -28,17 +43,19 @@ export const Flagix = {
2843

2944
isInitializing = true;
3045

31-
try {
32-
clientInstance = new FlagixClient(options);
33-
initializationPromise = clientInstance.initialize();
34-
await initializationPromise;
35-
} catch (error) {
36-
log("error", "Flagix SDK failed during initialization:", error);
37-
throw error;
38-
} finally {
39-
isInitializing = false;
40-
initializationPromise = null;
41-
}
46+
initializationPromise = (async () => {
47+
try {
48+
clientInstance = new FlagixClient(options);
49+
await clientInstance.initialize();
50+
} catch (error) {
51+
clientInstance = null;
52+
throw error;
53+
} finally {
54+
isInitializing = false;
55+
}
56+
})();
57+
58+
return await initializationPromise;
4259
},
4360

4461
/**
@@ -82,6 +99,17 @@ export const Flagix = {
8299
clientInstance.track(eventName, properties, contextOverrides);
83100
},
84101

102+
/**
103+
* Replaces the global evaluation context.
104+
*/
105+
identify(newContext: EvaluationContext): void {
106+
if (!clientInstance) {
107+
log("error", "Flagix SDK not initialized.");
108+
return;
109+
}
110+
clientInstance.identify(newContext);
111+
},
112+
85113
/**
86114
* Sets or updates the global evaluation context.
87115
* @param newContext New context attributes to merge or replace.
@@ -101,6 +129,8 @@ export const Flagix = {
101129
if (clientInstance) {
102130
clientInstance.close();
103131
clientInstance = null;
132+
initializationPromise = null;
133+
isInitializing = false;
104134
}
105135
},
106136

sdk/react/src/flagix-provider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const FlagixProvider = ({
5050

5151
return () => {
5252
mounted = false;
53+
Flagix.close();
5354
};
5455
}, [apiKey, apiBaseUrl]);
5556

sdk/react/src/hooks/use-flagix-actions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { Flagix } from "@flagix/js-sdk";
77
*/
88
export function useFlagixActions() {
99
return {
10-
track: Flagix.track.bind(Flagix),
11-
setContext: Flagix.setContext.bind(Flagix),
10+
track: Flagix.track.bind(Flagix), // event tracking method
11+
setContext: Flagix.setContext.bind(Flagix), // method to merge context
12+
identify: Flagix.identify.bind(Flagix), // method to replace context
1213
};
1314
}

0 commit comments

Comments
 (0)