@bbearai/core 0.5.4 → 0.7.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 CHANGED
@@ -22,13 +22,24 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  BUG_CATEGORIES: () => BUG_CATEGORIES,
24
24
  BugBearClient: () => BugBearClient,
25
+ DedupWindow: () => DedupWindow,
26
+ ErrorMonitor: () => ErrorMonitor,
25
27
  LocalStorageAdapter: () => LocalStorageAdapter,
26
28
  OfflineQueue: () => OfflineQueue,
29
+ RNApiFailureHandler: () => RNApiFailureHandler,
30
+ RNCrashHandler: () => RNCrashHandler,
31
+ RNRageClickHandler: () => RNRageClickHandler,
32
+ RageClickDetector: () => RageClickDetector,
33
+ WebApiFailureHandler: () => WebApiFailureHandler,
34
+ WebCrashHandler: () => WebCrashHandler,
35
+ WebRageClickHandler: () => WebRageClickHandler,
27
36
  captureError: () => captureError,
28
37
  contextCapture: () => contextCapture,
29
38
  createBugBear: () => createBugBear,
39
+ generateFingerprint: () => generateFingerprint,
30
40
  isBugCategory: () => isBugCategory,
31
- isNetworkError: () => isNetworkError
41
+ isNetworkError: () => isNetworkError,
42
+ scrubUrl: () => scrubUrl
32
43
  });
33
44
  module.exports = __toCommonJS(index_exports);
34
45
 
@@ -52,6 +63,7 @@ var ContextCaptureManager = class {
52
63
  this.networkRequests = [];
53
64
  this.navigationHistory = [];
54
65
  this.originalConsole = {};
66
+ this.fetchHost = null;
55
67
  this.isCapturing = false;
56
68
  }
