@savvagent/sdk 1.0.0 → 1.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/README.md +49 -0
- package/dist/index.d.mts +199 -10
- package/dist/index.d.ts +199 -10
- package/dist/index.js +484 -64
- package/dist/index.mjs +484 -64
- package/package.json +16 -15
package/dist/index.mjs
CHANGED
|
@@ -120,15 +120,23 @@ var TelemetryService = class {
|
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
122
|
* Send evaluation events to backend
|
|
123
|
+
* Per SDK Developer Guide: POST /api/telemetry/evaluations with { "evaluations": [...] }
|
|
123
124
|
*/
|
|
124
125
|
async sendEvaluations(events) {
|
|
126
|
+
const evaluations = events.map((e) => ({
|
|
127
|
+
flag_key: e.flagKey,
|
|
128
|
+
result: e.result,
|
|
129
|
+
user_id: e.context?.user_id,
|
|
130
|
+
context: e.context,
|
|
131
|
+
timestamp: Math.floor(new Date(e.timestamp).getTime() / 1e3)
|
|
132
|
+
}));
|
|
125
133
|
const response = await fetch(`${this.baseUrl}/api/telemetry/evaluations`, {
|
|
126
134
|
method: "POST",
|
|
127
135
|
headers: {
|
|
128
136
|
"Content-Type": "application/json",
|
|
129
137
|
Authorization: `Bearer ${this.apiKey}`
|
|
130
138
|
},
|
|
131
|
-
body: JSON.stringify({
|
|
139
|
+
body: JSON.stringify({ evaluations })
|
|
132
140
|
});
|
|
133
141
|
if (!response.ok) {
|
|
134
142
|
throw new Error(`Failed to send evaluations: ${response.status}`);
|
|
@@ -136,15 +144,25 @@ var TelemetryService = class {
|
|
|
136
144
|
}
|
|
137
145
|
/**
|
|
138
146
|
* Send error events to backend
|
|
147
|
+
* Per SDK Developer Guide: POST /api/telemetry/errors with { "errors": [...] }
|
|
139
148
|
*/
|
|
140
149
|
async sendErrors(events) {
|
|
150
|
+
const errors = events.map((e) => ({
|
|
151
|
+
flag_key: e.flagKey,
|
|
152
|
+
flag_enabled: e.flagEnabled,
|
|
153
|
+
error_type: e.errorType,
|
|
154
|
+
error_message: e.errorMessage,
|
|
155
|
+
stack_trace: e.stackTrace,
|
|
156
|
+
context: e.context,
|
|
157
|
+
timestamp: Math.floor(new Date(e.timestamp).getTime() / 1e3)
|
|
158
|
+
}));
|
|
141
159
|
const response = await fetch(`${this.baseUrl}/api/telemetry/errors`, {
|
|
142
160
|
method: "POST",
|
|
143
161
|
headers: {
|
|
144
162
|
"Content-Type": "application/json",
|
|
145
163
|
Authorization: `Bearer ${this.apiKey}`
|
|
146
164
|
},
|
|
147
|
-
body: JSON.stringify({
|
|
165
|
+
body: JSON.stringify({ errors })
|
|
148
166
|
});
|
|
149
167
|
if (!response.ok) {
|
|
150
168
|
throw new Error(`Failed to send errors: ${response.status}`);
|
|
@@ -169,88 +187,144 @@ var TelemetryService = class {
|
|
|
169
187
|
};
|
|
170
188
|
|
|
171
189
|
// src/realtime.ts
|
|
190
|
+
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
|
191
|
+
var FatalError = class extends Error {
|
|
192
|
+
constructor(message) {
|
|
193
|
+
super(message);
|
|
194
|
+
this.name = "FatalError";
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
var RetriableError = class extends Error {
|
|
198
|
+
constructor(message) {
|
|
199
|
+
super(message);
|
|
200
|
+
this.name = "RetriableError";
|
|
201
|
+
}
|
|
202
|
+
};
|
|
172
203
|
var RealtimeService = class {
|
|
204
|
+
// Track auth failures to prevent reconnection attempts
|
|
173
205
|
constructor(baseUrl, apiKey, onConnectionChange) {
|
|
174
|
-
this.
|
|
206
|
+
this.abortController = null;
|
|
175
207
|
this.reconnectAttempts = 0;
|
|
176
208
|
this.maxReconnectAttempts = 10;
|
|
177
|
-
// Increased from 5 to 10
|
|
178
209
|
this.reconnectDelay = 1e3;
|
|
179
210
|
// Start with 1 second
|
|
180
211
|
this.maxReconnectDelay = 3e4;
|
|
181
212
|
// Cap at 30 seconds
|
|
182
213
|
this.listeners = /* @__PURE__ */ new Map();
|
|
214
|
+
this.connected = false;
|
|
215
|
+
this.authFailed = false;
|
|
183
216
|
this.baseUrl = baseUrl;
|
|
184
217
|
this.apiKey = apiKey;
|
|
185
218
|
this.onConnectionChange = onConnectionChange;
|
|
186
219
|
}
|
|
187
220
|
/**
|
|
188
|
-
* Connect to SSE stream
|
|
221
|
+
* Connect to SSE stream using header-based authentication
|
|
222
|
+
* Per SDK Developer Guide: "Never pass API keys as query parameters"
|
|
189
223
|
*/
|
|
190
224
|
connect() {
|
|
191
|
-
if (this.
|
|
225
|
+
if (this.abortController) {
|
|
192
226
|
return;
|
|
193
227
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.eventSource = new EventSource(url);
|
|
197
|
-
this.eventSource.onopen = () => {
|
|
198
|
-
console.log("[Savvagent] Real-time connection established");
|
|
199
|
-
this.reconnectAttempts = 0;
|
|
200
|
-
this.reconnectDelay = 1e3;
|
|
201
|
-
this.onConnectionChange?.call(null, true);
|
|
202
|
-
};
|
|
203
|
-
this.eventSource.onerror = (error) => {
|
|
204
|
-
console.error("[Savvagent] SSE connection error:", error);
|
|
205
|
-
this.handleDisconnect();
|
|
206
|
-
};
|
|
207
|
-
this.eventSource.addEventListener("heartbeat", () => {
|
|
208
|
-
});
|
|
209
|
-
this.eventSource.addEventListener("flag.updated", (e) => {
|
|
210
|
-
this.handleMessage("flag.updated", e);
|
|
211
|
-
});
|
|
212
|
-
this.eventSource.addEventListener("flag.deleted", (e) => {
|
|
213
|
-
this.handleMessage("flag.deleted", e);
|
|
214
|
-
});
|
|
215
|
-
this.eventSource.addEventListener("flag.created", (e) => {
|
|
216
|
-
this.handleMessage("flag.created", e);
|
|
217
|
-
});
|
|
218
|
-
} catch (error) {
|
|
219
|
-
console.error("[Savvagent] Failed to create EventSource:", error);
|
|
220
|
-
this.handleDisconnect();
|
|
228
|
+
if (this.authFailed) {
|
|
229
|
+
return;
|
|
221
230
|
}
|
|
231
|
+
this.abortController = new AbortController();
|
|
232
|
+
const url = `${this.baseUrl}/api/flags/stream`;
|
|
233
|
+
fetchEventSource(url, {
|
|
234
|
+
method: "GET",
|
|
235
|
+
headers: {
|
|
236
|
+
"Authorization": `Bearer ${this.apiKey}`
|
|
237
|
+
},
|
|
238
|
+
signal: this.abortController.signal,
|
|
239
|
+
// Disable built-in retry behavior - we handle it ourselves
|
|
240
|
+
openWhenHidden: false,
|
|
241
|
+
onopen: async (response) => {
|
|
242
|
+
if (response.ok) {
|
|
243
|
+
console.log("[Savvagent] Real-time connection established");
|
|
244
|
+
this.reconnectAttempts = 0;
|
|
245
|
+
this.reconnectDelay = 1e3;
|
|
246
|
+
this.connected = true;
|
|
247
|
+
this.onConnectionChange?.(true);
|
|
248
|
+
} else if (response.status === 401 || response.status === 403) {
|
|
249
|
+
this.authFailed = true;
|
|
250
|
+
console.error(`[Savvagent] SSE authentication failed (${response.status}). Check your API key. Reconnection disabled.`);
|
|
251
|
+
throw new FatalError(`SSE authentication failed: ${response.status}`);
|
|
252
|
+
} else {
|
|
253
|
+
console.error(`[Savvagent] SSE connection failed: ${response.status}`);
|
|
254
|
+
throw new RetriableError(`SSE connection failed: ${response.status}`);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
onmessage: (event) => {
|
|
258
|
+
this.handleMessage(event);
|
|
259
|
+
},
|
|
260
|
+
onerror: (err) => {
|
|
261
|
+
if (this.authFailed) {
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
console.error("[Savvagent] SSE connection error:", err);
|
|
265
|
+
this.handleDisconnect();
|
|
266
|
+
},
|
|
267
|
+
onclose: () => {
|
|
268
|
+
console.log("[Savvagent] SSE connection closed");
|
|
269
|
+
if (!this.authFailed) {
|
|
270
|
+
this.handleDisconnect();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}).catch((error) => {
|
|
274
|
+
if (error.name !== "AbortError" && !(error instanceof FatalError)) {
|
|
275
|
+
console.error("[Savvagent] SSE connection error:", error);
|
|
276
|
+
if (!this.authFailed) {
|
|
277
|
+
this.handleDisconnect();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
222
281
|
}
|
|
223
282
|
/**
|
|
224
283
|
* Handle incoming SSE messages
|
|
225
284
|
*/
|
|
226
|
-
handleMessage(
|
|
285
|
+
handleMessage(event) {
|
|
286
|
+
if (event.event === "heartbeat") {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (event.event === "connected") {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const eventType = event.event;
|
|
293
|
+
if (!["flag.updated", "flag.deleted", "flag.created"].includes(eventType)) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
227
296
|
try {
|
|
228
297
|
const data = JSON.parse(event.data);
|
|
229
298
|
const updateEvent = {
|
|
230
|
-
type,
|
|
299
|
+
type: eventType,
|
|
231
300
|
flagKey: data.key,
|
|
232
301
|
data
|
|
233
302
|
};
|
|
234
|
-
const flagListeners = this.listeners.get(updateEvent.flagKey);
|
|
235
|
-
if (flagListeners) {
|
|
236
|
-
flagListeners.forEach((listener) => listener(updateEvent));
|
|
237
|
-
}
|
|
238
303
|
const wildcardListeners = this.listeners.get("*");
|
|
239
304
|
if (wildcardListeners) {
|
|
240
305
|
wildcardListeners.forEach((listener) => listener(updateEvent));
|
|
241
306
|
}
|
|
307
|
+
const flagListeners = this.listeners.get(updateEvent.flagKey);
|
|
308
|
+
if (flagListeners) {
|
|
309
|
+
flagListeners.forEach((listener) => listener(updateEvent));
|
|
310
|
+
}
|
|
242
311
|
} catch (error) {
|
|
243
312
|
console.error("[Savvagent] Failed to parse SSE message:", error);
|
|
244
313
|
}
|
|
245
314
|
}
|
|
246
315
|
/**
|
|
247
|
-
* Handle disconnection and attempt reconnect
|
|
316
|
+
* Handle disconnection and attempt reconnect with exponential backoff
|
|
248
317
|
*/
|
|
249
318
|
handleDisconnect() {
|
|
250
|
-
this.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
this.
|
|
319
|
+
this.connected = false;
|
|
320
|
+
this.onConnectionChange?.(false);
|
|
321
|
+
if (this.abortController) {
|
|
322
|
+
this.abortController.abort();
|
|
323
|
+
this.abortController = null;
|
|
324
|
+
}
|
|
325
|
+
if (this.authFailed) {
|
|
326
|
+
console.warn("[Savvagent] Authentication failed. Reconnection disabled.");
|
|
327
|
+
return;
|
|
254
328
|
}
|
|
255
329
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
256
330
|
this.reconnectAttempts++;
|
|
@@ -288,39 +362,48 @@ var RealtimeService = class {
|
|
|
288
362
|
* Disconnect from SSE stream
|
|
289
363
|
*/
|
|
290
364
|
disconnect() {
|
|
291
|
-
if (this.eventSource) {
|
|
292
|
-
this.eventSource.close();
|
|
293
|
-
this.eventSource = null;
|
|
294
|
-
}
|
|
295
365
|
this.reconnectAttempts = this.maxReconnectAttempts;
|
|
296
|
-
this.
|
|
366
|
+
if (this.abortController) {
|
|
367
|
+
this.abortController.abort();
|
|
368
|
+
this.abortController = null;
|
|
369
|
+
}
|
|
370
|
+
this.connected = false;
|
|
371
|
+
this.onConnectionChange?.(false);
|
|
297
372
|
}
|
|
298
373
|
/**
|
|
299
374
|
* Check if connected
|
|
300
375
|
*/
|
|
301
376
|
isConnected() {
|
|
302
|
-
return this.
|
|
377
|
+
return this.connected;
|
|
303
378
|
}
|
|
304
379
|
};
|
|
305
380
|
|
|
306
381
|
// src/client.ts
|
|
307
382
|
var FlagClient = class {
|
|
383
|
+
// Track auth failures to prevent request spam
|
|
308
384
|
constructor(config) {
|
|
309
385
|
this.realtime = null;
|
|
310
386
|
this.anonymousId = null;
|
|
311
387
|
this.userId = null;
|
|
312
388
|
this.detectedLanguage = null;
|
|
389
|
+
this.overrides = /* @__PURE__ */ new Map();
|
|
390
|
+
this.overrideListeners = /* @__PURE__ */ new Set();
|
|
391
|
+
this.authFailed = false;
|
|
313
392
|
this.config = {
|
|
314
393
|
apiKey: config.apiKey,
|
|
315
394
|
applicationId: config.applicationId || "",
|
|
316
395
|
baseUrl: config.baseUrl || "http://localhost:8080",
|
|
396
|
+
environment: config.environment || "production",
|
|
317
397
|
enableRealtime: config.enableRealtime ?? true,
|
|
318
398
|
cacheTtl: config.cacheTtl || 6e4,
|
|
319
399
|
enableTelemetry: config.enableTelemetry ?? true,
|
|
320
400
|
defaults: config.defaults || {},
|
|
321
401
|
onError: config.onError || ((error) => console.error("[Savvagent]", error)),
|
|
322
402
|
defaultLanguage: config.defaultLanguage || "",
|
|
323
|
-
disableLanguageDetection: config.disableLanguageDetection ?? false
|
|
403
|
+
disableLanguageDetection: config.disableLanguageDetection ?? false,
|
|
404
|
+
retryAttempts: config.retryAttempts ?? 3,
|
|
405
|
+
retryDelay: config.retryDelay ?? 1e3,
|
|
406
|
+
retryBackoff: config.retryBackoff ?? "exponential"
|
|
324
407
|
};
|
|
325
408
|
if (!this.config.disableLanguageDetection && typeof navigator !== "undefined") {
|
|
326
409
|
this.detectedLanguage = this.config.defaultLanguage || navigator.language || navigator.userLanguage || null;
|
|
@@ -334,7 +417,7 @@ var FlagClient = class {
|
|
|
334
417
|
this.config.apiKey,
|
|
335
418
|
this.config.enableTelemetry
|
|
336
419
|
);
|
|
337
|
-
if (this.config.enableRealtime && typeof
|
|
420
|
+
if (this.config.enableRealtime && typeof fetch !== "undefined") {
|
|
338
421
|
this.realtime = new RealtimeService(
|
|
339
422
|
this.config.baseUrl,
|
|
340
423
|
this.config.apiKey,
|
|
@@ -399,6 +482,21 @@ var FlagClient = class {
|
|
|
399
482
|
getUserId() {
|
|
400
483
|
return this.userId;
|
|
401
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Set the environment for flag evaluation
|
|
487
|
+
* Useful for dynamically switching environments (e.g., dev tools)
|
|
488
|
+
* @param environment - The environment name (e.g., "development", "staging", "production", "beta")
|
|
489
|
+
*/
|
|
490
|
+
setEnvironment(environment) {
|
|
491
|
+
this.config.environment = environment;
|
|
492
|
+
this.cache.clear();
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Get the current environment
|
|
496
|
+
*/
|
|
497
|
+
getEnvironment() {
|
|
498
|
+
return this.config.environment;
|
|
499
|
+
}
|
|
402
500
|
/**
|
|
403
501
|
* Get the current anonymous ID
|
|
404
502
|
*/
|
|
@@ -413,8 +511,7 @@ var FlagClient = class {
|
|
|
413
511
|
const context = {
|
|
414
512
|
user_id: this.userId || void 0,
|
|
415
513
|
anonymous_id: this.anonymousId || void 0,
|
|
416
|
-
environment:
|
|
417
|
-
// TODO: Make configurable
|
|
514
|
+
environment: this.config.environment,
|
|
418
515
|
...overrides
|
|
419
516
|
};
|
|
420
517
|
if (!context.application_id && this.config.applicationId) {
|
|
@@ -425,6 +522,86 @@ var FlagClient = class {
|
|
|
425
522
|
}
|
|
426
523
|
return context;
|
|
427
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Check if an error is retryable (transient failure)
|
|
527
|
+
* @param error - The error to check
|
|
528
|
+
* @param status - HTTP status code (if available)
|
|
529
|
+
*/
|
|
530
|
+
isRetryableError(error, status) {
|
|
531
|
+
if (status === 401 || status === 403) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
if (status && status >= 500) {
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
if (error.name === "AbortError" || error.name === "TypeError" || error.message.includes("network")) {
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Calculate delay for retry attempt
|
|
547
|
+
* @param attempt - Current attempt number (1-based)
|
|
548
|
+
*/
|
|
549
|
+
getRetryDelay(attempt) {
|
|
550
|
+
const baseDelay = this.config.retryDelay;
|
|
551
|
+
if (this.config.retryBackoff === "linear") {
|
|
552
|
+
return baseDelay * attempt;
|
|
553
|
+
}
|
|
554
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
|
555
|
+
const jitter = Math.random() * 0.3 * exponentialDelay;
|
|
556
|
+
return exponentialDelay + jitter;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Execute a fetch request with retry logic
|
|
560
|
+
* @param requestFn - Function that returns a fetch promise
|
|
561
|
+
* @param operationName - Name of the operation for logging
|
|
562
|
+
*/
|
|
563
|
+
async fetchWithRetry(requestFn, operationName) {
|
|
564
|
+
let lastError = null;
|
|
565
|
+
let lastStatus;
|
|
566
|
+
for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
|
|
567
|
+
try {
|
|
568
|
+
const response = await requestFn();
|
|
569
|
+
if (response.status === 401 || response.status === 403) {
|
|
570
|
+
this.authFailed = true;
|
|
571
|
+
this.realtime?.disconnect();
|
|
572
|
+
console.error(`[Savvagent] Authentication failed (${response.status}). Check your API key. Further requests disabled.`);
|
|
573
|
+
throw new Error(`Authentication failed: ${response.status}`);
|
|
574
|
+
}
|
|
575
|
+
if (response.ok) {
|
|
576
|
+
return response;
|
|
577
|
+
}
|
|
578
|
+
lastStatus = response.status;
|
|
579
|
+
lastError = new Error(`${operationName} failed: ${response.status}`);
|
|
580
|
+
if (!this.isRetryableError(lastError, response.status)) {
|
|
581
|
+
throw lastError;
|
|
582
|
+
}
|
|
583
|
+
if (attempt < this.config.retryAttempts) {
|
|
584
|
+
const delay = this.getRetryDelay(attempt);
|
|
585
|
+
console.warn(`[Savvagent] ${operationName} failed (${response.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt}/${this.config.retryAttempts})`);
|
|
586
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
587
|
+
}
|
|
588
|
+
} catch (error) {
|
|
589
|
+
lastError = error;
|
|
590
|
+
if (lastError.message.includes("Authentication failed")) {
|
|
591
|
+
throw lastError;
|
|
592
|
+
}
|
|
593
|
+
if (!this.isRetryableError(lastError, lastStatus)) {
|
|
594
|
+
throw lastError;
|
|
595
|
+
}
|
|
596
|
+
if (attempt < this.config.retryAttempts) {
|
|
597
|
+
const delay = this.getRetryDelay(attempt);
|
|
598
|
+
console.warn(`[Savvagent] ${operationName} error: ${lastError.message}, retrying in ${Math.round(delay)}ms (attempt ${attempt}/${this.config.retryAttempts})`);
|
|
599
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
throw lastError || new Error(`${operationName} failed after ${this.config.retryAttempts} attempts`);
|
|
604
|
+
}
|
|
428
605
|
/**
|
|
429
606
|
* Check if a feature flag is enabled
|
|
430
607
|
* @param flagKey - The flag key to evaluate
|
|
@@ -445,6 +622,29 @@ var FlagClient = class {
|
|
|
445
622
|
const startTime = Date.now();
|
|
446
623
|
const traceId = TelemetryService.generateTraceId();
|
|
447
624
|
try {
|
|
625
|
+
if (this.overrides.has(flagKey)) {
|
|
626
|
+
const overrideValue = this.overrides.get(flagKey);
|
|
627
|
+
return {
|
|
628
|
+
key: flagKey,
|
|
629
|
+
value: overrideValue,
|
|
630
|
+
reason: "default",
|
|
631
|
+
// Using 'default' to indicate override
|
|
632
|
+
metadata: {
|
|
633
|
+
description: "Local override active"
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (this.authFailed) {
|
|
638
|
+
const defaultValue = this.config.defaults[flagKey] ?? false;
|
|
639
|
+
return {
|
|
640
|
+
key: flagKey,
|
|
641
|
+
value: defaultValue,
|
|
642
|
+
reason: "error",
|
|
643
|
+
metadata: {
|
|
644
|
+
description: "Authentication failed - using default value"
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
}
|
|
448
648
|
const cachedValue = this.cache.get(flagKey);
|
|
449
649
|
if (cachedValue !== null) {
|
|
450
650
|
return {
|
|
@@ -457,17 +657,26 @@ var FlagClient = class {
|
|
|
457
657
|
const requestBody = {
|
|
458
658
|
context: evaluationContext
|
|
459
659
|
};
|
|
460
|
-
const response = await
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
660
|
+
const response = await this.fetchWithRetry(
|
|
661
|
+
() => {
|
|
662
|
+
const controller = new AbortController();
|
|
663
|
+
const timeoutId = setTimeout(() => {
|
|
664
|
+
controller.abort();
|
|
665
|
+
}, 1e4);
|
|
666
|
+
return fetch(`${this.config.baseUrl}/api/flags/${flagKey}/evaluate`, {
|
|
667
|
+
method: "POST",
|
|
668
|
+
headers: {
|
|
669
|
+
"Content-Type": "application/json",
|
|
670
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
671
|
+
},
|
|
672
|
+
body: JSON.stringify(requestBody),
|
|
673
|
+
signal: controller.signal
|
|
674
|
+
}).finally(() => {
|
|
675
|
+
clearTimeout(timeoutId);
|
|
676
|
+
});
|
|
465
677
|
},
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (!response.ok) {
|
|
469
|
-
throw new Error(`Flag evaluation failed: ${response.status}`);
|
|
470
|
-
}
|
|
678
|
+
`Flag evaluation (${flagKey})`
|
|
679
|
+
);
|
|
471
680
|
const data = await response.json();
|
|
472
681
|
const value = data.enabled || false;
|
|
473
682
|
this.cache.set(flagKey, value, data.key);
|
|
@@ -592,6 +801,217 @@ var FlagClient = class {
|
|
|
592
801
|
this.realtime?.disconnect();
|
|
593
802
|
this.cache.clear();
|
|
594
803
|
}
|
|
804
|
+
// =====================
|
|
805
|
+
// Local Override Methods
|
|
806
|
+
// =====================
|
|
807
|
+
/**
|
|
808
|
+
* Set a local override for a flag.
|
|
809
|
+
* Overrides take precedence over server values and cache.
|
|
810
|
+
*
|
|
811
|
+
* @param flagKey - The flag key to override
|
|
812
|
+
* @param value - The override value (true/false)
|
|
813
|
+
*
|
|
814
|
+
* @example
|
|
815
|
+
* ```typescript
|
|
816
|
+
* // Force a flag to be enabled locally
|
|
817
|
+
* client.setOverride('new-feature', true);
|
|
818
|
+
* ```
|
|
819
|
+
*/
|
|
820
|
+
setOverride(flagKey, value) {
|
|
821
|
+
this.overrides.set(flagKey, value);
|
|
822
|
+
this.notifyOverrideListeners();
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Clear a local override for a flag.
|
|
826
|
+
* The flag will return to using server/cached values.
|
|
827
|
+
*
|
|
828
|
+
* @param flagKey - The flag key to clear override for
|
|
829
|
+
*/
|
|
830
|
+
clearOverride(flagKey) {
|
|
831
|
+
this.overrides.delete(flagKey);
|
|
832
|
+
this.notifyOverrideListeners();
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Clear all local overrides.
|
|
836
|
+
*/
|
|
837
|
+
clearAllOverrides() {
|
|
838
|
+
this.overrides.clear();
|
|
839
|
+
this.notifyOverrideListeners();
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Check if a flag has a local override.
|
|
843
|
+
*
|
|
844
|
+
* @param flagKey - The flag key to check
|
|
845
|
+
* @returns true if the flag has an override
|
|
846
|
+
*/
|
|
847
|
+
hasOverride(flagKey) {
|
|
848
|
+
return this.overrides.has(flagKey);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Get the override value for a flag.
|
|
852
|
+
*
|
|
853
|
+
* @param flagKey - The flag key to get override for
|
|
854
|
+
* @returns The override value, or undefined if not set
|
|
855
|
+
*/
|
|
856
|
+
getOverride(flagKey) {
|
|
857
|
+
return this.overrides.get(flagKey);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Get all current overrides.
|
|
861
|
+
*
|
|
862
|
+
* @returns Record of flag keys to override values
|
|
863
|
+
*/
|
|
864
|
+
getOverrides() {
|
|
865
|
+
const result = {};
|
|
866
|
+
this.overrides.forEach((value, key) => {
|
|
867
|
+
result[key] = value;
|
|
868
|
+
});
|
|
869
|
+
return result;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Set multiple overrides at once.
|
|
873
|
+
*
|
|
874
|
+
* @param overrides - Record of flag keys to override values
|
|
875
|
+
*/
|
|
876
|
+
setOverrides(overrides) {
|
|
877
|
+
Object.entries(overrides).forEach(([key, value]) => {
|
|
878
|
+
this.overrides.set(key, value);
|
|
879
|
+
});
|
|
880
|
+
this.notifyOverrideListeners();
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Subscribe to override changes.
|
|
884
|
+
* Useful for React components to re-render when overrides change.
|
|
885
|
+
*
|
|
886
|
+
* @param callback - Function to call when overrides change
|
|
887
|
+
* @returns Unsubscribe function
|
|
888
|
+
*/
|
|
889
|
+
onOverrideChange(callback) {
|
|
890
|
+
this.overrideListeners.add(callback);
|
|
891
|
+
return () => {
|
|
892
|
+
this.overrideListeners.delete(callback);
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Notify all override listeners of a change.
|
|
897
|
+
*/
|
|
898
|
+
notifyOverrideListeners() {
|
|
899
|
+
this.overrideListeners.forEach((callback) => {
|
|
900
|
+
try {
|
|
901
|
+
callback();
|
|
902
|
+
} catch (e) {
|
|
903
|
+
console.error("[Savvagent] Override listener error:", e);
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Get all flags for the application (and enterprise-scoped flags).
|
|
909
|
+
* Per SDK Developer Guide: GET /api/sdk/flags
|
|
910
|
+
*
|
|
911
|
+
* Use cases:
|
|
912
|
+
* - Local override UI: Display all available flags for developers to toggle
|
|
913
|
+
* - Offline mode: Pre-fetch flags for mobile/desktop apps
|
|
914
|
+
* - SDK initialization: Bootstrap SDK with all flag values on startup
|
|
915
|
+
* - DevTools integration: Show available flags in browser dev panels
|
|
916
|
+
*
|
|
917
|
+
* @param environment - Environment to evaluate enabled state for (default: 'development')
|
|
918
|
+
* @returns Promise<FlagDefinition[]> - List of flag definitions
|
|
919
|
+
*
|
|
920
|
+
* @example
|
|
921
|
+
* ```typescript
|
|
922
|
+
* // Fetch all flags for development
|
|
923
|
+
* const flags = await client.getAllFlags('development');
|
|
924
|
+
*
|
|
925
|
+
* // Bootstrap local cache
|
|
926
|
+
* flags.forEach(flag => {
|
|
927
|
+
* console.log(`${flag.key}: ${flag.enabled}`);
|
|
928
|
+
* });
|
|
929
|
+
* ```
|
|
930
|
+
*/
|
|
931
|
+
async getAllFlags(environment = "development") {
|
|
932
|
+
if (this.authFailed) {
|
|
933
|
+
return [];
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const response = await this.fetchWithRetry(
|
|
937
|
+
() => {
|
|
938
|
+
const controller = new AbortController();
|
|
939
|
+
const timeoutId = setTimeout(() => {
|
|
940
|
+
controller.abort();
|
|
941
|
+
}, 1e4);
|
|
942
|
+
return fetch(
|
|
943
|
+
`${this.config.baseUrl}/api/sdk/flags?environment=${encodeURIComponent(environment)}`,
|
|
944
|
+
{
|
|
945
|
+
method: "GET",
|
|
946
|
+
headers: {
|
|
947
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
948
|
+
},
|
|
949
|
+
signal: controller.signal
|
|
950
|
+
}
|
|
951
|
+
).finally(() => {
|
|
952
|
+
clearTimeout(timeoutId);
|
|
953
|
+
});
|
|
954
|
+
},
|
|
955
|
+
"Get all flags"
|
|
956
|
+
);
|
|
957
|
+
const data = await response.json();
|
|
958
|
+
data.flags.forEach((flag) => {
|
|
959
|
+
this.cache.set(flag.key, flag.enabled, flag.key);
|
|
960
|
+
});
|
|
961
|
+
return data.flags;
|
|
962
|
+
} catch (error) {
|
|
963
|
+
this.config.onError(error);
|
|
964
|
+
return [];
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Get only enterprise-scoped flags for the organization.
|
|
969
|
+
* Per SDK Developer Guide: GET /api/sdk/enterprise-flags
|
|
970
|
+
*
|
|
971
|
+
* Enterprise flags are shared across all applications in the organization.
|
|
972
|
+
*
|
|
973
|
+
* @param environment - Environment to evaluate enabled state for (default: 'development')
|
|
974
|
+
* @returns Promise<FlagDefinition[]> - List of enterprise flag definitions
|
|
975
|
+
*
|
|
976
|
+
* @example
|
|
977
|
+
* ```typescript
|
|
978
|
+
* // Fetch enterprise-only flags
|
|
979
|
+
* const enterpriseFlags = await client.getEnterpriseFlags('production');
|
|
980
|
+
* ```
|
|
981
|
+
*/
|
|
982
|
+
async getEnterpriseFlags(environment = "development") {
|
|
983
|
+
if (this.authFailed) {
|
|
984
|
+
return [];
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
const response = await this.fetchWithRetry(
|
|
988
|
+
() => {
|
|
989
|
+
const controller = new AbortController();
|
|
990
|
+
const timeoutId = setTimeout(() => {
|
|
991
|
+
controller.abort();
|
|
992
|
+
}, 1e4);
|
|
993
|
+
return fetch(
|
|
994
|
+
`${this.config.baseUrl}/api/sdk/enterprise-flags?environment=${encodeURIComponent(environment)}`,
|
|
995
|
+
{
|
|
996
|
+
method: "GET",
|
|
997
|
+
headers: {
|
|
998
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
999
|
+
},
|
|
1000
|
+
signal: controller.signal
|
|
1001
|
+
}
|
|
1002
|
+
).finally(() => {
|
|
1003
|
+
clearTimeout(timeoutId);
|
|
1004
|
+
});
|
|
1005
|
+
},
|
|
1006
|
+
"Get enterprise flags"
|
|
1007
|
+
);
|
|
1008
|
+
const data = await response.json();
|
|
1009
|
+
return data.flags;
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
this.config.onError(error);
|
|
1012
|
+
return [];
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
595
1015
|
};
|
|
596
1016
|
export {
|
|
597
1017
|
FlagCache,
|