@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/README.md +443 -0
- package/dist/index.d.mts +599 -0
- package/dist/index.d.ts +599 -0
- package/dist/index.js +631 -0
- package/dist/index.mjs +601 -0
- package/package.json +67 -0
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
|
+
}
|