@savvagent/sdk 1.0.0 → 1.0.1

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/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({ events })
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({ events })
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.eventSource = null;
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.eventSource) {
225
+ if (this.abortController) {
192
226
  return;
193
227
  }
194
- const url = `${this.baseUrl}/api/flags/stream?apiKey=${encodeURIComponent(this.apiKey)}`;
195
- try {
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(type, event) {
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.onConnectionChange?.call(null, false);
251
- if (this.eventSource) {
252
- this.eventSource.close();
253
- this.eventSource = null;
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,28 +362,33 @@ 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.onConnectionChange?.call(null, false);
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.eventSource !== null && this.eventSource.readyState === EventSource.OPEN;
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 || "",
@@ -334,7 +413,7 @@ var FlagClient = class {
334
413
  this.config.apiKey,
335
414
  this.config.enableTelemetry
336
415
  );
337
- if (this.config.enableRealtime && typeof EventSource !== "undefined") {
416
+ if (this.config.enableRealtime && typeof fetch !== "undefined") {
338
417
  this.realtime = new RealtimeService(
339
418
  this.config.baseUrl,
340
419
  this.config.apiKey,
@@ -445,6 +524,29 @@ var FlagClient = class {
445
524
  const startTime = Date.now();
446
525
  const traceId = TelemetryService.generateTraceId();
447
526
  try {
527
+ if (this.overrides.has(flagKey)) {
528
+ const overrideValue = this.overrides.get(flagKey);
529
+ return {
530
+ key: flagKey,
531
+ value: overrideValue,
532
+ reason: "default",
533
+ // Using 'default' to indicate override
534
+ metadata: {
535
+ description: "Local override active"
536
+ }
537
+ };
538
+ }
539
+ if (this.authFailed) {
540
+ const defaultValue = this.config.defaults[flagKey] ?? false;
541
+ return {
542
+ key: flagKey,
543
+ value: defaultValue,
544
+ reason: "error",
545
+ metadata: {
546
+ description: "Authentication failed - using default value"
547
+ }
548
+ };
549
+ }
448
550
  const cachedValue = this.cache.get(flagKey);
449
551
  if (cachedValue !== null) {
450
552
  return {
@@ -457,15 +559,27 @@ var FlagClient = class {
457
559
  const requestBody = {
458
560
  context: evaluationContext
459
561
  };
562
+ const controller = new AbortController();
563
+ const timeoutId = setTimeout(() => {
564
+ controller.abort();
565
+ }, 1e4);
460
566
  const response = await fetch(`${this.config.baseUrl}/api/flags/${flagKey}/evaluate`, {
461
567
  method: "POST",
462
568
  headers: {
463
569
  "Content-Type": "application/json",
464
570
  Authorization: `Bearer ${this.config.apiKey}`
465
571
  },
466
- body: JSON.stringify(requestBody)
572
+ body: JSON.stringify(requestBody),
573
+ signal: controller.signal
467
574
  });
575
+ clearTimeout(timeoutId);
468
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
+ }
469
583
  throw new Error(`Flag evaluation failed: ${response.status}`);
470
584
  }
471
585
  const data = await response.json();
@@ -592,6 +706,223 @@ var FlagClient = class {
592
706
  this.realtime?.disconnect();
593
707
  this.cache.clear();
594
708
  }
709
+ // =====================
710
+ // Local Override Methods
711
+ // =====================
712
+ /**
713
+ * Set a local override for a flag.
714
+ * Overrides take precedence over server values and cache.
715
+ *
716
+ * @param flagKey - The flag key to override
717
+ * @param value - The override value (true/false)
718
+ *
719
+ * @example
720
+ * ```typescript
721
+ * // Force a flag to be enabled locally
722
+ * client.setOverride('new-feature', true);
723
+ * ```
724
+ */
725
+ setOverride(flagKey, value) {
726
+ this.overrides.set(flagKey, value);
727
+ this.notifyOverrideListeners();
728
+ }
729
+ /**
730
+ * Clear a local override for a flag.
731
+ * The flag will return to using server/cached values.
732
+ *
733
+ * @param flagKey - The flag key to clear override for
734
+ */
735
+ clearOverride(flagKey) {
736
+ this.overrides.delete(flagKey);
737
+ this.notifyOverrideListeners();
738
+ }
739
+ /**
740
+ * Clear all local overrides.
741
+ */
742
+ clearAllOverrides() {
743
+ this.overrides.clear();
744
+ this.notifyOverrideListeners();
745
+ }
746
+ /**
747
+ * Check if a flag has a local override.
748
+ *
749
+ * @param flagKey - The flag key to check
750
+ * @returns true if the flag has an override
751
+ */
752
+ hasOverride(flagKey) {
753
+ return this.overrides.has(flagKey);
754
+ }
755
+ /**
756
+ * Get the override value for a flag.
757
+ *
758
+ * @param flagKey - The flag key to get override for
759
+ * @returns The override value, or undefined if not set
760
+ */
761
+ getOverride(flagKey) {
762
+ return this.overrides.get(flagKey);
763
+ }
764
+ /**
765
+ * Get all current overrides.
766
+ *
767
+ * @returns Record of flag keys to override values
768
+ */
769
+ getOverrides() {
770
+ const result = {};
771
+ this.overrides.forEach((value, key) => {
772
+ result[key] = value;
773
+ });
774
+ return result;
775
+ }
776
+ /**
777
+ * Set multiple overrides at once.
778
+ *
779
+ * @param overrides - Record of flag keys to override values
780
+ */
781
+ setOverrides(overrides) {
782
+ Object.entries(overrides).forEach(([key, value]) => {
783
+ this.overrides.set(key, value);
784
+ });
785
+ this.notifyOverrideListeners();
786
+ }
787
+ /**
788
+ * Subscribe to override changes.
789
+ * Useful for React components to re-render when overrides change.
790
+ *
791
+ * @param callback - Function to call when overrides change
792
+ * @returns Unsubscribe function
793
+ */
794
+ onOverrideChange(callback) {
795
+ this.overrideListeners.add(callback);
796
+ return () => {
797
+ this.overrideListeners.delete(callback);
798
+ };
799
+ }
800
+ /**
801
+ * Notify all override listeners of a change.
802
+ */
803
+ notifyOverrideListeners() {
804
+ this.overrideListeners.forEach((callback) => {
805
+ try {
806
+ callback();
807
+ } catch (e) {
808
+ console.error("[Savvagent] Override listener error:", e);
809
+ }
810
+ });
811
+ }
812
+ /**
813
+ * Get all flags for the application (and enterprise-scoped flags).
814
+ * Per SDK Developer Guide: GET /api/sdk/flags
815
+ *
816
+ * Use cases:
817
+ * - Local override UI: Display all available flags for developers to toggle
818
+ * - Offline mode: Pre-fetch flags for mobile/desktop apps
819
+ * - SDK initialization: Bootstrap SDK with all flag values on startup
820
+ * - DevTools integration: Show available flags in browser dev panels
821
+ *
822
+ * @param environment - Environment to evaluate enabled state for (default: 'development')
823
+ * @returns Promise<FlagDefinition[]> - List of flag definitions
824
+ *
825
+ * @example
826
+ * ```typescript
827
+ * // Fetch all flags for development
828
+ * const flags = await client.getAllFlags('development');
829
+ *
830
+ * // Bootstrap local cache
831
+ * flags.forEach(flag => {
832
+ * console.log(`${flag.key}: ${flag.enabled}`);
833
+ * });
834
+ * ```
835
+ */
836
+ async getAllFlags(environment = "development") {
837
+ if (this.authFailed) {
838
+ return [];
839
+ }
840
+ try {
841
+ const controller = new AbortController();
842
+ const timeoutId = setTimeout(() => {
843
+ controller.abort();
844
+ }, 1e4);
845
+ const response = await fetch(
846
+ `${this.config.baseUrl}/api/sdk/flags?environment=${encodeURIComponent(environment)}`,
847
+ {
848
+ method: "GET",
849
+ headers: {
850
+ Authorization: `Bearer ${this.config.apiKey}`
851
+ },
852
+ signal: controller.signal
853
+ }
854
+ );
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
+ const data = await response.json();
866
+ data.flags.forEach((flag) => {
867
+ this.cache.set(flag.key, flag.enabled, flag.key);
868
+ });
869
+ return data.flags;
870
+ } catch (error) {
871
+ this.config.onError(error);
872
+ return [];
873
+ }
874
+ }
875
+ /**
876
+ * Get only enterprise-scoped flags for the organization.
877
+ * Per SDK Developer Guide: GET /api/sdk/enterprise-flags
878
+ *
879
+ * Enterprise flags are shared across all applications in the organization.
880
+ *
881
+ * @param environment - Environment to evaluate enabled state for (default: 'development')
882
+ * @returns Promise<FlagDefinition[]> - List of enterprise flag definitions
883
+ *
884
+ * @example
885
+ * ```typescript
886
+ * // Fetch enterprise-only flags
887
+ * const enterpriseFlags = await client.getEnterpriseFlags('production');
888
+ * ```
889
+ */
890
+ async getEnterpriseFlags(environment = "development") {
891
+ if (this.authFailed) {
892
+ return [];
893
+ }
894
+ try {
895
+ const controller = new AbortController();
896
+ const timeoutId = setTimeout(() => {
897
+ controller.abort();
898
+ }, 1e4);
899
+ const response = await fetch(
900
+ `${this.config.baseUrl}/api/sdk/enterprise-flags?environment=${encodeURIComponent(environment)}`,
901
+ {
902
+ method: "GET",
903
+ headers: {
904
+ Authorization: `Bearer ${this.config.apiKey}`
905
+ },
906
+ signal: controller.signal
907
+ }
908
+ );
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
+ const data = await response.json();
920
+ return data.flags;
921
+ } catch (error) {
922
+ this.config.onError(error);
923
+ return [];
924
+ }
925
+ }
595
926
  };
596
927
  export {
597
928
  FlagCache,
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@savvagent/sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Savvagent TypeScript/JavaScript SDK for feature flags with AI-powered error detection",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
+ "types": "./dist/index.d.ts",
10
11
  "import": "./dist/index.mjs",
11
- "require": "./dist/index.js",
12
- "types": "./dist/index.d.ts"
12
+ "require": "./dist/index.js"
13
13
  }
14
14
  },
15
15
  "files": [
@@ -48,19 +48,20 @@
48
48
  "access": "public"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/jest": "^29.5.0",
52
- "@types/node": "^20.0.0",
53
- "@typescript-eslint/eslint-plugin": "^6.0.0",
54
- "@typescript-eslint/parser": "^6.0.0",
55
- "eslint": "^8.0.0",
56
- "jest": "^29.5.0",
57
- "prettier": "^3.0.0",
58
- "ts-jest": "^29.1.0",
59
- "tsup": "^8.0.0",
60
- "typescript": "^5.0.0"
51
+ "@types/jest": "^29.5.14",
52
+ "@types/node": "^20.19.25",
53
+ "@typescript-eslint/eslint-plugin": "^6.21.0",
54
+ "@typescript-eslint/parser": "^6.21.0",
55
+ "eslint": "^8.57.1",
56
+ "jest": "^29.7.0",
57
+ "prettier": "^3.7.2",
58
+ "ts-jest": "^29.4.5",
59
+ "tsup": "^8.5.1",
60
+ "typescript": "^5.9.3"
61
+ },
62
+ "dependencies": {
63
+ "@microsoft/fetch-event-source": "^2.0.1"
61
64
  },
62
- "dependencies": {},
63
- "peerDependencies": {},
64
65
  "engines": {
65
66
  "node": ">=16"
66
67
  }