@flipswitch-io/sdk 0.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/dist/index.js ADDED
@@ -0,0 +1,645 @@
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
+ FlipswitchProvider: () => FlipswitchProvider,
25
+ SseClient: () => SseClient
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+
29
+ // src/provider.ts
30
+ var import_ofrep_web_provider = require("@openfeature/ofrep-web-provider");
31
+
32
+ // src/sse-client.ts
33
+ var MIN_RETRY_DELAY = 1e3;
34
+ var MAX_RETRY_DELAY = 3e4;
35
+ var SseClient = class {
36
+ constructor(baseUrl, apiKey, onFlagChange, onStatusChange, telemetryHeaders) {
37
+ this.baseUrl = baseUrl;
38
+ this.apiKey = apiKey;
39
+ this.onFlagChange = onFlagChange;
40
+ this.onStatusChange = onStatusChange;
41
+ this.telemetryHeaders = telemetryHeaders;
42
+ this.eventSource = null;
43
+ this.retryDelay = MIN_RETRY_DELAY;
44
+ this.reconnectTimeout = null;
45
+ this.closed = false;
46
+ this.status = "disconnected";
47
+ }
48
+ /**
49
+ * Start the SSE connection.
50
+ */
51
+ connect() {
52
+ if (this.closed) return;
53
+ if (this.eventSource) {
54
+ this.eventSource.close();
55
+ }
56
+ this.updateStatus("connecting");
57
+ const url = `${this.baseUrl}/api/v1/flags/events`;
58
+ try {
59
+ if (typeof EventSource !== "undefined") {
60
+ this.connectWithFetch(url);
61
+ } else {
62
+ this.connectWithPolyfill(url);
63
+ }
64
+ } catch (error) {
65
+ console.error("[Flipswitch] Failed to establish SSE connection:", error);
66
+ this.updateStatus("error");
67
+ this.scheduleReconnect();
68
+ }
69
+ }
70
+ /**
71
+ * Connect using fetch-based SSE (supports custom headers).
72
+ */
73
+ async connectWithFetch(url) {
74
+ try {
75
+ const headers = {
76
+ "X-API-Key": this.apiKey,
77
+ Accept: "text/event-stream",
78
+ "Cache-Control": "no-cache",
79
+ ...this.telemetryHeaders
80
+ };
81
+ const response = await fetch(url, {
82
+ method: "GET",
83
+ headers
84
+ });
85
+ if (!response.ok) {
86
+ throw new Error(`SSE connection failed: ${response.status}`);
87
+ }
88
+ if (!response.body) {
89
+ throw new Error("Response body is null");
90
+ }
91
+ this.updateStatus("connected");
92
+ this.retryDelay = MIN_RETRY_DELAY;
93
+ const reader = response.body.getReader();
94
+ const decoder = new TextDecoder();
95
+ let buffer = "";
96
+ const processStream = async () => {
97
+ while (!this.closed) {
98
+ const { done, value } = await reader.read();
99
+ if (done) {
100
+ this.updateStatus("disconnected");
101
+ this.scheduleReconnect();
102
+ return;
103
+ }
104
+ buffer += decoder.decode(value, { stream: true });
105
+ const lines = buffer.split("\n");
106
+ buffer = lines.pop() || "";
107
+ let eventType = "";
108
+ let eventData = "";
109
+ for (const line of lines) {
110
+ if (line.startsWith("event:")) {
111
+ eventType = line.slice(6).trim();
112
+ } else if (line.startsWith("data:")) {
113
+ eventData = line.slice(5).trim();
114
+ } else if (line === "" && eventData) {
115
+ this.handleEvent(eventType, eventData);
116
+ eventType = "";
117
+ eventData = "";
118
+ }
119
+ }
120
+ }
121
+ };
122
+ processStream().catch((error) => {
123
+ if (!this.closed) {
124
+ console.error("[Flipswitch] SSE stream error:", error);
125
+ this.updateStatus("error");
126
+ this.scheduleReconnect();
127
+ }
128
+ });
129
+ } catch (error) {
130
+ if (!this.closed) {
131
+ console.error("[Flipswitch] SSE connection error:", error);
132
+ this.updateStatus("error");
133
+ this.scheduleReconnect();
134
+ }
135
+ }
136
+ }
137
+ /**
138
+ * Connect using native EventSource (for environments that support it).
139
+ * Note: This requires server-side support for API key in query params.
140
+ */
141
+ connectWithPolyfill(url) {
142
+ this.connectWithFetch(url);
143
+ }
144
+ /**
145
+ * Handle incoming SSE events.
146
+ */
147
+ handleEvent(eventType, data) {
148
+ if (eventType === "heartbeat") {
149
+ return;
150
+ }
151
+ if (eventType === "flag-change") {
152
+ try {
153
+ const event = JSON.parse(data);
154
+ this.onFlagChange(event);
155
+ } catch (error) {
156
+ console.error("[Flipswitch] Failed to parse flag-change event:", error);
157
+ }
158
+ }
159
+ }
160
+ /**
161
+ * Schedule a reconnection attempt with exponential backoff.
162
+ */
163
+ scheduleReconnect() {
164
+ if (this.closed) return;
165
+ if (this.reconnectTimeout) {
166
+ clearTimeout(this.reconnectTimeout);
167
+ }
168
+ this.reconnectTimeout = setTimeout(() => {
169
+ if (!this.closed) {
170
+ this.connect();
171
+ this.retryDelay = Math.min(this.retryDelay * 2, MAX_RETRY_DELAY);
172
+ }
173
+ }, this.retryDelay);
174
+ }
175
+ /**
176
+ * Update and broadcast connection status.
177
+ */
178
+ updateStatus(status) {
179
+ this.status = status;
180
+ this.onStatusChange?.(status);
181
+ }
182
+ /**
183
+ * Get current connection status.
184
+ */
185
+ getStatus() {
186
+ return this.status;
187
+ }
188
+ /**
189
+ * Close the SSE connection and stop reconnection attempts.
190
+ */
191
+ close() {
192
+ this.closed = true;
193
+ this.updateStatus("disconnected");
194
+ if (this.reconnectTimeout) {
195
+ clearTimeout(this.reconnectTimeout);
196
+ this.reconnectTimeout = null;
197
+ }
198
+ if (this.eventSource) {
199
+ this.eventSource.close();
200
+ this.eventSource = null;
201
+ }
202
+ }
203
+ };
204
+
205
+ // src/provider.ts
206
+ var DEFAULT_BASE_URL = "https://api.flipswitch.io";
207
+ var SDK_VERSION = "0.1.0";
208
+ var FlipswitchProvider = class {
209
+ constructor(options, eventHandlers) {
210
+ this.metadata = {
211
+ name: "flipswitch"
212
+ };
213
+ this.rulesFromFlagValue = false;
214
+ this.sseClient = null;
215
+ this._status = "NOT_READY";
216
+ this.eventHandlers = /* @__PURE__ */ new Map();
217
+ this.userEventHandlers = {};
218
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
219
+ this.apiKey = options.apiKey;
220
+ this.enableRealtime = options.enableRealtime ?? true;
221
+ this.enableTelemetry = options.enableTelemetry ?? true;
222
+ this.fetchImpl = options.fetchImplementation ?? (typeof window !== "undefined" ? fetch.bind(window) : fetch);
223
+ this.userEventHandlers = eventHandlers ?? {};
224
+ const headers = [["X-API-Key", this.apiKey]];
225
+ if (this.enableTelemetry) {
226
+ headers.push(["X-Flipswitch-SDK", this.getTelemetrySdkHeader()]);
227
+ headers.push(["X-Flipswitch-Runtime", this.getTelemetryRuntimeHeader()]);
228
+ headers.push(["X-Flipswitch-OS", this.getTelemetryOsHeader()]);
229
+ headers.push(["X-Flipswitch-Features", this.getTelemetryFeaturesHeader()]);
230
+ }
231
+ this.ofrepProvider = new import_ofrep_web_provider.OFREPWebProvider({
232
+ baseUrl: this.baseUrl + "/ofrep/v1",
233
+ fetchImplementation: this.fetchImpl,
234
+ headers
235
+ });
236
+ }
237
+ getTelemetrySdkHeader() {
238
+ return `javascript/${SDK_VERSION}`;
239
+ }
240
+ getTelemetryRuntimeHeader() {
241
+ if (typeof process !== "undefined" && process.versions?.node) {
242
+ return `node/${process.versions.node}`;
243
+ }
244
+ if (typeof navigator !== "undefined") {
245
+ const ua = navigator.userAgent;
246
+ if (ua.includes("Chrome")) {
247
+ const match = ua.match(/Chrome\/(\d+)/);
248
+ return `chrome/${match?.[1] ?? "unknown"}`;
249
+ }
250
+ if (ua.includes("Firefox")) {
251
+ const match = ua.match(/Firefox\/(\d+)/);
252
+ return `firefox/${match?.[1] ?? "unknown"}`;
253
+ }
254
+ if (ua.includes("Safari") && !ua.includes("Chrome")) {
255
+ const match = ua.match(/Version\/(\d+)/);
256
+ return `safari/${match?.[1] ?? "unknown"}`;
257
+ }
258
+ return "browser/unknown";
259
+ }
260
+ return "unknown/unknown";
261
+ }
262
+ getTelemetryOsHeader() {
263
+ if (typeof process !== "undefined" && process.platform) {
264
+ const platform = process.platform;
265
+ const arch = process.arch;
266
+ const os = platform === "darwin" ? "darwin" : platform === "win32" ? "windows" : platform;
267
+ return `${os}/${arch}`;
268
+ }
269
+ if (typeof navigator !== "undefined") {
270
+ const ua = navigator.userAgent.toLowerCase();
271
+ let os = "unknown";
272
+ let arch = "unknown";
273
+ if (ua.includes("mac")) os = "darwin";
274
+ else if (ua.includes("win")) os = "windows";
275
+ else if (ua.includes("linux")) os = "linux";
276
+ else if (ua.includes("android")) os = "android";
277
+ else if (ua.includes("iphone") || ua.includes("ipad")) os = "ios";
278
+ if (ua.includes("arm64") || ua.includes("aarch64")) arch = "arm64";
279
+ else if (ua.includes("x64") || ua.includes("x86_64") || ua.includes("amd64")) arch = "amd64";
280
+ return `${os}/${arch}`;
281
+ }
282
+ return "unknown/unknown";
283
+ }
284
+ getTelemetryFeaturesHeader() {
285
+ return `sse=${this.enableRealtime}`;
286
+ }
287
+ getTelemetryHeaders() {
288
+ if (!this.enableTelemetry) return {};
289
+ return {
290
+ "X-Flipswitch-SDK": this.getTelemetrySdkHeader(),
291
+ "X-Flipswitch-Runtime": this.getTelemetryRuntimeHeader(),
292
+ "X-Flipswitch-OS": this.getTelemetryOsHeader(),
293
+ "X-Flipswitch-Features": this.getTelemetryFeaturesHeader()
294
+ };
295
+ }
296
+ get status() {
297
+ return this._status;
298
+ }
299
+ /**
300
+ * Initialize the provider.
301
+ * Validates the API key and starts SSE connection if real-time is enabled.
302
+ */
303
+ async initialize(context) {
304
+ this._status = "NOT_READY";
305
+ try {
306
+ await this.ofrepProvider.initialize(context);
307
+ } catch (error) {
308
+ try {
309
+ const response = await this.fetchImpl(`${this.baseUrl}/ofrep/v1/evaluate/flags`, {
310
+ method: "POST",
311
+ headers: {
312
+ "Content-Type": "application/json",
313
+ "X-API-Key": this.apiKey,
314
+ ...this.getTelemetryHeaders()
315
+ },
316
+ body: JSON.stringify({
317
+ context: { targetingKey: "_init_" }
318
+ })
319
+ });
320
+ if (response.status === 401 || response.status === 403) {
321
+ this._status = "ERROR";
322
+ throw new Error("Invalid API key");
323
+ }
324
+ if (!response.ok && response.status !== 404) {
325
+ this._status = "ERROR";
326
+ throw new Error(`Failed to connect to Flipswitch: ${response.status}`);
327
+ }
328
+ } catch (validationError) {
329
+ this._status = "ERROR";
330
+ throw validationError;
331
+ }
332
+ }
333
+ if (this.enableRealtime) {
334
+ this.startSseConnection();
335
+ }
336
+ this._status = "READY";
337
+ this.emit("PROVIDER_READY");
338
+ }
339
+ /**
340
+ * Called when the provider is shut down.
341
+ */
342
+ async onClose() {
343
+ this.sseClient?.close();
344
+ this.sseClient = null;
345
+ await this.ofrepProvider.onClose?.();
346
+ this._status = "NOT_READY";
347
+ }
348
+ /**
349
+ * Start the SSE connection for real-time updates.
350
+ */
351
+ startSseConnection() {
352
+ const telemetryHeaders = this.getTelemetryHeadersMap();
353
+ this.sseClient = new SseClient(
354
+ this.baseUrl,
355
+ this.apiKey,
356
+ (event) => {
357
+ this.handleFlagChange(event);
358
+ },
359
+ (status) => {
360
+ this.userEventHandlers.onConnectionStatusChange?.(status);
361
+ if (status === "error") {
362
+ this._status = "STALE";
363
+ this.emit("PROVIDER_STALE");
364
+ } else if (status === "connected" && this._status === "STALE") {
365
+ this._status = "READY";
366
+ this.emit("PROVIDER_READY");
367
+ }
368
+ },
369
+ telemetryHeaders
370
+ );
371
+ this.sseClient.connect();
372
+ }
373
+ /**
374
+ * Get telemetry headers as a map.
375
+ */
376
+ getTelemetryHeadersMap() {
377
+ if (!this.enableTelemetry) {
378
+ return void 0;
379
+ }
380
+ return {
381
+ "X-Flipswitch-SDK": this.getTelemetrySdkHeader(),
382
+ "X-Flipswitch-Runtime": this.getTelemetryRuntimeHeader(),
383
+ "X-Flipswitch-OS": this.getTelemetryOsHeader(),
384
+ "X-Flipswitch-Features": this.getTelemetryFeaturesHeader()
385
+ };
386
+ }
387
+ /**
388
+ * Handle a flag change event from SSE.
389
+ * Emits PROVIDER_CONFIGURATION_CHANGED to trigger re-evaluation.
390
+ */
391
+ handleFlagChange(event) {
392
+ this.userEventHandlers.onFlagChange?.(event);
393
+ this.emit("PROVIDER_CONFIGURATION_CHANGED");
394
+ }
395
+ /**
396
+ * Emit an event to registered handlers.
397
+ */
398
+ emit(event) {
399
+ const handlers = this.eventHandlers.get(event);
400
+ if (handlers) {
401
+ Array.from(handlers).forEach((handler) => handler());
402
+ }
403
+ }
404
+ /**
405
+ * Register an event handler.
406
+ */
407
+ onProviderEvent(event, handler) {
408
+ if (!this.eventHandlers.has(event)) {
409
+ this.eventHandlers.set(event, /* @__PURE__ */ new Set());
410
+ }
411
+ this.eventHandlers.get(event).add(handler);
412
+ }
413
+ // ===============================
414
+ // Flag Resolution Methods - Delegated to OFREP Provider
415
+ // ===============================
416
+ resolveBooleanEvaluation(flagKey, defaultValue, context) {
417
+ return this.ofrepProvider.resolveBooleanEvaluation(flagKey, defaultValue, context);
418
+ }
419
+ resolveStringEvaluation(flagKey, defaultValue, context) {
420
+ return this.ofrepProvider.resolveStringEvaluation(flagKey, defaultValue, context);
421
+ }
422
+ resolveNumberEvaluation(flagKey, defaultValue, context) {
423
+ return this.ofrepProvider.resolveNumberEvaluation(flagKey, defaultValue, context);
424
+ }
425
+ resolveObjectEvaluation(flagKey, defaultValue, context) {
426
+ return this.ofrepProvider.resolveObjectEvaluation(flagKey, defaultValue, context);
427
+ }
428
+ /**
429
+ * Get SSE connection status.
430
+ */
431
+ getSseStatus() {
432
+ return this.sseClient?.getStatus() ?? "disconnected";
433
+ }
434
+ /**
435
+ * Force reconnect SSE connection.
436
+ */
437
+ reconnectSse() {
438
+ if (this.enableRealtime && this.sseClient) {
439
+ this.sseClient.close();
440
+ this.startSseConnection();
441
+ }
442
+ }
443
+ // ===============================
444
+ // Bulk Flag Evaluation (Direct HTTP - OFREP providers don't expose bulk API)
445
+ // ===============================
446
+ /**
447
+ * Transform OpenFeature context to OFREP context format.
448
+ */
449
+ transformContext(context) {
450
+ const result = {};
451
+ if (context.targetingKey) {
452
+ result.targetingKey = context.targetingKey;
453
+ }
454
+ for (const [key, value] of Object.entries(context)) {
455
+ if (key !== "targetingKey") {
456
+ result[key] = value;
457
+ }
458
+ }
459
+ return result;
460
+ }
461
+ /**
462
+ * Infer the type of a value.
463
+ */
464
+ inferType(value) {
465
+ if (value === null) return "null";
466
+ if (Array.isArray(value)) return "array";
467
+ const t = typeof value;
468
+ if (t === "boolean" || t === "string" || t === "number" || t === "object") {
469
+ return t;
470
+ }
471
+ return "unknown";
472
+ }
473
+ /**
474
+ * Get flag type from metadata or infer from value.
475
+ */
476
+ getFlagType(flag) {
477
+ if (flag.metadata?.flagType) {
478
+ const metaType = flag.metadata.flagType;
479
+ if (metaType === "boolean" || metaType === "string" || metaType === "integer" || metaType === "decimal") {
480
+ return metaType === "integer" || metaType === "decimal" ? "number" : metaType;
481
+ }
482
+ }
483
+ return this.inferType(flag.value);
484
+ }
485
+ /**
486
+ * Format a value for display.
487
+ */
488
+ formatValue(value) {
489
+ if (value === null) return "null";
490
+ if (typeof value === "string") return `"${value}"`;
491
+ if (typeof value === "object") return JSON.stringify(value);
492
+ return String(value);
493
+ }
494
+ /**
495
+ * Evaluate all flags for the given context.
496
+ * Returns a list of all flag evaluations with their keys, values, types, and reasons.
497
+ *
498
+ * Note: This method makes direct HTTP calls since OFREP providers don't expose
499
+ * the bulk evaluation API.
500
+ *
501
+ * @param context The evaluation context
502
+ * @returns List of flag evaluations
503
+ */
504
+ async evaluateAllFlags(context) {
505
+ try {
506
+ const response = await this.fetchImpl(`${this.baseUrl}/ofrep/v1/evaluate/flags`, {
507
+ method: "POST",
508
+ headers: {
509
+ "Content-Type": "application/json",
510
+ "X-API-Key": this.apiKey,
511
+ ...this.getTelemetryHeaders()
512
+ },
513
+ body: JSON.stringify({
514
+ context: this.transformContext(context)
515
+ })
516
+ });
517
+ if (!response.ok) {
518
+ console.error(`Failed to evaluate all flags: ${response.status}`);
519
+ return [];
520
+ }
521
+ const result = await response.json();
522
+ const flags = [];
523
+ if (result.flags && Array.isArray(result.flags)) {
524
+ for (const flag of result.flags) {
525
+ if (flag.key) {
526
+ flags.push({
527
+ key: flag.key,
528
+ value: flag.value,
529
+ valueType: this.getFlagType(flag),
530
+ reason: flag.reason ?? null,
531
+ variant: flag.variant ?? null
532
+ });
533
+ }
534
+ }
535
+ }
536
+ return flags;
537
+ } catch (error) {
538
+ console.error("Error evaluating all flags:", error);
539
+ return [];
540
+ }
541
+ }
542
+ /**
543
+ * Evaluate a single flag and return its evaluation result.
544
+ *
545
+ * Note: This method makes direct HTTP calls for demo purposes.
546
+ * For standard flag evaluation, use the OpenFeature client methods.
547
+ *
548
+ * @param flagKey The flag key to evaluate
549
+ * @param context The evaluation context
550
+ * @returns The flag evaluation, or null if the flag doesn't exist
551
+ */
552
+ async evaluateFlag(flagKey, context) {
553
+ try {
554
+ const response = await this.fetchImpl(`${this.baseUrl}/ofrep/v1/evaluate/flags/${flagKey}`, {
555
+ method: "POST",
556
+ headers: {
557
+ "Content-Type": "application/json",
558
+ "X-API-Key": this.apiKey,
559
+ ...this.getTelemetryHeaders()
560
+ },
561
+ body: JSON.stringify({
562
+ context: this.transformContext(context)
563
+ })
564
+ });
565
+ if (!response.ok) {
566
+ return null;
567
+ }
568
+ const result = await response.json();
569
+ return {
570
+ key: result.key ?? flagKey,
571
+ value: result.value,
572
+ valueType: this.getFlagType(result),
573
+ reason: result.reason ?? null,
574
+ variant: result.variant ?? null
575
+ };
576
+ } catch (error) {
577
+ console.error(`Error evaluating flag '${flagKey}':`, error);
578
+ return null;
579
+ }
580
+ }
581
+ };
582
+
583
+ // src/cache.ts
584
+ var DEFAULT_TTL_MS = 5 * 60 * 1e3;
585
+ var FlagCache = class {
586
+ /**
587
+ * Create a new FlagCache.
588
+ * @param ttlMs Time-to-live in milliseconds (default: 5 minutes)
589
+ */
590
+ constructor(ttlMs = DEFAULT_TTL_MS) {
591
+ this.cache = /* @__PURE__ */ new Map();
592
+ this.ttlMs = ttlMs;
593
+ }
594
+ /**
595
+ * Get a value from the cache.
596
+ * Returns undefined if the key doesn't exist or has expired.
597
+ */
598
+ get(key) {
599
+ const entry = this.cache.get(key);
600
+ if (!entry) {
601
+ return void 0;
602
+ }
603
+ if (Date.now() > entry.expiresAt) {
604
+ this.cache.delete(key);
605
+ return void 0;
606
+ }
607
+ return entry.value;
608
+ }
609
+ /**
610
+ * Set a value in the cache.
611
+ */
612
+ set(key, value) {
613
+ this.cache.set(key, {
614
+ value,
615
+ expiresAt: Date.now() + this.ttlMs
616
+ });
617
+ }
618
+ /**
619
+ * Invalidate a specific key or all keys if no key is provided.
620
+ */
621
+ invalidate(key) {
622
+ if (key) {
623
+ this.cache.delete(key);
624
+ } else {
625
+ this.cache.clear();
626
+ }
627
+ }
628
+ /**
629
+ * Handle a flag change event from SSE.
630
+ * Invalidates the specific flag or all flags if flagKey is null.
631
+ */
632
+ handleFlagChange(event) {
633
+ if (event.flagKey) {
634
+ this.invalidate(event.flagKey);
635
+ } else {
636
+ this.invalidate();
637
+ }
638
+ }
639
+ };
640
+ // Annotate the CommonJS export names for ESM import in node:
641
+ 0 && (module.exports = {
642
+ FlagCache,
643
+ FlipswitchProvider,
644
+ SseClient
645
+ });