@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.mjs CHANGED
@@ -18,6 +18,7 @@ var ContextCaptureManager = class {
18
18
  this.networkRequests = [];
19
19
  this.navigationHistory = [];
20
20
  this.originalConsole = {};
21
+ this.fetchHost = null;
21
22
  this.isCapturing = false;
22
23
  }
23
24
  /**
@@ -40,8 +41,9 @@ var ContextCaptureManager = class {
40
41
  if (this.originalConsole.warn) console.warn = this.originalConsole.warn;
41
42
  if (this.originalConsole.error) console.error = this.originalConsole.error;
42
43
  if (this.originalConsole.info) console.info = this.originalConsole.info;
43
- if (this.originalFetch && typeof window !== "undefined") {
44
- window.fetch = this.originalFetch;
44
+ if (this.originalFetch && this.fetchHost) {
45
+ this.fetchHost.fetch = this.originalFetch;
46
+ this.fetchHost = null;
45
47
  }
46
48
  if (typeof window !== "undefined" && typeof history !== "undefined") {
47
49
  if (this.originalPushState) {
@@ -150,15 +152,19 @@ var ContextCaptureManager = class {
150
152
  });
151
153
  }
152
154
  captureFetch() {
153
- if (typeof window === "undefined" || typeof fetch === "undefined" || typeof document === "undefined") return;
154
- this.originalFetch = window.fetch;
155
+ if (typeof fetch === "undefined") return;
156
+ const host = typeof window !== "undefined" && typeof window.fetch === "function" ? window : typeof globalThis !== "undefined" && typeof globalThis.fetch === "function" ? globalThis : null;
157
+ if (!host) return;
158
+ const canCloneResponse = typeof document !== "undefined";
159
+ this.fetchHost = host;
160
+ this.originalFetch = host.fetch;
155
161
  const self = this;
156
- window.fetch = async function(input, init) {
162
+ host.fetch = async function(input, init) {
157
163
  const startTime = Date.now();
158
164
  const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
159
165
  const method = init?.method || "GET";
160
166
  try {
161
- const response = await self.originalFetch.call(window, input, init);
167
+ const response = await self.originalFetch.call(host, input, init);
162
168
  const requestEntry = {
163
169
  method,
164
170
  url: url.slice(0, 200),
@@ -167,7 +173,7 @@ var ContextCaptureManager = class {
167
173
  duration: Date.now() - startTime,
168
174
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
169
175
  };
170
- if (response.status >= 400) {
176
+ if (canCloneResponse && response.status >= 400) {
171
177
  try {
172
178
  const cloned = response.clone();
173
179
  const body = await cloned.text();
@@ -409,12 +415,442 @@ function isNetworkError(error) {
409
415
  msg.includes("the internet connection appears to be offline") || msg.includes("a]server with the specified hostname could not be found");
410
416
  }
411
417
 
418
+ // src/monitoring/fingerprint.ts
419
+ function generateFingerprint(source, message, route) {
420
+ const input = `${source}|${message}|${route}`;
421
+ let hash = 5381;
422
+ for (let i = 0; i < input.length; i++) {
423
+ hash = (hash << 5) + hash + input.charCodeAt(i) & 4294967295;
424
+ }
425
+ return `bb_${(hash >>> 0).toString(36)}`;
426
+ }
427
+ var DedupWindow = class {
428
+ constructor(windowMs) {
429
+ this.windowMs = windowMs;
430
+ this.seen = /* @__PURE__ */ new Map();
431
+ }
432
+ /** Returns true if this fingerprint should be reported (not a recent duplicate). */
433
+ shouldReport(fingerprint) {
434
+ const now = Date.now();
435
+ const lastSeen = this.seen.get(fingerprint);
436
+ if (lastSeen !== void 0 && now - lastSeen < this.windowMs) {
437
+ return false;
438
+ }
439
+ this.seen.set(fingerprint, now);
440
+ this.cleanup(now);
441
+ return true;
442
+ }
443
+ /** Remove expired entries to prevent memory leaks in long sessions. */
444
+ cleanup(now) {
445
+ if (this.seen.size > 100) {
446
+ for (const [fp, ts] of this.seen) {
447
+ if (now - ts >= this.windowMs) {
448
+ this.seen.delete(fp);
449
+ }
450
+ }
451
+ }
452
+ }
453
+ };
454
+
455
+ // src/monitoring/scrub.ts
456
+ var SENSITIVE_PARAMS = /^(token|key|password|secret|auth|credential|api_key|access_token|secret_key|apikey|session)$/i;
457
+ function scrubUrl(url) {
458
+ try {
459
+ const parsed = new URL(url);
460
+ let changed = false;
461
+ for (const [key] of parsed.searchParams) {
462
+ if (SENSITIVE_PARAMS.test(key)) {
463
+ parsed.searchParams.set(key, "[REDACTED]");
464
+ changed = true;
465
+ }
466
+ }
467
+ return changed ? parsed.toString() : url;
468
+ } catch {
469
+ return url;
470
+ }
471
+ }
472
+
473
+ // src/monitoring/error-monitor.ts
474
+ var SOURCE_TO_REPORT_SOURCE = {
475
+ crash: "auto_crash",
476
+ api_failure: "auto_api",
477
+ rage_click: "auto_rage_click"
478
+ };
479
+ var SEVERITY_ORDER = ["low", "medium", "high", "critical"];
480
+ var ErrorMonitor = class {
481
+ constructor(config, deps) {
482
+ this.deps = deps;
483
+ this.sessionReportCount = 0;
484
+ this.pendingSentryData = null;
485
+ this.config = {
486
+ sampleRate: 1,
487
+ maxReportsPerSession: 10,
488
+ dedupWindowMs: 3e5,
489
+ severityThreshold: "low",
490
+ scrubUrls: true,
491
+ ...config
492
+ };
493
+ this.dedup = new DedupWindow(this.config.dedupWindowMs);
494
+ }
495
+ async handleEvent(event) {
496
+ if (!this.dedup.shouldReport(event.fingerprint)) return;
497
+ if (Math.random() >= this.config.sampleRate) return;
498
+ if (this.sessionReportCount >= this.config.maxReportsPerSession) return;
499
+ const severity = this.getSeverity(event);
500
+ if (SEVERITY_ORDER.indexOf(severity) < SEVERITY_ORDER.indexOf(this.config.severityThreshold)) return;
501
+ if (this.config.beforeCapture && !this.config.beforeCapture(event)) return;
502
+ const scrubbedUrl = this.config.scrubUrls && event.requestUrl ? scrubUrl(event.requestUrl) : event.requestUrl;
503
+ if (this.pendingSentryData) {
504
+ event.sentryEventId = this.pendingSentryData.sentryEventId;
505
+ event.sentryBreadcrumbs = this.pendingSentryData.sentryBreadcrumbs;
506
+ this.pendingSentryData = null;
507
+ }
508
+ this.sessionReportCount++;
509
+ await this.deps.submitReport({
510
+ type: "bug",
511
+ reportSource: SOURCE_TO_REPORT_SOURCE[event.source],
512
+ title: this.generateTitle(event),
513
+ description: event.message,
514
+ severity,
515
+ category: event.source === "crash" ? "crash" : "functional",
516
+ appContext: {
517
+ currentRoute: event.route,
518
+ errorMessage: event.error?.message,
519
+ errorStack: event.error?.stack,
520
+ custom: {
521
+ monitoringSource: event.source,
522
+ fingerprint: event.fingerprint,
523
+ ...event.statusCode ? { statusCode: event.statusCode } : {},
524
+ ...scrubbedUrl ? { requestUrl: scrubbedUrl } : {},
525
+ ...event.requestMethod ? { requestMethod: event.requestMethod } : {},
526
+ ...event.clickCount ? { clickCount: event.clickCount } : {},
527
+ ...event.targetSelector ? { targetSelector: event.targetSelector } : {},
528
+ ...event.sentryEventId ? { sentryEventId: event.sentryEventId } : {}
529
+ }
530
+ },
531
+ enhancedContext: this.deps.getEnhancedContext(),
532
+ deviceInfo: this.deps.getDeviceInfo()
533
+ });
534
+ }
535
+ enrichWithSentry(data) {
536
+ this.pendingSentryData = data;
537
+ }
538
+ getSeverity(event) {
539
+ if (event.source === "crash") return "critical";
540
+ if (event.source === "api_failure") return event.statusCode && event.statusCode >= 500 ? "high" : "medium";
541
+ return "medium";
542
+ }
543
+ generateTitle(event) {
544
+ switch (event.source) {
545
+ case "crash":
546
+ return `[Auto] Crash on ${event.route}: ${event.message.slice(0, 80)}`;
547
+ case "api_failure":
548
+ return `[Auto] ${event.requestMethod || "API"} ${event.statusCode || "error"} on ${event.route}`;
549
+ case "rage_click":
550
+ return `[Auto] Rage click on ${event.route} (${event.targetSelector || "unknown"})`;
551
+ }
552
+ }
553
+ destroy() {
554
+ this.sessionReportCount = 0;
555
+ this.pendingSentryData = null;
556
+ }
557
+ };
558
+
559
+ // src/monitoring/rage-click-detector.ts
560
+ var RageClickDetector = class {
561
+ constructor(onRageClick) {
562
+ this.onRageClick = onRageClick;
563
+ this.clicks = [];
564
+ this.threshold = 3;
565
+ this.windowMs = 2e3;
566
+ this.destroyed = false;
567
+ }
568
+ recordClick(x, y, targetSelector) {
569
+ if (this.destroyed) return;
570
+ const now = Date.now();
571
+ this.clicks = this.clicks.filter((c) => now - c.time < this.windowMs);
572
+ this.clicks.push({ time: now, target: targetSelector, x, y });
573
+ const sameTarget = this.clicks.filter((c) => c.target === targetSelector);
574
+ if (sameTarget.length >= this.threshold) {
575
+ this.onRageClick({
576
+ clickCount: sameTarget.length,
577
+ targetSelector,
578
+ x,
579
+ y
580
+ });
581
+ this.clicks = [];
582
+ }
583
+ }
584
+ destroy() {
585
+ this.destroyed = true;
586
+ this.clicks = [];
587
+ }
588
+ };
589
+
590
+ // src/monitoring/web-handlers.ts
591
+ function getSelector(el) {
592
+ if (el.id) return `#${el.id}`;
593
+ const tag = el.tagName.toLowerCase();
594
+ const classes = Array.from(el.classList).slice(0, 2);
595
+ return classes.length > 0 ? `${tag}.${classes.join(".")}` : tag;
596
+ }
597
+ function currentRoute() {
598
+ try {
599
+ return typeof window !== "undefined" && window.location ? window.location.pathname : "unknown";
600
+ } catch {
601
+ return "unknown";
602
+ }
603
+ }
604
+ var WebCrashHandler = class {
605
+ constructor(onEvent) {
606
+ this.onEvent = onEvent;
607
+ this.prevOnError = null;
608
+ this.rejectionHandler = null;
609
+ }
610
+ start() {
611
+ this.prevOnError = window.onerror;
612
+ window.onerror = (message, source, lineno, colno, error) => {
613
+ const msg = typeof message === "string" ? message : "Unknown error";
614
+ const route = currentRoute();
615
+ this.onEvent({
616
+ source: "crash",
617
+ fingerprint: generateFingerprint("crash", msg, route),
618
+ message: msg,
619
+ route,
620
+ timestamp: Date.now(),
621
+ error: error ?? new Error(msg)
622
+ });
623
+ if (typeof this.prevOnError === "function") {
624
+ return this.prevOnError(message, source, lineno, colno, error);
625
+ }
626
+ return false;
627
+ };
628
+ this.rejectionHandler = (e) => {
629
+ const reason = e.reason;
630
+ const error = reason instanceof Error ? reason : new Error(String(reason));
631
+ const msg = error.message || "Unhandled promise rejection";
632
+ const route = currentRoute();
633
+ this.onEvent({
634
+ source: "crash",
635
+ fingerprint: generateFingerprint("crash", msg, route),
636
+ message: msg,
637
+ route,
638
+ timestamp: Date.now(),
639
+ error
640
+ });
641
+ };
642
+ window.addEventListener("unhandledrejection", this.rejectionHandler);
643
+ }
644
+ destroy() {
645
+ window.onerror = this.prevOnError;
646
+ if (this.rejectionHandler) {
647
+ window.removeEventListener("unhandledrejection", this.rejectionHandler);
648
+ this.rejectionHandler = null;
649
+ }
650
+ }
651
+ };
652
+ var WebApiFailureHandler = class {
653
+ constructor(onEvent) {
654
+ this.onEvent = onEvent;
655
+ this.originalFetch = null;
656
+ }
657
+ start() {
658
+ this.originalFetch = window.fetch;
659
+ const original = this.originalFetch;
660
+ const onEvent = this.onEvent;
661
+ window.fetch = async function patchedFetch(input, init) {
662
+ const response = await original.call(window, input, init);
663
+ if (response.status >= 400) {
664
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
665
+ const method = (init?.method ?? "GET").toUpperCase();
666
+ const route = currentRoute();
667
+ const msg = `${method} ${url} failed with ${response.status}`;
668
+ onEvent({
669
+ source: "api_failure",
670
+ fingerprint: generateFingerprint("api_failure", msg, route),
671
+ message: msg,
672
+ route,
673
+ timestamp: Date.now(),
674
+ requestUrl: url,
675
+ requestMethod: method,
676
+ statusCode: response.status
677
+ });
678
+ }
679
+ return response;
680
+ };
681
+ }
682
+ destroy() {
683
+ if (this.originalFetch) {
684
+ window.fetch = this.originalFetch;
685
+ this.originalFetch = null;
686
+ }
687
+ }
688
+ };
689
+ var WebRageClickHandler = class {
690
+ constructor(onEvent) {
691
+ this.onEvent = onEvent;
692
+ this.clickHandler = null;
693
+ this.detector = new RageClickDetector((rageEvent) => {
694
+ const route = currentRoute();
695
+ this.onEvent({
696
+ source: "rage_click",
697
+ fingerprint: generateFingerprint("rage_click", rageEvent.targetSelector, route),
698
+ message: `Rage click detected on ${rageEvent.targetSelector}`,
699
+ route,
700
+ timestamp: Date.now(),
701
+ clickCount: rageEvent.clickCount,
702
+ targetSelector: rageEvent.targetSelector
703
+ });
704
+ });
705
+ }
706
+ start() {
707
+ this.clickHandler = (e) => {
708
+ const target = e.target;
709
+ if (!(target instanceof Element)) return;
710
+ const selector = getSelector(target);
711
+ this.detector.recordClick(e.clientX, e.clientY, selector);
712
+ };
713
+ document.addEventListener("click", this.clickHandler, true);
714
+ }
715
+ destroy() {
716
+ if (this.clickHandler) {
717
+ document.removeEventListener("click", this.clickHandler, true);
718
+ this.clickHandler = null;
719
+ }
720
+ this.detector.destroy();
721
+ }
722
+ };
723
+
724
+ // src/monitoring/rn-handlers.ts
725
+ var RNCrashHandler = class {
726
+ constructor(onEvent, getCurrentRoute) {
727
+ this.onEvent = onEvent;
728
+ this.getCurrentRoute = getCurrentRoute;
729
+ this.originalHandler = null;
730
+ this.started = false;
731
+ }
732
+ start() {
733
+ if (this.started) return;
734
+ this.started = true;
735
+ const errorUtils = globalThis.ErrorUtils;
736
+ if (!errorUtils) return;
737
+ this.originalHandler = errorUtils.getGlobalHandler();
738
+ const self = this;
739
+ errorUtils.setGlobalHandler((error, isFatal) => {
740
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
741
+ const route = self.getCurrentRoute();
742
+ self.onEvent({
743
+ source: "crash",
744
+ fingerprint: generateFingerprint("crash", normalizedError.message, route),
745
+ message: normalizedError.message,
746
+ route,
747
+ timestamp: Date.now(),
748
+ error: normalizedError
749
+ });
750
+ if (self.originalHandler) {
751
+ self.originalHandler(error, isFatal);
752
+ }
753
+ });
754
+ }
755
+ destroy() {
756
+ if (!this.started) return;
757
+ this.started = false;
758
+ const errorUtils = globalThis.ErrorUtils;
759
+ if (errorUtils && this.originalHandler) {
760
+ errorUtils.setGlobalHandler(this.originalHandler);
761
+ }
762
+ this.originalHandler = null;
763
+ }
764
+ };
765
+ var RNApiFailureHandler = class {
766
+ constructor(onEvent, getCurrentRoute) {
767
+ this.onEvent = onEvent;
768
+ this.getCurrentRoute = getCurrentRoute;
769
+ this.originalFetch = null;
770
+ this.started = false;
771
+ }
772
+ start() {
773
+ if (this.started) return;
774
+ this.started = true;
775
+ this.originalFetch = globalThis.fetch;
776
+ const self = this;
777
+ globalThis.fetch = async function patchedFetch(input, init) {
778
+ const response = await self.originalFetch.call(globalThis, input, init);
779
+ if (response.status >= 400) {
780
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
781
+ const method = init?.method ?? "GET";
782
+ const route = self.getCurrentRoute();
783
+ self.onEvent({
784
+ source: "api_failure",
785
+ fingerprint: generateFingerprint("api_failure", `${method} ${response.status}`, route),
786
+ message: `${method} ${url} failed with ${response.status}`,
787
+ route,
788
+ timestamp: Date.now(),
789
+ requestUrl: url,
790
+ requestMethod: method,
791
+ statusCode: response.status
792
+ });
793
+ }
794
+ return response;
795
+ };
796
+ }
797
+ destroy() {
798
+ if (!this.started) return;
799
+ this.started = false;
800
+ if (this.originalFetch) {
801
+ globalThis.fetch = this.originalFetch;
802
+ this.originalFetch = null;
803
+ }
804
+ }
805
+ };
806
+ var RNRageClickHandler = class {
807
+ constructor(onEvent, getCurrentRoute) {
808
+ this.onEvent = onEvent;
809
+ this.getCurrentRoute = getCurrentRoute;
810
+ this.detector = null;
811
+ }
812
+ start() {
813
+ if (this.detector) return;
814
+ const self = this;
815
+ this.detector = new RageClickDetector((rageEvent) => {
816
+ const route = self.getCurrentRoute();
817
+ self.onEvent({
818
+ source: "rage_click",
819
+ fingerprint: generateFingerprint("rage_click", rageEvent.targetSelector, route),
820
+ message: `Rage click detected on ${rageEvent.targetSelector}`,
821
+ route,
822
+ timestamp: Date.now(),
823
+ clickCount: rageEvent.clickCount,
824
+ targetSelector: rageEvent.targetSelector
825
+ });
826
+ });
827
+ }
828
+ /**
829
+ * Record a touch event. Call this from the BugBearProvider's onTouchEnd handler.
830
+ * @param x - Touch x coordinate
831
+ * @param y - Touch y coordinate
832
+ * @param viewId - Identifier for the touched view (e.g., testID, accessibilityLabel, or nativeID)
833
+ */
834
+ recordTouch(x, y, viewId) {
835
+ this.detector?.recordClick(x, y, viewId);
836
+ }
837
+ destroy() {
838
+ if (this.detector) {
839
+ this.detector.destroy();
840
+ this.detector = null;
841
+ }
842
+ }
843
+ };
844
+
412
845
  // src/client.ts
