@savvagent/sdk 1.0.1 → 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 +35 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +160 -71
- package/dist/index.mjs +160 -71
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,6 +159,9 @@ interface FlagClientConfig {
|
|
|
159
159
|
/** Base URL for the Savvagent API (default: production) */
|
|
160
160
|
baseUrl?: string;
|
|
161
161
|
|
|
162
|
+
/** Environment for flag evaluation (default: 'production') */
|
|
163
|
+
environment?: string;
|
|
164
|
+
|
|
162
165
|
/** Enable real-time flag updates via SSE (default: true) */
|
|
163
166
|
enableRealtime?: boolean;
|
|
164
167
|
|
|
@@ -182,6 +185,7 @@ interface FlagClientConfig {
|
|
|
182
185
|
const client = new FlagClient({
|
|
183
186
|
apiKey: 'sdk_dev_abc123',
|
|
184
187
|
baseUrl: 'https://api.savvagent.com',
|
|
188
|
+
environment: 'staging', // Use staging environment flags
|
|
185
189
|
enableRealtime: true,
|
|
186
190
|
cacheTtl: 30000, // 30 seconds
|
|
187
191
|
enableTelemetry: true,
|
|
@@ -196,6 +200,47 @@ const client = new FlagClient({
|
|
|
196
200
|
});
|
|
197
201
|
```
|
|
198
202
|
|
|
203
|
+
### Environment Configuration
|
|
204
|
+
|
|
205
|
+
The `environment` option controls which environment's flag values are used during evaluation. Common values include:
|
|
206
|
+
|
|
207
|
+
- `'production'` (default) - Production environment
|
|
208
|
+
- `'staging'` - Staging/pre-production environment
|
|
209
|
+
- `'development'` - Local development environment
|
|
210
|
+
- `'beta'` - Beta testing environment
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
// Production app
|
|
214
|
+
const client = new FlagClient({
|
|
215
|
+
apiKey: 'sdk_prod_...',
|
|
216
|
+
environment: 'production',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Staging/QA app
|
|
220
|
+
const client = new FlagClient({
|
|
221
|
+
apiKey: 'sdk_staging_...',
|
|
222
|
+
environment: 'staging',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Development
|
|
226
|
+
const client = new FlagClient({
|
|
227
|
+
apiKey: 'sdk_dev_...',
|
|
228
|
+
environment: 'development',
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
You can also change the environment at runtime:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// Switch to beta environment
|
|
236
|
+
client.setEnvironment('beta');
|
|
237
|
+
|
|
238
|
+
// Get current environment
|
|
239
|
+
console.log(client.getEnvironment()); // 'beta'
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Note: Changing the environment clears the cache since flag values may differ between environments.
|
|
243
|
+
|
|
199
244
|
## Framework Integration
|
|
200
245
|
|
|
201
246
|
### React
|
|
@@ -408,6 +453,10 @@ const client = new FlagClient({
|
|
|
408
453
|
- `withFlag(flagKey, callback, context?)`: Execute code conditionally
|
|
409
454
|
- `trackError(flagKey, error, context?)`: Manually track an error
|
|
410
455
|
- `subscribe(flagKey, callback)`: Subscribe to flag updates
|
|
456
|
+
- `setEnvironment(environment)`: Set the environment for flag evaluation
|
|
457
|
+
- `getEnvironment()`: Get the current environment
|
|
458
|
+
- `setUserId(userId)`: Set the user ID for targeted rollouts
|
|
459
|
+
- `getUserId()`: Get the current user ID
|
|
411
460
|
- `getCachedFlags()`: Get all cached flag keys
|
|
412
461
|
- `clearCache()`: Clear the flag cache
|
|
413
462
|
- `isRealtimeConnected()`: Check real-time connection status
|
package/dist/index.d.mts
CHANGED
|
@@ -11,6 +11,8 @@ interface FlagClientConfig {
|
|
|
11
11
|
applicationId?: string;
|
|
12
12
|
/** Base URL for the Savvagent API (default: production URL) */
|
|
13
13
|
baseUrl?: string;
|
|
14
|
+
/** Environment for flag evaluation (e.g., "development", "staging", "production", "beta"). Default: "production" */
|
|
15
|
+
environment?: string;
|
|
14
16
|
/** Enable real-time flag updates via SSE (default: true) */
|
|
15
17
|
enableRealtime?: boolean;
|
|
16
18
|
/** Cache TTL in milliseconds (default: 60000 = 1 minute) */
|
|
@@ -25,6 +27,12 @@ interface FlagClientConfig {
|
|
|
25
27
|
defaultLanguage?: string;
|
|
26
28
|
/** Disable automatic browser language detection (default: false) */
|
|
27
29
|
disableLanguageDetection?: boolean;
|
|
30
|
+
/** Number of retry attempts for transient failures (default: 3) */
|
|
31
|
+
retryAttempts?: number;
|
|
32
|
+
/** Base delay between retries in milliseconds (default: 1000) */
|
|
33
|
+
retryDelay?: number;
|
|
34
|
+
/** Retry backoff strategy: 'linear' or 'exponential' (default: 'exponential') */
|
|
35
|
+
retryBackoff?: 'linear' | 'exponential';
|
|
28
36
|
}
|
|
29
37
|
/**
|
|
30
38
|
* Context passed to flag evaluation
|
|
@@ -182,6 +190,16 @@ declare class FlagClient {
|
|
|
182
190
|
* Get the current user ID
|
|
183
191
|
*/
|
|
184
192
|
getUserId(): string | null;
|
|
193
|
+
/**
|
|
194
|
+
* Set the environment for flag evaluation
|
|
195
|
+
* Useful for dynamically switching environments (e.g., dev tools)
|
|
196
|
+
* @param environment - The environment name (e.g., "development", "staging", "production", "beta")
|
|
197
|
+
*/
|
|
198
|
+
setEnvironment(environment: string): void;
|
|
199
|
+
/**
|
|
200
|
+
* Get the current environment
|
|
201
|
+
*/
|
|
202
|
+
getEnvironment(): string;
|
|
185
203
|
/**
|
|
186
204
|
* Get the current anonymous ID
|
|
187
205
|
*/
|
|
@@ -191,6 +209,23 @@ declare class FlagClient {
|
|
|
191
209
|
* @param overrides - Context overrides
|
|
192
210
|
*/
|
|
193
211
|
private buildContext;
|
|
212
|
+
/**
|
|
213
|
+
* Check if an error is retryable (transient failure)
|
|
214
|
+
* @param error - The error to check
|
|
215
|
+
* @param status - HTTP status code (if available)
|
|
216
|
+
*/
|
|
217
|
+
private isRetryableError;
|
|
218
|
+
/**
|
|
219
|
+
* Calculate delay for retry attempt
|
|
220
|
+
* @param attempt - Current attempt number (1-based)
|
|
221
|
+
*/
|
|
222
|
+
private getRetryDelay;
|
|
223
|
+
/**
|
|
224
|
+
* Execute a fetch request with retry logic
|
|
225
|
+
* @param requestFn - Function that returns a fetch promise
|
|
226
|
+
* @param operationName - Name of the operation for logging
|
|
227
|
+
*/
|
|
228
|
+
private fetchWithRetry;
|
|
194
229
|
/**
|
|
195
230
|
* Check if a feature flag is enabled
|
|
196
231
|
* @param flagKey - The flag key to evaluate
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ interface FlagClientConfig {
|
|
|
11
11
|
applicationId?: string;
|
|
12
12
|
/** Base URL for the Savvagent API (default: production URL) */
|
|
13
13
|
baseUrl?: string;
|
|
14
|
+
/** Environment for flag evaluation (e.g., "development", "staging", "production", "beta"). Default: "production" */
|
|
15
|
+
environment?: string;
|
|
14
16
|
/** Enable real-time flag updates via SSE (default: true) */
|
|
15
17
|
enableRealtime?: boolean;
|
|
16
18
|
/** Cache TTL in milliseconds (default: 60000 = 1 minute) */
|
|
@@ -25,6 +27,12 @@ interface FlagClientConfig {
|
|
|
25
27
|
defaultLanguage?: string;
|
|
26
28
|
/** Disable automatic browser language detection (default: false) */
|
|
27
29
|
disableLanguageDetection?: boolean;
|
|
30
|
+
/** Number of retry attempts for transient failures (default: 3) */
|
|
31
|
+
retryAttempts?: number;
|
|
32
|
+
/** Base delay between retries in milliseconds (default: 1000) */
|
|
33
|
+
retryDelay?: number;
|
|
34
|
+
/** Retry backoff strategy: 'linear' or 'exponential' (default: 'exponential') */
|
|
35
|
+
retryBackoff?: 'linear' | 'exponential';
|
|
28
36
|
}
|
|
29
37
|
/**
|
|
30
38
|
* Context passed to flag evaluation
|
|
@@ -182,6 +190,16 @@ declare class FlagClient {
|
|
|
182
190
|
* Get the current user ID
|
|
183
191
|
*/
|
|
184
192
|
getUserId(): string | null;
|
|
193
|
+
/**
|
|
194
|
+
* Set the environment for flag evaluation
|
|
195
|
+
* Useful for dynamically switching environments (e.g., dev tools)
|
|
196
|
+
* @param environment - The environment name (e.g., "development", "staging", "production", "beta")
|
|
197
|
+
*/
|
|
198
|
+
setEnvironment(environment: string): void;
|
|
199
|
+
/**
|
|
200
|
+
* Get the current environment
|
|
201
|
+
*/
|
|
202
|
+
getEnvironment(): string;
|
|
185
203
|
/**
|
|
186
204
|
* Get the current anonymous ID
|
|
187
205
|
*/
|
|
@@ -191,6 +209,23 @@ declare class FlagClient {
|
|
|
191
209
|
* @param overrides - Context overrides
|
|
192
210
|
*/
|
|
193
211
|
private buildContext;
|
|
212
|
+
/**
|
|
213
|
+
* Check if an error is retryable (transient failure)
|
|
214
|
+
* @param error - The error to check
|
|
215
|
+
* @param status - HTTP status code (if available)
|
|
216
|
+
*/
|
|
217
|
+
private isRetryableError;
|
|
218
|
+
/**
|
|
219
|
+
* Calculate delay for retry attempt
|
|
220
|
+
* @param attempt - Current attempt number (1-based)
|
|
221
|
+
*/
|
|
222
|
+
private getRetryDelay;
|
|
223
|
+
/**
|
|
224
|
+
* Execute a fetch request with retry logic
|
|
225
|
+
* @param requestFn - Function that returns a fetch promise
|
|
226
|
+
* @param operationName - Name of the operation for logging
|
|
227
|
+
*/
|
|
228
|
+
private fetchWithRetry;
|
|
194
229
|
/**
|
|
195
230
|
* Check if a feature flag is enabled
|
|
196
231
|
* @param flagKey - The flag key to evaluate
|
package/dist/index.js
CHANGED
|
@@ -422,13 +422,17 @@ var FlagClient = class {
|
|
|
422
422
|
apiKey: config.apiKey,
|
|
423
423
|
applicationId: config.applicationId || "",
|
|
424
424
|
baseUrl: config.baseUrl || "http://localhost:8080",
|
|
425
|
+
environment: config.environment || "production",
|
|
425
426
|
enableRealtime: config.enableRealtime ?? true,
|
|
426
427
|
cacheTtl: config.cacheTtl || 6e4,
|
|
427
428
|
enableTelemetry: config.enableTelemetry ?? true,
|
|
428
429
|
defaults: config.defaults || {},
|
|
429
430
|
onError: config.onError || ((error) => console.error("[Savvagent]", error)),
|
|
430
431
|
defaultLanguage: config.defaultLanguage || "",
|
|
431
|
-
disableLanguageDetection: config.disableLanguageDetection ?? false
|
|
432
|
+
disableLanguageDetection: config.disableLanguageDetection ?? false,
|
|
433
|
+
retryAttempts: config.retryAttempts ?? 3,
|
|
434
|
+
retryDelay: config.retryDelay ?? 1e3,
|
|
435
|
+
retryBackoff: config.retryBackoff ?? "exponential"
|
|
432
436
|
};
|
|
433
437
|
if (!this.config.disableLanguageDetection && typeof navigator !== "undefined") {
|
|
434
438
|
this.detectedLanguage = this.config.defaultLanguage || navigator.language || navigator.userLanguage || null;
|
|
@@ -507,6 +511,21 @@ var FlagClient = class {
|
|
|
507
511
|
getUserId() {
|
|
508
512
|
return this.userId;
|
|
509
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
|
+
}
|
|
510
529
|
/**
|
|
511
530
|
* Get the current anonymous ID
|
|
512
531
|
*/
|
|
@@ -521,8 +540,7 @@ var FlagClient = class {
|
|
|
521
540
|
const context = {
|
|
522
541
|
user_id: this.userId || void 0,
|
|
523
542
|
anonymous_id: this.anonymousId || void 0,
|
|
524
|
-
environment:
|
|
525
|
-
// TODO: Make configurable
|
|
543
|
+
environment: this.config.environment,
|
|
526
544
|
...overrides
|
|
527
545
|
};
|
|
528
546
|
if (!context.application_id && this.config.applicationId) {
|
|
@@ -533,6 +551,86 @@ var FlagClient = class {
|
|
|
533
551
|
}
|
|
534
552
|
return context;
|
|
535
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
|
+
}
|
|
536
634
|
/**
|
|
537
635
|
* Check if a feature flag is enabled
|
|
538
636
|
* @param flagKey - The flag key to evaluate
|
|
@@ -588,29 +686,26 @@ var FlagClient = class {
|
|
|
588
686
|
const requestBody = {
|
|
589
687
|
context: evaluationContext
|
|
590
688
|
};
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
+
});
|
|
600
706
|
},
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
});
|
|
604
|
-
clearTimeout(timeoutId);
|
|
605
|
-
if (!response.ok) {
|
|
606
|
-
if (response.status === 401 || response.status === 403) {
|
|
607
|
-
this.authFailed = true;
|
|
608
|
-
this.realtime?.disconnect();
|
|
609
|
-
console.error(`[Savvagent] Authentication failed (${response.status}). Check your API key. Further requests disabled.`);
|
|
610
|
-
throw new Error(`Authentication failed: ${response.status}`);
|
|
611
|
-
}
|
|
612
|
-
throw new Error(`Flag evaluation failed: ${response.status}`);
|
|
613
|
-
}
|
|
707
|
+
`Flag evaluation (${flagKey})`
|
|
708
|
+
);
|
|
614
709
|
const data = await response.json();
|
|
615
710
|
const value = data.enabled || false;
|
|
616
711
|
this.cache.set(flagKey, value, data.key);
|
|
@@ -867,30 +962,27 @@ var FlagClient = class {
|
|
|
867
962
|
return [];
|
|
868
963
|
}
|
|
869
964
|
try {
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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"
|
|
883
985
|
);
|
|
884
|
-
clearTimeout(timeoutId);
|
|
885
|
-
if (!response.ok) {
|
|
886
|
-
if (response.status === 401 || response.status === 403) {
|
|
887
|
-
this.authFailed = true;
|
|
888
|
-
this.realtime?.disconnect();
|
|
889
|
-
console.error(`[Savvagent] Authentication failed (${response.status}). Check your API key. Further requests disabled.`);
|
|
890
|
-
throw new Error(`Authentication failed: ${response.status}`);
|
|
891
|
-
}
|
|
892
|
-
throw new Error(`Failed to fetch flags: ${response.status}`);
|
|
893
|
-
}
|
|
894
986
|
const data = await response.json();
|
|
895
987
|
data.flags.forEach((flag) => {
|
|
896
988
|
this.cache.set(flag.key, flag.enabled, flag.key);
|
|
@@ -921,30 +1013,27 @@ var FlagClient = class {
|
|
|
921
1013
|
return [];
|
|
922
1014
|
}
|
|
923
1015
|
try {
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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"
|
|
937
1036
|
);
|
|
938
|
-
clearTimeout(timeoutId);
|
|
939
|
-
if (!response.ok) {
|
|
940
|
-
if (response.status === 401 || response.status === 403) {
|
|
941
|
-
this.authFailed = true;
|
|
942
|
-
this.realtime?.disconnect();
|
|
943
|
-
console.error(`[Savvagent] Authentication failed (${response.status}). Check your API key. Further requests disabled.`);
|
|
944
|
-
throw new Error(`Authentication failed: ${response.status}`);
|
|
945
|
-
}
|
|
946
|
-
throw new Error(`Failed to fetch enterprise flags: ${response.status}`);
|
|
947
|
-
}
|
|
948
1037
|
const data = await response.json();
|
|
949
1038
|
return data.flags;
|
|
950
1039
|
} catch (error) {
|
package/dist/index.mjs
CHANGED
|
@@ -393,13 +393,17 @@ var FlagClient = class {
|
|
|
393
393
|
apiKey: config.apiKey,
|
|
394
394
|
applicationId: config.applicationId || "",
|
|
395
395
|
baseUrl: config.baseUrl || "http://localhost:8080",
|
|
396
|
+
environment: config.environment || "production",
|
|
396
397
|
enableRealtime: config.enableRealtime ?? true,
|
|
397
398
|
cacheTtl: config.cacheTtl || 6e4,
|
|
398
399
|
enableTelemetry: config.enableTelemetry ?? true,
|
|
399
400
|
defaults: config.defaults || {},
|
|
400
401
|
onError: config.onError || ((error) => console.error("[Savvagent]", error)),
|
|
401
402
|
defaultLanguage: config.defaultLanguage || "",
|
|
402
|
-
disableLanguageDetection: config.disableLanguageDetection ?? false
|
|
403
|
+
disableLanguageDetection: config.disableLanguageDetection ?? false,
|
|
404
|
+
retryAttempts: config.retryAttempts ?? 3,
|
|
405
|
+
retryDelay: config.retryDelay ?? 1e3,
|
|
406
|
+
retryBackoff: config.retryBackoff ?? "exponential"
|
|
403
407
|
};
|
|
404
408
|
if (!this.config.disableLanguageDetection && typeof navigator !== "undefined") {
|
|
405
409
|
this.detectedLanguage = this.config.defaultLanguage || navigator.language || navigator.userLanguage || null;
|
|
@@ -478,6 +482,21 @@ var FlagClient = class {
|
|
|
478
482
|
getUserId() {
|
|
479
483
|
return this.userId;
|
|
480
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
|
+
}
|
|
481
500
|
/**
|
|
482
501
|
* Get the current anonymous ID
|
|
483
502
|
*/
|
|
@@ -492,8 +511,7 @@ var FlagClient = class {
|
|
|
492
511
|
const context = {
|
|
493
512
|
user_id: this.userId || void 0,
|
|
494
513
|
anonymous_id: this.anonymousId || void 0,
|
|
495
|
-
environment:
|
|
496
|
-
// TODO: Make configurable
|
|
514
|
+
environment: this.config.environment,
|
|
497
515
|
...overrides
|
|
498
516
|
};
|
|
499
517
|
if (!context.application_id && this.config.applicationId) {
|
|
@@ -504,6 +522,86 @@ var FlagClient = class {
|
|
|
504
522
|
}
|
|
505
523
|
return context;
|
|
506
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
|
+
}
|
|
507
605
|
/**
|
|
508
606
|
* Check if a feature flag is enabled
|
|
509
607
|
* @param flagKey - The flag key to evaluate
|
|
@@ -559,29 +657,26 @@ var FlagClient = class {
|
|
|
559
657
|
const requestBody = {
|
|
560
658
|
context: evaluationContext
|
|
561
659
|
};
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
+
});
|
|
571
677
|
},
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
});
|
|
575
|
-
clearTimeout(timeoutId);
|
|
576
|
-
if (!response.ok) {
|
|
577
|
-
if (response.status === 401 || response.status === 403) {
|
|
578
|
-
this.authFailed = true;
|
|
579
|
-
this.realtime?.disconnect();
|
|
580
|
-
console.error(`[Savvagent] Authentication failed (${response.status}). Check your API key. Further requests disabled.`);
|
|
581
|
-
throw new Error(`Authentication failed: ${response.status}`);
|
|
582
|
-
}
|
|
583
|
-
throw new Error(`Flag evaluation failed: ${response.status}`);
|
|
584
|
-
}
|
|
678
|
+
`Flag evaluation (${flagKey})`
|
|
679
|
+
);
|
|
585
680
|
const data = await response.json();
|
|
586
681
|
const value = data.enabled || false;
|
|
587
682
|
this.cache.set(flagKey, value, data.key);
|
|
@@ -838,30 +933,27 @@ var FlagClient = class {
|
|
|
838
933
|
return [];
|
|
839
934
|
}
|
|
840
935
|
try {
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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"
|
|
854
956
|
);
|
|
855
|
-
clearTimeout(timeoutId);
|
|
856
|
-
if (!response.ok) {
|
|
857
|
-
if (response.status === 401 || response.status === 403) {
|
|
858
|
-
this.authFailed = true;
|
|
859
|
-
this.realtime?.disconnect();
|
|
860
|
-
console.error(`[Savvagent] Authentication failed (${response.status}). Check your API key. Further requests disabled.`);
|
|
861
|
-
throw new Error(`Authentication failed: ${response.status}`);
|
|
862
|
-
}
|
|
863
|
-
throw new Error(`Failed to fetch flags: ${response.status}`);
|
|
864
|
-
}
|
|
865
957
|
const data = await response.json();
|
|
866
958
|
data.flags.forEach((flag) => {
|
|
867
959
|
this.cache.set(flag.key, flag.enabled, flag.key);
|
|
@@ -892,30 +984,27 @@ var FlagClient = class {
|
|
|
892
984
|
return [];
|
|
893
985
|
}
|
|
894
986
|
try {
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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"
|
|
908
1007
|
);
|
|
909
|
-
clearTimeout(timeoutId);
|
|
910
|
-
if (!response.ok) {
|
|
911
|
-
if (response.status === 401 || response.status === 403) {
|
|
912
|
-
this.authFailed = true;
|
|
913
|
-
this.realtime?.disconnect();
|
|
914
|
-
console.error(`[Savvagent] Authentication failed (${response.status}). Check your API key. Further requests disabled.`);
|
|
915
|
-
throw new Error(`Authentication failed: ${response.status}`);
|
|
916
|
-
}
|
|
917
|
-
throw new Error(`Failed to fetch enterprise flags: ${response.status}`);
|
|
918
|
-
}
|
|
919
1008
|
const data = await response.json();
|
|
920
1009
|
return data.flags;
|
|
921
1010
|
} catch (error) {
|