57
69
  /**
@@ -74,8 +86,9 @@ var ContextCaptureManager = class {
74
86
  if (this.originalConsole.warn) console.warn = this.originalConsole.warn;
75
87
  if (this.originalConsole.error) console.error = this.originalConsole.error;
76
88
  if (this.originalConsole.info) console.info = this.originalConsole.info;
77
- if (this.originalFetch && typeof window !== "undefined") {
78
- window.fetch = this.originalFetch;
89
+ if (this.originalFetch && this.fetchHost) {
90
+ this.fetchHost.fetch = this.originalFetch;
91
+ this.fetchHost = null;
79
92
  }
80
93
  if (typeof window !== "undefined" && typeof history !== "undefined") {
81
94
  if (this.originalPushState) {
@@ -184,15 +197,19 @@ var ContextCaptureManager = class {
184
197
  });
185
198
  }
186
199
  captureFetch() {
187
- if (typeof window === "undefined" || typeof fetch === "undefined" || typeof document === "undefined") return;
188
- this.originalFetch = window.fetch;
200
+ if (typeof fetch === "undefined") return;
201
+ const host = typeof window !== "undefined" && typeof window.fetch === "function" ? window : typeof globalThis !== "undefined" && typeof globalThis.fetch === "function" ? globalThis : null;
202
+ if (!host) return;
203
+ const canCloneResponse = typeof document !== "undefined";
204
+ this.fetchHost = host;
205
+ this.originalFetch = host.fetch;
189
206
  const self = this;
190
- window.fetch = async function(input, init) {
207
+ host.fetch = async function(input, init) {
191
208
  const startTime = Date.now();
192
209
  const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
193
210
  const method = init?.method || "GET";
194
211
  try {
195
- const response = await self.originalFetch.call(window, input, init);
212
+ const response = await self.originalFetch.call(host, input, init);
196
213
  const requestEntry = {
197
214
  method,
198
215
  url: url.slice(0, 200),
@@ -201,7 +218,7 @@ var ContextCaptureManager = class {
201
218
  duration: Date.now() - startTime,
202
219
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
203
220
  };
204
- if (response.status >= 400) {
221
+ if (canCloneResponse && response.status >= 400) {
205
222
  try {
206
223
  const cloned = response.clone();
207
224
  const body = await cloned.text();
@@ -443,12 +460,442 @@ function isNetworkError(error) {
443
460
  msg.includes("the internet connection appears to be offline") || msg.includes("a]server with the specified hostname could not be found");
444
461
  }
445
462
 
463
+ // src/monitoring/fingerprint.ts
464
+ function generateFingerprint(source, message, route) {
465
+ const input = `${source}|${message}|${route}`;
466
+ let hash = 5381;
467
+ for (let i = 0; i < input.length; i++) {
468
+ hash = (hash << 5) + hash + input.charCodeAt(i) & 4294967295;
469
+ }
470
+ return `bb_${(hash >>> 0).toString(36)}`;
471
+ }
472
+ var DedupWindow = class {
473
+ constructor(windowMs) {
474
+ this.windowMs = windowMs;
475
+ this.seen = /* @__PURE__ */ new Map();
476
+ }
477
+ /** Returns true if this fingerprint should be reported (not a recent duplicate). */
478
+ shouldReport(fingerprint) {
479
+ const now = Date.now();
480
+ const lastSeen = this.seen.get(fingerprint);
481
+ if (lastSeen !== void 0 && now - lastSeen < this.windowMs) {
482
+ return false;
483
+ }
484
+ this.seen.set(fingerprint, now);
485
+ this.cleanup(now);
486
+ return true;
487
+ }
488
+ /** Remove expired entries to prevent memory leaks in long sessions. */
489
+ cleanup(now) {
490
+ if (this.seen.size > 100) {
491
+ for (const [fp, ts] of this.seen) {
492
+ if (now - ts >= this.windowMs) {
493
+ this.seen.delete(fp);
494
+ }
495
+ }
496
+ }
497
+ }
498
+ };
499
+
500
+ // src/monitoring/scrub.ts
501
+ var SENSITIVE_PARAMS = /^(token|key|password|secret|auth|credential|api_key|access_token|secret_key|apikey|session)$/i;
502
+ function scrubUrl(url) {
503
+ try {
504
+ const parsed = new URL(url);
505
+ let changed = false;
506
+ for (const [key] of parsed.searchParams) {
507
+ if (SENSITIVE_PARAMS.test(key)) {
508
+ parsed.searchParams.set(key, "[REDACTED]");
509
+ changed = true;
510
+ }
511
+ }
512
+ return changed ? parsed.toString() : url;
513
+ } catch {
514
+ return url;
515
+ }
516
+ }
517
+
518
+ // src/monitoring/error-monitor.ts
519
+ var SOURCE_TO_REPORT_SOURCE = {
520
+ crash: "auto_crash",
521
+ api_failure: "auto_api",
522
+ rage_click: "auto_rage_click"
523
+ };
524
+ var SEVERITY_ORDER = ["low", "medium", "high", "critical"];
525
+ var ErrorMonitor = class {
526
+ constructor(config, deps) {
527
+ this.deps = deps;
528
+ this.sessionReportCount = 0;
529
+ this.pendingSentryData = null;
530
+ this.config = {
531
+ sampleRate: 1,
532
+ maxReportsPerSession: 10,
533
+ dedupWindowMs: 3e5,
534
+ severityThreshold: "low",
535
+ scrubUrls: true,
536
+ ...config
537
+ };
538
+ this.dedup = new DedupWindow(this.config.dedupWindowMs);
539
+ }
540
+ async handleEvent(event) {
541
+ if (!this.dedup.shouldReport(event.fingerprint)) return;
542
+ if (Math.random() >= this.config.sampleRate) return;
543
+ if (this.sessionReportCount >= this.config.maxReportsPerSession) return;
544
+ const severity = this.getSeverity(event);
545
+ if (SEVERITY_ORDER.indexOf(severity) < SEVERITY_ORDER.indexOf(this.config.severityThreshold)) return;
546
+ if (this.config.beforeCapture && !this.config.beforeCapture(event)) return;
547
+ const scrubbedUrl = this.config.scrubUrls && event.requestUrl ? scrubUrl(event.requestUrl) : event.requestUrl;
548
+ if (this.pendingSentryData) {
549
+ event.sentryEventId = this.pendingSentryData.sentryEventId;
550
+ event.sentryBreadcrumbs = this.pendingSentryData.sentryBreadcrumbs;
551
+ this.pendingSentryData = null;
552
+ }
553
+ this.sessionReportCount++;
554
+ await this.deps.submitReport({
555
+ type: "bug",
556
+ reportSource: SOURCE_TO_REPORT_SOURCE[event.source],
557
+ title: this.generateTitle(event),
558
+ description: event.message,
559
+ severity,
560
+ category: event.source === "crash" ? "crash" : "functional",
561
+ appContext: {
562
+ currentRoute: event.route,
563
+ errorMessage: event.error?.message,
564
+ errorStack: event.error?.stack,
565
+ custom: {
566
+ monitoringSource: event.source,
567
+ fingerprint: event.fingerprint,
568
+ ...event.statusCode ? { statusCode: event.statusCode } : {},
569
+ ...scrubbedUrl ? { requestUrl: scrubbedUrl } : {},
570
+ ...event.requestMethod ? { requestMethod: event.requestMethod } : {},
571
+ ...event.clickCount ? { clickCount: event.clickCount } : {},
572
+ ...event.targetSelector ? { targetSelector: event.targetSelector } : {},
573
+ ...event.sentryEventId ? { sentryEventId: event.sentryEventId } : {}
574
+ }
575
+ },
576
+ enhancedContext: this.deps.getEnhancedContext(),
577
+ deviceInfo: this.deps.getDeviceInfo()
578
+ });
579
+ }
580
+ enrichWithSentry(data) {
581
+ this.pendingSentryData = data;
582
+ }
583
+ getSeverity(event) {
584
+ if (event.source === "crash") return "critical";
585
+ if (event.source === "api_failure") return event.statusCode && event.statusCode >= 500 ? "high" : "medium";
586
+ return "medium";
587
+ }
588
+ generateTitle(event) {
589
+ switch (event.source) {
590
+ case "crash":
591
+ return `[Auto] Crash on ${event.route}: ${event.message.slice(0, 80)}`;
592
+ case "api_failure":
593
+ return `[Auto] ${event.requestMethod || "API"} ${event.statusCode || "error"} on ${event.route}`;
594
+ case "rage_click":
595
+ return `[Auto] Rage click on ${event.route} (${event.targetSelector || "unknown"})`;
596
+ }
597
+ }
598
+ destroy() {
599
+ this.sessionReportCount = 0;
600
+ this.pendingSentryData = null;
601
+ }
602
+ };
603
+
604
+ // src/monitoring/rage-click-detector.ts
605
+ var RageClickDetector = class {
606
+ constructor(onRageClick) {
607
+ this.onRageClick = onRageClick;
608
+ this.clicks = [];
609
+ this.threshold = 3;
610
+ this.windowMs = 2e3;
611
+ this.destroyed = false;
612
+ }
613
+ recordClick(x, y, targetSelector) {
614
+ if (this.destroyed) return;
615
+ const now = Date.now();
616
+ this.clicks = this.clicks.filter((c) => now - c.time < this.windowMs);
617
+ this.clicks.push({ time: now, target: targetSelector, x, y });
618
+ const sameTarget = this.clicks.filter((c) => c.target === targetSelector);
619
+ if (sameTarget.length >= this.threshold) {
620
+ this.onRageClick({
621
+ clickCount: sameTarget.length,
622
+ targetSelector,
623
+ x,
624
+ y
625
+ });
626
+ this.clicks = [];
627
+ }
628
+ }
629
+ destroy() {
630
+ this.destroyed = true;
631
+ this.clicks = [];
632
+ }
633
+ };
634
+
635
+ // src/monitoring/web-handlers.ts
636
+ function getSelector(el) {
637
+ if (el.id) return `#${el.id}`;
638
+ const tag = el.tagName.toLowerCase();
639
+ const classes = Array.from(el.classList).slice(0, 2);
640
+ return classes.length > 0 ? `${tag}.${classes.join(".")}` : tag;
641
+ }
642
+ function currentRoute() {
643
+ try {
644
+ return typeof window !== "undefined" && window.location ? window.location.pathname : "unknown";
645
+ } catch {
646
+ return "unknown";
647
+ }
648
+ }
649
+ var WebCrashHandler = class {
650
+ constructor(onEvent) {
651
+ this.onEvent = onEvent;
652
+ this.prevOnError = null;
653
+ this.rejectionHandler = null;
654
+ }
655
+ start() {
656
+ this.prevOnError = window.onerror;
657
+ window.onerror = (message, source, lineno, colno, error) => {
658
+ const msg = typeof message === "string" ? message : "Unknown error";
659
+ const route = currentRoute();
660
+ this.onEvent({
661
+ source: "crash",
662
+ fingerprint: generateFingerprint("crash", msg, route),
663
+ message: msg,
664
+ route,
665
+ timestamp: Date.now(),
666
+ error: error ?? new Error(msg)
667
+ });
668
+ if (typeof this.prevOnError === "function") {
669
+ return this.prevOnError(message, source, lineno, colno, error);
670
+ }
671
+ return false;
672
+ };
673
+ this.rejectionHandler = (e) => {
674
+ const reason = e.reason;
675
+ const error = reason instanceof Error ? reason : new Error(String(reason));
676
+ const msg = error.message || "Unhandled promise rejection";
677
+ const route = currentRoute();
678
+ this.onEvent({
679
+ source: "crash",
680
+ fingerprint: generateFingerprint("crash", msg, route),
681
+ message: msg,
682
+ route,
683
+ timestamp: Date.now(),
684
+ error
685
+ });
686
+ };
687
+ window.addEventListener("unhandledrejection", this.rejectionHandler);
688
+ }
689
+ destroy() {
690
+ window.onerror = this.prevOnError;
691
+ if (this.rejectionHandler) {
692
+ window.removeEventListener("unhandledrejection", this.rejectionHandler);
693
+ this.rejectionHandler = null;
694
+ }
695
+ }
696
+ };
697
+ var WebApiFailureHandler = class {
698
+ constructor(onEvent) {
699
+ this.onEvent = onEvent;
700
+ this.originalFetch = null;
701
+ }
702
+ start() {
703
+ this.originalFetch = window.fetch;
704
+ const original = this.originalFetch;
705
+ const onEvent = this.onEvent;
706
+ window.fetch = async function patchedFetch(input, init) {
707
+ const response = await original.call(window, input, init);
708
+ if (response.status >= 400) {
709
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
710
+ const method = (init?.method ?? "GET").toUpperCase();
711
+ const route = currentRoute();
712
+ const msg = `${method} ${url} failed with ${response.status}`;
713
+ onEvent({
714
+ source: "api_failure",
715
+ fingerprint: generateFingerprint("api_failure", msg, route),
716
+ message: msg,
717
+ route,
718
+ timestamp: Date.now(),
719
+ requestUrl: url,
720
+ requestMethod: method,
721
+ statusCode: response.status
722
+ });
723
+ }
724
+ return response;
725
+ };
726
+ }
727
+ destroy() {
728
+ if (this.originalFetch) {
729
+ window.fetch = this.originalFetch;
730
+ this.originalFetch = null;
731
+ }
732
+ }
733
+ };
734
+ var WebRageClickHandler = class {
735
+ constructor(onEvent) {
736
+ this.onEvent = onEvent;
737
+ this.clickHandler = null;
738
+ this.detector = new RageClickDetector((rageEvent) => {
739
+ const route = currentRoute();
740
+ this.onEvent({
741
+ source: "rage_click",
742
+ fingerprint: generateFingerprint("rage_click", rageEvent.targetSelector, route),
743
+ message: `Rage click detected on ${rageEvent.targetSelector}`,
744
+ route,
745
+ timestamp: Date.now(),
746
+ clickCount: rageEvent.clickCount,
747
+ targetSelector: rageEvent.targetSelector
748
+ });
749
+ });
750
+ }
751
+ start() {
752
+ this.clickHandler = (e) => {
753
+ const target = e.target;
754
+ if (!(target instanceof Element)) return;
755
+ const selector = getSelector(target);
756
+ this.detector.recordClick(e.clientX, e.clientY, selector);
757
+ };
758
+ document.addEventListener("click", this.clickHandler, true);
759
+ }
760
+ destroy() {
761
+ if (this.clickHandler) {
762
+ document.removeEventListener("click", this.clickHandler, true);
763
+ this.clickHandler = null;
764
+ }
765
+ this.detector.destroy();
766
+ }
767
+ };
768
+
769
+ // src/monitoring/rn-handlers.ts
770
+ var RNCrashHandler = class {
771
+ constructor(onEvent, getCurrentRoute) {
772
+ this.onEvent = onEvent;
773
+ this.getCurrentRoute = getCurrentRoute;
774
+ this.originalHandler = null;
775
+ this.started = false;
776
+ }
777
+ start() {
778
+ if (this.started) return;
779
+ this.started = true;
780
+ const errorUtils = globalThis.ErrorUtils;
781
+ if (!errorUtils) return;
782
+ this.originalHandler = errorUtils.getGlobalHandler();
783
+ const self = this;
784
+ errorUtils.setGlobalHandler((error, isFatal) => {
785
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
786
+ const route = self.getCurrentRoute();
787
+ self.onEvent({
788
+ source: "crash",
789
+ fingerprint: generateFingerprint("crash", normalizedError.message, route),
790
+ message: normalizedError.message,
791
+ route,
792
+ timestamp: Date.now(),
793
+ error: normalizedError
794
+ });
795
+ if (self.originalHandler) {
796
+ self.originalHandler(error, isFatal);
797
+ }
798
+ });
799
+ }
800
+ destroy() {
801
+ if (!this.started) return;
802
+ this.started = false;
803
+ const errorUtils = globalThis.ErrorUtils;
804
+ if (errorUtils && this.originalHandler) {
805
+ errorUtils.setGlobalHandler(this.originalHandler);
806
+ }
807
+ this.originalHandler = null;
808
+ }
809
+ };
810
+ var RNApiFailureHandler = class {
811
+ constructor(onEvent, getCurrentRoute) {
812
+ this.onEvent = onEvent;
813
+ this.getCurrentRoute = getCurrentRoute;
814
+ this.originalFetch = null;
815
+ this.started = false;
816
+ }
817
+ start() {
818
+ if (this.started) return;
819
+ this.started = true;
820
+ this.originalFetch = globalThis.fetch;
821
+ const self = this;
822
+ globalThis.fetch = async function patchedFetch(input, init) {
823
+ const response = await self.originalFetch.call(globalThis, input, init);
824
+ if (response.status >= 400) {
825
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
826
+ const method = init?.method ?? "GET";
827
+ const route = self.getCurrentRoute();
828
+ self.onEvent({
829
+ source: "api_failure",
830
+ fingerprint: generateFingerprint("api_failure", `${method} ${response.status}`, route),
831
+ message: `${method} ${url} failed with ${response.status}`,
832
+ route,
833
+ timestamp: Date.now(),
834
+ requestUrl: url,
835
+ requestMethod: method,
836
+ statusCode: response.status
837
+ });
838
+ }
839
+ return response;
840
+ };
841
+ }
842
+ destroy() {
843
+ if (!this.started) return;
844
+ this.started = false;
845
+ if (this.originalFetch) {
846
+ globalThis.fetch = this.originalFetch;
847
+ this.originalFetch = null;
848
+ }
849
+ }
850
+ };
851
+ var RNRageClickHandler = class {
852
+ constructor(onEvent, getCurrentRoute) {
853
+ this.onEvent = onEvent;
854
+ this.getCurrentRoute = getCurrentRoute;
855
+ this.detector = null;
856
+ }
857
+ start() {
858
+ if (this.detector) return;
859
+ const self = this;
860
+ this.detector = new RageClickDetector((rageEvent) => {
861
+ const route = self.getCurrentRoute();
862
+ self.onEvent({
863
+ source: "rage_click",
864
+ fingerprint: generateFingerprint("rage_click", rageEvent.targetSelector, route),
865
+ message: `Rage click detected on ${rageEvent.targetSelector}`,
866
+ route,
867
+ timestamp: Date.now(),
868
+ clickCount: rageEvent.clickCount,
869
+ targetSelector: rageEvent.targetSelector
870
+ });
871
+ });
872
+ }
873
+ /**
874
+ * Record a touch event. Call this from the BugBearProvider's onTouchEnd handler.
875
+ * @param x - Touch x coordinate
876
+ * @param y - Touch y coordinate
877
+ * @param viewId - Identifier for the touched view (e.g., testID, accessibilityLabel, or nativeID)
878
+ */
879
+ recordTouch(x, y, viewId) {
880
+ this.detector?.recordClick(x, y, viewId);
881
+ }
882
+ destroy() {
883
+ if (this.detector) {
884
+ this.detector.destroy();
885
+ this.detector = null;
886
+ }
887
+ }
888
+ };
889
+
446
890
  // src/client.ts
