@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/updates.js ADDED
@@ -0,0 +1,1062 @@
1
+ // Live status changes on the reports this browser has filed.
2
+ //
3
+ // The person who reported a bug is the one person a tracker usually forgets to
4
+ // tell. They are already in your app, so this is where they hear about it —
5
+ // not in an email they may not read, and not on a page they have to remember
6
+ // to visit.
7
+ //
8
+ // Authentication is the token handed back when the report was filed, kept in
9
+ // localStorage. Deliberately not the API key: that is publishable and sits in
10
+ // your page source, so a key-authenticated feed keyed on an email address would
11
+ // let anyone read anyone's reports. Holding this token is proof of having
12
+ // filed them.
13
+
14
+ const TOKEN_KEY = 'bugcatch.watch';
15
+ const BACKOFF_MS = [2000, 5000, 15000, 30000, 60000];
16
+
17
+ /**
18
+ * How long a stream has to survive before it counts as having worked.
19
+ *
20
+ * The backoff above is only a backoff if "it failed" and "it worked" are told
21
+ * apart by more than the response arriving, and they were not: the counter
22
+ * reset the moment the headers landed. A server that accepts the connection and
23
+ * ends it immediately therefore reset the backoff every time, so the delay
24
+ * never left its first rung and the retry became a two-second loop that no
25
+ * amount of failing could slow down.
26
+ *
27
+ * That is not hypothetical here. An expired watch token used to be answered
28
+ * with a 200 whose body closed at once — the status could not be set, because
29
+ * an SSE response has already begun by the time the token is opened — and this
30
+ * client only treats a 401 as fatal. Prod on 2026-08-31: 6,001 of 6,012
31
+ * requests to the stream aborted under 10ms, ~50/min, for hours. The server
32
+ * sends a real 401 now (see WatchTokenGuard in the API), and this is the other
33
+ * half: a stream that ends the instant it opens must cost a longer wait than
34
+ * the one before it, whatever the reason.
35
+ *
36
+ * Comfortably over the connect-and-die case and well under the 25s server
37
+ * heartbeat, so a stream genuinely carrying traffic always qualifies.
38
+ */
39
+ const STABLE_MS = 5000;
40
+
41
+ /** Where the watch token lives. Storage can throw in private modes — never fatal. */
42
+ export function readToken() {
43
+ try {
44
+ return window.localStorage?.getItem(TOKEN_KEY) || null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export function writeToken(token) {
51
+ if (!token) return;
52
+ try {
53
+ window.localStorage?.setItem(TOKEN_KEY, token);
54
+ } catch { /* storage unavailable — updates just won't survive a reload */ }
55
+ }
56
+
57
+ export function clearToken() {
58
+ try {
59
+ window.localStorage?.removeItem(TOKEN_KEY);
60
+ } catch { /* ignore */ }
61
+ // Anything still waiting to be read goes with it. Unread updates are about
62
+ // reports this browser can no longer prove it filed — and on the sign-out
63
+ // path, about somebody who has left a machine the next person will use.
64
+ forgetAllUpdates();
65
+ }
66
+
67
+ /* ---------------------------------------------------------------------------
68
+ * Unread updates.
69
+ *
70
+ * An update used to be shown for eight seconds and then dropped on the floor.
71
+ * Somebody switching tabs, reading something else on the page, or away from
72
+ * the keyboard when their bug was fixed simply never found out — the one
73
+ * notification they were waiting for was the one most likely to be missed.
74
+ *
75
+ * So an update is now held until it has been read, and "read" is something the
76
+ * reporter says rather than something a timer decides. The store below is what
77
+ * remembers that across a reload: the stream replays nothing on reconnect, so
78
+ * an unread notification that lived only in a variable would not survive the
79
+ * reporter pressing refresh.
80
+ *
81
+ * Kept beside the watch token, and dropped with it — a browser that can no
82
+ * longer prove it filed anything has no reports to be told about.
83
+ * ------------------------------------------------------------------------ */
84
+
85
+ export const UNREAD_KEY = 'bugcatch.unread';
86
+ // Enough that a fortnight away does not lose anything, small enough that the
87
+ // store stays a notification list rather than a history. The full record of
88
+ // what happened lives on the report itself.
89
+ const UNREAD_LIMIT = 20;
90
+
91
+ /**
92
+ * A stable id for an update, so the same one is not shown twice and so a host
93
+ * app can say which one it has read.
94
+ *
95
+ * Built from the frame rather than generated, because it has to survive a
96
+ * reload: the server sends no id of its own, and a random one would make every
97
+ * restored notification a new notification.
98
+ */
99
+ export function updateId(event) {
100
+ return [event?.bugId || 'report', event?.kind || 'status', event?.at || ''].join('|');
101
+ }
102
+
103
+ /** Unread updates, oldest first. Never throws — a corrupt store reads as empty. */
104
+ export function readUnread() {
105
+ try {
106
+ const raw = window.localStorage?.getItem(UNREAD_KEY);
107
+ const list = raw ? JSON.parse(raw) : [];
108
+ return Array.isArray(list) ? list.filter((u) => u && typeof u.id === 'string') : [];
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ function writeUnread(list) {
115
+ try {
116
+ if (list.length) window.localStorage?.setItem(UNREAD_KEY, JSON.stringify(list));
117
+ else window.localStorage?.removeItem(UNREAD_KEY);
118
+ } catch { /* storage unavailable — unread then lasts only for this page */ }
119
+ }
120
+
121
+ /**
122
+ * Hold an update until it is read. Returns the stored record, which is the
123
+ * frame the API sent plus the id everything else refers to it by.
124
+ *
125
+ * Re-delivery of one already held replaces it in place rather than stacking a
126
+ * second copy: a reconnect that repeats a frame must not double the badge.
127
+ */
128
+ export function rememberUpdate(event) {
129
+ const record = { ...event, id: updateId(event) };
130
+ const list = readUnread().filter((u) => u.id !== record.id);
131
+ list.push(record);
132
+ writeUnread(list.slice(-UNREAD_LIMIT));
133
+ return record;
134
+ }
135
+
136
+ /** Read. Returns what is still unread. */
137
+ export function forgetUpdate(id) {
138
+ const list = readUnread().filter((u) => u.id !== id);
139
+ writeUnread(list);
140
+ return list;
141
+ }
142
+
143
+ /**
144
+ * Everything about one report, read.
145
+ *
146
+ * Opening the report *is* reading its updates — the widget calls this when the
147
+ * thread opens — and leaving a "your report was fixed" card up over the screen
148
+ * where they are reading that it was fixed would be the notification arguing
149
+ * with itself.
150
+ */
151
+ export function forgetReportUpdates(bugId) {
152
+ const list = readUnread().filter((u) => u.bugId !== bugId);
153
+ writeUnread(list);
154
+ return list;
155
+ }
156
+
157
+ /** All read. */
158
+ export function forgetAllUpdates() {
159
+ writeUnread([]);
160
+ return [];
161
+ }
162
+
163
+ /**
164
+ * Pull complete SSE frames out of a growing buffer, returning the partial one
165
+ * left over. Frames are separated by a blank line and one can span several
166
+ * `data:` lines, so this cannot split on newlines — a payload cut in half by
167
+ * the network would parse as garbage.
168
+ */
169
+ function drain(buffer, onFrame) {
170
+ let rest = buffer;
171
+ let split = rest.indexOf('\n\n');
172
+ while (split !== -1) {
173
+ const frame = rest.slice(0, split);
174
+ rest = rest.slice(split + 2);
175
+
176
+ const data = frame
177
+ .split('\n')
178
+ .filter((line) => line.startsWith('data:'))
179
+ .map((line) => line.slice(5).trim())
180
+ .join('\n');
181
+
182
+ if (data) {
183
+ try {
184
+ onFrame(JSON.parse(data));
185
+ } catch { /* a malformed frame is skipped, never fatal to the stream */ }
186
+ }
187
+ split = rest.indexOf('\n\n');
188
+ }
189
+ return rest;
190
+ }
191
+
192
+ /**
193
+ * Everything this browser has reported, with where each one got to.
194
+ *
195
+ * Same credential as the stream: the token handed back when a report was
196
+ * filed. Returns an empty history rather than throwing when nothing has been
197
+ * filed here — "you have not reported anything" is an answer, not an error.
198
+ */
199
+ export async function fetchMyReports({ apiBase }) {
200
+ const token = readToken();
201
+ if (!token) return { reporter: null, reports: [] };
202
+
203
+ const res = await fetch(`${apiBase}/ingest/watch/reports`, {
204
+ headers: { Authorization: `Bearer ${token}` },
205
+ });
206
+
207
+ // Expired or revoked. Drop it so a later report can replace it, and answer
208
+ // as though nothing had been filed — which, as far as this browser can now
209
+ // prove, is true.
210
+ if (res.status === 401) {
211
+ clearToken();
212
+ return { reporter: null, reports: [] };
213
+ }
214
+ if (!res.ok) throw new Error(`[BugCatch] could not load your reports (HTTP ${res.status})`);
215
+ return res.json();
216
+ }
217
+
218
+ /**
219
+ * One report in full, including how it got to where it is.
220
+ *
221
+ * The list endpoint answers "what have I reported and where is each one"; this
222
+ * one answers "what has actually happened to this", which is the question
223
+ * somebody opening a report is asking. Same credential as the rest.
224
+ */
225
+ export async function fetchReport({ apiBase, bugId }) {
226
+ const token = readToken();
227
+ if (!token) return null;
228
+
229
+ const res = await fetch(`${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}`, {
230
+ headers: { Authorization: `Bearer ${token}` },
231
+ });
232
+ if (res.status === 401) {
233
+ clearToken();
234
+ return null;
235
+ }
236
+ if (res.status === 404) return null;
237
+ if (!res.ok) throw new Error(`[BugCatch] could not load that report (HTTP ${res.status})`);
238
+ return res.json();
239
+ }
240
+
241
+ /**
242
+ * Write back on a report this browser filed.
243
+ *
244
+ * The other half of the feed. Everything above tells the reporter what happened
245
+ * to their report; this is how they answer — "still broken on Safari", "here is
246
+ * the file that breaks it" — without leaving the app they hit the bug in.
247
+ *
248
+ * Same credential as the rest: the watch token, which is proof of having filed
249
+ * this. Resolves to the stored message, so the caller can render exactly what
250
+ * the server kept rather than what it hoped it sent.
251
+ */
252
+ export async function sendMessage({ apiBase, bugId, body, attachments }) {
253
+ const token = readToken();
254
+ if (!token) throw new Error('[BugCatch] nothing filed from this browser yet.');
255
+
256
+ const res = await fetch(
257
+ `${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}/messages`,
258
+ {
259
+ method: 'POST',
260
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
261
+ body: JSON.stringify({
262
+ ...(body ? { body } : {}),
263
+ ...(attachments?.length ? { attachments } : {}),
264
+ }),
265
+ },
266
+ );
267
+
268
+ if (res.status === 401) {
269
+ clearToken();
270
+ throw new Error('[BugCatch] this browser can no longer prove it filed that report.');
271
+ }
272
+ if (!res.ok) {
273
+ // The API's own message is worth surfacing — "Unsupported file type" and
274
+ // "Write something, or attach a file" are both things the sender can fix,
275
+ // and an HTTP code is not.
276
+ let detail = '';
277
+ try {
278
+ const problem = await res.json();
279
+ detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
280
+ } catch { /* non-JSON error */ }
281
+ throw new Error(detail || `[BugCatch] could not send that (HTTP ${res.status}).`);
282
+ }
283
+ return res.json();
284
+ }
285
+
286
+ /**
287
+ * Whether this project can take a call instead of a form.
288
+ *
289
+ * Asked before anything has been filed, so this one is authenticated by the
290
+ * publishable API key rather than by a watch token — there is no report yet to
291
+ * hold a token for. It answers a fact about the project's configuration and
292
+ * nothing about any report, which is all a publishable key should ever be able
293
+ * to learn.
294
+ */
295
+ export async function callAvailableForProject({ apiBase, apiKey, projectId }) {
296
+ return Boolean(await callKindForProject({ apiBase, apiKey, projectId }));
297
+ }
298
+
299
+ /**
300
+ * Which kind of call this project can take: 'agent', 'live', or null for none.
301
+ *
302
+ * The same round trip as above, answering the question that actually matters.
303
+ * A deployment offers one or the other and never both — a sphoro.voice agent has
304
+ * exactly one mode, and it is either a model that answers or a rendezvous that
305
+ * rings the team. Asking only "can it call" is what drew a triage button on a
306
+ * deployment whose agent had been switched to person-to-person: pressing it filed
307
+ * the report and then failed the call, so the reporter got a ticket they had not
308
+ * asked for and nobody's dashboard rang.
309
+ *
310
+ * Null for every failure, and for an older API that answers without `kind` but
311
+ * says it is available — that deployment is on the agent, because live calling
312
+ * did not exist before the field did.
313
+ */
314
+ export async function callKindForProject({ apiBase, apiKey, projectId }) {
315
+ try {
316
+ const res = await fetch(
317
+ `${apiBase}/ingest/calls?projectId=${encodeURIComponent(projectId)}`,
318
+ { headers: { 'X-BugCatch-Key': apiKey } },
319
+ );
320
+ if (!res.ok) return null;
321
+ const body = await res.json();
322
+ if (!body?.available) return null;
323
+ return body.kind === 'live' || body.kind === 'agent' ? body.kind : 'agent';
324
+ } catch {
325
+ return null;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * File a report by calling about it, and get back the socket to talk on.
331
+ *
332
+ * The report is written before the call starts rather than after it ends, so
333
+ * whatever this page was doing survives a call that drops or a reporter who
334
+ * changes their mind. `realtime_url` is null when the report was filed but no
335
+ * call could be placed — which is a success for the report and a failure only
336
+ * for the call, and the caller is expected to tell them apart.
337
+ */
338
+ export async function reportByCall({ apiBase, apiKey, token, payload }) {
339
+ const res = await fetch(`${apiBase}/ingest/calls`, {
340
+ method: 'POST',
341
+ headers: {
342
+ 'Content-Type': 'application/json',
343
+ 'X-BugCatch-Key': apiKey,
344
+ // The same header `report()` sends, and for the same reason: this route
345
+ // files a report too. Without it a project that requires sign-in refuses
346
+ // the call outright — and the reporter is told to sign in by a panel that
347
+ // is saying, an inch below the message, who they are signed in as.
348
+ ...(token ? { 'X-BugCatch-Session': token } : {}),
349
+ },
350
+ body: JSON.stringify(payload),
351
+ });
352
+
353
+ if (!res.ok) {
354
+ let detail = '';
355
+ try {
356
+ const problem = await res.json();
357
+ detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
358
+ } catch { /* non-JSON error */ }
359
+ const err = new Error(
360
+ detail || `[BugCatch] the call could not be started (HTTP ${res.status}).`,
361
+ );
362
+ // Carried so a session the server has stopped recognising can be told from
363
+ // every other refusal and forgotten, rather than pressing Call into the
364
+ // same 401 again.
365
+ err.status = res.status;
366
+ throw err;
367
+ }
368
+ return res.json();
369
+ }
370
+
371
+ /**
372
+ * Whether this report can be talked through with the triage agent.
373
+ *
374
+ * Asked before anything is rendered, so a button never appears on a deployment
375
+ * with no sphoro.voice configured, on a workspace that has not switched calling
376
+ * on, or on a report that has already had its allowance. False for every
377
+ * failure too: a call is an extra, and an extra that cannot be offered quietly
378
+ * is better than an error about a feature nobody asked for.
379
+ */
380
+ export async function callAvailable({ apiBase, bugId }) {
381
+ const token = readToken();
382
+ if (!token) return false;
383
+
384
+ try {
385
+ const res = await fetch(
386
+ `${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}/voice-call`,
387
+ { headers: { Authorization: `Bearer ${token}` } },
388
+ );
389
+ if (!res.ok) return false;
390
+ const body = await res.json();
391
+ return Boolean(body?.offered);
392
+ } catch {
393
+ return false;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Ask for a call, and get back the socket to hold it on.
399
+ *
400
+ * The response carries a URL with a short-lived credential in it and nothing
401
+ * else — no key ever reaches this page. Which is also why the URL is passed
402
+ * straight to the caller and never logged or stored.
403
+ */
404
+ export async function requestCall({ apiBase, bugId }) {
405
+ const token = readToken();
406
+ if (!token) throw new Error('[BugCatch] nothing filed from this browser yet.');
407
+
408
+ const res = await fetch(
409
+ `${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}/voice-call`,
410
+ { method: 'POST', headers: { Authorization: `Bearer ${token}` } },
411
+ );
412
+
413
+ if (res.status === 401) {
414
+ clearToken();
415
+ throw new Error('[BugCatch] this browser can no longer prove it filed that report.');
416
+ }
417
+ if (!res.ok) {
418
+ // The API's own message is the one worth showing: "you have already talked
419
+ // this report through" is something the reader can act on, and a status
420
+ // code is not.
421
+ let detail = '';
422
+ try {
423
+ const problem = await res.json();
424
+ detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
425
+ } catch { /* non-JSON error */ }
426
+ throw new Error(detail || `[BugCatch] the call could not be started (HTTP ${res.status}).`);
427
+ }
428
+ return res.json();
429
+ }
430
+
431
+ /**
432
+ * Whether this report can be talked through with a *person*.
433
+ *
434
+ * A separate question from `callAvailable` and separately switched on, so the
435
+ * widget cannot infer either answer from the other: a workspace may run the
436
+ * triage agent, or a live line to its team, or both, or neither — and neither
437
+ * is the common case.
438
+ */
439
+ export async function liveCallAvailable({ apiBase, bugId }) {
440
+ const token = readToken();
441
+ if (!token) return false;
442
+
443
+ try {
444
+ const res = await fetch(
445
+ `${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}/live-call`,
446
+ { headers: { Authorization: `Bearer ${token}` } },
447
+ );
448
+ if (!res.ok) return false;
449
+ const body = await res.json();
450
+ return Boolean(body?.offered);
451
+ } catch {
452
+ return false;
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Ring the team, and get back the socket to wait on.
458
+ *
459
+ * Carries `rings_until` as well as the socket, because the browser is what
460
+ * decides the ringing is over — it is the one client guaranteed to still be
461
+ * listening at that moment, and a server-side timer would have to guess whether
462
+ * anybody was still there to hear it stop.
463
+ */
464
+ export async function requestLiveCall({ apiBase, bugId }) {
465
+ const token = readToken();
466
+ if (!token) throw new Error('[BugCatch] nothing filed from this browser yet.');
467
+
468
+ const res = await fetch(
469
+ `${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}/live-call`,
470
+ { method: 'POST', headers: { Authorization: `Bearer ${token}` } },
471
+ );
472
+
473
+ if (res.status === 401) {
474
+ clearToken();
475
+ throw new Error('[BugCatch] this browser can no longer prove it filed that report.');
476
+ }
477
+ if (!res.ok) {
478
+ // The API's own message is the one worth showing: "there is already a call
479
+ // in progress about this report" is something the reader can act on, and a
480
+ // status code is not.
481
+ let detail = '';
482
+ try {
483
+ const problem = await res.json();
484
+ detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
485
+ } catch { /* non-JSON error */ }
486
+ throw new Error(detail || `[BugCatch] the call could not be started (HTTP ${res.status}).`);
487
+ }
488
+ return res.json();
489
+ }
490
+
491
+ /**
492
+ * Say that nobody picked up.
493
+ *
494
+ * Best-effort and deliberately unreported: the reporter's call is over either
495
+ * way, and the only thing this changes is what the team's thread says about it
496
+ * afterwards. sphoro.voice refuses it while the call is genuinely still
497
+ * ringing, so a browser with a fast clock cannot hang up on somebody halfway
498
+ * through answering.
499
+ */
500
+ export async function giveUpLiveCall({ apiBase, bugId, callId }) {
501
+ const token = readToken();
502
+ if (!token) return;
503
+
504
+ try {
505
+ await fetch(
506
+ `${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}`
507
+ + `/live-call/${encodeURIComponent(callId)}/give-up`,
508
+ { method: 'POST', headers: { Authorization: `Bearer ${token}` } },
509
+ );
510
+ } catch { /* the call is over regardless */ }
511
+ }
512
+
513
+ /**
514
+ * Take a call the team placed, and get back the socket to hold it on.
515
+ *
516
+ * The other direction, and the only call this SDK does not start. A member
517
+ * pressed a button on the report and the update stream put a card on screen;
518
+ * this is what happens when the person reading it presses Answer, and nothing
519
+ * connects — and no microphone opens — until they do.
520
+ *
521
+ * 409 is an ordinary outcome and says so plainly: the ringing stopped while the
522
+ * button was being pressed, or this is a second tab losing to the one the
523
+ * person actually answered in.
524
+ */
525
+ export async function answerIncomingCall({ apiBase, bugId, callId }) {
526
+ const token = readToken();
527
+ if (!token) throw new Error('[BugCatch] nothing filed from this browser yet.');
528
+
529
+ const res = await fetch(
530
+ `${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}`
531
+ + `/live-call/${encodeURIComponent(callId)}/answer`,
532
+ { method: 'POST', headers: { Authorization: `Bearer ${token}` } },
533
+ );
534
+
535
+ if (res.status === 401) {
536
+ clearToken();
537
+ throw new Error('[BugCatch] this browser can no longer prove it filed that report.');
538
+ }
539
+ if (res.status === 409) {
540
+ throw new Error('[BugCatch] that call is no longer ringing.');
541
+ }
542
+ if (!res.ok) {
543
+ let detail = '';
544
+ try {
545
+ const problem = await res.json();
546
+ detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
547
+ } catch { /* non-JSON error */ }
548
+ throw new Error(detail || `[BugCatch] the call could not be answered (HTTP ${res.status}).`);
549
+ }
550
+ return res.json();
551
+ }
552
+
553
+ /**
554
+ * Or don't.
555
+ *
556
+ * Sent rather than left to time out, because the two are different facts on the
557
+ * report: somebody who was not at the screen invites being rung again in ten
558
+ * minutes and somebody who declined does not. Best-effort and unreported — the
559
+ * card is going away either way, and there is nothing the reader could do about
560
+ * a failure here.
561
+ */
562
+ export async function declineIncomingCall({ apiBase, bugId, callId }) {
563
+ const token = readToken();
564
+ if (!token) return;
565
+
566
+ try {
567
+ await fetch(
568
+ `${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}`
569
+ + `/live-call/${encodeURIComponent(callId)}/decline`,
570
+ { method: 'POST', headers: { Authorization: `Bearer ${token}` } },
571
+ );
572
+ } catch { /* they have declined; nothing here can change that */ }
573
+ }
574
+
575
+ /**
576
+ * Watch for updates until stopped. Returns a stop function.
577
+ *
578
+ * Reconnects with backoff, which starts slower than the dashboard's: this runs
579
+ * on someone else's site, and a tight retry loop against their network is not
580
+ * ours to spend.
581
+ */
582
+ export function watchUpdates({ apiBase, onUpdate }) {
583
+ let running = true;
584
+ let attempt = 0;
585
+ let controller = null;
586
+
587
+ async function connect() {
588
+ const token = readToken();
589
+ if (!token) throw new Error('nothing filed from this browser yet');
590
+
591
+ controller = new AbortController();
592
+ const res = await fetch(`${apiBase}/ingest/watch/stream`, {
593
+ headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
594
+ signal: controller.signal,
595
+ });
596
+
597
+ // The token expired or was revoked. Retrying cannot fix that, and the
598
+ // stale token is dropped so a later report can replace it.
599
+ if (res.status === 401) {
600
+ running = false;
601
+ clearToken();
602
+ throw new Error('subscription no longer valid');
603
+ }
604
+ if (!res.ok || !res.body) throw new Error(`stream failed (${res.status})`);
605
+
606
+ const openedAt = Date.now();
607
+ const reader = res.body.getReader();
608
+ const decoder = new TextDecoder();
609
+ let buffer = '';
610
+
611
+ try {
612
+ for (;;) {
613
+ const { value, done } = await reader.read();
614
+ if (done) break;
615
+ buffer += decoder.decode(value, { stream: true });
616
+ buffer = drain(buffer, (event) => {
617
+ if (event.type !== 'ping') {
618
+ try {
619
+ onUpdate(event);
620
+ } catch { /* a host handler that throws must not kill the stream */ }
621
+ }
622
+ });
623
+ }
624
+ } finally {
625
+ // In a `finally` so a stream that ends by throwing is judged the same way
626
+ // as one that ends cleanly — both have to earn the reset by lasting.
627
+ if (Date.now() - openedAt >= STABLE_MS) attempt = 0;
628
+ }
629
+ }
630
+
631
+ (async () => {
632
+ while (running) {
633
+ try {
634
+ await connect();
635
+ } catch (err) {
636
+ if (err?.name === 'AbortError') return;
637
+ }
638
+ if (!running) return;
639
+ const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
640
+ attempt += 1;
641
+ await new Promise((resolve) => setTimeout(resolve, wait));
642
+ }
643
+ })();
644
+
645
+ return () => {
646
+ running = false;
647
+ controller?.abort();
648
+ controller = null;
649
+ };
650
+ }
651
+
652
+ const STATUS_COPY = {
653
+ OPEN: 'is open',
654
+ IN_PROGRESS: 'is being worked on',
655
+ RESOLVED: 'has been fixed',
656
+ CLOSED: 'has been closed',
657
+ };
658
+
659
+ /**
660
+ * The colour each kind of update wears, so a fix and a severity change are not
661
+ * the same anonymous grey card. Keyed on the update kind the API sends.
662
+ */
663
+ const KIND_ACCENT = {
664
+ status: null, // falls back to the host's configured accent
665
+ severity: '#e8912a',
666
+ assignment: '#4da3ff',
667
+ reply: '#35b87f',
668
+ };
669
+
670
+ /**
671
+ * The headline for an update.
672
+ *
673
+ * The API writes this line — it knows what changed and phrases it for the
674
+ * person who reported it, and one copy of that wording serves the toast, the
675
+ * timeline and the portal. `summary` is preferred for exactly that reason; the
676
+ * status fallback below is only for a page still talking to an older API.
677
+ */
678
+ export function updateHeadline(event) {
679
+ if (event?.summary) return event.summary;
680
+ return `Your report ${STATUS_COPY[event?.status] || 'was updated'}`;
681
+ }
682
+
683
+ /** The second line: a shared reply reads as the reply, everything else names the report. */
684
+ function updateDetail(event) {
685
+ if (event?.kind === 'reply' && event.reply?.body) {
686
+ const who = event.reply.author ? `${event.reply.author}: ` : '';
687
+ return `${who}${event.reply.body}`;
688
+ }
689
+ return event?.title || '';
690
+ }
691
+
692
+ /** How long ago, in the fewest words. Persisted cards outlive the moment. */
693
+ function agoLabel(at) {
694
+ const then = Date.parse(at || '');
695
+ if (!Number.isFinite(then)) return '';
696
+ const mins = Math.round((Date.now() - then) / 60000);
697
+ if (mins < 1) return 'just now';
698
+ if (mins < 60) return `${mins}m ago`;
699
+ const hours = Math.round(mins / 60);
700
+ if (hours < 24) return `${hours}h ago`;
701
+ const days = Math.round(hours / 24);
702
+ return days === 1 ? 'yesterday' : `${days}d ago`;
703
+ }
704
+
705
+ // At most this many cards on screen at once; the rest are counted on a line
706
+ // under them. Somebody who has been away for a week should find a corner they
707
+ // can still see their app through.
708
+ const MAX_CARDS = 3;
709
+
710
+ /**
711
+ * How far above the corner to start, so the stack clears the widget's launcher
712
+ * rather than sitting on it. Measured rather than assumed: the dock is a
713
+ * launcher, sometimes with a theme switch above it, and its height is the only
714
+ * honest source for how much room it takes.
715
+ */
716
+ function dockOffset() {
717
+ try {
718
+ const host = document.querySelector('[data-bugcatch="widget"]');
719
+ const dock = host?.shadowRoot?.querySelector('.dock');
720
+ const height = dock?.getBoundingClientRect().height || 0;
721
+ return height ? Math.round(height) + 12 : 0;
722
+ } catch {
723
+ return 0;
724
+ }
725
+ }
726
+
727
+ /**
728
+ * The built-in notifications, for integrations that do not want to render their
729
+ * own.
730
+ *
731
+ * A card per unread update, stacked in the same corner as the widget and
732
+ * staying there until the reporter marks it read. Nothing here is on a timer:
733
+ * the whole point of telling somebody their bug was fixed is that they find
734
+ * out, and a card that removes itself after eight seconds only reaches the
735
+ * people who happened to be looking.
736
+ *
737
+ * Everything is inline-styled and scoped to elements this owns: the SDK runs on
738
+ * someone else's page and must not depend on, or contribute to, their CSS. It
739
+ * sits *below* the widget's own layers, so an open report panel covers it
740
+ * instead of a notification floating over the report it is about.
741
+ */
742
+ export function createUpdateNotifier({
743
+ accent = '#4F46E5',
744
+ position = 'bottom-right',
745
+ onRead = null,
746
+ onReadAll = null,
747
+ onCall = null,
748
+ } = {}) {
749
+ const [vertical, horizontal] = String(position).split('-');
750
+ const atTop = vertical === 'top';
751
+ const atLeft = horizontal === 'left';
752
+ // id -> card element, so an update already on screen is left alone. Rebuilding
753
+ // the stack on every frame would restart the animations and take the button
754
+ // out from under a finger already on its way to it.
755
+ const cards = new Map();
756
+ let stack = null;
757
+ let more = null;
758
+
759
+ function ensureStack() {
760
+ if (stack?.isConnected) return stack;
761
+ stack = document.createElement('div');
762
+ stack.setAttribute('data-bugcatch', 'updates');
763
+ stack.style.cssText = [
764
+ 'position:fixed',
765
+ `${atTop ? 'top' : 'bottom'}:${20 + dockOffset()}px`,
766
+ `${atLeft ? 'left' : 'right'}:20px`,
767
+ // Under the widget's dock and its panel scrim, both of which are higher.
768
+ // A notification is worth interrupting a page for, not the report being
769
+ // read on top of it.
770
+ 'z-index:2147482998',
771
+ 'display:flex',
772
+ 'flex-direction:column',
773
+ 'gap:10px',
774
+ `align-items:${atLeft ? 'flex-start' : 'flex-end'}`,
775
+ 'width:min(320px,calc(100vw - 40px))',
776
+ // The gaps between cards belong to the page underneath.
777
+ 'pointer-events:none',
778
+ ].join(';');
779
+ document.body.appendChild(stack);
780
+ return stack;
781
+ }
782
+
783
+ /** Fade out and go. Instant for a card that has not been painted yet. */
784
+ function drop(el) {
785
+ if (!el._bcShown) { el.remove(); return; }
786
+ el.style.opacity = '0';
787
+ el.style.transform = `translateY(${atTop ? -8 : 8}px)`;
788
+ setTimeout(() => el.remove(), 250);
789
+ }
790
+
791
+ function button(label, primary) {
792
+ const el = document.createElement('button');
793
+ el.type = 'button';
794
+ el.textContent = label;
795
+ el.style.cssText = [
796
+ 'padding:5px 11px',
797
+ `border:${primary ? '0' : '1px solid rgba(236,238,242,.24)'}`,
798
+ 'border-radius:6px',
799
+ `background:${primary ? accent : 'transparent'}`,
800
+ `color:${primary ? '#fff' : '#eceef2'}`,
801
+ 'font:600 12px/1.4 inherit',
802
+ 'cursor:pointer',
803
+ 'white-space:nowrap',
804
+ ].join(';');
805
+ return el;
806
+ }
807
+
808
+ function card(record) {
809
+ const el = document.createElement('div');
810
+ el.setAttribute('role', 'status');
811
+ el.style.cssText = [
812
+ 'box-sizing:border-box',
813
+ 'width:100%',
814
+ 'padding:12px 14px',
815
+ 'border-radius:10px',
816
+ 'background:#1b1e26',
817
+ 'color:#eceef2',
818
+ 'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
819
+ 'box-shadow:0 12px 30px rgba(0,0,0,0.45)',
820
+ `border-left:3px solid ${KIND_ACCENT[record?.kind] || accent}`,
821
+ 'opacity:0',
822
+ `transform:translateY(${atTop ? -8 : 8}px)`,
823
+ 'transition:opacity .2s ease,transform .2s ease',
824
+ 'pointer-events:auto',
825
+ ].join(';');
826
+
827
+ const title = document.createElement('div');
828
+ title.style.cssText = 'font-weight:600;margin-bottom:2px';
829
+ title.textContent = updateHeadline(record);
830
+
831
+ const body = document.createElement('div');
832
+ // A reply is the one kind whose detail is worth more room than a title, and
833
+ // it is the only text here the team wrote rather than the reporter.
834
+ body.style.cssText = record?.kind === 'reply'
835
+ ? 'color:#a4abba;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden'
836
+ : 'color:#a4abba';
837
+ // textContent, not innerHTML: this is text a person typed, being written
838
+ // back into somebody else's page.
839
+ body.textContent = updateDetail(record);
840
+
841
+ const actions = document.createElement('div');
842
+ actions.style.cssText =
843
+ 'display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap';
844
+
845
+ const when = document.createElement('span');
846
+ when.style.cssText = 'color:#7b8394;font-size:11px;margin-right:auto';
847
+ when.textContent = agoLabel(record?.at);
848
+
849
+ const read = button('Mark as read', false);
850
+ read.setAttribute('aria-label', `Mark as read: ${updateHeadline(record)}`);
851
+ read.addEventListener('click', () => onRead?.(record.id));
852
+
853
+ actions.append(when, read);
854
+
855
+ // The call offer, when there is one. A button rather than a call: a page
856
+ // somebody may have walked away from must not raise a microphone prompt by
857
+ // itself, and a reporter who would rather type has lost nothing by ignoring
858
+ // this.
859
+ if (record?.call?.offered && typeof onCall === 'function') {
860
+ const talk = button('Talk it through', true);
861
+ talk.addEventListener('click', () => onCall(record));
862
+ actions.insertBefore(talk, read);
863
+ }
864
+
865
+ el.append(title, body, actions);
866
+ return el;
867
+ }
868
+
869
+ /** The line under the stack when there is more unread than fits. */
870
+ function moreRow(count) {
871
+ const el = document.createElement('div');
872
+ el.style.cssText = [
873
+ 'box-sizing:border-box',
874
+ 'width:100%',
875
+ 'display:flex',
876
+ 'align-items:center',
877
+ 'gap:8px',
878
+ 'padding:8px 12px',
879
+ 'border-radius:10px',
880
+ 'background:#1b1e26',
881
+ 'color:#a4abba',
882
+ 'font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
883
+ 'box-shadow:0 12px 30px rgba(0,0,0,0.45)',
884
+ 'pointer-events:auto',
885
+ ].join(';');
886
+
887
+ const label = document.createElement('span');
888
+ label.style.cssText = 'margin-right:auto';
889
+ label.textContent = `${count} more update${count === 1 ? '' : 's'}`;
890
+
891
+ const all = button('Mark all as read', false);
892
+ all.style.cssText += ';padding:4px 9px';
893
+ all.addEventListener('click', () => onReadAll?.());
894
+
895
+ el.append(label, all);
896
+ return el;
897
+ }
898
+
899
+ return {
900
+ /**
901
+ * Draw what is unread, oldest first.
902
+ *
903
+ * The newest card sits nearest the corner the widget is in, which is the
904
+ * corner the reporter's eye is already trained on — so the stack grows away
905
+ * from the launcher rather than over it.
906
+ */
907
+ render(list) {
908
+ const unread = Array.isArray(list) ? list : [];
909
+ if (!unread.length) {
910
+ for (const el of cards.values()) drop(el);
911
+ cards.clear();
912
+ more?.remove();
913
+ more = null;
914
+ // The container goes too: an empty fixed element in someone else's page
915
+ // is one more thing for their own layout debugging to trip over.
916
+ stack?.remove();
917
+ stack = null;
918
+ return;
919
+ }
920
+
921
+ const visible = unread.slice(-MAX_CARDS);
922
+ const shown = new Set(visible.map((u) => u.id));
923
+ const root = ensureStack();
924
+
925
+ for (const [id, el] of cards) {
926
+ if (!shown.has(id)) { drop(el); cards.delete(id); }
927
+ }
928
+
929
+ const fresh = [];
930
+ for (const record of visible) {
931
+ if (cards.has(record.id)) continue;
932
+ const el = card(record);
933
+ cards.set(record.id, el);
934
+ fresh.push(el);
935
+ }
936
+
937
+ // Nearest the corner is the newest, so a top-anchored stack is drawn in
938
+ // the opposite order to a bottom-anchored one.
939
+ const ordered = atTop ? [...visible].reverse() : visible;
940
+ for (const record of ordered) root.appendChild(cards.get(record.id));
941
+
942
+ const hidden = unread.length - visible.length;
943
+ if (hidden > 0) {
944
+ more?.remove();
945
+ more = moreRow(hidden);
946
+ // Furthest from the corner, past the oldest card on screen.
947
+ if (atTop) root.appendChild(more);
948
+ else root.insertBefore(more, root.firstChild);
949
+ } else {
950
+ more?.remove();
951
+ more = null;
952
+ }
953
+
954
+ // One frame later, so the transition has a start to run from. Cards read
955
+ // as unpainted until this fires, which is what lets an update marked read
956
+ // in the same tick — by the widget, with the report already open — leave
957
+ // without a card flashing up and fading out behind it.
958
+ requestAnimationFrame(() => {
959
+ for (const el of fresh) {
960
+ if (!el.isConnected) continue;
961
+ el._bcShown = true;
962
+ el.style.opacity = '1';
963
+ el.style.transform = 'translateY(0)';
964
+ }
965
+ });
966
+ },
967
+
968
+ destroy() {
969
+ cards.clear();
970
+ more = null;
971
+ stack?.remove();
972
+ stack = null;
973
+ },
974
+ };
975
+ }
976
+
977
+ /**
978
+ * What this project is willing to say is already broken.
979
+ *
980
+ * Asked when the widget opens, before anything is typed. Failure is silent and
981
+ * answers an empty list: this is a help panel in front of a form, and a page
982
+ * that cannot reach the API still has to let somebody report the thing they
983
+ * came to report.
984
+ */
985
+ export async function fetchKnownIssues({ apiBase, apiKey, projectId, sessionToken }) {
986
+ try {
987
+ const res = await fetch(
988
+ `${apiBase}/ingest/known-issues?projectId=${encodeURIComponent(projectId)}`,
989
+ { headers: knownIssueHeaders(apiKey, sessionToken) },
990
+ );
991
+ if (!res.ok) return [];
992
+ const body = await res.json();
993
+ return Array.isArray(body?.issues) ? body.issues : [];
994
+ } catch {
995
+ return [];
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * The listed issues that look like what somebody is typing.
1001
+ *
1002
+ * Takes an AbortSignal because it runs on a keystroke debounce: without one, a
1003
+ * fast typist has six of these in flight and the panel renders whichever
1004
+ * happens to land last, which is not necessarily the one matching what is now
1005
+ * in the box.
1006
+ */
1007
+ export async function matchKnownIssues({ apiBase, apiKey, projectId, q, sessionToken, signal }) {
1008
+ try {
1009
+ const url = `${apiBase}/ingest/known-issues/match`
1010
+ + `?projectId=${encodeURIComponent(projectId)}&q=${encodeURIComponent(q)}`;
1011
+ const res = await fetch(url, { headers: knownIssueHeaders(apiKey, sessionToken), signal });
1012
+ if (!res.ok) return [];
1013
+ const body = await res.json();
1014
+ return Array.isArray(body?.issues) ? body.issues : [];
1015
+ } catch {
1016
+ // Includes the AbortError of a superseded keystroke, which is the ordinary
1017
+ // case rather than a failure.
1018
+ return [];
1019
+ }
1020
+ }
1021
+
1022
+ /**
1023
+ * Say an existing report is happening to you too.
1024
+ *
1025
+ * Throws, unlike the two reads above: this one is a button somebody pressed,
1026
+ * and a press that silently does nothing is worse than an error they can read.
1027
+ * The watch token comes back for the same reason it does on a filed report —
1028
+ * it is what lets this browser hear that the bug was fixed.
1029
+ */
1030
+ export async function joinKnownIssue({
1031
+ apiBase, apiKey, projectId, bugId, reporterEmail, reporterName, metadata, logs, sessionToken,
1032
+ }) {
1033
+ const res = await fetch(
1034
+ `${apiBase}/ingest/known-issues/${encodeURIComponent(bugId)}/join`,
1035
+ {
1036
+ method: 'POST',
1037
+ headers: knownIssueHeaders(apiKey, sessionToken),
1038
+ body: JSON.stringify({ projectId, reporterEmail, reporterName, metadata, logs }),
1039
+ },
1040
+ );
1041
+ if (!res.ok) throw new Error(await readJoinError(res));
1042
+ const body = await res.json();
1043
+ if (body?.watchToken) writeToken(body.watchToken);
1044
+ return body;
1045
+ }
1046
+
1047
+ function knownIssueHeaders(apiKey, sessionToken) {
1048
+ return {
1049
+ 'Content-Type': 'application/json',
1050
+ 'X-BugCatch-Key': apiKey,
1051
+ ...(sessionToken ? { 'X-BugCatch-Session': sessionToken } : {}),
1052
+ };
1053
+ }
1054
+
1055
+ async function readJoinError(res) {
1056
+ try {
1057
+ const body = await res.json();
1058
+ const message = Array.isArray(body.message) ? body.message.join(', ') : body.message;
1059
+ if (message) return message;
1060
+ } catch { /* non-JSON error */ }
1061
+ return `Could not add you to this issue (HTTP ${res.status})`;
1062
+ }