@sphoro/bugcatch-web-sdk 0.13.1

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/src/index.js ADDED
@@ -0,0 +1,1217 @@
1
+ import {
2
+ createRingBuffer, installConsoleCapture, installNetworkCapture,
3
+ installErrorCapture, collectEnvironment, collectEnvironmentSync,
4
+ } from './collectors.js';
5
+ import { mountWidget } from './widget.js';
6
+ import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
7
+ import {
8
+ watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
9
+ clearToken, sendMessage, callAvailable, callKindForProject, requestCall,
10
+ reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
11
+ answerIncomingCall, declineIncomingCall,
12
+ readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
13
+ fetchKnownIssues, matchKnownIssues, joinKnownIssue,
14
+ UNREAD_KEY,
15
+ } from './updates.js';
16
+ import {
17
+ loginState, startSignIn, confirmSignIn, endSignIn, readSession, clearSession,
18
+ } from './session.js';
19
+ import { startVoiceCall } from './voice.js';
20
+ import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
21
+ import { showIncomingCall } from './incoming-call.js';
22
+
23
+ // Replaced at build time from package.json. The guard is for anyone importing
24
+ // src/ directly, where the define never runs.
25
+ const SDK_IDENTITY = {
26
+ name: typeof __SDK_NAME__ === 'string' ? __SDK_NAME__ : '@sphoro/bugcatch-web-sdk',
27
+ version: typeof __SDK_VERSION__ === 'string' ? __SDK_VERSION__ : '0.0.0-dev',
28
+ };
29
+
30
+ const DEFAULTS = {
31
+ apiBase: 'http://localhost:3000',
32
+ apiKey: null,
33
+ projectId: null,
34
+ consoleLimit: 100,
35
+ networkLimit: 50,
36
+ captureConsole: true,
37
+ captureNetwork: true,
38
+ captureErrors: true,
39
+ // Shows the floating "Report a bug" button. Turn off to drive it yourself.
40
+ widget: true,
41
+ widgetPosition: 'bottom-right',
42
+ // 'auto' follows the visitor's system setting; 'light' / 'dark' pin it.
43
+ theme: 'auto',
44
+ accent: '#4F46E5',
45
+ // Screenshot / screen recording / file attach. Capture needs getDisplayMedia,
46
+ // so it is offered only where the browser has it — file attach always works.
47
+ attachments: true,
48
+ maxAttachments: 5,
49
+ maxAttachmentBytes: 25 * 1024 * 1024,
50
+ maxRecordingMs: 120_000,
51
+ // Called before send; return false to cancel, or mutate the payload to scrub.
52
+ beforeSend: null,
53
+ user: null,
54
+ // Tell the reporter when a bug they filed changes status — live, in your
55
+ // app, where they already are. Off means no connection is opened at all.
56
+ updates: true,
57
+ // Show the built-in notification when an update arrives. It stays until the
58
+ // reporter marks it read — including across a reload — so an update is not
59
+ // lost to whoever happened to be looking at the tab. Turn it off and drive
60
+ // the notification yourself from onUpdate and unreadUpdates().
61
+ updateToast: true,
62
+ // Called with { id, bugId, title, status, at } whenever one of this browser's
63
+ // reports moves. Runs whether or not the built-in notification is on; `id` is
64
+ // what markUpdateRead() takes.
65
+ onUpdate: null,
66
+ // Offer a voice call when a report is picked up, if the workspace has
67
+ // sphoro.voice configured. Nothing is dialled and no permission is asked for
68
+ // until somebody presses the button — see startCall().
69
+ voice: true,
70
+ // Called as a call moves: 'connecting', 'listening', 'speaking', 'ended',
71
+ // 'failed'. The SDK shows nothing of its own beyond the offer, so an
72
+ // integration that wants a call UI builds it from this and onCallTranscript.
73
+ onCallState: null,
74
+ // Called with { role, text, final } for each line, as it is recognised.
75
+ onCallTranscript: null,
76
+ // Called with { bugId, callId, from, project, reference, ringsUntil,
77
+ // recorded } when somebody on the team rings this page about a report filed
78
+ // from it. The SDK has already drawn the card by then — this is for a host
79
+ // application that wants to duck its own audio or pause a video, not a hook
80
+ // that can suppress a call the person can see ringing. Set `voice: false` to
81
+ // switch the whole thing off.
82
+ onIncomingCall: null,
83
+ };
84
+
85
+ class BugCatchClient {
86
+ constructor() {
87
+ this.config = { ...DEFAULTS };
88
+ this.started = false;
89
+ this.teardown = [];
90
+ this.consoleBuffer = createRingBuffer(DEFAULTS.consoleLimit);
91
+ this.networkBuffer = createRingBuffer(DEFAULTS.networkLimit);
92
+ this.stopWatching = null;
93
+ // The built-in notification stack, built on first use: an integration that
94
+ // renders its own never has one, and neither does a visitor who has nothing
95
+ // unread.
96
+ this.notifier = null;
97
+ // The call in progress, if any, and whether one is being connected. One at
98
+ // a time: a second would be two microphones on one page talking over each
99
+ // other.
100
+ this.call = null;
101
+ this.callStarting = false;
102
+ // The card on screen for a call the team is placing to this page, if any.
103
+ // Distinct from `call` above, which is a conversation in progress: this is
104
+ // a telephone that has not been picked up, and the two exist at different
105
+ // moments — a call cannot be ringing and connected at once.
106
+ this.ringing = null;
107
+ // event -> Set<handler>. Several parts of a host app can listen without
108
+ // having to share the single `onUpdate` config callback between them.
109
+ this.listeners = new Map();
110
+ }
111
+
112
+ init(options = {}) {
113
+ if (this.started) {
114
+ console.warn('[BugCatch] init() called twice — ignoring the second call.');
115
+ return this;
116
+ }
117
+ this.config = { ...DEFAULTS, ...options };
118
+
119
+ if (!this.config.apiKey) throw new Error('[BugCatch] apiKey is required.');
120
+ if (!this.config.projectId) throw new Error('[BugCatch] projectId is required.');
121
+
122
+ this.consoleBuffer = createRingBuffer(this.config.consoleLimit);
123
+ this.networkBuffer = createRingBuffer(this.config.networkLimit);
124
+
125
+ if (this.config.captureConsole) this.teardown.push(installConsoleCapture(this.consoleBuffer));
126
+ if (this.config.captureNetwork) this.teardown.push(installNetworkCapture(this.networkBuffer));
127
+ if (this.config.captureErrors) this.teardown.push(installErrorCapture(this.consoleBuffer));
128
+ if (this.config.widget) this.teardown.push(mountWidget(this));
129
+
130
+ // Only if this browser has actually filed something. A visitor who has
131
+ // never reported has nothing to be told about, and opening a connection
132
+ // for them would be a request on every page load that can never deliver.
133
+ if (this.config.updates && readToken()) {
134
+ this.startWatching();
135
+ // Whatever was still unread when they last closed the tab. The stream
136
+ // replays nothing on connect, so without this an update that arrived
137
+ // while somebody was away would be gone by the time they came back.
138
+ //
139
+ // A tick late, so the count reaches a listener registered on the line
140
+ // after init() — which is where every integration registers one, and
141
+ // where a synchronous emit would fire into an empty room.
142
+ queueMicrotask(() => this.#drawUnread());
143
+ }
144
+
145
+ this.started = true;
146
+ return this;
147
+ }
148
+
149
+ /**
150
+ * Open the update stream. Idempotent — init() and a first report both reach
151
+ * here, and a second connection would double every notification.
152
+ */
153
+ startWatching() {
154
+ if (this.stopWatching) return this;
155
+ this.stopWatching = watchUpdates({
156
+ apiBase: this.config.apiBase,
157
+ onUpdate: (event) => {
158
+ // A telephone, not an update — taken first and returned on, so it
159
+ // neither queues behind a status line nor lands in the unread store. A
160
+ // call is worthless thirty seconds after it is sent, and an unread
161
+ // badge that still says "someone called" tomorrow morning is a badge
162
+ // nobody trusts.
163
+ if (event?.incomingCall) { this.#ring(event); return; }
164
+ if (event?.callWithdrawn) { this.#stopRinging(event.callWithdrawn.callId); return; }
165
+
166
+ // Held before it is shown, and held until it is read. The card on
167
+ // screen is a view of this store rather than the notification itself,
168
+ // which is what lets a reload, a second tab, or the widget marking it
169
+ // read all agree about what is still outstanding.
170
+ const update = rememberUpdate(event);
171
+ if (typeof this.config.onUpdate === 'function') this.config.onUpdate(update);
172
+ // Listeners registered with on('update') get the same frame. The
173
+ // widget uses this to refresh a report the reader has open, so a
174
+ // change lands on screen rather than waiting for them to reopen it —
175
+ // and to mark it read, which is why this runs before the redraw.
176
+ this.emit('update', update);
177
+ this.#drawUnread();
178
+ },
179
+ });
180
+ this.teardown.push(() => this.stopWatching?.());
181
+ // And a card left ringing when the SDK is torn down is a card that rings
182
+ // forever: the stream that would withdraw it has just been closed.
183
+ this.teardown.push(() => this.#stopRinging());
184
+
185
+ // Another tab of the same app reads one. They share the store, so they
186
+ // share what is outstanding — without this, a card marked read in one tab
187
+ // sits there in the other, over an app that already knows about it.
188
+ const onStorage = (event) => {
189
+ // A null key is localStorage.clear(), which takes the store with it.
190
+ if (event.key === UNREAD_KEY || event.key === null) this.#drawUnread();
191
+ };
192
+ window.addEventListener('storage', onStorage);
193
+ this.teardown.push(() => window.removeEventListener('storage', onStorage));
194
+ return this;
195
+ }
196
+
197
+ /**
198
+ * Subscribe to updates on this browser's reports. Returns an unsubscribe.
199
+ *
200
+ * const off = BugCatch.on('update', (u) => console.log(u.summary));
201
+ *
202
+ * Alongside the `onUpdate` config option rather than replacing it: that one
203
+ * is the single handler an integration sets at init, this one lets several
204
+ * parts of an app listen independently — a badge, a toast of your own, an
205
+ * open report view — without them having to agree on one callback.
206
+ */
207
+ on(event, handler) {
208
+ if (typeof handler !== 'function') return () => {};
209
+ const set = this.listeners.get(event) || new Set();
210
+ set.add(handler);
211
+ this.listeners.set(event, set);
212
+ return () => this.off(event, handler);
213
+ }
214
+
215
+ off(event, handler) {
216
+ this.listeners.get(event)?.delete(handler);
217
+ return this;
218
+ }
219
+
220
+ /** Notify listeners; one that throws must not stop the rest. */
221
+ emit(event, payload) {
222
+ for (const fn of this.listeners.get(event) || []) {
223
+ try {
224
+ fn(payload);
225
+ } catch (err) {
226
+ // Someone else's handler, on someone else's page.
227
+ // eslint-disable-next-line no-console
228
+ console.error('[BugCatch] an update listener threw:', err);
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Updates this browser has been told about and nobody has read yet, oldest
235
+ * first.
236
+ *
237
+ * BugCatch.unreadUpdates().length // a badge on your own bell
238
+ *
239
+ * Survives a reload: the stream replays nothing when it reconnects, so an
240
+ * update that arrived while the tab was closed would otherwise be gone.
241
+ * Each carries the `id` the mark-read calls take.
242
+ */
243
+ unreadUpdates() {
244
+ return readUnread();
245
+ }
246
+
247
+ /**
248
+ * Mark one update read. It stops being shown, here and on the next load.
249
+ *
250
+ * BugCatch.on('update', (u) => myBell.add(u, () => BugCatch.markUpdateRead(u.id)));
251
+ *
252
+ * Read is something the reporter says, not something a timer decides — which
253
+ * is the whole reason these are held rather than shown for eight seconds and
254
+ * dropped. An integration rendering its own notifications is the one that
255
+ * knows when its reader has seen one, so it has to say.
256
+ */
257
+ markUpdateRead(id) {
258
+ forgetUpdate(id);
259
+ this.#drawUnread();
260
+ return this;
261
+ }
262
+
263
+ /**
264
+ * Mark everything about one report read.
265
+ *
266
+ * Opening the report is reading its updates, so the widget calls this when
267
+ * the thread opens; an integration with its own report view should too.
268
+ */
269
+ markReportRead(bugId) {
270
+ forgetReportUpdates(bugId);
271
+ this.#drawUnread();
272
+ return this;
273
+ }
274
+
275
+ /** Mark everything read. The "clear all" behind your own notification list. */
276
+ markUpdatesRead() {
277
+ forgetAllUpdates();
278
+ this.#drawUnread();
279
+ return this;
280
+ }
281
+
282
+ /**
283
+ * Redraw the built-in notifications, and tell anyone counting.
284
+ *
285
+ * The single path out of the unread store: every add and every mark-read ends
286
+ * here, so the cards on screen and a host app's own badge cannot disagree
287
+ * about what is outstanding.
288
+ */
289
+ #drawUnread() {
290
+ const unread = readUnread();
291
+ // Emitted whether or not the built-in cards are on — this is the number a
292
+ // host app puts on its own bell.
293
+ this.emit('unread', { count: unread.length, updates: unread });
294
+
295
+ if (!this.config.updateToast) return;
296
+ if (!this.notifier) {
297
+ if (!unread.length) return;
298
+ this.notifier = createUpdateNotifier({
299
+ accent: this.config.accent,
300
+ position: this.config.widgetPosition,
301
+ onRead: (id) => this.markUpdateRead(id),
302
+ onReadAll: () => this.markUpdatesRead(),
303
+ // The call button appears only where the API said the offer stands and
304
+ // this page has calling switched on. Pressing it reads the update —
305
+ // acting on a notification is the clearest way there is of having seen
306
+ // it — and `startCall` does the rest.
307
+ onCall: this.config.voice
308
+ ? (update) => {
309
+ this.markUpdateRead(update.id);
310
+ void this.startCall(update.bugId);
311
+ }
312
+ : null,
313
+ });
314
+ this.teardown.push(() => this.notifier?.destroy());
315
+ }
316
+ this.notifier.render(unread);
317
+ }
318
+
319
+ /**
320
+ * One report in full, with its timeline — every step from filing to now.
321
+ *
322
+ * const report = await BugCatch.myReport(id);
323
+ * report.timeline // [{ kind: 'filed' | 'status' | 'severity' | ... , summary, at }]
324
+ *
325
+ * Resolves to null when this browser cannot prove it filed that report.
326
+ */
327
+ myReport(bugId) {
328
+ return fetchReport({ apiBase: this.config.apiBase, bugId });
329
+ }
330
+
331
+ /**
332
+ * Write back on a report this browser filed.
333
+ *
334
+ * const key = await BugCatch.uploadAttachment(file, file.type);
335
+ * await BugCatch.replyToReport(id, {
336
+ * body: 'Still happening on Safari',
337
+ * attachments: [{ ...key, name: file.name, size: file.size }],
338
+ * });
339
+ *
340
+ * The widget's own conversation view calls this; it is public so an
341
+ * integration rendering its own can too. Resolves to the stored message, so
342
+ * the caller renders what the server kept rather than what it hoped it sent.
343
+ */
344
+ replyToReport(bugId, { body, attachments } = {}) {
345
+ return sendMessage({ apiBase: this.config.apiBase, bugId, body, attachments });
346
+ }
347
+
348
+ /**
349
+ * Everything a report carries, however it was started.
350
+ *
351
+ * Shared by `report()` and by pressing Call, because the two file the same
352
+ * thing — the difference is only whether a person typed the title or the
353
+ * agent asks for it. Two copies of this would drift, and the half that
354
+ * drifted would be the one that stopped attaching the console.
355
+ */
356
+ async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [] }) {
357
+ return {
358
+ projectId: this.config.projectId,
359
+ ...(title ? { title } : {}),
360
+ description,
361
+ priority,
362
+ url: window.location.href,
363
+ reporterEmail: this.config.user?.email,
364
+ // Sent alongside the address so the dashboard can show a person rather
365
+ // than a mailbox, and so the resolution email can open with their name.
366
+ reporterName: this.config.user?.name,
367
+ metadata: {
368
+ ...(await collectEnvironment()),
369
+ ...metadata,
370
+ user: this.config.user || undefined,
371
+ sdk: SDK_IDENTITY,
372
+ },
373
+ logs: {
374
+ console: { entries: this.consoleBuffer.all() },
375
+ network: { entries: this.networkBuffer.all() },
376
+ },
377
+ // Omitted rather than sent empty: the field is optional server-side, and
378
+ // an empty array in every payload makes beforeSend hooks harder to read.
379
+ ...(media.length ? { attachments: media } : {}),
380
+ };
381
+ }
382
+
383
+ /**
384
+ * File a report because somebody pressed Call instead of filling the form in.
385
+ *
386
+ * Both kinds of call come through here — the triage agent and ringing the
387
+ * team — because filing is the half the two share, and it is the half that
388
+ * has to say who is filing. Held in one place after a session header went
389
+ * missing from this route while `report()` kept sending it: nothing looked
390
+ * wrong until a project switched sign-in on, and then a signed-in reporter
391
+ * pressing Call was told to sign in.
392
+ */
393
+ async #fileByCall() {
394
+ const session = readSession(this.config.projectId);
395
+
396
+ let filed;
397
+ try {
398
+ filed = await reportByCall({
399
+ apiBase: this.config.apiBase,
400
+ apiKey: this.config.apiKey,
401
+ token: session?.token,
402
+ payload: await this.#payload({}),
403
+ });
404
+ } catch (err) {
405
+ // A session the server no longer recognises, dropped exactly as
406
+ // `report()` drops it: the widget re-reads state every time it opens, so
407
+ // forgetting it now is what makes it draw the sign-in step instead.
408
+ if (err.status === 401 && session) clearSession(this.config.projectId);
409
+ throw err;
410
+ }
411
+
412
+ // The same token `report()` keeps, and for the same reason: it is what lets
413
+ // this browser hear what happens to the report afterwards.
414
+ if (filed.watchToken) {
415
+ writeToken(filed.watchToken);
416
+ if (this.config.updates) this.startWatching();
417
+ }
418
+ return filed;
419
+ }
420
+
421
+ /**
422
+ * Whether this visitor can report something by talking about it instead.
423
+ *
424
+ * if (await BugCatch.canCallNow()) showYourOwnCallButton();
425
+ *
426
+ * Two conditions, and both have to hold before anything is drawn. The project
427
+ * has to have calling configured — false on most deployments — and this
428
+ * browser has to know who the visitor is, because a call with nobody to
429
+ * attribute it to has no watch token to follow it with and nobody to send the
430
+ * transcript to afterwards. That is the same limit every other follow-up
431
+ * feature in this SDK has: it all hangs off `identify()`.
432
+ */
433
+ async canCallNow() {
434
+ return Boolean(await this.callKind());
435
+ }
436
+
437
+ /**
438
+ * Which kind of call this visitor would get: 'agent', 'live', or null.
439
+ *
440
+ * const kind = await BugCatch.callKind();
441
+ * // 'agent' — a model answers and asks the questions the form did not
442
+ * // 'live' — the team's dashboards ring and a colleague picks up
443
+ *
444
+ * A deployment offers one or the other, never both: a sphoro.voice agent has
445
+ * exactly one mode. Worth asking rather than assuming, because the two are
446
+ * wholly different promises to make to somebody — and the wording next to the
447
+ * button has to match which one it is.
448
+ *
449
+ * The answer is remembered for the life of the page. It is a property of the
450
+ * deployment's configuration rather than of this visitor, and re-asking on
451
+ * every dialog open is a request per page view for something that changes
452
+ * about once a year.
453
+ */
454
+ async callKind() {
455
+ if (!this.config.voice) return null;
456
+ if (!this.config.user?.email) return null;
457
+ if (this.kind !== undefined) return this.kind;
458
+ this.kind = await callKindForProject({
459
+ apiBase: this.config.apiBase,
460
+ apiKey: this.config.apiKey,
461
+ projectId: this.config.projectId,
462
+ });
463
+ return this.kind;
464
+ }
465
+
466
+ /**
467
+ * Whether a report can be talked through right now.
468
+ *
469
+ * if (await BugCatch.canCall(id)) showYourOwnButton();
470
+ *
471
+ * False on every deployment without sphoro.voice configured, on every
472
+ * workspace that has not switched calling on, and on a report that has had
473
+ * its allowance of calls — so an integration rendering its own affordance can
474
+ * ask rather than guess.
475
+ */
476
+ canCall(bugId) {
477
+ if (!this.config.voice) return Promise.resolve(false);
478
+ return callAvailable({ apiBase: this.config.apiBase, bugId });
479
+ }
480
+
481
+ /**
482
+ * Talk to the triage agent.
483
+ *
484
+ * await BugCatch.startCall(id); // about a report already filed
485
+ * await BugCatch.startCall(); // instead of filling the form in
486
+ *
487
+ * With a report id, it talks about that report. With none, it files one first
488
+ * — carrying the console, network and environment this SDK is already holding
489
+ * — and then calls about that. The report is written before the call starts
490
+ * either way, so a call that drops still leaves the context behind; the
491
+ * handle carries `bugId` so the caller can say what it was filed as.
492
+ *
493
+ * Asks for the microphone, opens the call, and resolves once it is connected.
494
+ * What is said reaches the team as a message in the report's thread when the
495
+ * call ends — nobody from the team is on the line, and the agent says so.
496
+ *
497
+ * Rejects if the microphone is refused, if calling is not available, or if
498
+ * one is already running. There is deliberately no way to start a second: two
499
+ * microphones on one page talk over each other, and the second call would be
500
+ * transcribing the first. A rejection carrying `.filed` means the report was
501
+ * written and only the call failed.
502
+ */
503
+ async startCall(bugId) {
504
+ if (!this.config.voice) throw new Error('[BugCatch] calling is switched off.');
505
+ // Two checks, because there are two ways to already be on a call: one that
506
+ // has connected, and one still asking for the microphone. A double-click
507
+ // races past a check on `this.call` alone — the second press gets through
508
+ // while the first is still awaiting, and the page ends up with two sockets
509
+ // and two microphones transcribing each other.
510
+ if (this.call || this.callStarting) {
511
+ throw new Error('[BugCatch] a call is already running.');
512
+ }
513
+ this.callStarting = true;
514
+
515
+ try {
516
+ return await this.#connectCall(bugId);
517
+ } finally {
518
+ this.callStarting = false;
519
+ }
520
+ }
521
+
522
+ /** The body of startCall, so the in-flight flag is cleared on every path. */
523
+ async #connectCall(bugId) {
524
+ // Which kind of call this deployment actually offers, asked before anything
525
+ // is filed. `startCall` is the one entry point most integrations use — the
526
+ // widget's own button included — so it has to land on whichever kind the
527
+ // agent is, rather than on the one this method was originally written for.
528
+ //
529
+ // Without this the agent path ran against a person-to-person agent: the call
530
+ // came back *ringing*, the API refused it rather than connect a reporter to a
531
+ // rendezvous over the µ-law agent protocol, and all the reporter saw was a
532
+ // report they had not meant to file.
533
+ if (await this.callKind() === 'live') return this.#ringTheTeam(bugId);
534
+
535
+ let realtimeUrl;
536
+ let filed = null;
537
+
538
+ if (bugId) {
539
+ ({ realtime_url: realtimeUrl } = await requestCall({
540
+ apiBase: this.config.apiBase, bugId,
541
+ }));
542
+ } else {
543
+ // No report yet: this is somebody who pressed Call instead of filling the
544
+ // form in. One round trip files what the page was doing and starts the
545
+ // call about it, so a call that never connects still leaves the context
546
+ // behind.
547
+ filed = await this.#fileByCall();
548
+ bugId = filed.id;
549
+ realtimeUrl = filed.realtime_url;
550
+ if (!realtimeUrl) {
551
+ // Filed, but no call. Reported as such rather than thrown: the report
552
+ // exists and the caller has to be able to say so.
553
+ const err = new Error('[BugCatch] the report was filed, but no call could be started.');
554
+ err.filed = filed;
555
+ throw err;
556
+ }
557
+ }
558
+
559
+ const handle = await startVoiceCall({
560
+ realtimeUrl,
561
+ onState: (state, detail) => {
562
+ if (state === 'ended' || state === 'failed') this.call = null;
563
+ if (typeof this.config.onCallState === 'function') {
564
+ this.config.onCallState(state, { bugId, ...detail });
565
+ }
566
+ this.emit('call', { bugId, state, ...detail });
567
+ },
568
+ onTranscript: (line) => {
569
+ if (typeof this.config.onCallTranscript === 'function') {
570
+ this.config.onCallTranscript({ bugId, ...line });
571
+ }
572
+ this.emit('call-transcript', { bugId, ...line });
573
+ },
574
+ });
575
+
576
+ // Set after the await, not before: a call that never connected is not one
577
+ // this page is on, and leaving the slot taken would block the retry.
578
+ this.call = handle;
579
+ // `bugId` on the handle so a caller that did not name one — the Call
580
+ // button — can tell the reporter what their report is called.
581
+ return Object.assign(handle, { bugId, filed });
582
+ }
583
+
584
+ /**
585
+ * Talk to a person on the team.
586
+ *
587
+ * await BugCatch.startLiveCall(id);
588
+ *
589
+ * The other kind of call. `startCall` reaches the triage agent, which answers
590
+ * instantly and asks the three questions the form did not; this rings the
591
+ * team, and whoever is free picks it up. What comes back is a conversation
592
+ * with a colleague — the audio travels directly between the two browsers over
593
+ * WebRTC and never reaches bugcatch or sphoro.voice, so it is not recorded and
594
+ * not transcribed. The thread gets a note saying it happened and who took it.
595
+ *
596
+ * Resolves once the call is *ringing*, not once somebody answers — the
597
+ * handle's `onState` is how the caller follows what happens next:
598
+ *
599
+ * 'ringing' the team's dashboards are lighting up
600
+ * 'connecting' somebody answered; the two browsers are finding each other
601
+ * 'connected' they can hear each other
602
+ * 'missed' nobody picked up before the ringing stopped
603
+ * 'ended' it is over
604
+ *
605
+ * A report id is required, unlike `startCall`. Ringing a room about a report
606
+ * nobody can read would ask somebody to answer a call they know nothing
607
+ * about — and the agent call's trick of filing first and taking a title from
608
+ * the transcript is not available here, because there is no transcript.
609
+ *
610
+ * Rejects if the microphone is refused, if live calling is not available on
611
+ * this report, or if a call of either kind is already running: this page has
612
+ * one microphone, and two calls on it would talk over each other.
613
+ */
614
+ async startLiveCall(bugId) {
615
+ if (!this.config.voice) throw new Error('[BugCatch] calling is switched off.');
616
+ if (!bugId) throw new Error('[BugCatch] startLiveCall needs the id of a filed report.');
617
+ if (!liveCallSupported()) {
618
+ throw new Error('[BugCatch] this browser cannot make live calls.');
619
+ }
620
+ // The same two checks `startCall` makes, against the same slot, because the
621
+ // two kinds of call compete for the same microphone — a live call started
622
+ // while the agent is mid-sentence would be two conversations in one room.
623
+ if (this.call || this.callStarting) {
624
+ throw new Error('[BugCatch] a call is already running.');
625
+ }
626
+ this.callStarting = true;
627
+
628
+ try {
629
+ return await this.#ringTheTeam(bugId);
630
+ } finally {
631
+ this.callStarting = false;
632
+ }
633
+ }
634
+
635
+ /** The body of startLiveCall, so the in-flight flag clears on every path. */
636
+ async #ringTheTeam(bugId) {
637
+ // Before the call is placed, not after. A permission prompt takes as long
638
+ // as the person takes to read it, and asking afterwards would have a room
639
+ // full of dashboards ringing about a call its own caller may yet refuse to
640
+ // be on. That ordering is why the report below is filed *after* the
641
+ // microphone too, even though filing it first is what the agent path does:
642
+ // on this kind of call, filing is what makes the phones ring.
643
+ if (!liveCallSupported()) {
644
+ throw new Error('[BugCatch] this browser cannot make live calls.');
645
+ }
646
+ const microphone = await openMicrophone();
647
+
648
+ let started;
649
+ let filed = null;
650
+ try {
651
+ if (bugId) {
652
+ started = await requestLiveCall({ apiBase: this.config.apiBase, bugId });
653
+ } else {
654
+ // Nobody filled the form in. One round trip writes what the page was
655
+ // doing and rings the team about it — the same trade the agent path
656
+ // makes, so a call nobody answers still leaves the context behind.
657
+ filed = await this.#fileByCall();
658
+ bugId = filed.id;
659
+ started = filed.live_call;
660
+ if (!started?.realtime_url) {
661
+ // Filed, but nobody rang. Reported as such rather than thrown, so the
662
+ // caller can say what the report is called instead of losing it.
663
+ const err = new Error('[BugCatch] the report was filed, but the team could not be rung.');
664
+ err.filed = filed;
665
+ throw err;
666
+ }
667
+ }
668
+ } catch (err) {
669
+ for (const track of microphone.getTracks()) track.stop();
670
+ throw err;
671
+ }
672
+
673
+ const handle = await joinLiveCall({
674
+ realtimeUrl: started.realtime_url,
675
+ stream: microphone,
676
+ ringsUntil: started.rings_until,
677
+ onState: (state, detail) => {
678
+ if (state === 'ended' || state === 'failed' || state === 'missed') this.call = null;
679
+ // Nobody picked up, as decided by the one client that was listening to
680
+ // it. Told to the API so the thread says so; the reporter has already
681
+ // been shown it, and nothing here waits for the answer.
682
+ if (state === 'missed') {
683
+ giveUpLiveCall({ apiBase: this.config.apiBase, bugId, callId: started.call_id });
684
+ }
685
+ if (typeof this.config.onCallState === 'function') {
686
+ this.config.onCallState(state, { bugId, live: true, ...detail });
687
+ }
688
+ this.emit('call', { bugId, live: true, state, ...detail });
689
+ },
690
+ });
691
+
692
+ // Set after the await, exactly as the agent path does: a call that never
693
+ // connected is not one this page is on, and leaving the slot taken would
694
+ // block the retry.
695
+ this.call = handle;
696
+ return Object.assign(handle, { bugId, callId: started.call_id, live: true, filed });
697
+ }
698
+
699
+ /**
700
+ * Whether this report can be talked through with a person.
701
+ *
702
+ * For a host application drawing its own button. The widget asks this itself.
703
+ */
704
+ canCallTeam(bugId) {
705
+ if (!this.config.voice) return Promise.resolve(false);
706
+ return liveCallAvailable({ apiBase: this.config.apiBase, bugId });
707
+ }
708
+
709
+ // --- being called ------------------------------------------------------------
710
+
711
+ /**
712
+ * Somebody on the team is ringing this page.
713
+ *
714
+ * The only thing in this SDK that starts without the person on the page
715
+ * asking for it, which is why what it does is so much narrower than what the
716
+ * outgoing calls do: it draws a card and makes a soft noise. No microphone is
717
+ * opened and nothing is connected until Answer is pressed, and if nothing is
718
+ * pressed the card takes itself away when the ringing stops.
719
+ *
720
+ * Silently ignored when calling is switched off in this integration's config
721
+ * or the browser cannot hold a peer connection. A host application that has
722
+ * said `voice: false` has said its customers are not to be rung, and a card
723
+ * offering a call this page could not join would be worse than none.
724
+ */
725
+ #ring(event) {
726
+ const call = event.incomingCall;
727
+ if (!this.config.voice || !liveCallSupported()) return;
728
+ // Already ringing about this one — a reconnect replaying the frame, or a
729
+ // second delivery. One card either way.
730
+ if (this.ringing?.callId === call.callId) return;
731
+ // One microphone on this page, so one call. Somebody already talking is not
732
+ // interrupted by a card offering them a second conversation.
733
+ if (this.call || this.callStarting) return;
734
+
735
+ this.#stopRinging();
736
+
737
+ const dismiss = showIncomingCall({
738
+ call,
739
+ accent: this.config.accent,
740
+ position: this.config.widgetPosition,
741
+ onAnswer: () => { void this.#answer(event.bugId, call); },
742
+ onDecline: () => {
743
+ this.ringing = null;
744
+ declineIncomingCall({
745
+ apiBase: this.config.apiBase, bugId: event.bugId, callId: call.callId,
746
+ });
747
+ this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'declined' });
748
+ },
749
+ // Nothing is reported here. The member who placed it is watching it ring
750
+ // on their own screen and their browser tells the API when it stops —
751
+ // the same rule as the other direction, where whoever placed the call is
752
+ // the one that reports nobody answered.
753
+ onExpire: () => {
754
+ this.ringing = null;
755
+ this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'missed' });
756
+ },
757
+ });
758
+
759
+ this.ringing = { callId: call.callId, dismiss };
760
+ if (typeof this.config.onIncomingCall === 'function') {
761
+ // After the card, not instead of it. This is for a host application that
762
+ // wants to know — to duck its own audio, to pause a video — and not a
763
+ // hook that can suppress a call the person can see ringing.
764
+ try { this.config.onIncomingCall({ bugId: event.bugId, ...call }); } catch { /* theirs */ }
765
+ }
766
+ this.emit('incoming-call', { bugId: event.bugId, callId: call.callId, state: 'ringing' });
767
+ }
768
+
769
+ /** Take the card away, whatever it is showing. */
770
+ #stopRinging(callId) {
771
+ if (!this.ringing) return;
772
+ if (callId && this.ringing.callId !== callId) return;
773
+ try { this.ringing.dismiss(); } catch { /* already gone */ }
774
+ this.ringing = null;
775
+ }
776
+
777
+ /**
778
+ * Answer it: microphone first, then the claim, then the socket.
779
+ *
780
+ * That order is the same one every other call in this file uses and it
781
+ * matters most here. The permission prompt takes as long as the person takes
782
+ * to read it, and claiming the call before they have answered it would put
783
+ * somebody on a conversation they may yet decline to be able to speak on —
784
+ * with a member of the team on the other end saying hello to a muted browser.
785
+ */
786
+ async #answer(bugId, call) {
787
+ this.ringing = null;
788
+ if (this.call || this.callStarting) return;
789
+ this.callStarting = true;
790
+
791
+ let microphone;
792
+ try {
793
+ microphone = await openMicrophone();
794
+ } catch (err) {
795
+ this.callStarting = false;
796
+ // Refused the prompt, or no microphone on this machine. The call is still
797
+ // ringing at the other end, so it is declined rather than left — the
798
+ // member calling should not wait out the deadline for somebody who
799
+ // cannot speak to them.
800
+ declineIncomingCall({ apiBase: this.config.apiBase, bugId, callId: call.callId });
801
+ this.emit('incoming-call', { bugId, callId: call.callId, state: 'failed', error: err });
802
+ return;
803
+ }
804
+
805
+ let answered;
806
+ try {
807
+ answered = await answerIncomingCall({
808
+ apiBase: this.config.apiBase, bugId, callId: call.callId,
809
+ });
810
+ } catch (err) {
811
+ for (const track of microphone.getTracks()) track.stop();
812
+ this.callStarting = false;
813
+ // 409 is the ordinary one: it stopped ringing while Answer was being
814
+ // pressed. Reported as a state rather than thrown, because nobody called
815
+ // this — the page did, on somebody's behalf, and there is no promise
816
+ // waiting to reject.
817
+ this.emit('incoming-call', { bugId, callId: call.callId, state: 'missed', error: err });
818
+ return;
819
+ }
820
+
821
+ try {
822
+ const handle = await joinLiveCall({
823
+ realtimeUrl: answered.realtime_url,
824
+ stream: microphone,
825
+ // No deadline on this side. The ringing is over — it ended when this
826
+ // browser claimed the call — and a countdown here would hang up on a
827
+ // conversation that has already started.
828
+ onState: (state, detail) => {
829
+ if (state === 'ended' || state === 'failed' || state === 'missed') this.call = null;
830
+ if (typeof this.config.onCallState === 'function') {
831
+ this.config.onCallState(state, { bugId, live: true, incoming: true, ...detail });
832
+ }
833
+ this.emit('call', { bugId, live: true, incoming: true, state, ...detail });
834
+ },
835
+ });
836
+ this.call = handle;
837
+ this.emit('incoming-call', { bugId, callId: call.callId, state: 'answered' });
838
+ } catch (err) {
839
+ for (const track of microphone.getTracks()) track.stop();
840
+ this.emit('incoming-call', { bugId, callId: call.callId, state: 'failed', error: err });
841
+ } finally {
842
+ this.callStarting = false;
843
+ }
844
+ }
845
+
846
+ /**
847
+ * End the call in progress, of either kind. Does nothing when there is not
848
+ * one — there is a single slot, because there is a single microphone.
849
+ */
850
+ endCall() {
851
+ this.call?.hangUp();
852
+ this.call = null;
853
+ return this;
854
+ }
855
+
856
+ /**
857
+ * Everything this browser has reported, newest first, with the status of
858
+ * each — the history a reporter normally has no way to see.
859
+ *
860
+ * const { reports } = await BugCatch.myReports();
861
+ * // [{ reference: '3F2A9B01', title, status, priority, createdAt, ... }]
862
+ *
863
+ * Resolves to an empty history rather than throwing when nothing has been
864
+ * filed from this browser, or when identify() supplied no email — there is
865
+ * nothing to look up in either case.
866
+ */
867
+ myReports() {
868
+ return fetchMyReports({ apiBase: this.config.apiBase });
869
+ }
870
+
871
+ /** True when this browser has filed something, so a "your reports" entry
872
+ * point can be hidden until it would show anything. */
873
+ hasReports() {
874
+ return Boolean(readToken());
875
+ }
876
+
877
+ /**
878
+ * Forget this browser's reports — call on sign-out, or the next person at
879
+ * this machine inherits the previous one's notifications.
880
+ */
881
+ forgetReports() {
882
+ this.stopWatching?.();
883
+ this.stopWatching = null;
884
+ // Ended, not just forgotten. This is the sign-out path, and a call left
885
+ // running on it would keep a microphone open on behalf of somebody who has
886
+ // just left — and go on transcribing them into a report they can no longer
887
+ // prove is theirs.
888
+ this.endCall();
889
+ // Drops the unread notifications with the token — see clearToken — so the
890
+ // next person at a shared machine does not find the last one's bug reports
891
+ // stacked in the corner.
892
+ clearToken();
893
+ this.#drawUnread();
894
+ return this;
895
+ }
896
+
897
+ /**
898
+ * Attach the signed-in user so reports say who hit the bug.
899
+ *
900
+ * `email` is the field that matters beyond display: it threads every report
901
+ * from the same person together in the dashboard, and it is where the team's
902
+ * "your issue is resolved" email goes. Because your app has already
903
+ * authenticated this person, an address sent here is treated as reachable —
904
+ * unlike one typed into a public recording-link form, which stays unusable
905
+ * for outbound mail until the reporter answers a confirmation code.
906
+ *
907
+ * BugCatch.identify({ id: 'u_42', email: 'alex@acme.com', name: 'Alex' });
908
+ *
909
+ * Pass null to forget them again on sign-out.
910
+ */
911
+ identify(user) {
912
+ this.config.user = user;
913
+ return this;
914
+ }
915
+
916
+ /**
917
+ * Upload a screenshot, recording or file and get back the storage key that
918
+ * `report({ attachments })` references.
919
+ *
920
+ * The blob goes straight to storage on a presigned URL — it never passes
921
+ * through the API — so this is also what you call when you drive the SDK
922
+ * yourself with `widget: false`.
923
+ */
924
+ async uploadAttachment(blob, mime, onProgress) {
925
+ if (!this.started) throw new Error('[BugCatch] call init() first.');
926
+ return uploadAttachment({
927
+ apiBase: this.config.apiBase,
928
+ apiKey: this.config.apiKey,
929
+ blob,
930
+ mime: mime || blob?.type,
931
+ onProgress,
932
+ });
933
+ }
934
+
935
+ /** File a bug with everything captured so far. */
936
+ async report({
937
+ title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
938
+ } = {}) {
939
+ if (!this.started) throw new Error('[BugCatch] call init() first.');
940
+ if (!title || !title.trim()) throw new Error('[BugCatch] a title is required.');
941
+
942
+ // Drop anything the API would reject outright, so one bad entry can't cost
943
+ // the reporter the whole report.
944
+ const media = (attachments || [])
945
+ // REPORT_MIMES, not the full upload set: a report's attachments are typed
946
+ // SCREENSHOT or VIDEO, so a document reaching here would be filed as a
947
+ // screenshot. Documents and voice notes belong on a message.
948
+ .filter((a) => a?.storageKey && normalizeMime(a.mime, REPORT_MIMES))
949
+ .map((a) => ({
950
+ type: a.type === 'VIDEO' ? 'VIDEO' : 'SCREENSHOT',
951
+ storageKey: a.storageKey,
952
+ mime: normalizeMime(a.mime, REPORT_MIMES),
953
+ }));
954
+
955
+ let payload = await this.#payload({ title: title.trim(), description, priority, metadata, media });
956
+
957
+ if (typeof this.config.beforeSend === 'function') {
958
+ const result = this.config.beforeSend(payload);
959
+ if (result === false) return { cancelled: true };
960
+ if (result && typeof result === 'object') payload = result;
961
+ }
962
+
963
+ const session = readSession(this.config.projectId);
964
+ const res = await fetch(`${this.config.apiBase}/ingest/bugs`, {
965
+ method: 'POST',
966
+ headers: {
967
+ 'Content-Type': 'application/json',
968
+ 'X-BugCatch-Key': this.config.apiKey,
969
+ // Present only where the reporter signed in. The server takes the
970
+ // address from here rather than from the payload when it is — on a
971
+ // project that requires sign-in, letting the page name a different
972
+ // reporter would undo the requirement.
973
+ ...(session?.token ? { 'X-BugCatch-Session': session.token } : {}),
974
+ },
975
+ body: JSON.stringify(payload),
976
+ });
977
+
978
+ // A session the server no longer recognises — expired, or ended by a
979
+ // sign-in somewhere else. Dropped here rather than left to fail again on
980
+ // the next report: the widget re-reads state every time it opens, so
981
+ // forgetting it now is what makes it draw the sign-in step instead.
982
+ if (res.status === 401 && session) clearSession(this.config.projectId);
983
+
984
+ if (!res.ok) {
985
+ let detail = '';
986
+ try {
987
+ const body = await res.json();
988
+ detail = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
989
+ } catch { /* non-JSON error */ }
990
+ throw new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
991
+ }
992
+
993
+ const result = await res.json();
994
+
995
+ // The API returns a watch token only when it could attribute the report to
996
+ // someone — which means identify() was called with an email. Without one
997
+ // there is nobody to notify, and none comes back.
998
+ if (result.watchToken) {
999
+ writeToken(result.watchToken);
1000
+ if (this.config.updates) this.startWatching();
1001
+ }
1002
+
1003
+ return result;
1004
+ }
1005
+
1006
+ /**
1007
+ * What this project asks of the person reporting, and who this browser is
1008
+ * signed in as right now.
1009
+ *
1010
+ * const { mode, signedIn } = await BugCatch.loginState();
1011
+ * // mode: 'off' | 'optional' | 'required'
1012
+ *
1013
+ * The widget calls this itself every time it opens. It is here for
1014
+ * integrations running with `widget: false`, which have to draw their own
1015
+ * sign-in and need the same answer to know whether to draw one at all.
1016
+ *
1017
+ * The mode is the project's setting, changed in BugCatch under Project
1018
+ * Settings → General, not in this config. A widget that could opt out of a
1019
+ * required sign-in would not be a requirement.
1020
+ */
1021
+ loginState() {
1022
+ return loginState({
1023
+ apiBase: this.config.apiBase,
1024
+ apiKey: this.config.apiKey,
1025
+ projectId: this.config.projectId,
1026
+ token: readSession(this.config.projectId)?.token,
1027
+ });
1028
+ }
1029
+
1030
+ /**
1031
+ * Who this browser is signed in as on this project, from storage alone.
1032
+ *
1033
+ * BugCatch.signedInAs() // { email, name } | null
1034
+ *
1035
+ * Synchronous, and therefore only as current as the last round trip: a
1036
+ * session ended elsewhere still reads as signed in here until something asks
1037
+ * the server. Use loginState() where that matters.
1038
+ */
1039
+ signedInAs() {
1040
+ const session = readSession(this.config.projectId);
1041
+ return session ? { email: session.email, name: session.name } : null;
1042
+ }
1043
+
1044
+ /**
1045
+ * Step one of signing in: mail a code to any address.
1046
+ *
1047
+ * await BugCatch.signIn('someone@example.com');
1048
+ * await BugCatch.confirmSignIn('someone@example.com', '481920');
1049
+ *
1050
+ * Any address, deliberately — there is no list of people who may report a
1051
+ * bug in somebody's product, and asking for one would be asking the customer
1052
+ * to maintain it. What the code proves is that whoever typed it can read the
1053
+ * address, which is all a reply-to has ever needed to be worth.
1054
+ */
1055
+ signIn(email, { name } = {}) {
1056
+ return startSignIn({
1057
+ apiBase: this.config.apiBase,
1058
+ apiKey: this.config.apiKey,
1059
+ projectId: this.config.projectId,
1060
+ email,
1061
+ // The name the host app already knows beats asking for one again.
1062
+ name: name ?? this.config.user?.name ?? null,
1063
+ });
1064
+ }
1065
+
1066
+ /**
1067
+ * Step two: spend the code and hold the session.
1068
+ *
1069
+ * Signing in signs this address out of this project in every other browser —
1070
+ * one live session per address per project. Somebody who signs in on their
1071
+ * phone has signed their laptop out, on purpose.
1072
+ */
1073
+ async confirmSignIn(email, code) {
1074
+ const session = await confirmSignIn({
1075
+ apiBase: this.config.apiBase,
1076
+ apiKey: this.config.apiKey,
1077
+ projectId: this.config.projectId,
1078
+ email,
1079
+ code,
1080
+ });
1081
+
1082
+ // A signed-in reporter is an identity, so reports carry it the same way
1083
+ // identify() makes them — but never over one the host application set:
1084
+ // that address is its own claim about its own user, and this SDK is not
1085
+ // the thing that decides it was wrong.
1086
+ if (!this.config.user?.email) {
1087
+ this.config.user = { email: session.email, name: session.name || undefined };
1088
+ }
1089
+ this.emit('signin', { email: session.email, name: session.name });
1090
+ return { email: session.email, name: session.name };
1091
+ }
1092
+
1093
+ /**
1094
+ * Sign out of this project.
1095
+ *
1096
+ * Ends the session on the server too, so the token cannot be replayed from a
1097
+ * copy of this browser's storage. Local state goes whatever the network
1098
+ * says — sign-out on a shared machine has to mean signed out on it.
1099
+ */
1100
+ async signOut() {
1101
+ const session = readSession(this.config.projectId);
1102
+ const result = await endSignIn({
1103
+ apiBase: this.config.apiBase,
1104
+ apiKey: this.config.apiKey,
1105
+ projectId: this.config.projectId,
1106
+ token: session?.token,
1107
+ });
1108
+ // Only the identity this session put there. One the host application set
1109
+ // through identify() is theirs, and outlives a widget sign-out.
1110
+ if (session?.email && this.config.user?.email === session.email) this.config.user = null;
1111
+ this.emit('signout', { email: session?.email || null });
1112
+ return result;
1113
+ }
1114
+
1115
+ /** What would be sent right now — useful for debugging integrations. */
1116
+ /**
1117
+ * What this project is already known to be having trouble with.
1118
+ *
1119
+ * The widget asks this itself when it opens; it is public for a host
1120
+ * application drawing its own help surface with `widget: false`. Resolves to
1121
+ * an empty list on every project that has not switched the feature on, which
1122
+ * is the default — draw nothing rather than an empty heading.
1123
+ */
1124
+ knownIssues() {
1125
+ return fetchKnownIssues({
1126
+ apiBase: this.config.apiBase,
1127
+ apiKey: this.config.apiKey,
1128
+ projectId: this.config.projectId,
1129
+ sessionToken: readSession(this.config.projectId)?.token,
1130
+ });
1131
+ }
1132
+
1133
+ /**
1134
+ * The known issues that look like a description somebody is writing.
1135
+ *
1136
+ * Pass an AbortSignal when calling this on a keystroke, or a fast typist gets
1137
+ * the results of whichever request happens to return last.
1138
+ */
1139
+ matchKnownIssues(query, { signal } = {}) {
1140
+ return matchKnownIssues({
1141
+ apiBase: this.config.apiBase,
1142
+ apiKey: this.config.apiKey,
1143
+ projectId: this.config.projectId,
1144
+ q: query || '',
1145
+ sessionToken: readSession(this.config.projectId)?.token,
1146
+ signal,
1147
+ });
1148
+ }
1149
+
1150
+ /**
1151
+ * Say a known issue is happening to you too, instead of filing a second
1152
+ * report about it.
1153
+ *
1154
+ * Sends the same capture a report would — this browser's console, network and
1155
+ * environment — because that is what the team gets out of somebody joining
1156
+ * rather than filing: one report with everybody's environment on it.
1157
+ *
1158
+ * Needs an address. A signed-in widget has one, and identify() supplies one
1159
+ * otherwise; pass `email` for the case where neither did. Joining means "tell
1160
+ * me when this is fixed", and there is no way to keep that promise to
1161
+ * somebody anonymous.
1162
+ */
1163
+ async meToo(bugId, { email, name } = {}) {
1164
+ const session = readSession(this.config.projectId);
1165
+ const environment = await collectEnvironment();
1166
+ return joinKnownIssue({
1167
+ apiBase: this.config.apiBase,
1168
+ apiKey: this.config.apiKey,
1169
+ projectId: this.config.projectId,
1170
+ bugId,
1171
+ reporterEmail: email || this.config.user?.email,
1172
+ reporterName: name || this.config.user?.name,
1173
+ metadata: { ...environment, user: this.config.user || undefined, sdk: SDK_IDENTITY },
1174
+ logs: {
1175
+ console: { entries: this.consoleBuffer.all() },
1176
+ network: { entries: this.networkBuffer.all() },
1177
+ },
1178
+ sessionToken: session?.token,
1179
+ });
1180
+ }
1181
+
1182
+ snapshot() {
1183
+ return {
1184
+ console: this.consoleBuffer.all(),
1185
+ network: this.networkBuffer.all(),
1186
+ environment: collectEnvironmentSync(),
1187
+ };
1188
+ }
1189
+
1190
+ clear() {
1191
+ this.consoleBuffer.clear();
1192
+ this.networkBuffer.clear();
1193
+ }
1194
+
1195
+ /** Restore every patched global and remove the widget. */
1196
+ destroy() {
1197
+ // The call first. Everything else here is listeners and buffers; this one
1198
+ // holds a live microphone, and a page that tore the SDK down without
1199
+ // releasing it would keep the tab's recording indicator lit.
1200
+ this.endCall();
1201
+ this.teardown.forEach((fn) => { try { fn(); } catch { /* ignore */ } });
1202
+ this.teardown = [];
1203
+ // Torn down with everything else, so a later init() builds a fresh one
1204
+ // rather than rendering into a stack that has been removed.
1205
+ this.notifier = null;
1206
+ this.started = false;
1207
+ }
1208
+ }
1209
+
1210
+ const BugCatch = new BugCatchClient();
1211
+
1212
+ // Exposed on the singleton rather than as a second named export: with a UMD
1213
+ // build, a named export alongside the default turns `window.BugCatch` into a
1214
+ // namespace object, so `BugCatch.init(...)` from a <script> tag would break.
1215
+ BugCatch.Client = BugCatchClient;
1216
+
1217
+ export default BugCatch;