@databuddy/sdk 2.3.30 → 2.4.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.
@@ -0,0 +1,601 @@
1
+ class Logger {
2
+ debugEnabled = false;
3
+ /**
4
+ * Enable or disable debug logging
5
+ */
6
+ setDebug(enabled) {
7
+ this.debugEnabled = enabled;
8
+ }
9
+ /**
10
+ * Log debug messages (only when debug is enabled)
11
+ */
12
+ debug(...args) {
13
+ if (this.debugEnabled) {
14
+ console.log("[Databuddy]", ...args);
15
+ }
16
+ }
17
+ /**
18
+ * Log info messages (always enabled)
19
+ */
20
+ info(...args) {
21
+ console.info("[Databuddy]", ...args);
22
+ }
23
+ /**
24
+ * Log warning messages (always enabled)
25
+ */
26
+ warn(...args) {
27
+ console.warn("[Databuddy]", ...args);
28
+ }
29
+ /**
30
+ * Log error messages (always enabled)
31
+ */
32
+ error(...args) {
33
+ console.error("[Databuddy]", ...args);
34
+ }
35
+ /**
36
+ * Log with table format (only when debug is enabled)
37
+ */
38
+ table(data) {
39
+ if (this.debugEnabled) {
40
+ console.table(data);
41
+ }
42
+ }
43
+ /**
44
+ * Time a function execution (only when debug is enabled)
45
+ */
46
+ time(label) {
47
+ if (this.debugEnabled) {
48
+ console.time(`[Databuddy] ${label}`);
49
+ }
50
+ }
51
+ /**
52
+ * End timing a function execution (only when debug is enabled)
53
+ */
54
+ timeEnd(label) {
55
+ if (this.debugEnabled) {
56
+ console.timeEnd(`[Databuddy] ${label}`);
57
+ }
58
+ }
59
+ /**
60
+ * Log JSON data (only when debug is enabled)
61
+ */
62
+ json(data) {
63
+ if (this.debugEnabled) {
64
+ console.log("[Databuddy]", JSON.stringify(data, null, 2));
65
+ }
66
+ }
67
+ }
68
+ const logger = new Logger();
69
+
70
+ const DEFAULT_RESULT = {
71
+ enabled: false,
72
+ value: false,
73
+ payload: null,
74
+ reason: "DEFAULT"
75
+ };
76
+ function getCacheKey(key, user) {
77
+ if (!(user?.userId || user?.email)) {
78
+ return key;
79
+ }
80
+ return `${key}:${user.userId ?? ""}:${user.email ?? ""}`;
81
+ }
82
+ function buildQueryParams(config, user) {
83
+ const params = new URLSearchParams();
84
+ params.set("clientId", config.clientId);
85
+ const u = user ?? config.user;
86
+ if (u?.userId) {
87
+ params.set("userId", u.userId);
88
+ }
89
+ if (u?.email) {
90
+ params.set("email", u.email);
91
+ }
92
+ if (u?.organizationId) {
93
+ params.set("organizationId", u.organizationId);
94
+ }
95
+ if (u?.teamId) {
96
+ params.set("teamId", u.teamId);
97
+ }
98
+ if (u?.properties) {
99
+ params.set("properties", JSON.stringify(u.properties));
100
+ }
101
+ if (config.environment) {
102
+ params.set("environment", config.environment);
103
+ }
104
+ return params;
105
+ }
106
+ async function fetchFlags(apiUrl, keys, params) {
107
+ const batchParams = new URLSearchParams(params);
108
+ batchParams.set("keys", keys.join(","));
109
+ const url = `${apiUrl}/public/v1/flags/bulk?${batchParams}`;
110
+ const response = await fetch(url);
111
+ if (!response.ok) {
112
+ const result = {};
113
+ for (const key of keys) {
114
+ result[key] = { ...DEFAULT_RESULT, reason: "ERROR" };
115
+ }
116
+ return result;
117
+ }
118
+ const data = await response.json();
119
+ return data.flags ?? {};
120
+ }
121
+ async function fetchAllFlags(apiUrl, params) {
122
+ const url = `${apiUrl}/public/v1/flags/bulk?${params}`;
123
+ const response = await fetch(url);
124
+ if (!response.ok) {
125
+ return {};
126
+ }
127
+ const data = await response.json();
128
+ return data.flags ?? {};
129
+ }
130
+ class RequestBatcher {
131
+ pending = /* @__PURE__ */ new Map();
132
+ timer = null;
133
+ batchDelayMs;
134
+ apiUrl;
135
+ params;
136
+ constructor(apiUrl, params, batchDelayMs = 10) {
137
+ this.apiUrl = apiUrl;
138
+ this.params = params;
139
+ this.batchDelayMs = batchDelayMs;
140
+ }
141
+ request(key) {
142
+ return new Promise((resolve, reject) => {
143
+ const existing = this.pending.get(key);
144
+ if (existing) {
145
+ existing.push({ resolve, reject });
146
+ } else {
147
+ this.pending.set(key, [{ resolve, reject }]);
148
+ }
149
+ if (!this.timer) {
150
+ this.timer = setTimeout(() => this.flush(), this.batchDelayMs);
151
+ }
152
+ });
153
+ }
154
+ async flush() {
155
+ this.timer = null;
156
+ const keys = [...this.pending.keys()];
157
+ const callbacks = new Map(this.pending);
158
+ this.pending.clear();
159
+ if (keys.length === 0) {
160
+ return;
161
+ }
162
+ try {
163
+ const results = await fetchFlags(this.apiUrl, keys, this.params);
164
+ for (const [key, cbs] of callbacks) {
165
+ const result = results[key] ?? {
166
+ ...DEFAULT_RESULT,
167
+ reason: "NOT_FOUND"
168
+ };
169
+ for (const cb of cbs) {
170
+ cb.resolve(result);
171
+ }
172
+ }
173
+ } catch (err) {
174
+ const error = err instanceof Error ? err : new Error("Fetch failed");
175
+ for (const cbs of callbacks.values()) {
176
+ for (const cb of cbs) {
177
+ cb.reject(error);
178
+ }
179
+ }
180
+ }
181
+ }
182
+ destroy() {
183
+ if (this.timer) {
184
+ clearTimeout(this.timer);
185
+ this.timer = null;
186
+ }
187
+ this.pending.clear();
188
+ }
189
+ }
190
+
191
+ const ANON_ID_KEY = "did";
192
+ const DEFAULT_API = "https://api.databuddy.cc";
193
+ function resolved(result, ttl, staleTime) {
194
+ const now = Date.now();
195
+ return {
196
+ promise: Promise.resolve(result),
197
+ result,
198
+ expiresAt: now + ttl,
199
+ staleAt: now + staleTime
200
+ };
201
+ }
202
+ function isValid(entry) {
203
+ return entry !== void 0 && Date.now() <= entry.expiresAt;
204
+ }
205
+ function isStale(entry) {
206
+ return Date.now() > entry.staleAt;
207
+ }
208
+ class BaseFlagsManager {
209
+ config;
210
+ storage;
211
+ cache = /* @__PURE__ */ new Map();
212
+ batcher = null;
213
+ ready = false;
214
+ listeners = /* @__PURE__ */ new Set();
215
+ snapshot = { flags: {}, isReady: false };
216
+ constructor(options) {
217
+ this.config = {
218
+ apiUrl: DEFAULT_API,
219
+ disabled: false,
220
+ debug: false,
221
+ autoFetch: true,
222
+ cacheTtl: 6e4,
223
+ staleTime: 3e4,
224
+ ...options.config
225
+ };
226
+ this.storage = options.storage;
227
+ logger.setDebug(this.config.debug ?? false);
228
+ }
229
+ shouldSkipFetch() {
230
+ return false;
231
+ }
232
+ onCacheUpdated() {
233
+ }
234
+ onFlagEvaluated(_key, _result) {
235
+ }
236
+ async runInit() {
237
+ if (this.storage) {
238
+ this.hydrate();
239
+ }
240
+ if (this.config.autoFetch && !this.config.isPending) {
241
+ await this.fetchAllFlags();
242
+ }
243
+ this.ready = true;
244
+ }
245
+ hydrate() {
246
+ if (!this.storage) {
247
+ return;
248
+ }
249
+ try {
250
+ const stored = this.storage.getAll();
251
+ const { ttl, stale } = this.ttls();
252
+ for (const [key, value] of Object.entries(stored)) {
253
+ if (value && typeof value === "object") {
254
+ this.cache.set(key, resolved(value, ttl, stale));
255
+ }
256
+ }
257
+ if (this.cache.size > 0) {
258
+ this.emit();
259
+ }
260
+ } catch (err) {
261
+ logger.warn("Failed to load from storage:", err);
262
+ }
263
+ }
264
+ persist() {
265
+ if (!this.storage) {
266
+ return;
267
+ }
268
+ try {
269
+ const flags = {};
270
+ for (const [key, entry] of this.cache) {
271
+ if (entry.result) {
272
+ flags[key] = entry.result;
273
+ }
274
+ }
275
+ this.storage.setAll(flags);
276
+ } catch (err) {
277
+ logger.warn("Failed to save to storage:", err);
278
+ }
279
+ }
280
+ ttls() {
281
+ const ttl = this.config.cacheTtl ?? 6e4;
282
+ return { ttl, stale: this.config.staleTime ?? ttl / 2 };
283
+ }
284
+ validEntry(cacheKey) {
285
+ const entry = this.cache.get(cacheKey);
286
+ if (isValid(entry)) {
287
+ return entry;
288
+ }
289
+ if (entry) {
290
+ this.cache.delete(cacheKey);
291
+ }
292
+ return null;
293
+ }
294
+ ensureBatcher() {
295
+ if (!this.batcher) {
296
+ const params = buildQueryParams(this.config);
297
+ this.batcher = new RequestBatcher(
298
+ this.config.apiUrl ?? DEFAULT_API,
299
+ params,
300
+ this.batchDelay()
301
+ );
302
+ }
303
+ return this.batcher;
304
+ }
305
+ batchDelay() {
306
+ return 10;
307
+ }
308
+ pruneStaleKeys(validKeys, user) {
309
+ const ctx = user ?? this.config.user;
310
+ const suffix = ctx?.userId || ctx?.email ? `:${ctx.userId ?? ""}:${ctx.email ?? ""}` : "";
311
+ for (const key of this.cache.keys()) {
312
+ const belongsToUser = suffix ? key.endsWith(suffix) : !key.includes(":");
313
+ if (belongsToUser && !validKeys.has(key)) {
314
+ this.cache.delete(key);
315
+ }
316
+ }
317
+ }
318
+ revalidate(key, cacheKey) {
319
+ const existing = this.cache.get(cacheKey);
320
+ if (existing && !existing.result) {
321
+ return;
322
+ }
323
+ const { ttl, stale } = this.ttls();
324
+ const promise = this.ensureBatcher().request(key);
325
+ this.cache.set(cacheKey, {
326
+ promise,
327
+ result: existing?.result ?? null,
328
+ expiresAt: existing?.expiresAt ?? Date.now() + ttl,
329
+ staleAt: existing?.staleAt ?? Date.now() + stale
330
+ });
331
+ promise.then((result) => {
332
+ this.cache.set(cacheKey, resolved(result, ttl, stale));
333
+ this.emit();
334
+ this.onCacheUpdated();
335
+ }).catch((err) => {
336
+ logger.error(`Revalidation error: ${key}`, err);
337
+ });
338
+ }
339
+ async getFlag(key, user) {
340
+ if (this.config.disabled) {
341
+ return DEFAULT_RESULT;
342
+ }
343
+ if (this.config.isPending) {
344
+ return { ...DEFAULT_RESULT, reason: "SESSION_PENDING" };
345
+ }
346
+ const cacheKey = getCacheKey(key, user ?? this.config.user);
347
+ const entry = this.validEntry(cacheKey);
348
+ if (entry) {
349
+ if (isStale(entry) && !this.shouldSkipFetch()) {
350
+ this.revalidate(key, cacheKey);
351
+ }
352
+ return entry.result ?? entry.promise;
353
+ }
354
+ const pending = this.cache.get(cacheKey);
355
+ if (pending) {
356
+ return pending.promise;
357
+ }
358
+ const { ttl, stale } = this.ttls();
359
+ const promise = this.ensureBatcher().request(key);
360
+ this.cache.set(cacheKey, {
361
+ promise,
362
+ result: null,
363
+ expiresAt: Date.now() + ttl,
364
+ staleAt: Date.now() + stale
365
+ });
366
+ try {
367
+ const result = await promise;
368
+ this.cache.set(cacheKey, resolved(result, ttl, stale));
369
+ this.emit();
370
+ this.onCacheUpdated();
371
+ this.onFlagEvaluated(key, result);
372
+ return result;
373
+ } catch (err) {
374
+ this.cache.delete(cacheKey);
375
+ throw err;
376
+ }
377
+ }
378
+ async fetchAllFlags(user) {
379
+ if (this.config.disabled || this.config.isPending) {
380
+ return;
381
+ }
382
+ if (this.shouldSkipFetch() && this.cache.size > 0) {
383
+ return;
384
+ }
385
+ const params = buildQueryParams(this.config, user);
386
+ const { ttl, stale } = this.ttls();
387
+ try {
388
+ const flags = await fetchAllFlags(
389
+ this.config.apiUrl ?? DEFAULT_API,
390
+ params
391
+ );
392
+ const entries = Object.entries(flags).map(([key, result]) => ({
393
+ cacheKey: getCacheKey(key, user ?? this.config.user),
394
+ entry: resolved(result, ttl, stale)
395
+ }));
396
+ this.pruneStaleKeys(
397
+ new Set(entries.map(({ cacheKey }) => cacheKey)),
398
+ user
399
+ );
400
+ for (const { cacheKey, entry } of entries) {
401
+ this.cache.set(cacheKey, entry);
402
+ }
403
+ this.ready = true;
404
+ this.emit();
405
+ this.onCacheUpdated();
406
+ } catch (err) {
407
+ logger.error("Bulk fetch error:", err);
408
+ }
409
+ }
410
+ isEnabled(key) {
411
+ const cacheKey = getCacheKey(key, this.config.user);
412
+ const entry = this.validEntry(cacheKey);
413
+ if (entry?.result) {
414
+ if (isStale(entry) && !this.shouldSkipFetch()) {
415
+ this.revalidate(key, cacheKey);
416
+ }
417
+ return {
418
+ on: entry.result.enabled,
419
+ status: entry.result.reason === "ERROR" ? "error" : "ready",
420
+ loading: false,
421
+ value: entry.result.value,
422
+ variant: entry.result.variant
423
+ };
424
+ }
425
+ if (!entry) {
426
+ this.getFlag(key).catch(
427
+ (err) => logger.error(`Background fetch error: ${key}`, err)
428
+ );
429
+ }
430
+ return { on: false, status: "loading", loading: true };
431
+ }
432
+ getValue(key, defaultValue) {
433
+ const cacheKey = getCacheKey(key, this.config.user);
434
+ const entry = this.validEntry(cacheKey);
435
+ if (entry?.result) {
436
+ if (isStale(entry) && !this.shouldSkipFetch()) {
437
+ this.revalidate(key, cacheKey);
438
+ }
439
+ return entry.result.value;
440
+ }
441
+ if (!entry) {
442
+ this.getFlag(key).catch(
443
+ (err) => logger.error(`Background fetch error: ${key}`, err)
444
+ );
445
+ }
446
+ return defaultValue ?? this.config.defaults?.[key] ?? false;
447
+ }
448
+ updateUser(user) {
449
+ this.config = { ...this.config, user: this.enrichUser(user) };
450
+ this.resetBatcher();
451
+ this.refresh().catch((err) => logger.error("Refresh error:", err));
452
+ }
453
+ async refresh(forceClear = false) {
454
+ if (forceClear) {
455
+ this.cache.clear();
456
+ this.storage?.clear();
457
+ this.emit();
458
+ }
459
+ await this.fetchAllFlags();
460
+ }
461
+ updateConfig(config) {
462
+ const wasInactive = this.config.disabled || this.config.isPending;
463
+ this.config = { ...this.config, ...config };
464
+ this.resetBatcher();
465
+ if (wasInactive && !this.config.disabled && !this.config.isPending) {
466
+ this.fetchAllFlags().catch((err) => logger.error("Fetch error:", err));
467
+ }
468
+ }
469
+ getMemoryFlags() {
470
+ const flags = {};
471
+ for (const [key, entry] of this.cache) {
472
+ if (entry.result) {
473
+ flags[key.split(":").at(0) ?? key] = entry.result;
474
+ }
475
+ }
476
+ return flags;
477
+ }
478
+ isReady() {
479
+ return this.ready;
480
+ }
481
+ destroy() {
482
+ this.batcher?.destroy();
483
+ this.cache.clear();
484
+ this.listeners.clear();
485
+ }
486
+ subscribe = (cb) => {
487
+ this.listeners.add(cb);
488
+ return () => {
489
+ this.listeners.delete(cb);
490
+ };
491
+ };
492
+ getSnapshot = () => this.snapshot;
493
+ enrichUser(user) {
494
+ return user;
495
+ }
496
+ emit() {
497
+ this.snapshot = { flags: this.getMemoryFlags(), isReady: this.ready };
498
+ for (const listener of this.listeners) {
499
+ listener();
500
+ }
501
+ }
502
+ resetBatcher() {
503
+ this.batcher?.destroy();
504
+ this.batcher = null;
505
+ }
506
+ revalidateStale() {
507
+ for (const entry of this.cache.values()) {
508
+ if (isStale(entry)) {
509
+ this.fetchAllFlags().catch(
510
+ (err) => logger.error("Revalidation error:", err)
511
+ );
512
+ return;
513
+ }
514
+ }
515
+ }
516
+ }
517
+ class BrowserFlagsManager extends BaseFlagsManager {
518
+ isVisible = true;
519
+ visibilityCleanup;
520
+ trackedFlags = /* @__PURE__ */ new Set();
521
+ constructor(options) {
522
+ super(options);
523
+ this.config.user = this.enrichUser(this.config.user ?? {});
524
+ this.config.autoFetch = options.config.autoFetch !== false;
525
+ this.setupVisibilityListener();
526
+ this.runInit();
527
+ }
528
+ shouldSkipFetch() {
529
+ return !this.isVisible;
530
+ }
531
+ onCacheUpdated() {
532
+ this.persist();
533
+ }
534
+ onFlagEvaluated(key, result) {
535
+ const dedupeKey = `${key}:${String(result.value)}`;
536
+ if (this.trackedFlags.has(dedupeKey)) {
537
+ return;
538
+ }
539
+ this.trackedFlags.add(dedupeKey);
540
+ try {
541
+ if (typeof window !== "undefined" && (window.databuddy || window.db)) {
542
+ const tracker = window.databuddy ?? window.db;
543
+ tracker?.track?.("$flag_evaluated", {
544
+ flag: key,
545
+ value: result.value,
546
+ variant: result.variant,
547
+ enabled: result.enabled
548
+ });
549
+ }
550
+ } catch {
551
+ }
552
+ }
553
+ enrichUser(user) {
554
+ if (user.userId || user.email) {
555
+ return user;
556
+ }
557
+ const anonId = this.getOrCreateAnonId();
558
+ if (!anonId) {
559
+ return user;
560
+ }
561
+ return { ...user, userId: anonId };
562
+ }
563
+ destroy() {
564
+ super.destroy();
565
+ this.visibilityCleanup?.();
566
+ this.trackedFlags.clear();
567
+ }
568
+ getOrCreateAnonId() {
569
+ if (typeof localStorage === "undefined") {
570
+ return null;
571
+ }
572
+ try {
573
+ let id = localStorage.getItem(ANON_ID_KEY);
574
+ if (id) {
575
+ return id;
576
+ }
577
+ id = `anon_${crypto.randomUUID()}`;
578
+ localStorage.setItem(ANON_ID_KEY, id);
579
+ return id;
580
+ } catch {
581
+ return null;
582
+ }
583
+ }
584
+ setupVisibilityListener() {
585
+ if (typeof document === "undefined") {
586
+ return;
587
+ }
588
+ const handler = () => {
589
+ this.isVisible = document.visibilityState === "visible";
590
+ if (this.isVisible) {
591
+ this.revalidateStale();
592
+ }
593
+ };
594
+ document.addEventListener("visibilitychange", handler);
595
+ this.visibilityCleanup = () => {
596
+ document.removeEventListener("visibilitychange", handler);
597
+ };
598
+ }
599
+ }
600
+
601
+ export { BrowserFlagsManager as B, BaseFlagsManager as a, logger as l };
@@ -0,0 +1,81 @@
1
+ interface FlagResult {
2
+ enabled: boolean;
3
+ value: boolean | string | number;
4
+ payload: Record<string, unknown> | null;
5
+ reason: string;
6
+ variant?: string;
7
+ }
8
+ interface UserContext {
9
+ userId?: string;
10
+ email?: string;
11
+ organizationId?: string;
12
+ teamId?: string;
13
+ properties?: Record<string, unknown>;
14
+ }
15
+ interface FlagsConfig {
16
+ clientId: string;
17
+ apiUrl?: string;
18
+ user?: UserContext;
19
+ disabled?: boolean;
20
+ debug?: boolean;
21
+ /** Skip persistent storage (browser only) */
22
+ skipStorage?: boolean;
23
+ /** Defer evaluation until session resolves */
24
+ isPending?: boolean;
25
+ /** Auto-fetch all flags on init (default: true) */
26
+ autoFetch?: boolean;
27
+ environment?: string;
28
+ /** Cache TTL in ms (default: 60000) */
29
+ cacheTtl?: number;
30
+ /** Stale time in ms — revalidate in background after this (default: cacheTtl/2) */
31
+ staleTime?: number;
32
+ /** Default values by flag key */
33
+ defaults?: Record<string, boolean | string | number>;
34
+ }
35
+ type FlagStatus = "loading" | "ready" | "error" | "pending";
36
+ interface FlagState {
37
+ on: boolean;
38
+ status: FlagStatus;
39
+ loading: boolean;
40
+ value?: boolean | string | number;
41
+ variant?: string;
42
+ }
43
+ interface FlagsContext {
44
+ getFlag: (key: string) => FlagState;
45
+ getValue: <T extends boolean | string | number = boolean>(key: string, defaultValue?: T) => T;
46
+ isOn: (key: string) => boolean;
47
+ fetchFlag: (key: string) => Promise<FlagResult>;
48
+ fetchAllFlags: () => Promise<void>;
49
+ updateUser: (user: UserContext) => void;
50
+ refresh: (forceClear?: boolean) => Promise<void>;
51
+ isReady: boolean;
52
+ }
53
+ interface FlagsSnapshot {
54
+ flags: Record<string, FlagResult>;
55
+ isReady: boolean;
56
+ }
57
+ interface StorageInterface {
58
+ getAll(): Record<string, FlagResult>;
59
+ setAll(flags: Record<string, FlagResult>): void;
60
+ clear(): void;
61
+ }
62
+ interface FlagsManagerOptions {
63
+ config: FlagsConfig;
64
+ storage?: StorageInterface;
65
+ }
66
+ interface FlagsManager {
67
+ getFlag(key: string, user?: UserContext): Promise<FlagResult>;
68
+ isEnabled(key: string): FlagState;
69
+ getValue<T = boolean | string | number>(key: string, defaultValue?: T): T;
70
+ fetchAllFlags(user?: UserContext): Promise<void>;
71
+ updateUser(user: UserContext): void;
72
+ refresh(forceClear?: boolean): Promise<void>;
73
+ updateConfig(config: FlagsConfig): void;
74
+ getMemoryFlags(): Record<string, FlagResult>;
75
+ isReady(): boolean;
76
+ destroy(): void;
77
+ subscribe(callback: () => void): () => void;
78
+ getSnapshot(): FlagsSnapshot;
79
+ }
80
+
81
+ export type { FlagsConfig as F, StorageInterface as S, UserContext as U, FlagState as a, FlagsContext as b, FlagResult as c, FlagsManager as d, FlagsManagerOptions as e, FlagsSnapshot as f };