@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.js
CHANGED
|
@@ -149,15 +149,23 @@ var TelemetryService = class {
|
|
|
149
149
|
}
|
|
150
150
|
/**
|
|
151
151
|
* Send evaluation events to backend
|
|
152
|
+
* Per SDK Developer Guide: POST /api/telemetry/evaluations with { "evaluations": [...] }
|
|
152
153
|
*/
|
|
153
154
|
async sendEvaluations(events) {
|
|
155
|
+
const evaluations = events.map((e) => ({
|
|
156
|
+
flag_key: e.flagKey,
|
|
157
|
+
result: e.result,
|
|
158
|
+
user_id: e.context?.user_id,
|
|
159
|
+
context: e.context,
|
|
160
|
+
timestamp: Math.floor(new Date(e.timestamp).getTime() / 1e3)
|
|
161
|
+
}));
|
|
154
162
|
const response = await fetch(`${this.baseUrl}/api/telemetry/evaluations`, {
|
|
155
163
|
method: "POST",
|
|
156
164
|
headers: {
|
|
157
165
|
"Content-Type": "application/json",
|
|
158
166
|
Authorization: `Bearer ${this.apiKey}`
|
|
159
167
|
},
|
|
160
|
-
body: JSON.stringify({
|
|
168
|
+
body: JSON.stringify({ evaluations })
|
|
161
169
|
});
|
|
162
170
|
if (!response.ok) {
|
|
163
171
|
throw new Error(`Failed to send evaluations: ${response.status}`);
|
|
@@ -165,15 +173,25 @@ var TelemetryService = class {
|
|
|
165
173
|
}
|
|
166
174
|
/**
|
|
167
175
|
* Send error events to backend
|
|
176
|
+
* Per SDK Developer Guide: POST /api/telemetry/errors with { "errors": [...] }
|
|
168
177
|
*/
|
|
169
178
|
async sendErrors(events) {
|
|
179
|
+
const errors = events.map((e) => ({
|
|
180
|
+
flag_key: e.flagKey,
|
|
181
|
+
flag_enabled: e.flagEnabled,
|
|
182
|
+
error_type: e.errorType,
|
|
183
|
+
error_message: e.errorMessage,
|
|
184
|
+
stack_trace: e.stackTrace,
|
|
185
|
+
context: e.context,
|
|
186
|
+
timestamp: Math.floor(new Date(e.timestamp).getTime() / 1e3)
|
|
187
|
+
}));
|
|
170
188
|
const response = await fetch(`${this.baseUrl}/api/telemetry/errors`, {
|
|
171
189
|
method: "POST",
|
|
172
190
|
headers: {
|
|
173
191
|
"Content-Type": "application/json",
|
|
174
192
|
Authorization: `Bearer ${this.apiKey}`
|
|
175
193
|
},
|
|
176
|
-
body: JSON.stringify({
|
|
194
|
+
body: JSON.stringify({ errors })
|
|
177
195
|
});
|
|
178
196
|
if (!response.ok) {
|
|
179
197
|
throw new Error(`Failed to send errors: ${response.status}`);
|
|
@@ -198,88 +216,144 @@ var TelemetryService = class {
|
|
|
198
216
|
};
|
|
199
217
|
|
|
200
218
|
// src/realtime.ts
|
|
219
|
+
var import_fetch_event_source = require("@microsoft/fetch-event-source");
|
|
220
|
+
var FatalError = class extends Error {
|
|
221
|
+
constructor(message) {
|
|
222
|
+
super(message);
|
|
223
|
+
this.name = "FatalError";
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
var RetriableError = class extends Error {
|
|
227
|
+
constructor(message) {
|
|
228
|
+
super(message);
|
|
229
|
+
this.name = "RetriableError";
|
|
230
|
+
}
|
|
231
|
+
};
|
|
201
232
|
var RealtimeService = class {
|
|
233
|
+
// Track auth failures to prevent reconnection attempts
|
|
202
234
|
constructor(baseUrl, apiKey, onConnectionChange) {
|
|
203
|
-
this.
|
|
235
|
+
this.abortController = null;
|
|
204
236
|
this.reconnectAttempts = 0;
|
|
205
237
|
this.maxReconnectAttempts = 10;
|
|
206
|
-
// Increased from 5 to 10
|
|
207
238
|
this.reconnectDelay = 1e3;
|
|
208
239
|
// Start with 1 second
|
|
209
240
|
this.maxReconnectDelay = 3e4;
|
|
210
241
|
// Cap at 30 seconds
|
|
211
242
|
this.listeners = /* @__PURE__ */ new Map();
|
|
243
|
+
this.connected = false;
|
|
244
|
+
this.authFailed = false;
|
|
212
245
|
this.baseUrl = baseUrl;
|
|
213
246
|
this.apiKey = apiKey;
|
|
214
247
|
this.onConnectionChange = onConnectionChange;
|
|
215
248
|
}
|
|
216
249
|
/**
|
|
217
|
-
* Connect to SSE stream
|
|
250
|
+
* Connect to SSE stream using header-based authentication
|
|
251
|
+
* Per SDK Developer Guide: "Never pass API keys as query parameters"
|
|
218
252
|
*/
|
|
219
253
|
connect() {
|
|
220
|
-
if (this.
|
|
254
|
+
if (this.abortController) {
|
|
221
255
|
return;
|
|
222
256
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
this.eventSource = new EventSource(url);
|
|
226
|
-
this.eventSource.onopen = () => {
|
|
227
|
-
console.log("[Savvagent] Real-time connection established");
|
|
228
|
-
this.reconnectAttempts = 0;
|
|
229
|
-
this.reconnectDelay = 1e3;
|
|
230
|
-
this.onConnectionChange?.call(null, true);
|
|
231
|
-
};
|
|
232
|
-
this.eventSource.onerror = (error) => {
|
|
233
|
-
console.error("[Savvagent] SSE connection error:", error);
|
|
234
|
-
this.handleDisconnect();
|
|
235
|
-
};
|
|
236
|
-
this.eventSource.addEventListener("heartbeat", () => {
|
|
237
|
-
});
|
|
238
|
-
this.eventSource.addEventListener("flag.updated", (e) => {
|
|
239
|
-
this.handleMessage("flag.updated", e);
|
|
240
|
-
});
|
|
241
|
-
this.eventSource.addEventListener("flag.deleted", (e) => {
|
|
242
|
-
this.handleMessage("flag.deleted", e);
|
|
243
|
-
});
|
|
244
|
-
this.eventSource.addEventListener("flag.created", (e) => {
|
|
245
|
-
this.handleMessage("flag.created", e);
|
|
246
|
-
});
|
|
247
|
-
} catch (error) {
|
|
248
|
-
console.error("[Savvagent] Failed to create EventSource:", error);
|
|
249
|
-
this.handleDisconnect();
|
|
257
|
+
if (this.authFailed) {
|
|
258
|
+
return;
|
|
250
259
|
}
|
|
260
|
+
this.abortController = new AbortController();
|
|
261
|
+
const url = `${this.baseUrl}/api/flags/stream`;
|
|
262
|
+
(0, import_fetch_event_source.fetchEventSource)(url, {
|
|
263
|
+
method: "GET",
|
|
264
|
+
headers: {
|
|
265
|
+
"Authorization": `Bearer ${this.apiKey}`
|
|
266
|
+
},
|
|
267
|
+
signal: this.abortController.signal,
|
|
268
|
+
// Disable built-in retry behavior - we handle it ourselves
|
|
269
|
+
openWhenHidden: false,
|
|
270
|
+
onopen: async (response) => {
|
|
271
|
+
if (response.ok) {
|
|
272
|
+
console.log("[Savvagent] Real-time connection established");
|
|
273
|
+
this.reconnectAttempts = 0;
|
|
274
|
+
this.reconnectDelay = 1e3;
|
|
275
|
+
this.connected = true;
|
|
276
|
+
this.onConnectionChange?.(true);
|
|
277
|
+
} else if (response.status === 401 || response.status === 403) {
|
|
278
|
+
this.authFailed = true;
|
|
279
|
+
console.error(`[Savvagent] SSE authentication failed (${response.status}). Check your API key. Reconnection disabled.`);
|
|
280
|
+
throw new FatalError(`SSE authentication failed: ${response.status}`);
|
|
281
|
+
} else {
|
|
282
|
+
console.error(`[Savvagent] SSE connection failed: ${response.status}`);
|
|
283
|
+
throw new RetriableError(`SSE connection failed: ${response.status}`);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
onmessage: (event) => {
|
|
287
|
+
this.handleMessage(event);
|
|
288
|
+
},
|
|
289
|
+
onerror: (err) => {
|
|
290
|
+
if (this.authFailed) {
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
console.error("[Savvagent] SSE connection error:", err);
|
|
294
|
+
this.handleDisconnect();
|
|
295
|
+
},
|
|
296
|
+
onclose: () => {
|
|
297
|
+
console.log("[Savvagent] SSE connection closed");
|
|
298
|
+
if (!this.authFailed) {
|
|
299
|
+
this.handleDisconnect();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}).catch((error) => {
|
|
303
|
+
if (error.name !== "AbortError" && !(error instanceof FatalError)) {
|
|
304
|
+
console.error("[Savvagent] SSE connection error:", error);
|
|
305
|
+
if (!this.authFailed) {
|
|
306
|
+
this.handleDisconnect();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
});
|
|
251
310
|
}
|
|
252
311
|
/**
|
|
253
312
|
* Handle incoming SSE messages
|
|
254
313
|
*/
|
|
255
|
-
handleMessage(
|
|
314
|
+
handleMessage(event) {
|
|
315
|
+
if (event.event === "heartbeat") {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (event.event === "connected") {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const eventType = event.event;
|
|
322
|
+
if (!["flag.updated", "flag.deleted", "flag.created"].includes(eventType)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
256
325
|
try {
|
|
257
326
|
const data = JSON.parse(event.data);
|
|
258
327
|
const updateEvent = {
|
|
259
|
-
type,
|
|
328
|
+
type: eventType,
|
|
260
329
|
flagKey: data.key,
|
|
261
330
|
data
|
|
262
331
|
};
|
|
263
|
-
const flagListeners = this.listeners.get(updateEvent.flagKey);
|
|
264
|
-
if (flagListeners) {
|
|
265
|
-
flagListeners.forEach((listener) => listener(updateEvent));
|
|
266
|
-
}
|
|
267
332
|
const wildcardListeners = this.listeners.get("*");
|
|
268
333
|
if (wildcardListeners) {
|
|
269
334
|
wildcardListeners.forEach((listener) => listener(updateEvent));
|
|
270
335
|
}
|
|
336
|
+
const flagListeners = this.listeners.get(updateEvent.flagKey);
|
|
337
|
+
if (flagListeners) {
|
|
338
|
+
flagListeners.forEach((listener) => listener(updateEvent));
|
|
339
|
+
}
|
|
271
340
|
} catch (error) {
|
|
272
341
|
console.error("[Savvagent] Failed to parse SSE message:", error);
|
|
273
342
|
}
|
|
274
343
|
}
|
|
275
344
|
/**
|
|
276
|
-
* Handle disconnection and attempt reconnect
|
|
345
|
+
* Handle disconnection and attempt reconnect with exponential backoff
|
|
277
346
|
*/
|
|
278
347
|
handleDisconnect() {
|
|
279
|
-
this.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
this.
|
|
348
|
+
this.connected = false;
|
|
349
|
+
this.onConnectionChange?.(false);
|
|
350
|
+
if (this.abortController) {
|
|
351
|
+
this.abortController.abort();
|
|
352
|
+
this.abortController = null;
|
|
353
|
+
}
|
|
354
|
+
if (this.authFailed) {
|
|
355
|
+
console.warn("[Savvagent] Authentication failed. Reconnection disabled.");
|
|
356
|
+
return;
|
|
283
357
|
}
|
|
284
358
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
285
359
|
this.reconnectAttempts++;
|
|
@@ -317,39 +391,48 @@ var RealtimeService = class {
|
|
|
317
391
|
* Disconnect from SSE stream
|
|
318
392
|
*/
|
|
319
393
|
disconnect() {
|
|
320
|
-
if (this.eventSource) {
|
|
321
|
-
this.eventSource.close();
|
|
322
|
-
this.eventSource = null;
|
|
323
|
-
}
|
|
324
394
|
this.reconnectAttempts = this.maxReconnectAttempts;
|
|
325
|
-
this.
|
|
395
|
+
if (this.abortController) {
|
|
396
|
+
this.abortController.abort();
|
|
397
|
+
this.abortController = null;
|
|
398
|
+
}
|
|
399
|
+
this.connected = false;
|
|
400
|
+
this.onConnectionChange?.(false);
|
|
326
401
|
}
|
|
327
402
|
/**
|
|
328
403
|
* Check if connected
|
|
329
404
|
*/
|
|
330
405
|
isConnected() {
|
|
331
|
-
return this.
|
|
406
|
+
return this.connected;
|
|
332
407
|
}
|
|
333
408
|
};
|
|
334
409
|
|
|
335
410
|
// src/client.ts
|
|
336
411
|
var FlagClient = class {
|
|
412
|
+
// Track auth failures to prevent request spam
|
|
337
413
|
constructor(config) {
|
|
338
414
|
this.realtime = null;
|
|
339
415
|
this.anonymousId = null;
|
|
340
416
|
this.userId = null;
|
|
341
417
|
this.detectedLanguage = null;
|
|
418
|
+
this.overrides = /* @__PURE__ */ new Map();
|
|
419
|
+
this.overrideListeners = /* @__PURE__ */ new Set();
|
|
420
|
+
this.authFailed = false;
|
|
342
421
|
this.config = {
|
|
343
422
|
apiKey: config.apiKey,
|
|
344
423
|
applicationId: config.applicationId || "",
|
|
345
424
|
baseUrl: config.baseUrl || "http://localhost:8080",
|
|
425
|
+
environment: config.environment || "production",
|
|
346
426
|
enableRealtime: config.enableRealtime ?? true,
|
|
347
427
|
cacheTtl: config.cacheTtl || 6e4,
|
|
348
428
|
enableTelemetry: config.enableTelemetry ?? true,
|
|
349
429
|
defaults: config.defaults || {},
|
|
350
430
|
onError: config.onError || ((error) => console.error("[Savvagent]", error)),
|
|
351
431
|
defaultLanguage: config.defaultLanguage || "",
|
|
352
|
-
disableLanguageDetection: config.disableLanguageDetection ?? false
|
|
432
|
+
disableLanguageDetection: config.disableLanguageDetection ?? false,
|
|
433
|
+
retryAttempts: config.retryAttempts ?? 3,
|
|
434
|
+
retryDelay: config.retryDelay ?? 1e3,
|
|
435
|
+
retryBackoff: config.retryBackoff ?? "exponential"
|
|
353
436
|
};
|
|
354
437
|
if (!this.config.disableLanguageDetection && typeof navigator !== "undefined") {
|
|
355
438
|
this.detectedLanguage = this.config.defaultLanguage || navigator.language || navigator.userLanguage || null;
|
|
@@ -363,7 +446,7 @@ var FlagClient = class {
|
|
|
363
446
|
this.config.apiKey,
|
|
364
447
|
this.config.enableTelemetry
|
|
365
448
|
);
|
|
366
|
-
if (this.config.enableRealtime && typeof
|
|
449
|
+
if (this.config.enableRealtime && typeof fetch !== "undefined") {
|
|
367
450
|
this.realtime = new RealtimeService(
|
|
368
451
|
this.config.baseUrl,
|
|
369
452
|
this.config.apiKey,
|
|
@@ -428,6 +511,21 @@ var FlagClient = class {
|
|
|
428
511
|
getUserId() {
|
|
429
512
|
return this.userId;
|
|
430
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* Set the environment for flag evaluation
|
|
516
|
+
* Useful for dynamically switching environments (e.g., dev tools)
|
|
517
|
+
* @param environment - The environment name (e.g., "development", "staging", "production", "beta")
|
|
518
|
+
*/
|
|
519
|
+
setEnvironment(environment) {
|
|
520
|
+
this.config.environment = environment;
|
|
521
|
+
this.cache.clear();
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Get the current environment
|
|
525
|
+
*/
|
|
526
|
+
getEnvironment() {
|
|
527
|
+
return this.config.environment;
|
|
528
|
+
}
|
|
431
529
|
/**
|
|
432
530
|
* Get the current anonymous ID
|
|
433
531
|
*/
|
|
@@ -442,8 +540,7 @@ var FlagClient = class {
|
|
|
442
540
|
const context = {
|
|
443
541
|
user_id: this.userId || void 0,
|
|
444
542
|
anonymous_id: this.anonymousId || void 0,
|
|
445
|
-
environment:
|
|
446
|
-
// TODO: Make configurable
|
|
543
|
+
environment: this.config.environment,
|
|
447
544
|
...overrides
|
|
448
545
|
};
|
|
449
546
|
if (!context.application_id && this.config.applicationId) {
|
|
@@ -454,6 +551,86 @@ var FlagClient = class {
|
|
|
454
551
|
}
|
|
455
552
|
return context;
|
|
456
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* Check if an error is retryable (transient failure)
|
|
556
|
+
* @param error - The error to check
|
|
557
|
+
* @param status - HTTP status code (if available)
|
|
558
|
+
*/
|
|
559
|
+
isRetryableError(error, status) {
|
|
560
|
+
if (status === 401 || status === 403) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
if (status && status >= 500) {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
if (error.name === "AbortError" || error.name === "TypeError" || error.message.includes("network")) {
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Calculate delay for retry attempt
|
|
576
|
+
* @param attempt - Current attempt number (1-based)
|
|
577
|
+
*/
|
|
578
|
+
getRetryDelay(attempt) {
|
|
579
|
+
const baseDelay = this.config.retryDelay;
|
|
580
|
+
if (this.config.retryBackoff === "linear") {
|
|
581
|
+
return baseDelay * attempt;
|
|
582
|
+
}
|
|
583
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
|
584
|
+
const jitter = Math.random() * 0.3 * exponentialDelay;
|
|
585
|
+
return exponentialDelay + jitter;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Execute a fetch request with retry logic
|
|
589
|
+
* @param requestFn - Function that returns a fetch promise
|
|
590
|
+
* @param operationName - Name of the operation for logging
|
|
591
|
+
*/
|
|
592
|
+
async fetchWithRetry(requestFn, operationName) {
|
|
593
|
+
let lastError = null;
|
|
594
|
+
let lastStatus;
|
|
595
|
+
for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
|
|
596
|
+
try {
|
|
597
|
+
const response = await requestFn();
|
|
598
|
+
if (response.status === 401 || response.status === 403) {
|
|
599
|
+
this.authFailed = true;
|
|
600
|
+
this.realtime?.disconnect();
|
|
601
|
+
console.error(`[Savvagent] Authentication failed (${response.status}). Check your API key. Further requests disabled.`);
|
|
602
|
+
throw new Error(`Authentication failed: ${response.status}`);
|
|
603
|
+
}
|
|
604
|
+
if (response.ok) {
|
|
605
|
+
return response;
|
|
606
|
+
}
|
|
607
|
+
lastStatus = response.status;
|
|
608
|
+
lastError = new Error(`${operationName} failed: ${response.status}`);
|
|
609
|
+
if (!this.isRetryableError(lastError, response.status)) {
|
|
610
|
+
throw lastError;
|
|
611
|
+
}
|
|
612
|
+
if (attempt < this.config.retryAttempts) {
|
|
613
|
+
const delay = this.getRetryDelay(attempt);
|
|
614
|
+
console.warn(`[Savvagent] ${operationName} failed (${response.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt}/${this.config.retryAttempts})`);
|
|
615
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
616
|
+
}
|
|
617
|
+
} catch (error) {
|
|
618
|
+
lastError = error;
|
|
619
|
+
if (lastError.message.includes("Authentication failed")) {
|
|
620
|
+
throw lastError;
|
|
621
|
+
}
|
|
622
|
+
if (!this.isRetryableError(lastError, lastStatus)) {
|
|
623
|
+
throw lastError;
|
|
624
|
+
}
|
|
625
|
+
if (attempt < this.config.retryAttempts) {
|
|
626
|
+
const delay = this.getRetryDelay(attempt);
|
|
627
|
+
console.warn(`[Savvagent] ${operationName} error: ${lastError.message}, retrying in ${Math.round(delay)}ms (attempt ${attempt}/${this.config.retryAttempts})`);
|
|
628
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
throw lastError || new Error(`${operationName} failed after ${this.config.retryAttempts} attempts`);
|
|
633
|
+
}
|
|
457
634
|
/**
|
|
458
635
|
* Check if a feature flag is enabled
|
|
459
636
|
* @param flagKey - The flag key to evaluate
|
|
@@ -474,6 +651,29 @@ var FlagClient = class {
|
|
|
474
651
|
const startTime = Date.now();
|
|
475
652
|
const traceId = TelemetryService.generateTraceId();
|
|
476
653
|
try {
|
|
654
|
+
if (this.overrides.has(flagKey)) {
|
|
655
|
+
const overrideValue = this.overrides.get(flagKey);
|
|
656
|
+
return {
|
|
657
|
+
key: flagKey,
|
|
658
|
+
value: overrideValue,
|
|
659
|
+
reason: "default",
|
|
660
|
+
// Using 'default' to indicate override
|
|
661
|
+
metadata: {
|
|
662
|
+
description: "Local override active"
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (this.authFailed) {
|
|
667
|
+
const defaultValue = this.config.defaults[flagKey] ?? false;
|
|
668
|
+
return {
|
|
669
|
+
key: flagKey,
|
|
670
|
+
value: defaultValue,
|
|
671
|
+
reason: "error",
|
|
672
|
+
metadata: {
|
|
673
|
+
description: "Authentication failed - using default value"
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
477
677
|
const cachedValue = this.cache.get(flagKey);
|
|
478
678
|
if (cachedValue !== null) {
|
|
479
679
|
return {
|
|
@@ -486,17 +686,26 @@ var FlagClient = class {
|
|
|
486
686
|
const requestBody = {
|
|
487
687
|
context: evaluationContext
|
|
488
688
|
};
|
|
489
|
-
const response = await
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
689
|
+
const response = await this.fetchWithRetry(
|
|
690
|
+
() => {
|
|
691
|
+
const controller = new AbortController();
|
|
692
|
+
const timeoutId = setTimeout(() => {
|
|
693
|
+
controller.abort();
|
|
694
|
+
}, 1e4);
|
|
695
|
+
return fetch(`${this.config.baseUrl}/api/flags/${flagKey}/evaluate`, {
|
|
696
|
+
method: "POST",
|
|
697
|
+
headers: {
|
|
698
|
+
"Content-Type": "application/json",
|
|
699
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
700
|
+
},
|
|
701
|
+
body: JSON.stringify(requestBody),
|
|
702
|
+
signal: controller.signal
|
|
703
|
+
}).finally(() => {
|
|
704
|
+
clearTimeout(timeoutId);
|
|
705
|
+
});
|
|
494
706
|
},
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (!response.ok) {
|
|
498
|
-
throw new Error(`Flag evaluation failed: ${response.status}`);
|
|
499
|
-
}
|
|
707
|
+
`Flag evaluation (${flagKey})`
|
|
708
|
+
);
|
|
500
709
|
const data = await response.json();
|
|
501
710
|
const value = data.enabled || false;
|
|
502
711
|
this.cache.set(flagKey, value, data.key);
|
|
@@ -621,6 +830,217 @@ var FlagClient = class {
|
|
|
621
830
|
this.realtime?.disconnect();
|
|
622
831
|
this.cache.clear();
|
|
623
832
|
}
|
|
833
|
+
// =====================
|
|
834
|
+
// Local Override Methods
|
|
835
|
+
// =====================
|
|
836
|
+
/**
|
|
837
|
+
* Set a local override for a flag.
|
|
838
|
+
* Overrides take precedence over server values and cache.
|
|
839
|
+
*
|
|
840
|
+
* @param flagKey - The flag key to override
|
|
841
|
+
* @param value - The override value (true/false)
|
|
842
|
+
*
|
|
843
|
+
* @example
|
|
844
|
+
* ```typescript
|
|
845
|
+
* // Force a flag to be enabled locally
|
|
846
|
+
* client.setOverride('new-feature', true);
|
|
847
|
+
* ```
|
|
848
|
+
*/
|
|
849
|
+
setOverride(flagKey, value) {
|
|
850
|
+
this.overrides.set(flagKey, value);
|
|
851
|
+
this.notifyOverrideListeners();
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Clear a local override for a flag.
|
|
855
|
+
* The flag will return to using server/cached values.
|
|
856
|
+
*
|
|
857
|
+
* @param flagKey - The flag key to clear override for
|
|
858
|
+
*/
|
|
859
|
+
clearOverride(flagKey) {
|
|
860
|
+
this.overrides.delete(flagKey);
|
|
861
|
+
this.notifyOverrideListeners();
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Clear all local overrides.
|
|
865
|
+
*/
|
|
866
|
+
clearAllOverrides() {
|
|
867
|
+
this.overrides.clear();
|
|
868
|
+
this.notifyOverrideListeners();
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Check if a flag has a local override.
|
|
872
|
+
*
|
|
873
|
+
* @param flagKey - The flag key to check
|
|
874
|
+
* @returns true if the flag has an override
|
|
875
|
+
*/
|
|
876
|
+
hasOverride(flagKey) {
|
|
877
|
+
return this.overrides.has(flagKey);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Get the override value for a flag.
|
|
881
|
+
*
|
|
882
|
+
* @param flagKey - The flag key to get override for
|
|
883
|
+
* @returns The override value, or undefined if not set
|
|
884
|
+
*/
|
|
885
|
+
getOverride(flagKey) {
|
|
886
|
+
return this.overrides.get(flagKey);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Get all current overrides.
|
|
890
|
+
*
|
|
891
|
+
* @returns Record of flag keys to override values
|
|
892
|
+
*/
|
|
893
|
+
getOverrides() {
|
|
894
|
+
const result = {};
|
|
895
|
+
this.overrides.forEach((value, key) => {
|
|
896
|
+
result[key] = value;
|
|
897
|
+
});
|
|
898
|
+
return result;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Set multiple overrides at once.
|
|
902
|
+
*
|
|
903
|
+
* @param overrides - Record of flag keys to override values
|
|
904
|
+
*/
|
|
905
|
+
setOverrides(overrides) {
|
|
906
|
+
Object.entries(overrides).forEach(([key, value]) => {
|
|
907
|
+
this.overrides.set(key, value);
|
|
908
|
+
});
|
|
909
|
+
this.notifyOverrideListeners();
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Subscribe to override changes.
|
|
913
|
+
* Useful for React components to re-render when overrides change.
|
|
914
|
+
*
|
|
915
|
+
* @param callback - Function to call when overrides change
|
|
916
|
+
* @returns Unsubscribe function
|
|
917
|
+
*/
|
|
918
|
+
onOverrideChange(callback) {
|
|
919
|
+
this.overrideListeners.add(callback);
|
|
920
|
+
return () => {
|
|
921
|
+
this.overrideListeners.delete(callback);
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Notify all override listeners of a change.
|
|
926
|
+
*/
|
|
927
|
+
notifyOverrideListeners() {
|
|
928
|
+
this.overrideListeners.forEach((callback) => {
|
|
929
|
+
try {
|
|
930
|
+
callback();
|
|
931
|
+
} catch (e) {
|
|
932
|
+
console.error("[Savvagent] Override listener error:", e);
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Get all flags for the application (and enterprise-scoped flags).
|
|
938
|
+
* Per SDK Developer Guide: GET /api/sdk/flags
|
|
939
|
+
*
|
|
940
|
+
* Use cases:
|
|
941
|
+
* - Local override UI: Display all available flags for developers to toggle
|
|
942
|
+
* - Offline mode: Pre-fetch flags for mobile/desktop apps
|
|
943
|
+
* - SDK initialization: Bootstrap SDK with all flag values on startup
|
|
944
|
+
* - DevTools integration: Show available flags in browser dev panels
|
|
945
|
+
*
|
|
946
|
+
* @param environment - Environment to evaluate enabled state for (default: 'development')
|
|
947
|
+
* @returns Promise<FlagDefinition[]> - List of flag definitions
|
|
948
|
+
*
|
|
949
|
+
* @example
|
|
950
|
+
* ```typescript
|
|
951
|
+
* // Fetch all flags for development
|
|
952
|
+
* const flags = await client.getAllFlags('development');
|
|
953
|
+
*
|
|
954
|
+
* // Bootstrap local cache
|
|
955
|
+
* flags.forEach(flag => {
|
|
956
|
+
* console.log(`${flag.key}: ${flag.enabled}`);
|
|
957
|
+
* });
|
|
958
|
+
* ```
|
|
959
|
+
*/
|
|
960
|
+
async getAllFlags(environment = "development") {
|
|
961
|
+
if (this.authFailed) {
|
|
962
|
+
return [];
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
const response = await this.fetchWithRetry(
|
|
966
|
+
() => {
|
|
967
|
+
const controller = new AbortController();
|
|
968
|
+
const timeoutId = setTimeout(() => {
|
|
969
|
+
controller.abort();
|
|
970
|
+
}, 1e4);
|
|
971
|
+
return fetch(
|
|
972
|
+
`${this.config.baseUrl}/api/sdk/flags?environment=${encodeURIComponent(environment)}`,
|
|
973
|
+
{
|
|
974
|
+
method: "GET",
|
|
975
|
+
headers: {
|
|
976
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
977
|
+
},
|
|
978
|
+
signal: controller.signal
|
|
979
|
+
}
|
|
980
|
+
).finally(() => {
|
|
981
|
+
clearTimeout(timeoutId);
|
|
982
|
+
});
|
|
983
|
+
},
|
|
984
|
+
"Get all flags"
|
|
985
|
+
);
|
|
986
|
+
const data = await response.json();
|
|
987
|
+
data.flags.forEach((flag) => {
|
|
988
|
+
this.cache.set(flag.key, flag.enabled, flag.key);
|
|
989
|
+
});
|
|
990
|
+
return data.flags;
|
|
991
|
+
} catch (error) {
|
|
992
|
+
this.config.onError(error);
|
|
993
|
+
return [];
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Get only enterprise-scoped flags for the organization.
|
|
998
|
+
* Per SDK Developer Guide: GET /api/sdk/enterprise-flags
|
|
999
|
+
*
|
|
1000
|
+
* Enterprise flags are shared across all applications in the organization.
|
|
1001
|
+
*
|
|
1002
|
+
* @param environment - Environment to evaluate enabled state for (default: 'development')
|
|
1003
|
+
* @returns Promise<FlagDefinition[]> - List of enterprise flag definitions
|
|
1004
|
+
*
|
|
1005
|
+
* @example
|
|
1006
|
+
* ```typescript
|
|
1007
|
+
* // Fetch enterprise-only flags
|
|
1008
|
+
* const enterpriseFlags = await client.getEnterpriseFlags('production');
|
|
1009
|
+
* ```
|
|
1010
|
+
*/
|
|
1011
|
+
async getEnterpriseFlags(environment = "development") {
|
|
1012
|
+
if (this.authFailed) {
|
|
1013
|
+
return [];
|
|
1014
|
+
}
|
|
1015
|
+
try {
|
|
1016
|
+
const response = await this.fetchWithRetry(
|
|
1017
|
+
() => {
|
|
1018
|
+
const controller = new AbortController();
|
|
1019
|
+
const timeoutId = setTimeout(() => {
|
|
1020
|
+
controller.abort();
|
|
1021
|
+
}, 1e4);
|
|
1022
|
+
return fetch(
|
|
1023
|
+
`${this.config.baseUrl}/api/sdk/enterprise-flags?environment=${encodeURIComponent(environment)}`,
|
|
1024
|
+
{
|
|
1025
|
+
method: "GET",
|
|
1026
|
+
headers: {
|
|
1027
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
1028
|
+
},
|
|
1029
|
+
signal: controller.signal
|
|
1030
|
+
}
|
|
1031
|
+
).finally(() => {
|
|
1032
|
+
clearTimeout(timeoutId);
|
|
1033
|
+
});
|
|
1034
|
+
},
|
|
1035
|
+
"Get enterprise flags"
|
|
1036
|
+
);
|
|
1037
|
+
const data = await response.json();
|
|
1038
|
+
return data.flags;
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
this.config.onError(error);
|
|
1041
|
+
return [];
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
624
1044
|
};
|
|
625
1045
|
// Annotate the CommonJS export names for ESM import in node:
|
|
626
1046
|
0 && (module.exports = {
|