413
846
  var formatPgError = (e) => {
414
847
  if (!e || typeof e !== "object") return { raw: e };
415
848
  const { message, code, details, hint } = e;
416
849
  return { message, code, details, hint };
417
850
  };
851
+ var DEFAULT_API_BASE_URL = "https://app.bugbear.ai";
852
+ var CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
853
+ var CONFIG_CACHE_PREFIX = "bugbear_config_";
418
854
  var BugBearClient = class {
419
855
  constructor(config) {
420
856
  this.navigationHistory = [];
@@ -424,23 +860,148 @@ var BugBearClient = class {
424
860
  /** Active Realtime channel references for cleanup. */
425
861
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
426
862
  this.realtimeChannels = [];
427
- if (!config.supabaseUrl) {
428
- throw new Error("BugBear: supabaseUrl is required. Get it from your BugBear project settings.");
429
- }
430
- if (!config.supabaseAnonKey) {
431
- throw new Error("BugBear: supabaseAnonKey is required. Get it from your BugBear project settings.");
432
- }
863
+ /** Error monitor instance — created when config.monitoring is present. */
864
+ this.monitor = null;
865
+ /** Whether the client has been successfully initialized. */
866
+ this.initialized = false;
867
+ /** Initialization error, if any. */
868
+ this.initError = null;
433
869
  this.config = config;
434
- this.supabase = createClient(config.supabaseUrl, config.supabaseAnonKey);
435
- if (config.offlineQueue?.enabled) {
870
+ if (config.apiKey) {
871
+ this.pendingInit = this.resolveFromApiKey(config.apiKey);
872
+ } else if (config.supabaseUrl && config.supabaseAnonKey) {
873
+ if (!config.projectId) {
874
+ throw new Error(
875
+ "BugBear: projectId is required when using explicit Supabase credentials. Tip: Use apiKey instead for simpler setup \u2014 it resolves everything automatically."
876
+ );
877
+ }
878
+ this.supabase = createClient(config.supabaseUrl, config.supabaseAnonKey);
879
+ this.initialized = true;
880
+ this.pendingInit = Promise.resolve();
881
+ this.initOfflineQueue();
882
+ this.initMonitoring();
883
+ } else {
884
+ throw new Error(
885
+ "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"
886
+ );
887
+ }
888
+ }
889
+ /** Whether the client is ready for requests. */
890
+ get isReady() {
891
+ return this.initialized;
892
+ }
893
+ /** Wait until the client is ready. Throws if initialization failed. */
894
+ async ready() {
895
+ await this.pendingInit;
896
+ if (this.initError) throw this.initError;
897
+ }
898
+ /**
899
+ * Resolve Supabase credentials from a BugBear API key.
900
+ * Checks localStorage cache first, falls back to /api/v1/config.
901
+ */
902
+ async resolveFromApiKey(apiKey) {
903
+ try {
904
+ const cached = this.readConfigCache(apiKey);
905
+ if (cached) {
906
+ this.applyResolvedConfig(cached);
907
+ return;
908
+ }
909
+ const baseUrl = (this.config.apiBaseUrl || DEFAULT_API_BASE_URL).replace(/\/$/, "");
910
+ const response = await fetch(`${baseUrl}/api/v1/config`, {
911
+ headers: { Authorization: `Bearer ${apiKey}` }
912
+ });
913
+ if (!response.ok) {
914
+ const body = await response.json().catch(() => ({}));
915
+ const message = body.error || `HTTP ${response.status}`;
916
+ throw new Error(
917
+ `BugBear: Invalid API key \u2014 ${message}. Get yours at https://app.bugbear.ai/settings/projects`
918
+ );
919
+ }
920
+ const data = await response.json();
921
+ this.writeConfigCache(apiKey, data);
922
+ this.applyResolvedConfig(data);
923
+ } catch (err) {
924
+ this.initError = err instanceof Error ? err : new Error(String(err));
925
+ this.config.onError?.(this.initError, { context: "apikey_resolution_failed" });
926
+ throw this.initError;
927
+ }
928
+ }
929
+ /** Apply resolved credentials and create the Supabase client. */
930
+ applyResolvedConfig(resolved) {
931
+ this.config = {
932
+ ...this.config,
933
+ projectId: resolved.projectId,
934
+ supabaseUrl: resolved.supabaseUrl,
935
+ supabaseAnonKey: resolved.supabaseAnonKey
936
+ };
937
+ this.supabase = createClient(resolved.supabaseUrl, resolved.supabaseAnonKey);
938
+ this.initialized = true;
939
+ this.initOfflineQueue();
940
+ this.initMonitoring();
941
+ }
942
+ /** Initialize offline queue if configured. Shared by both init paths. */
943
+ initOfflineQueue() {
944
+ if (this.config.offlineQueue?.enabled) {
436
945
  this._queue = new OfflineQueue({
437
946
  enabled: true,
438
- maxItems: config.offlineQueue.maxItems,
439
- maxRetries: config.offlineQueue.maxRetries
947
+ maxItems: this.config.offlineQueue.maxItems,
948
+ maxRetries: this.config.offlineQueue.maxRetries
440
949
  });
441
950
  this.registerQueueHandlers();
442
951
  }
443
952
  }
953
+ /** Initialize error monitoring if configured. Shared by both init paths. */
954
+ initMonitoring() {
955
+ const mc = this.config.monitoring;
956
+ if (!mc || !mc.crashes && !mc.apiFailures && !mc.rageClicks) return;
957
+ this.monitor = new ErrorMonitor(mc, {
958
+ submitReport: (report) => this.submitReport(report),
959
+ getCurrentRoute: () => contextCapture.getCurrentRoute(),
960
+ getEnhancedContext: () => contextCapture.getEnhancedContext(),
961
+ getDeviceInfo: () => this.getDeviceInfo()
962
+ });
963
+ }
964
+ /** Read cached config from localStorage if available and not expired. */
965
+ readConfigCache(apiKey) {
966
+ if (typeof localStorage === "undefined") return null;
967
+ try {
968
+ const key = CONFIG_CACHE_PREFIX + this.hashKey(apiKey);
969
+ const raw = localStorage.getItem(key);
970
+ if (!raw) return null;
971
+ const cached = JSON.parse(raw);
972
+ if (Date.now() - cached.cachedAt > CONFIG_CACHE_TTL_MS) {
973
+ localStorage.removeItem(key);
974
+ return null;
975
+ }
976
+ return cached;
977
+ } catch {
978
+ return null;
979
+ }
980
+ }
981
+ /** Write resolved config to localStorage cache. */
982
+ writeConfigCache(apiKey, data) {
983
+ if (typeof localStorage === "undefined") return;
984
+ try {
985
+ const key = CONFIG_CACHE_PREFIX + this.hashKey(apiKey);
986
+ const cached = { ...data, cachedAt: Date.now() };
987
+ localStorage.setItem(key, JSON.stringify(cached));
988
+ } catch {
989
+ }
990
+ }
991
+ /** Simple string hash for cache keys — avoids storing raw API keys. */
992
+ hashKey(apiKey) {
993
+ let hash = 0;
994
+ for (let i = 0; i < apiKey.length; i++) {
995
+ hash = (hash << 5) - hash + apiKey.charCodeAt(i) | 0;
996
+ }
997
+ return hash.toString(36);
998
+ }
999
+ /** Ensure the client is initialized before making requests. */
1000
+ async ensureReady() {
1001
+ if (this.initialized) return;
1002
+ await this.pendingInit;
1003
+ if (this.initError) throw this.initError;
1004
+ }
444
1005
  // ── Offline Queue ─────────────────────────────────────────
445
1006
  /**
446
1007
  * Access the offline queue (if enabled).
@@ -596,6 +1157,7 @@ var BugBearClient = class {
596
1157
  * Get current user info from host app or BugBear's own auth
597
1158
  */
598
1159
  async getCurrentUserInfo() {
1160
+ await this.ensureReady();
599
1161
  if (this.config.getCurrentUser) {
600
1162
  return await this.config.getCurrentUser();
601
1163
  }
@@ -654,7 +1216,9 @@ var BugBearClient = class {
654
1216
  navigation_history: this.getNavigationHistory(),
655
1217
  enhanced_context: report.enhancedContext || contextCapture.getEnhancedContext(),
656
1218
  assignment_id: report.assignmentId,
657
- test_case_id: report.testCaseId
1219
+ test_case_id: report.testCaseId,
1220
+ report_source: report.reportSource || "manual",
1221
+ error_fingerprint: report.appContext?.custom?.fingerprint || null
658
1222
  };
659
1223
  const { data, error } = await this.supabase.from("reports").insert(fullReport).select("id").single();
660
1224
  if (error) {
@@ -736,7 +1300,7 @@ var BugBearClient = class {
736
1300
  `;
737
1301
  const [pendingResult, completedResult] = await Promise.all([
738
1302
  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),
739
- 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)
1303
+ 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)
740
1304
  ]);
741
1305
  if (pendingResult.error) {
742
1306
  console.error("BugBear: Failed to fetch assignments", formatPgError(pendingResult.error));
@@ -815,6 +1379,7 @@ var BugBearClient = class {
815
1379
  */
816
1380
  async getAssignment(assignmentId) {
817
1381
  try {
1382
+ await this.ensureReady();
818
1383
  const { data, error } = await this.supabase.from("test_assignments").select(`
819
1384
  id,
820
1385
  status,
@@ -886,6 +1451,7 @@ var BugBearClient = class {
886
1451
  */
887
1452
  async updateAssignmentStatus(assignmentId, status, options) {
888
1453
  try {
1454
+ await this.ensureReady();
889
1455
  const { data: currentAssignment, error: fetchError } = await this.supabase.from("test_assignments").select("status, started_at").eq("id", assignmentId).single();
890
1456
  if (fetchError || !currentAssignment) {
891
1457
  console.error("BugBear: Assignment not found", {
@@ -972,6 +1538,7 @@ var BugBearClient = class {
972
1538
  */
973
1539
  async reopenAssignment(assignmentId) {
974
1540
  try {
1541
+ await this.ensureReady();
975
1542
  const { data: current, error: fetchError } = await this.supabase.from("test_assignments").select("status").eq("id", assignmentId).single();
976
1543
  if (fetchError || !current) {
977
1544
  return { success: false, error: "Assignment not found" };
@@ -1011,6 +1578,7 @@ var BugBearClient = class {
1011
1578
  actualNotes = notes;
1012
1579
  }
1013
1580
  try {
1581
+ await this.ensureReady();
1014
1582
  const updateData = {
1015
1583
  status: "skipped",
1016
1584
  skip_reason: actualReason,
@@ -1180,6 +1748,7 @@ var BugBearClient = class {
1180
1748
  */
1181
1749
  async getTesterInfo() {
1182
1750
  try {
1751
+ await this.ensureReady();
1183
1752
  const userInfo = await this.getCurrentUserInfo();
1184
1753
  if (!userInfo?.email) return null;
1185
1754
  if (!this.isValidEmail(userInfo.email)) {
@@ -1200,7 +1769,8 @@ var BugBearClient = class {
1200
1769
  avatarUrl: tester.avatar_url || void 0,
1201
1770
  platforms: tester.platforms || [],
1202
1771
  assignedTests: tester.assigned_count || 0,
1203
- completedTests: tester.completed_count || 0
1772
+ completedTests: tester.completed_count || 0,
1773
+ role: tester.role || "tester"
1204
1774
  };
1205
1775
  } catch (err) {
1206
1776
  console.error("BugBear: getTesterInfo error", err);
@@ -1471,6 +2041,7 @@ var BugBearClient = class {
1471
2041
  */
1472
2042
  async isQAEnabled() {
1473
2043
  try {
2044
+ await this.ensureReady();
1474
2045
  const { data, error } = await this.supabase.rpc("check_qa_enabled", {
1475
2046
  p_project_id: this.config.projectId
1476
2047
  });
@@ -1487,21 +2058,85 @@ var BugBearClient = class {
1487
2058
  }
1488
2059
  }
1489
2060
  /**
1490
- * Check if the widget should be visible
1491
- * (QA mode enabled AND current user is a tester)
2061
+ * Check if the widget should be visible.
2062
+ * Behavior depends on the configured mode:
2063
+ * - 'qa': QA enabled AND user is a registered tester
2064
+ * - 'feedback': Any authenticated user
2065
+ * - 'auto': Either QA tester OR authenticated non-tester
1492
2066
  */
1493
2067
  async shouldShowWidget() {
1494
- const [qaEnabled, isTester] = await Promise.all([
2068
+ const mode = this.config.mode || "qa";
2069
+ if (mode === "qa") {
2070
+ const [qaEnabled2, tester] = await Promise.all([
2071
+ this.isQAEnabled(),
2072
+ this.isTester()
2073
+ ]);
2074
+ return qaEnabled2 && tester;
2075
+ }
2076
+ if (mode === "feedback") {
2077
+ const userInfo2 = await this.getCurrentUserInfo();
2078
+ return userInfo2 !== null;
2079
+ }
2080
+ const [qaEnabled, testerInfo, userInfo] = await Promise.all([
2081
+ this.isQAEnabled(),
2082
+ this.getTesterInfo(),
2083
+ this.getCurrentUserInfo()
2084
+ ]);
2085
+ if (qaEnabled && testerInfo && testerInfo.role !== "feedback") return true;
2086
+ if (userInfo) return true;
2087
+ return false;
2088
+ }
2089
+ /**
2090
+ * Resolve the effective widget mode for the current user.
2091
+ * - 'qa' or 'feedback' config → returned as-is
2092
+ * - 'auto' → checks if user is a QA tester (role='tester') → 'qa', otherwise 'feedback'
2093
+ */
2094
+ async getEffectiveMode() {
2095
+ const mode = this.config.mode || "qa";
2096
+ if (mode === "qa") return "qa";
2097
+ if (mode === "feedback") return "feedback";
2098
+ const [qaEnabled, testerInfo] = await Promise.all([
1495
2099
  this.isQAEnabled(),
1496
- this.isTester()
2100
+ this.getTesterInfo()
1497
2101
  ]);
1498
- return qaEnabled && isTester;
2102
+ if (qaEnabled && testerInfo && testerInfo.role !== "feedback") return "qa";
2103
+ return "feedback";
2104
+ }
2105
+ /**
2106
+ * Auto-provision a feedback user record in the testers table.
2107
+ * Called during initialization when mode is 'feedback' or 'auto' for non-testers.
2108
+ * Idempotent — safe to call multiple times. Returns the tester info for the user.
2109
+ */
2110
+ async ensureFeedbackUser() {
2111
+ try {
2112
+ await this.ensureReady();
2113
+ const userInfo = await this.getCurrentUserInfo();
2114
+ if (!userInfo?.email) return null;
2115
+ const { error } = await this.supabase.rpc("ensure_feedback_user", {
2116
+ p_project_id: this.config.projectId,
2117
+ p_user_id: userInfo.id,
2118
+ p_email: userInfo.email,
2119
+ p_name: userInfo.name || null
2120
+ });
2121
+ if (error) {
2122
+ console.error("BugBear: Failed to ensure feedback user", error.message);
2123
+ this.config.onError?.(new Error(`ensure_feedback_user failed: ${error.message}`), {
2124
+ context: "feedback_user_provisioning"
2125
+ });
2126
+ return null;
2127
+ }
2128
+ return this.getTesterInfo();
2129
+ } catch (err) {
2130
+ console.error("BugBear: Error ensuring feedback user", err);
2131
+ return null;
2132
+ }
1499
2133
  }
1500
2134
  /**
1501
2135
  * Upload a screenshot (web - uses File/Blob)
1502
2136
  */
1503
2137
  async uploadScreenshot(file, filename, bucket = "screenshots") {
1504
2138
  try {
2139
+ await this.ensureReady();
1505
2140
  const contentType = file.type || "image/png";
1506
2141
  const ext = contentType.includes("png") ? "png" : "jpg";
1507
2142
  const name = filename || `screenshot-${Date.now()}.${ext}`;
@@ -1542,6 +2177,7 @@ var BugBearClient = class {
1542
2177
  */
1543
2178
  async uploadImageFromUri(uri, filename, bucket = "screenshots") {
1544
2179
  try {
2180
+ await this.ensureReady();
1545
2181
  const response = await fetch(uri);
1546
2182
  const blob = await response.blob();
1547
2183
  const contentType = blob.type || "image/jpeg";
@@ -1636,6 +2272,7 @@ var BugBearClient = class {
1636
2272
  */
1637
2273
  async getFixRequests(options) {
1638
2274
  try {
2275
+ await this.ensureReady();
1639
2276
  let query = this.supabase.from("fix_requests").select("*").eq("project_id", this.config.projectId).order("created_at", { ascending: false }).limit(options?.limit || 20);
1640
2277
  if (options?.status) {
1641
2278
  query = query.eq("status", options.status);
@@ -1707,6 +2344,7 @@ var BugBearClient = class {
1707
2344
  */
1708
2345
  async getThreadMessages(threadId) {
1709
2346
  try {
2347
+ await this.ensureReady();
1710
2348
  const { data, error } = await this.supabase.from("discussion_messages").select(`
1711
2349
  id,
1712
2350
  thread_id,
@@ -1909,6 +2547,7 @@ var BugBearClient = class {
1909
2547
  */
1910
2548
  async endSession(sessionId, options = {}) {
1911
2549
  try {
2550
+ await this.ensureReady();
1912
2551
  const { data, error } = await this.supabase.rpc("end_qa_session", {
1913
2552
  p_session_id: sessionId,
1914
2553
  p_notes: options.notes || null,
@@ -1946,6 +2585,7 @@ var BugBearClient = class {
1946
2585
  */
1947
2586
  async getSession(sessionId) {
1948
2587
  try {
2588
+ await this.ensureReady();
1949
2589
  const { data, error } = await this.supabase.from("qa_sessions").select("*").eq("id", sessionId).single();
1950
2590
  if (error || !data) return null;
1951
2591
  return this.transformSession(data);
@@ -1977,6 +2617,7 @@ var BugBearClient = class {
1977
2617
  */
1978
2618
  async addFinding(sessionId, options) {
1979
2619
  try {
2620
+ await this.ensureReady();
1980
2621
  const { data, error } = await this.supabase.rpc("add_session_finding", {
1981
2622
  p_session_id: sessionId,
1982
2623
  p_type: options.type,
@@ -2007,6 +2648,7 @@ var BugBearClient = class {
2007
2648
  */
2008
2649
  async getSessionFindings(sessionId) {
2009
2650
  try {
2651
+ await this.ensureReady();
2010
2652
  const { data, error } = await this.supabase.from("qa_findings").select("*").eq("session_id", sessionId).order("created_at", { ascending: true }).limit(100);
2011
2653
  if (error) {
2012
2654
  console.error("BugBear: Failed to fetch findings", formatPgError(error));
@@ -2023,6 +2665,7 @@ var BugBearClient = class {
2023
2665
  */
2024
2666
  async convertFindingToBug(findingId) {
2025
2667
  try {
2668
+ await this.ensureReady();
2026
2669
  const { data, error } = await this.supabase.rpc("convert_finding_to_bug", {
2027
2670
  p_finding_id: findingId
2028
2671
  });
@@ -2042,6 +2685,7 @@ var BugBearClient = class {
2042
2685
  */
2043
2686
  async dismissFinding(findingId, reason) {
2044
2687
  try {
2688
+ await this.ensureReady();
2045
2689
  const { error } = await this.supabase.from("qa_findings").update({
2046
2690
  dismissed: true,
2047
2691
  dismissed_reason: reason || null,
@@ -2115,11 +2759,22 @@ function createBugBear(config) {
2115
2759
  export {
2116
2760
  BUG_CATEGORIES,
2117
2761
  BugBearClient,
2762
+ DedupWindow,
2763
+ ErrorMonitor,
2118
2764
  LocalStorageAdapter,
2119
2765
  OfflineQueue,
2766
+ RNApiFailureHandler,
2767
+ RNCrashHandler,
2768
+ RNRageClickHandler,
2769
+ RageClickDetector,
2770
+ WebApiFailureHandler,
2771
+ WebCrashHandler,
2772
+ WebRageClickHandler,
2120
2773
  captureError,
2121
2774
  contextCapture,
2122
2775
  createBugBear,
2776
+ generateFingerprint,
2123
2777
  isBugCategory,
2124
- isNetworkError
2778
+ isNetworkError,
2779
+ scrubUrl
2125
2780
  };