447
891
  var formatPgError = (e) => {
448
892
  if (!e || typeof e !== "object") return { raw: e };
449
893
  const { message, code, details, hint } = e;
450
894
  return { message, code, details, hint };
451
895
  };
896
+ var DEFAULT_API_BASE_URL = "https://app.bugbear.ai";
897
+ var CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
898
+ var CONFIG_CACHE_PREFIX = "bugbear_config_";
452
899
  var BugBearClient = class {
453
900
  constructor(config) {
454
901
  this.navigationHistory = [];
@@ -458,23 +905,148 @@ var BugBearClient = class {
458
905
  /** Active Realtime channel references for cleanup. */
459
906
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
460
907
  this.realtimeChannels = [];
461
- if (!config.supabaseUrl) {
462
- throw new Error("BugBear: supabaseUrl is required. Get it from your BugBear project settings.");
463
- }
464
- if (!config.supabaseAnonKey) {
465
- throw new Error("BugBear: supabaseAnonKey is required. Get it from your BugBear project settings.");
466
- }
908
+ /** Error monitor instance — created when config.monitoring is present. */
909
+ this.monitor = null;
910
+ /** Whether the client has been successfully initialized. */
911
+ this.initialized = false;
912
+ /** Initialization error, if any. */
913
+ this.initError = null;
467
914
  this.config = config;
468
- this.supabase = (0, import_supabase_js.createClient)(config.supabaseUrl, config.supabaseAnonKey);
469
- if (config.offlineQueue?.enabled) {
915
+ if (config.apiKey) {
916
+ this.pendingInit = this.resolveFromApiKey(config.apiKey);
917
+ } else if (config.supabaseUrl && config.supabaseAnonKey) {
918
+ if (!config.projectId) {
919
+ throw new Error(
920
+ "BugBear: projectId is required when using explicit Supabase credentials. Tip: Use apiKey instead for simpler setup \u2014 it resolves everything automatically."
921
+ );
922
+ }
923
+ this.supabase = (0, import_supabase_js.createClient)(config.supabaseUrl, config.supabaseAnonKey);
924
+ this.initialized = true;
925
+ this.pendingInit = Promise.resolve();
926
+ this.initOfflineQueue();
927
+ this.initMonitoring();
928
+ } else {
929
+ throw new Error(
930
+ "BugBear: Missing configuration. Provide either:\n \u2022 apiKey (recommended) \u2014 resolves everything automatically\n \u2022 projectId + supabaseUrl + supabaseAnonKey \u2014 explicit credentials\n\nGet your API key at https://app.bugbear.ai/settings/projects"
931
+ );
932
+ }
933
+ }
934
+ /** Whether the client is ready for requests. */
935
+ get isReady() {
936
+ return this.initialized;
937
+ }
938
+ /** Wait until the client is ready. Throws if initialization failed. */
939
+ async ready() {
940
+ await this.pendingInit;
941
+ if (this.initError) throw this.initError;
942
+ }
943
+ /**
944
+ * Resolve Supabase credentials from a BugBear API key.
945
+ * Checks localStorage cache first, falls back to /api/v1/config.
946
+ */
947
+ async resolveFromApiKey(apiKey) {
948
+ try {
949
+ const cached = this.readConfigCache(apiKey);
950
+ if (cached) {
951
+ this.applyResolvedConfig(cached);
952
+ return;
953
+ }
954
+ const baseUrl = (this.config.apiBaseUrl || DEFAULT_API_BASE_URL).replace(/\/$/, "");
955
+ const response = await fetch(`${baseUrl}/api/v1/config`, {
956
+ headers: { Authorization: `Bearer ${apiKey}` }
957
+ });
958
+ if (!response.ok) {
959
+ const body = await response.json().catch(() => ({}));
960
+ const message = body.error || `HTTP ${response.status}`;
961
+ throw new Error(
962
+ `BugBear: Invalid API key \u2014 ${message}. Get yours at https://app.bugbear.ai/settings/projects`
963
+ );
964
+ }
965
+ const data = await response.json();
966
+ this.writeConfigCache(apiKey, data);
967
+ this.applyResolvedConfig(data);
968
+ } catch (err) {
969
+ this.initError = err instanceof Error ? err : new Error(String(err));
970
+ this.config.onError?.(this.initError, { context: "apikey_resolution_failed" });
971
+ throw this.initError;
972
+ }
973
+ }
974
+ /** Apply resolved credentials and create the Supabase client. */
975
+ applyResolvedConfig(resolved) {
976
+ this.config = {
977
+ ...this.config,
978
+ projectId: resolved.projectId,
979
+ supabaseUrl: resolved.supabaseUrl,
980
+ supabaseAnonKey: resolved.supabaseAnonKey
981
+ };
982
+ this.supabase = (0, import_supabase_js.createClient)(resolved.supabaseUrl, resolved.supabaseAnonKey);
983
+ this.initialized = true;
984
+ this.initOfflineQueue();
985
+ this.initMonitoring();
986
+ }
987
+ /** Initialize offline queue if configured. Shared by both init paths. */
988
+ initOfflineQueue() {
989
+ if (this.config.offlineQueue?.enabled) {
470
990
  this._queue = new OfflineQueue({
471
991
  enabled: true,
472
- maxItems: config.offlineQueue.maxItems,
473
- maxRetries: config.offlineQueue.maxRetries
992
+ maxItems: this.config.offlineQueue.maxItems,
993
+ maxRetries: this.config.offlineQueue.maxRetries
474
994
  });
475
995
  this.registerQueueHandlers();
476
996
  }
477
997
  }
998
+ /** Initialize error monitoring if configured. Shared by both init paths. */
999
+ initMonitoring() {
1000
+ const mc = this.config.monitoring;
1001
+ if (!mc || !mc.crashes && !mc.apiFailures && !mc.rageClicks) return;
1002
+ this.monitor = new ErrorMonitor(mc, {
1003
+ submitReport: (report) => this.submitReport(report),
1004
+ getCurrentRoute: () => contextCapture.getCurrentRoute(),
1005
+ getEnhancedContext: () => contextCapture.getEnhancedContext(),
1006
+ getDeviceInfo: () => this.getDeviceInfo()
1007
+ });
1008
+ }
1009
+ /** Read cached config from localStorage if available and not expired. */
1010
+ readConfigCache(apiKey) {
1011
+ if (typeof localStorage === "undefined") return null;
1012
+ try {
1013
+ const key = CONFIG_CACHE_PREFIX + this.hashKey(apiKey);
1014
+ const raw = localStorage.getItem(key);
1015
+ if (!raw) return null;
1016
+ const cached = JSON.parse(raw);
1017
+ if (Date.now() - cached.cachedAt > CONFIG_CACHE_TTL_MS) {
1018
+ localStorage.removeItem(key);
1019
+ return null;
1020
+ }
1021
+ return cached;
1022
+ } catch {
1023
+ return null;
1024
+ }
1025
+ }
1026
+ /** Write resolved config to localStorage cache. */
1027
+ writeConfigCache(apiKey, data) {
1028
+ if (typeof localStorage === "undefined") return;
1029
+ try {
1030
+ const key = CONFIG_CACHE_PREFIX + this.hashKey(apiKey);
1031
+ const cached = { ...data, cachedAt: Date.now() };
1032
+ localStorage.setItem(key, JSON.stringify(cached));
1033
+ } catch {
1034
+ }
1035
+ }
1036
+ /** Simple string hash for cache keys — avoids storing raw API keys. */
1037
+ hashKey(apiKey) {
1038
+ let hash = 0;
1039
+ for (let i = 0; i < apiKey.length; i++) {
1040
+ hash = (hash << 5) - hash + apiKey.charCodeAt(i) | 0;
1041
+ }
1042
+ return hash.toString(36);
1043
+ }
1044
+ /** Ensure the client is initialized before making requests. */
1045
+ async ensureReady() {
1046
+ if (this.initialized) return;
1047
+ await this.pendingInit;
1048
+ if (this.initError) throw this.initError;
1049
+ }
478
1050
  // ── Offline Queue ─────────────────────────────────────────
479
1051
  /**
480
1052
  * Access the offline queue (if enabled).
@@ -630,6 +1202,7 @@ var BugBearClient = class {
630
1202
  * Get current user info from host app or BugBear's own auth
631
1203
  */
632
1204
  async getCurrentUserInfo() {
1205
+ await this.ensureReady();
633
1206
  if (this.config.getCurrentUser) {
634
1207
  return await this.config.getCurrentUser();
635
1208
  }
@@ -688,7 +1261,9 @@ var BugBearClient = class {
688
1261
  navigation_history: this.getNavigationHistory(),
689
1262
  enhanced_context: report.enhancedContext || contextCapture.getEnhancedContext(),
690
1263
  assignment_id: report.assignmentId,
691
- test_case_id: report.testCaseId
1264
+ test_case_id: report.testCaseId,
1265
+ report_source: report.reportSource || "manual",
1266
+ error_fingerprint: report.appContext?.custom?.fingerprint || null
692
1267
  };
693
1268
  const { data, error } = await this.supabase.from("reports").insert(fullReport).select("id").single();
694
1269
  if (error) {
@@ -770,7 +1345,7 @@ var BugBearClient = class {
770
1345
  `;
771
1346
  const [pendingResult, completedResult] = await Promise.all([
772
1347
  this.supabase.from("test_assignments").select(selectFields).eq("project_id", this.config.projectId).eq("tester_id", testerInfo.id).in("status", ["pending", "in_progress"]).order("created_at", { ascending: true }).range(from, to),
773
- this.supabase.from("test_assignments").select(selectFields).eq("project_id", this.config.projectId).eq("tester_id", testerInfo.id).in("status", ["passed", "failed", "skipped", "blocked"]).order("completed_at", { ascending: false }).limit(100)
1348
+ this.supabase.from("test_assignments").select(selectFields).eq("project_id", this.config.projectId).eq("tester_id", testerInfo.id).in("status", ["passed", "failed", "skipped", "blocked"]).or("is_verification.eq.false,is_verification.is.null").order("completed_at", { ascending: false }).limit(100)
774
1349
  ]);
775
1350
  if (pendingResult.error) {
776
1351
  console.error("BugBear: Failed to fetch assignments", formatPgError(pendingResult.error));
@@ -849,6 +1424,7 @@ var BugBearClient = class {
849
1424
  */
850
1425
  async getAssignment(assignmentId) {
851
1426
  try {
1427
+ await this.ensureReady();
852
1428
  const { data, error } = await this.supabase.from("test_assignments").select(`
853
1429
  id,
854
1430
  status,
@@ -920,6 +1496,7 @@ var BugBearClient = class {
920
1496
  */
921
1497
  async updateAssignmentStatus(assignmentId, status, options) {
922
1498
  try {
1499
+ await this.ensureReady();
923
1500
  const { data: currentAssignment, error: fetchError } = await this.supabase.from("test_assignments").select("status, started_at").eq("id", assignmentId).single();
924
1501
  if (fetchError || !currentAssignment) {
925
1502
  console.error("BugBear: Assignment not found", {
@@ -1006,6 +1583,7 @@ var BugBearClient = class {
1006
1583
  */
1007
1584
  async reopenAssignment(assignmentId) {
1008
1585
  try {
1586
+ await this.ensureReady();
1009
1587
  const { data: current, error: fetchError } = await this.supabase.from("test_assignments").select("status").eq("id", assignmentId).single();
1010
1588
  if (fetchError || !current) {
1011
1589
  return { success: false, error: "Assignment not found" };
@@ -1045,6 +1623,7 @@ var BugBearClient = class {
1045
1623
  actualNotes = notes;
1046
1624
  }
1047
1625
  try {
1626
+ await this.ensureReady();
1048
1627
  const updateData = {
1049
1628
  status: "skipped",
1050
1629
  skip_reason: actualReason,
@@ -1214,6 +1793,7 @@ var BugBearClient = class {
1214
1793
  */
1215
1794
  async getTesterInfo() {
1216
1795
  try {
1796
+ await this.ensureReady();
1217
1797
  const userInfo = await this.getCurrentUserInfo();
1218
1798
  if (!userInfo?.email) return null;
1219
1799
  if (!this.isValidEmail(userInfo.email)) {
@@ -1234,7 +1814,8 @@ var BugBearClient = class {
1234
1814
  avatarUrl: tester.avatar_url || void 0,
1235
1815
  platforms: tester.platforms || [],
1236
1816
  assignedTests: tester.assigned_count || 0,
1237
- completedTests: tester.completed_count || 0
1817
+ completedTests: tester.completed_count || 0,
1818
+ role: tester.role || "tester"
1238
1819
  };
1239
1820
  } catch (err) {
1240
1821
  console.error("BugBear: getTesterInfo error", err);
@@ -1505,6 +2086,7 @@ var BugBearClient = class {
1505
2086
  */
1506
2087
  async isQAEnabled() {
1507
2088
  try {
2089
+ await this.ensureReady();
1508
2090
  const { data, error } = await this.supabase.rpc("check_qa_enabled", {
1509
2091
  p_project_id: this.config.projectId
1510
2092
  });
@@ -1521,21 +2103,85 @@ var BugBearClient = class {
1521
2103
  }
1522
2104
  }
1523
2105
  /**
1524
- * Check if the widget should be visible
1525
- * (QA mode enabled AND current user is a tester)
2106
+ * Check if the widget should be visible.
2107
+ * Behavior depends on the configured mode:
2108
+ * - 'qa': QA enabled AND user is a registered tester
2109
+ * - 'feedback': Any authenticated user
2110
+ * - 'auto': Either QA tester OR authenticated non-tester
1526
2111
  */
1527
2112
  async shouldShowWidget() {
1528
- const [qaEnabled, isTester] = await Promise.all([
2113
+ const mode = this.config.mode || "qa";
2114
+ if (mode === "qa") {
2115
+ const [qaEnabled2, tester] = await Promise.all([
2116
+ this.isQAEnabled(),
2117
+ this.isTester()
2118
+ ]);
2119
+ return qaEnabled2 && tester;
2120
+ }
2121
+ if (mode === "feedback") {
2122
+ const userInfo2 = await this.getCurrentUserInfo();
2123
+ return userInfo2 !== null;
2124
+ }
2125
+ const [qaEnabled, testerInfo, userInfo] = await Promise.all([
2126
+ this.isQAEnabled(),
2127
+ this.getTesterInfo(),
2128
+ this.getCurrentUserInfo()
2129
+ ]);
2130
+ if (qaEnabled && testerInfo && testerInfo.role !== "feedback") return true;
2131
+ if (userInfo) return true;
2132
+ return false;
2133
+ }
2134
+ /**
2135
+ * Resolve the effective widget mode for the current user.
2136
+ * - 'qa' or 'feedback' config → returned as-is
2137
+ * - 'auto' → checks if user is a QA tester (role='tester') → 'qa', otherwise 'feedback'
2138
+ */
2139
+ async getEffectiveMode() {
2140
+ const mode = this.config.mode || "qa";
2141
+ if (mode === "qa") return "qa";
2142
+ if (mode === "feedback") return "feedback";
2143
+ const [qaEnabled, testerInfo] = await Promise.all([
1529
2144
  this.isQAEnabled(),
1530
- this.isTester()
2145
+ this.getTesterInfo()
1531
2146
  ]);
1532
- return qaEnabled && isTester;
2147
+ if (qaEnabled && testerInfo && testerInfo.role !== "feedback") return "qa";
2148
+ return "feedback";
2149
+ }
2150
+ /**
2151
+ * Auto-provision a feedback user record in the testers table.
2152
+ * Called during initialization when mode is 'feedback' or 'auto' for non-testers.
2153
+ * Idempotent — safe to call multiple times. Returns the tester info for the user.
2154
+ */
2155
+ async ensureFeedbackUser() {
2156
+ try {
2157
+ await this.ensureReady();
2158
+ const userInfo = await this.getCurrentUserInfo();
2159
+ if (!userInfo?.email) return null;
2160
+ const { error } = await this.supabase.rpc("ensure_feedback_user", {
2161
+ p_project_id: this.config.projectId,
2162
+ p_user_id: userInfo.id,
2163
+ p_email: userInfo.email,
2164
+ p_name: userInfo.name || null
2165
+ });
2166
+ if (error) {
2167
+ console.error("BugBear: Failed to ensure feedback user", error.message);
2168
+ this.config.onError?.(new Error(`ensure_feedback_user failed: ${error.message}`), {
2169
+ context: "feedback_user_provisioning"
2170
+ });
2171
+ return null;
2172
+ }
2173
+ return this.getTesterInfo();
2174
+ } catch (err) {
2175
+ console.error("BugBear: Error ensuring feedback user", err);
2176
+ return null;
2177
+ }
1533
2178
  }
1534
2179
  /**
1535
2180
  * Upload a screenshot (web - uses File/Blob)
1536
2181
  */
1537
2182
  async uploadScreenshot(file, filename, bucket = "screenshots") {
1538
2183
  try {
2184
+ await this.ensureReady();
1539
2185
  const contentType = file.type || "image/png";
1540
2186
  const ext = contentType.includes("png") ? "png" : "jpg";
1541
2187
  const name = filename || `screenshot-${Date.now()}.${ext}`;
@@ -1576,6 +2222,7 @@ var BugBearClient = class {
1576
2222
  */
1577
2223
  async uploadImageFromUri(uri, filename, bucket = "screenshots") {
1578
2224
  try {
2225
+ await this.ensureReady();
1579
2226
  const response = await fetch(uri);
1580
2227
  const blob = await response.blob();
1581
2228
  const contentType = blob.type || "image/jpeg";
@@ -1670,6 +2317,7 @@ var BugBearClient = class {
1670
2317
  */
1671
2318
  async getFixRequests(options) {
1672
2319
  try {
2320
+ await this.ensureReady();
1673
2321
  let query = this.supabase.from("fix_requests").select("*").eq("project_id", this.config.projectId).order("created_at", { ascending: false }).limit(options?.limit || 20);
1674
2322
  if (options?.status) {
1675
2323
  query = query.eq("status", options.status);
@@ -1741,6 +2389,7 @@ var BugBearClient = class {
1741
2389
  */
1742
2390
  async getThreadMessages(threadId) {
1743
2391
  try {
2392
+ await this.ensureReady();
1744
2393
  const { data, error } = await this.supabase.from("discussion_messages").select(`
1745
2394
  id,
1746
2395
  thread_id,
@@ -1943,6 +2592,7 @@ var BugBearClient = class {
1943
2592
  */
1944
2593
  async endSession(sessionId, options = {}) {
1945
2594
  try {
2595
+ await this.ensureReady();
1946
2596
  const { data, error } = await this.supabase.rpc("end_qa_session", {
1947
2597
  p_session_id: sessionId,
1948
2598
  p_notes: options.notes || null,
@@ -1980,6 +2630,7 @@ var BugBearClient = class {
1980
2630
  */
1981
2631
  async getSession(sessionId) {
1982
2632
  try {
2633
+ await this.ensureReady();
1983
2634
  const { data, error } = await this.supabase.from("qa_sessions").select("*").eq("id", sessionId).single();
1984
2635
  if (error || !data) return null;
1985
2636
  return this.transformSession(data);
@@ -2011,6 +2662,7 @@ var BugBearClient = class {
2011
2662
  */
2012
2663
  async addFinding(sessionId, options) {
2013
2664
  try {
2665
+ await this.ensureReady();
2014
2666
  const { data, error } = await this.supabase.rpc("add_session_finding", {
2015
2667
  p_session_id: sessionId,
2016
2668
  p_type: options.type,
@@ -2041,6 +2693,7 @@ var BugBearClient = class {
2041
2693
  */
2042
2694
  async getSessionFindings(sessionId) {
2043
2695
  try {
2696
+ await this.ensureReady();
2044
2697
  const { data, error } = await this.supabase.from("qa_findings").select("*").eq("session_id", sessionId).order("created_at", { ascending: true }).limit(100);
2045
2698
  if (error) {
2046
2699
  console.error("BugBear: Failed to fetch findings", formatPgError(error));
@@ -2057,6 +2710,7 @@ var BugBearClient = class {
2057
2710
  */
2058
2711
  async convertFindingToBug(findingId) {
2059
2712
  try {
2713
+ await this.ensureReady();
2060
2714
  const { data, error } = await this.supabase.rpc("convert_finding_to_bug", {
2061
2715
  p_finding_id: findingId
2062
2716
  });
@@ -2076,6 +2730,7 @@ var BugBearClient = class {
2076
2730
  */
2077
2731
  async dismissFinding(findingId, reason) {
2078
2732
  try {
2733
+ await this.ensureReady();
2079
2734
  const { error } = await this.supabase.from("qa_findings").update({
2080
2735
  dismissed: true,
2081
2736
  dismissed_reason: reason || null,
@@ -2150,11 +2805,22 @@ function createBugBear(config) {
2150
2805
  0 && (module.exports = {
2151
2806
  BUG_CATEGORIES,
2152
2807
  BugBearClient,
2808
+ DedupWindow,
2809
+ ErrorMonitor,
2153
2810
  LocalStorageAdapter,
2154
2811
  OfflineQueue,
2812
+ RNApiFailureHandler,
2813
+ RNCrashHandler,
2814
+ RNRageClickHandler,
2815
+ RageClickDetector,
2816
+ WebApiFailureHandler,
2817
+ WebCrashHandler,
2818
+ WebRageClickHandler,
2155
2819
  captureError,
2156
2820
  contextCapture,
2157
2821
  createBugBear,
2822
+ generateFingerprint,
2158
2823
  isBugCategory,
2159
- isNetworkError
2824
+ isNetworkError,
2825
+ scrubUrl
2160
2826
  });