@savvagent/sdk 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,601 @@
1
+ // src/cache.ts
2
+ var FlagCache = class {
3
+ constructor(ttl = 6e4) {
4
+ this.cache = /* @__PURE__ */ new Map();
5
+ this.ttl = ttl;
6
+ }
7
+ /**
8
+ * Get a cached flag value
9
+ */
10
+ get(key) {
11
+ const entry = this.cache.get(key);
12
+ if (!entry) {
13
+ return null;
14
+ }
15
+ if (Date.now() > entry.expiresAt) {
16
+ this.cache.delete(key);
17
+ return null;
18
+ }
19
+ return entry.value;
20
+ }
21
+ /**
22
+ * Set a flag value in cache
23
+ */
24
+ set(key, value, flagId) {
25
+ this.cache.set(key, {
26
+ value,
27
+ expiresAt: Date.now() + this.ttl,
28
+ flagId
29
+ });
30
+ }
31
+ /**
32
+ * Invalidate a specific flag
33
+ */
34
+ invalidate(key) {
35
+ this.cache.delete(key);
36
+ }
37
+ /**
38
+ * Clear all cached flags
39
+ */
40
+ clear() {
41
+ this.cache.clear();
42
+ }
43
+ /**
44
+ * Get all cached keys
45
+ */
46
+ keys() {
47
+ return Array.from(this.cache.keys());
48
+ }
49
+ };
50
+
51
+ // src/telemetry.ts
52
+ var TelemetryService = class {
53
+ constructor(baseUrl, apiKey, enabled = true) {
54
+ this.queue = [];
55
+ this.flushInterval = 5e3;
56
+ // 5 seconds
57
+ this.maxBatchSize = 50;
58
+ this.timer = null;
59
+ this.baseUrl = baseUrl;
60
+ this.apiKey = apiKey;
61
+ this.enabled = enabled;
62
+ if (this.enabled) {
63
+ this.startBatchSender();
64
+ }
65
+ }
66
+ /**
67
+ * Track a flag evaluation
68
+ */
69
+ trackEvaluation(event) {
70
+ if (!this.enabled) return;
71
+ this.queue.push(event);
72
+ if (this.queue.length >= this.maxBatchSize) {
73
+ this.flush();
74
+ }
75
+ }
76
+ /**
77
+ * Track an error in flagged code
78
+ */
79
+ trackError(event) {
80
+ if (!this.enabled) return;
81
+ this.queue.push(event);
82
+ this.flush();
83
+ }
84
+ /**
85
+ * Start the batch sender interval
86
+ */
87
+ startBatchSender() {
88
+ this.timer = setInterval(() => {
89
+ if (this.queue.length > 0) {
90
+ this.flush();
91
+ }
92
+ }, this.flushInterval);
93
+ if (typeof window !== "undefined") {
94
+ window.addEventListener("beforeunload", () => {
95
+ this.flush();
96
+ });
97
+ }
98
+ }
99
+ /**
100
+ * Flush the telemetry queue
101
+ */
102
+ async flush() {
103
+ if (this.queue.length === 0) return;
104
+ const batch = this.queue.splice(0, this.maxBatchSize);
105
+ try {
106
+ const evaluations = batch.filter((e) => "durationMs" in e);
107
+ const errors = batch.filter((e) => "errorType" in e);
108
+ if (evaluations.length > 0) {
109
+ await this.sendEvaluations(evaluations);
110
+ }
111
+ if (errors.length > 0) {
112
+ await this.sendErrors(errors);
113
+ }
114
+ } catch (error) {
115
+ if (this.queue.length < 1e3) {
116
+ this.queue.unshift(...batch);
117
+ }
118
+ console.error("[Savvagent] Failed to send telemetry:", error);
119
+ }
120
+ }
121
+ /**
122
+ * Send evaluation events to backend
123
+ */
124
+ async sendEvaluations(events) {
125
+ const response = await fetch(`${this.baseUrl}/api/telemetry/evaluations`, {
126
+ method: "POST",
127
+ headers: {
128
+ "Content-Type": "application/json",
129
+ Authorization: `Bearer ${this.apiKey}`
130
+ },
131
+ body: JSON.stringify({ events })
132
+ });
133
+ if (!response.ok) {
134
+ throw new Error(`Failed to send evaluations: ${response.status}`);
135
+ }
136
+ }
137
+ /**
138
+ * Send error events to backend
139
+ */
140
+ async sendErrors(events) {
141
+ const response = await fetch(`${this.baseUrl}/api/telemetry/errors`, {
142
+ method: "POST",
143
+ headers: {
144
+ "Content-Type": "application/json",
145
+ Authorization: `Bearer ${this.apiKey}`
146
+ },
147
+ body: JSON.stringify({ events })
148
+ });
149
+ if (!response.ok) {
150
+ throw new Error(`Failed to send errors: ${response.status}`);
151
+ }
152
+ }
153
+ /**
154
+ * Stop the telemetry service
155
+ */
156
+ stop() {
157
+ if (this.timer) {
158
+ clearInterval(this.timer);
159
+ this.timer = null;
160
+ }
161
+ this.flush();
162
+ }
163
+ /**
164
+ * Generate a trace ID for distributed tracing
165
+ */
166
+ static generateTraceId() {
167
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
168
+ }
169
+ };
170
+
171
+ // src/realtime.ts
172
+ var RealtimeService = class {
173
+ constructor(baseUrl, apiKey, onConnectionChange) {
174
+ this.eventSource = null;
175
+ this.reconnectAttempts = 0;
176
+ this.maxReconnectAttempts = 10;
177
+ // Increased from 5 to 10
178
+ this.reconnectDelay = 1e3;
179
+ // Start with 1 second
180
+ this.maxReconnectDelay = 3e4;
181
+ // Cap at 30 seconds
182
+ this.listeners = /* @__PURE__ */ new Map();
183
+ this.baseUrl = baseUrl;
184
+ this.apiKey = apiKey;
185
+ this.onConnectionChange = onConnectionChange;
186
+ }
187
+ /**
188
+ * Connect to SSE stream
189
+ */
190
+ connect() {
191
+ if (this.eventSource) {
192
+ return;
193
+ }
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();
221
+ }
222
+ }
223
+ /**
224
+ * Handle incoming SSE messages
225
+ */
226
+ handleMessage(type, event) {
227
+ try {
228
+ const data = JSON.parse(event.data);
229
+ const updateEvent = {
230
+ type,
231
+ flagKey: data.key,
232
+ data
233
+ };
234
+ const flagListeners = this.listeners.get(updateEvent.flagKey);
235
+ if (flagListeners) {
236
+ flagListeners.forEach((listener) => listener(updateEvent));
237
+ }
238
+ const wildcardListeners = this.listeners.get("*");
239
+ if (wildcardListeners) {
240
+ wildcardListeners.forEach((listener) => listener(updateEvent));
241
+ }
242
+ } catch (error) {
243
+ console.error("[Savvagent] Failed to parse SSE message:", error);
244
+ }
245
+ }
246
+ /**
247
+ * Handle disconnection and attempt reconnect
248
+ */
249
+ handleDisconnect() {
250
+ this.onConnectionChange?.call(null, false);
251
+ if (this.eventSource) {
252
+ this.eventSource.close();
253
+ this.eventSource = null;
254
+ }
255
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
256
+ this.reconnectAttempts++;
257
+ const exponentialDelay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
258
+ const delay = Math.min(exponentialDelay, this.maxReconnectDelay);
259
+ console.log(`[Savvagent] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
260
+ setTimeout(() => {
261
+ this.connect();
262
+ }, delay);
263
+ } else {
264
+ console.warn("[Savvagent] Max reconnection attempts reached. Connection will not be retried automatically.");
265
+ }
266
+ }
267
+ /**
268
+ * Subscribe to flag updates
269
+ * @param flagKey - Specific flag key or '*' for all flags
270
+ * @param listener - Callback function
271
+ */
272
+ subscribe(flagKey, listener) {
273
+ if (!this.listeners.has(flagKey)) {
274
+ this.listeners.set(flagKey, /* @__PURE__ */ new Set());
275
+ }
276
+ this.listeners.get(flagKey).add(listener);
277
+ return () => {
278
+ const listeners = this.listeners.get(flagKey);
279
+ if (listeners) {
280
+ listeners.delete(listener);
281
+ if (listeners.size === 0) {
282
+ this.listeners.delete(flagKey);
283
+ }
284
+ }
285
+ };
286
+ }
287
+ /**
288
+ * Disconnect from SSE stream
289
+ */
290
+ disconnect() {
291
+ if (this.eventSource) {
292
+ this.eventSource.close();
293
+ this.eventSource = null;
294
+ }
295
+ this.reconnectAttempts = this.maxReconnectAttempts;
296
+ this.onConnectionChange?.call(null, false);
297
+ }
298
+ /**
299
+ * Check if connected
300
+ */
301
+ isConnected() {
302
+ return this.eventSource !== null && this.eventSource.readyState === EventSource.OPEN;
303
+ }
304
+ };
305
+
306
+ // src/client.ts
307
+ var FlagClient = class {
308
+ constructor(config) {
309
+ this.realtime = null;
310
+ this.anonymousId = null;
311
+ this.userId = null;
312
+ this.detectedLanguage = null;
313
+ this.config = {
314
+ apiKey: config.apiKey,
315
+ applicationId: config.applicationId || "",
316
+ baseUrl: config.baseUrl || "http://localhost:8080",
317
+ enableRealtime: config.enableRealtime ?? true,
318
+ cacheTtl: config.cacheTtl || 6e4,
319
+ enableTelemetry: config.enableTelemetry ?? true,
320
+ defaults: config.defaults || {},
321
+ onError: config.onError || ((error) => console.error("[Savvagent]", error)),
322
+ defaultLanguage: config.defaultLanguage || "",
323
+ disableLanguageDetection: config.disableLanguageDetection ?? false
324
+ };
325
+ if (!this.config.disableLanguageDetection && typeof navigator !== "undefined") {
326
+ this.detectedLanguage = this.config.defaultLanguage || navigator.language || navigator.userLanguage || null;
327
+ }
328
+ if (!this.config.apiKey || !this.config.apiKey.startsWith("sdk_")) {
329
+ throw new Error('Invalid API key. SDK keys must start with "sdk_"');
330
+ }
331
+ this.cache = new FlagCache(this.config.cacheTtl);
332
+ this.telemetry = new TelemetryService(
333
+ this.config.baseUrl,
334
+ this.config.apiKey,
335
+ this.config.enableTelemetry
336
+ );
337
+ if (this.config.enableRealtime && typeof EventSource !== "undefined") {
338
+ this.realtime = new RealtimeService(
339
+ this.config.baseUrl,
340
+ this.config.apiKey,
341
+ (connected) => {
342
+ console.log(`[Savvagent] Real-time connection: ${connected ? "connected" : "disconnected"}`);
343
+ }
344
+ );
345
+ this.realtime.subscribe("*", (event) => {
346
+ console.log(`[Savvagent] Flag ${event.type}: ${event.flagKey}`);
347
+ this.cache.invalidate(event.flagKey);
348
+ });
349
+ this.realtime.connect();
350
+ }
351
+ this.anonymousId = this.getOrCreateAnonymousId();
352
+ }
353
+ /**
354
+ * Get or create an anonymous ID for consistent flag evaluation
355
+ * @returns Anonymous ID from localStorage or newly generated
356
+ */
357
+ getOrCreateAnonymousId() {
358
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
359
+ return `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
360
+ }
361
+ const storageKey = "savvagent_anonymous_id";
362
+ let anonId = localStorage.getItem(storageKey);
363
+ if (!anonId) {
364
+ anonId = `anon_${crypto.randomUUID()}`;
365
+ try {
366
+ localStorage.setItem(storageKey, anonId);
367
+ } catch (e) {
368
+ console.warn("[Savvagent] Could not save anonymous ID to localStorage:", e);
369
+ }
370
+ }
371
+ return anonId;
372
+ }
373
+ /**
374
+ * Set a custom anonymous ID
375
+ * Useful for cross-device tracking or custom identifier schemes
376
+ * @param id - The anonymous ID to use
377
+ */
378
+ setAnonymousId(id) {
379
+ this.anonymousId = id;
380
+ if (typeof localStorage !== "undefined") {
381
+ try {
382
+ localStorage.setItem("savvagent_anonymous_id", id);
383
+ } catch (e) {
384
+ console.warn("[Savvagent] Could not save anonymous ID to localStorage:", e);
385
+ }
386
+ }
387
+ }
388
+ /**
389
+ * Set the user ID for logged-in users
390
+ * This takes precedence over anonymous ID
391
+ * @param userId - The user ID (or null to clear)
392
+ */
393
+ setUserId(userId) {
394
+ this.userId = userId;
395
+ }
396
+ /**
397
+ * Get the current user ID
398
+ */
399
+ getUserId() {
400
+ return this.userId;
401
+ }
402
+ /**
403
+ * Get the current anonymous ID
404
+ */
405
+ getAnonymousId() {
406
+ return this.anonymousId || this.getOrCreateAnonymousId();
407
+ }
408
+ /**
409
+ * Build the context for flag evaluation
410
+ * @param overrides - Context overrides
411
+ */
412
+ buildContext(overrides) {
413
+ const context = {
414
+ user_id: this.userId || void 0,
415
+ anonymous_id: this.anonymousId || void 0,
416
+ environment: "production",
417
+ // TODO: Make configurable
418
+ ...overrides
419
+ };
420
+ if (!context.application_id && this.config.applicationId) {
421
+ context.application_id = this.config.applicationId;
422
+ }
423
+ if (!context.language && this.detectedLanguage) {
424
+ context.language = this.detectedLanguage;
425
+ }
426
+ return context;
427
+ }
428
+ /**
429
+ * Check if a feature flag is enabled
430
+ * @param flagKey - The flag key to evaluate
431
+ * @param context - Optional context for targeting
432
+ * @returns Promise<boolean>
433
+ */
434
+ async isEnabled(flagKey, context) {
435
+ const result = await this.evaluate(flagKey, context);
436
+ return result.value;
437
+ }
438
+ /**
439
+ * Evaluate a feature flag and return detailed result
440
+ * @param flagKey - The flag key to evaluate
441
+ * @param context - Optional context for targeting
442
+ * @returns Promise<FlagEvaluationResult>
443
+ */
444
+ async evaluate(flagKey, context) {
445
+ const startTime = Date.now();
446
+ const traceId = TelemetryService.generateTraceId();
447
+ try {
448
+ const cachedValue = this.cache.get(flagKey);
449
+ if (cachedValue !== null) {
450
+ return {
451
+ key: flagKey,
452
+ value: cachedValue,
453
+ reason: "cached"
454
+ };
455
+ }
456
+ const evaluationContext = this.buildContext(context);
457
+ const requestBody = {
458
+ context: evaluationContext
459
+ };
460
+ const response = await fetch(`${this.config.baseUrl}/api/flags/${flagKey}/evaluate`, {
461
+ method: "POST",
462
+ headers: {
463
+ "Content-Type": "application/json",
464
+ Authorization: `Bearer ${this.config.apiKey}`
465
+ },
466
+ body: JSON.stringify(requestBody)
467
+ });
468
+ if (!response.ok) {
469
+ throw new Error(`Flag evaluation failed: ${response.status}`);
470
+ }
471
+ const data = await response.json();
472
+ const value = data.enabled || false;
473
+ this.cache.set(flagKey, value, data.key);
474
+ const durationMs = Date.now() - startTime;
475
+ this.telemetry.trackEvaluation({
476
+ flagKey,
477
+ result: value,
478
+ context: evaluationContext,
479
+ durationMs,
480
+ traceId,
481
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
482
+ });
483
+ return {
484
+ key: flagKey,
485
+ value,
486
+ reason: "evaluated",
487
+ metadata: {
488
+ scope: data.scope,
489
+ configuration: data.configuration,
490
+ variation: data.variation,
491
+ timestamp: data.timestamp
492
+ }
493
+ };
494
+ } catch (error) {
495
+ const defaultValue = this.config.defaults[flagKey] ?? false;
496
+ this.config.onError(error);
497
+ return {
498
+ key: flagKey,
499
+ value: defaultValue,
500
+ reason: "error"
501
+ };
502
+ }
503
+ }
504
+ /**
505
+ * Execute code conditionally based on flag value
506
+ * @param flagKey - The flag key to check
507
+ * @param callback - Function to execute if flag is enabled
508
+ * @param context - Optional context for targeting
509
+ */
510
+ async withFlag(flagKey, callback, context) {
511
+ const enabled = await this.isEnabled(flagKey, context);
512
+ if (!enabled) {
513
+ return null;
514
+ }
515
+ const evaluationContext = this.buildContext(context);
516
+ const traceId = TelemetryService.generateTraceId();
517
+ try {
518
+ return await callback();
519
+ } catch (error) {
520
+ this.telemetry.trackError({
521
+ flagKey,
522
+ flagEnabled: true,
523
+ errorType: error.name || "Error",
524
+ errorMessage: error.message || "Unknown error",
525
+ stackTrace: error.stack,
526
+ context: evaluationContext,
527
+ traceId,
528
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
529
+ });
530
+ throw error;
531
+ }
532
+ }
533
+ /**
534
+ * Manually track an error with flag context
535
+ * @param flagKey - The flag key associated with the error
536
+ * @param error - The error that occurred
537
+ * @param context - Optional context
538
+ */
539
+ trackError(flagKey, error, context) {
540
+ const evaluationContext = this.buildContext(context);
541
+ this.telemetry.trackError({
542
+ flagKey,
543
+ flagEnabled: true,
544
+ // Assume enabled if tracking manually
545
+ errorType: error.name || "Error",
546
+ errorMessage: error.message || "Unknown error",
547
+ stackTrace: error.stack,
548
+ context: evaluationContext,
549
+ traceId: TelemetryService.generateTraceId(),
550
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
551
+ });
552
+ }
553
+ /**
554
+ * Subscribe to real-time updates for a specific flag
555
+ * @param flagKey - Flag key or '*' for all flags
556
+ * @param callback - Callback when flag is updated
557
+ * @returns Unsubscribe function
558
+ */
559
+ subscribe(flagKey, callback) {
560
+ if (!this.realtime) {
561
+ console.warn("[Savvagent] Real-time updates are disabled");
562
+ return () => {
563
+ };
564
+ }
565
+ return this.realtime.subscribe(flagKey, () => {
566
+ callback();
567
+ });
568
+ }
569
+ /**
570
+ * Get all cached flag keys
571
+ */
572
+ getCachedFlags() {
573
+ return this.cache.keys();
574
+ }
575
+ /**
576
+ * Clear the flag cache
577
+ */
578
+ clearCache() {
579
+ this.cache.clear();
580
+ }
581
+ /**
582
+ * Check if real-time connection is active
583
+ */
584
+ isRealtimeConnected() {
585
+ return this.realtime?.isConnected() || false;
586
+ }
587
+ /**
588
+ * Close the client and cleanup resources
589
+ */
590
+ close() {
591
+ this.telemetry.stop();
592
+ this.realtime?.disconnect();
593
+ this.cache.clear();
594
+ }
595
+ };
596
+ export {
597
+ FlagCache,
598
+ FlagClient,
599
+ RealtimeService,
600
+ TelemetryService
601
+ };
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@savvagent/sdk",
3
+ "version": "1.0.0",
4
+ "description": "Savvagent TypeScript/JavaScript SDK for feature flags with AI-powered error detection",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.mjs",
11
+ "require": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup src/index.ts --format cjs,esm --dts",
21
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
22
+ "test": "jest",
23
+ "test:watch": "jest --watch",
24
+ "lint": "eslint src --ext .ts",
25
+ "format": "prettier --write \"src/**/*.ts\"",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "keywords": [
29
+ "feature-flags",
30
+ "feature-toggles",
31
+ "savvagent",
32
+ "ai",
33
+ "error-detection",
34
+ "typescript"
35
+ ],
36
+ "author": "Rob Hicks",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/savvagent/savvagent-sdks",
41
+ "directory": "packages/typescript"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/savvagent/savvagent-sdks/issues"
45
+ },
46
+ "homepage": "https://github.com/savvagent/savvagent-sdks/tree/main/packages/typescript#readme",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
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"
61
+ },
62
+ "dependencies": {},
63
+ "peerDependencies": {},
64
+ "engines": {
65
+ "node": ">=16"
66
+ }
67
+ }