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