@sphoro/bugcatch-web-sdk 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +92 -5
  2. package/dist/bugcatch-call.es.js +586 -0
  3. package/dist/bugcatch-call.es.js.map +1 -0
  4. package/dist/bugcatch-call.umd.js +2 -0
  5. package/dist/bugcatch-call.umd.js.map +1 -0
  6. package/dist/bugcatch-core.es.js +3214 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2402 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1240 -0
  11. package/dist/bugcatch-panel.umd.js.map +1 -0
  12. package/dist/bugcatch.es.js +2 -5522
  13. package/dist/bugcatch.es.js.map +1 -1
  14. package/dist/bugcatch.standalone.umd.js +1536 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +89 -1183
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/agent-issues.js +30 -0
  20. package/src/announcements.js +147 -0
  21. package/src/busy.js +18 -0
  22. package/src/call.js +7 -0
  23. package/src/capture.js +9 -0
  24. package/src/chunks.js +56 -0
  25. package/src/gesture.js +33 -0
  26. package/src/icons.js +18 -0
  27. package/src/identity.js +14 -9
  28. package/src/incoming-call.js +8 -30
  29. package/src/index.d.ts +50 -0
  30. package/src/index.js +170 -37
  31. package/src/lazy.js +23 -0
  32. package/src/notice.js +68 -0
  33. package/src/panel/call.js +242 -0
  34. package/src/panel/compose.js +416 -0
  35. package/src/panel/composer.js +235 -0
  36. package/src/panel/icons.js +34 -0
  37. package/src/panel/index.js +251 -0
  38. package/src/panel/known.js +130 -0
  39. package/src/panel/messages.js +176 -0
  40. package/src/panel/notices.js +34 -0
  41. package/src/panel/picks.js +61 -0
  42. package/src/panel/refs.js +321 -0
  43. package/src/panel/render.js +124 -0
  44. package/src/panel/signin.js +228 -0
  45. package/src/panel/styles.js +960 -0
  46. package/src/panel/thread.js +384 -0
  47. package/src/session.js +8 -3
  48. package/src/umd/call.js +6 -0
  49. package/src/umd/lazy.js +98 -0
  50. package/src/umd/panel.js +6 -0
  51. package/src/updates.js +20 -7
  52. package/src/widget.js +243 -3164
@@ -0,0 +1,384 @@
1
+ // What happens to a report once it is filed: the confirmation, the
2
+ // reporter's list of everything they have filed, and one report as a
3
+ // conversation — with the assistant's live card over it, the "does the fix
4
+ // work" question under it, and the reply box at the bottom (composer.js).
5
+
6
+ import * as ICONS from './icons.js';
7
+ import { threadHtml, typingHtml } from './messages.js';
8
+ import {
9
+ headHtml, emptyHtml, skeletonRows, skeletonBubbles, escapeHtml, statusWord, relativeDay,
10
+ atLatest, scrollToLatest,
11
+ } from './render.js';
12
+ import { mountComposer } from './composer.js';
13
+
14
+ // What each open thread was last drawn from, so a poll that brings nothing new
15
+ // leaves the DOM (and the reader's place in it) alone.
16
+ const threadDrawn = new WeakMap();
17
+
18
+ export function mountThread(ctx) {
19
+ const {
20
+ client, scrim, close,
21
+ } = ctx;
22
+
23
+ // The report currently open, if any. A live update for a different report
24
+ // must not redraw the one being read.
25
+ let openReportId = null;
26
+
27
+ /**
28
+ * The confirmation, and the one answer somebody actually wants from it.
29
+ *
30
+ * "How long will this take" is the first thing anybody thinks after reporting
31
+ * a bug, and until now the answer was nothing at all — so the honest reading
32
+ * of a confirmation screen was "into the void". The API computes the figure
33
+ * on the same request that files the report, precisely so it can be said
34
+ * here: an estimate that needed a second call would arrive after this panel
35
+ * had closed itself.
36
+ *
37
+ * Absent whenever the project has decided not to tell reporters, or has
38
+ * nothing to base a figure on. Nothing is drawn in that case — no "unknown",
39
+ * no placeholder — because a confirmation that admits it cannot answer is
40
+ * worse than one that does not raise the question.
41
+ *
42
+ * The dwell is longer when there is something to read. Two seconds is right
43
+ * for one line and is not enough for two, and a panel that closes over the
44
+ * sentence somebody started reading is the reason they open it again.
45
+ */
46
+ function succeed(res, { callFailed = null } = {}) {
47
+ // The assistant: straight into the report's conversation, where the
48
+ // investigation shows itself working ("Analyzing the issue…") and asks
49
+ // what it needs. The plain "Thanks" below stays for when that view cannot
50
+ // load — a report filed without a way to follow it.
51
+ if (res?.id && !callFailed) {
52
+ showReport(res.id, { fresh: true }).then((ok) => { if (!ok) doneScreen(res, { callFailed }); });
53
+ return;
54
+ }
55
+ doneScreen(res, { callFailed });
56
+ }
57
+
58
+ function doneScreen(res, { callFailed = null } = {}) {
59
+ const ref = res?.id ? String(res.id).slice(0, 8) : null;
60
+ const eta = res?.estimate?.label ? String(res.estimate.label) : null;
61
+ // A press of Call that filed the report and then could not ring anybody.
62
+ // Shown as a plain "Thanks" it read as a call that had quietly worked, and
63
+ // the reporter sat waiting for a phone that was never going to ring.
64
+ const failed = callFailed
65
+ ? `<p class="eta">${escapeHtml(callFailed)} Your report is filed, and the team will reply in the chat.</p>`
66
+ : '';
67
+ scrim.querySelector('.panel').innerHTML = `
68
+ <div class="done">
69
+ <div class="tick">${ICONS.check}</div>
70
+ <h3>Thanks — that's on its way.</h3>
71
+ <p>${ref ? `Filed as <code>${escapeHtml(ref)}</code>.` : 'The team has what they need to reproduce it.'}</p>
72
+ ${failed}
73
+ ${eta ? `<p class="eta">Reports like this usually take <strong>${escapeHtml(eta)}</strong>. We'll let you know when it moves.</p>` : ''}
74
+ </div>`;
75
+ setTimeout(close, failed ? 6000 : eta ? 4000 : 2000);
76
+ }
77
+
78
+ /**
79
+ * The reporter's own history: what they have filed and where each one got to.
80
+ *
81
+ * Replaces the panel rather than opening a second surface — it is the same
82
+ * dialog answering a different question, and the reporter came here from one
83
+ * launcher.
84
+ */
85
+ async function showHistory() {
86
+ const panel = scrim.querySelector('.panel');
87
+ clearTimeout(assistantTimer);
88
+ openReportId = null;
89
+ panel.classList.remove('is-chat');
90
+ panel.innerHTML = `
91
+ ${headHtml({ title: 'Your reports', sub: "Everything you've reported, and where each one got to." })}
92
+ <div class="history" aria-busy="true">${skeletonRows(3)}</div>`;
93
+
94
+ panel.querySelector('[data-act="close"]').addEventListener('click', close);
95
+
96
+ let reports = [];
97
+ try {
98
+ ({ reports } = await client.myReports());
99
+ } catch {
100
+ const failed = panel.querySelector('.history');
101
+ if (failed) failed.innerHTML = emptyHtml('Couldn\'t load your reports just now.', ICONS.alert);
102
+ return;
103
+ }
104
+
105
+ // The panel can be closed while the request is in flight.
106
+ const list = panel.querySelector('.history');
107
+ if (!list) return;
108
+
109
+ list.removeAttribute('aria-busy');
110
+ list.innerHTML = reports.length
111
+ ? reports.map((r, i) => `
112
+ <button class="row" type="button" data-report="${escapeHtml(r.id)}" style="animation-delay:${Math.min(i, 8) * 30}ms">
113
+ <span class="row-ico" aria-hidden="true">${ICONS.chat}</span>
114
+ <span class="who">
115
+ <span class="ttl">${escapeHtml(r.title)}</span>
116
+ <span class="sub">${escapeHtml(r.reference)} · ${escapeHtml(relativeDay(r.createdAt))}</span>
117
+ </span>
118
+ <span class="pill ${escapeHtml(String(r.status).toLowerCase())}">${escapeHtml(statusWord(r.status))}</span>
119
+ <span class="chev" aria-hidden="true">${ICONS.chevronRight}</span>
120
+ </button>`).join('')
121
+ : emptyHtml('You haven\'t reported anything yet.', ICONS.inbox);
122
+
123
+ for (const row of list.querySelectorAll('[data-report]')) {
124
+ row.addEventListener('click', () => showReport(row.dataset.report));
125
+ }
126
+ }
127
+
128
+ /**
129
+ * One report as a conversation.
130
+ *
131
+ * This screen used to be a timeline: everything that had happened, read-only,
132
+ * newest first. It answered "what happened to my report" and nothing else —
133
+ * so when the team's reply was a question ("which browser?"), the person best
134
+ * placed to answer it was looking straight at it with no way to.
135
+ *
136
+ * Now it is a thread. Status changes still appear, as the quiet centred lines
137
+ * that mark events in any chat; the messages between the two sides are
138
+ * bubbles either side of them; and there is a box at the bottom. Oldest at
139
+ * the top, because that is the direction a conversation is read.
140
+ */
141
+ async function showReport(bugId, { fresh = false } = {}) {
142
+ const panel = scrim.querySelector('.panel');
143
+ clearTimeout(assistantTimer);
144
+ panel.classList.add('is-chat');
145
+ panel.innerHTML = `
146
+ ${headHtml({
147
+ back: true,
148
+ title: fresh ? 'Looking into it' : 'Your report',
149
+ sub: fresh ? 'Our assistant is on it — it may ask you a question here.' : 'Everything that has happened, and a way to answer.',
150
+ })}
151
+ <div class="history chatting" aria-busy="true"><div class="thread">${skeletonBubbles()}</div></div>`;
152
+
153
+ panel.querySelector('[data-act="close"]').addEventListener('click', close);
154
+ panel.querySelector('[data-act="back"]').addEventListener('click', showHistory);
155
+
156
+ let report = null;
157
+ try {
158
+ report = await client.myReport(bugId);
159
+ } catch {
160
+ report = null;
161
+ }
162
+
163
+ const body = panel.querySelector('.history');
164
+ // The panel can be closed, or navigated away from, while this is in flight.
165
+ if (!body || !panel.querySelector('[data-act="back"]')) return false;
166
+
167
+ if (!report) {
168
+ if (fresh) { panel.classList.remove('is-chat'); return false; }
169
+ body.removeAttribute('aria-busy');
170
+ body.innerHTML = emptyHtml('Couldn\'t load that report just now.', ICONS.alert);
171
+ return false;
172
+ }
173
+
174
+ openReportId = bugId;
175
+ // Reading the report is reading its updates. Leaving a "your report was
176
+ // fixed" card up over the screen where they are reading that it was fixed
177
+ // would be the notification arguing with itself.
178
+ client.markReportRead(bugId);
179
+ const drawn = threadHtml(report.timeline);
180
+ body.classList.add('chatting');
181
+ body.removeAttribute('aria-busy');
182
+ body.innerHTML = `
183
+ <div class="report-head">
184
+ <span class="ttl">${escapeHtml(report.title)}</span>
185
+ <span class="sub">${escapeHtml(report.reference)} · ${escapeHtml(report.project || '')}</span>
186
+ <span class="pill ${escapeHtml(String(report.status).toLowerCase())}">${escapeHtml(statusWord(report.status))}</span>
187
+ ${report.estimate?.label
188
+ // The same figure they were given when they filed it, so coming back
189
+ // to check is not a worse experience than not checking. Absent on a
190
+ // report that has already been settled — the API stops sending it —
191
+ // because a prediction beside a finished report is either redundant
192
+ // or an argument.
193
+ ? `<span class="eta-line">Usually takes ${escapeHtml(String(report.estimate.label))}</span>`
194
+ : ''}
195
+ </div>
196
+ <div class="thread" role="log" aria-live="polite" aria-label="Conversation">${drawn}</div>`;
197
+
198
+ const thread = body.querySelector('.thread');
199
+ threadDrawn.set(thread, drawn);
200
+ scrollToLatest(thread);
201
+ mountVerification(body, report);
202
+ if (report.canReply) mountComposer(ctx, body, thread, bugId);
203
+ mountAssistant(body, report, bugId);
204
+ // Again, now the cards above it have taken their share of the height: the
205
+ // latest message — and any quick answers under it — is what they came for.
206
+ scrollToLatest(thread);
207
+ return true;
208
+ }
209
+
210
+ /**
211
+ * The assistant's live card: where the investigation is, and a gentle
212
+ * refresh while it works. Analyzing polls every few seconds until the
213
+ * outcome lands — a question, a found cause, or a hand-off — so the
214
+ * reporter watches it happen rather than wondering whether anything is.
215
+ */
216
+ let assistantTimer = null;
217
+ function mountAssistant(body, report, bugId) {
218
+ clearTimeout(assistantTimer);
219
+ const a = report.assistant;
220
+ const thread = body.querySelector('.thread');
221
+ let card = body.querySelector('.assistant');
222
+ if (!a) {
223
+ card?.remove();
224
+ thread?.querySelector('.typing-row')?.remove();
225
+ return;
226
+ }
227
+
228
+ // Updated in place rather than rebuilt: the card is polled every few
229
+ // seconds while the assistant works, and a card that re-entered on every
230
+ // poll would flicker — and re-announce itself to a screen reader — for
231
+ // nothing. It only animates when the phase actually moves.
232
+ const previous = card?.dataset.phase || null;
233
+ if (!card) {
234
+ card = document.createElement('div');
235
+ card.setAttribute('role', 'status');
236
+ card.setAttribute('aria-live', 'polite');
237
+ body.querySelector('.report-head')?.after(card);
238
+ }
239
+ card.className = `assistant ${escapeHtml(a.phase)}`;
240
+ card.dataset.phase = a.phase;
241
+
242
+ const steps = a.steps || [];
243
+ const done = steps.filter((st) => st.state === 'done').length;
244
+ const active = steps.some((st) => st.state === 'active') ? 0.5 : 0;
245
+ const pct = steps.length ? Math.round(((done + active) / steps.length) * 100) : 0;
246
+ const content = `
247
+ <div class="as-body${previous && previous !== a.phase ? ' entering' : ''}">
248
+ <div class="as-top">
249
+ <span class="as-orb" aria-hidden="true">${a.phase === 'analyzing' ? '<span class="as-spin"></span>'
250
+ : a.phase === 'calling' ? ICONS.phone
251
+ : a.phase === 'resolved' ? ICONS.check : ICONS.sparkle}</span>
252
+ <div class="as-text">
253
+ <div class="as-title">${escapeHtml(a.title)}</div>
254
+ <div class="as-detail">${escapeHtml(a.detail)}</div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ ${steps.length ? `
259
+ <div class="as-progress" role="progressbar" aria-label="Progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><i style="width:${pct}%"></i></div>
260
+ <ol class="as-steps">${steps.map((st) => `<li class="${escapeHtml(st.state)}"${st.state === 'active' ? ' aria-current="step"' : ''}><span class="dot"></span><span class="lbl">${escapeHtml(st.label)}</span></li>`).join('')}</ol>` : ''}`;
261
+ const key = JSON.stringify([a.phase, a.title, a.detail, steps]);
262
+ if (card.dataset.key !== key) {
263
+ const bar = card.querySelector('.as-progress i');
264
+ const from = bar?.style.width;
265
+ card.innerHTML = content;
266
+ card.dataset.key = key;
267
+ // Let the bar travel from where it was to where it is now.
268
+ const next = card.querySelector('.as-progress i');
269
+ if (next && from) {
270
+ next.style.width = from;
271
+ requestAnimationFrame(() => { next.style.width = `${pct}%`; });
272
+ }
273
+ }
274
+
275
+ // While it works, a "thinking" bubble sits where its next message will land.
276
+ // Never during a call: somebody is on the phone about this report, nothing
277
+ // is being analysed (the analysis waits for the transcript), and a bubble
278
+ // typing away underneath a live call is the assistant talking over them.
279
+ if (thread) {
280
+ const pinned = atLatest(thread);
281
+ const typing = thread.querySelector('.typing-row');
282
+ if (a.phase !== 'analyzing') typing?.remove();
283
+ else if (!typing || typing !== thread.lastElementChild) {
284
+ typing?.remove();
285
+ thread.insertAdjacentHTML('beforeend', typingHtml());
286
+ if (pinned) scrollToLatest(thread);
287
+ }
288
+ }
289
+
290
+ if (a.phase === 'analyzing') {
291
+ assistantTimer = setTimeout(() => refreshThread(bugId), 3000);
292
+ } else if (a.phase === 'calling') {
293
+ // Slower: the call's own card is what the reporter is watching, and this
294
+ // only has to notice when the call ends and the analysis begins.
295
+ assistantTimer = setTimeout(() => refreshThread(bugId), 10000);
296
+ } else if (a.phase === 'needs_info' && previous !== 'needs_info') {
297
+ body.querySelector('.composer textarea')?.focus();
298
+ }
299
+ }
300
+
301
+ function mountVerification(body, report) {
302
+ body.querySelector('.fix-verification')?.remove();
303
+ if (!report.resolvedAt || !['RESOLVED', 'CLOSED'].includes(report.status)) return;
304
+ const box = document.createElement('div'); box.className = 'fix-verification';
305
+ if (report.verification) { box.textContent = report.verification === 'CONFIRMED' ? 'You confirmed that this fix works.' : 'Your feedback was recorded.'; }
306
+ else {
307
+ box.innerHTML = '<p>Does the fix work for you?</p><textarea maxlength="4000" aria-label="Fix feedback" placeholder="What happens when you try again? (optional)"></textarea><button type="button" data-result="CONFIRMED">It works now</button> <button type="button" data-result="STILL_HAPPENING">Still happening</button><p class="feedback-status" role="status"></p>';
308
+ box.querySelectorAll('[data-result]').forEach((btn) => { btn.onclick = async () => {
309
+ box.querySelectorAll('button').forEach((b) => { b.disabled = true; });
310
+ try { await client.verifyFix(report.id, { result: btn.dataset.result, resolvedAt: report.resolvedAt, note: box.querySelector('textarea').value }); await showReport(report.id); }
311
+ catch (err) { box.querySelector('.feedback-status').textContent = err.message; box.querySelectorAll('button').forEach((b) => { b.disabled = false; }); }
312
+ }; });
313
+ }
314
+ body.querySelector('.report-head')?.after(box);
315
+ }
316
+
317
+ /**
318
+ * Redraw the open thread in place, keeping whatever is half-typed.
319
+ *
320
+ * Rebuilding the whole screen on every incoming message would be simpler and
321
+ * would also throw away a draft, a staged file and the scroll position — for
322
+ * someone mid-sentence, a reply arriving would look like the widget crashed.
323
+ */
324
+ async function refreshThread(bugId) {
325
+ const body = scrim.querySelector('.history.chatting');
326
+ const thread = body?.querySelector('.thread');
327
+ if (!thread || openReportId !== bugId) return;
328
+
329
+ let report = null;
330
+ try {
331
+ report = await client.myReport(bugId);
332
+ } catch { /* a failed refresh leaves what is on screen, which is not wrong */ }
333
+ // Closed while it was asking: nothing to draw, and no next poll to set.
334
+ if (ctx.closed || !report || openReportId !== bugId) return;
335
+
336
+ mountVerification(body, report);
337
+ const pinned = atLatest(thread);
338
+ const html = threadHtml(report.timeline);
339
+ // Only rewritten when something changed, so a poll that brings nothing new
340
+ // does not replay every bubble's entrance or re-announce the log.
341
+ if (threadDrawn.get(thread) !== html) {
342
+ thread.innerHTML = html;
343
+ threadDrawn.set(thread, html);
344
+ }
345
+ mountAssistant(body, report, bugId);
346
+ // Only follow the conversation down if they were already at the bottom.
347
+ // Yanking someone back from something they scrolled up to read is worse
348
+ // than making them scroll.
349
+ if (pinned) scrollToLatest(thread);
350
+
351
+ const pill = body.querySelector('.report-head .pill');
352
+ if (pill) {
353
+ pill.className = `pill ${String(report.status).toLowerCase()}`;
354
+ pill.textContent = statusWord(report.status);
355
+ }
356
+ }
357
+
358
+ // Re-read the open thread when something happens to it. Without this the
359
+ // reader is looking at a conversation that stopped being true while they read
360
+ // it — the very screen where a stale view is most obvious, and now the screen
361
+ // where the other side may be typing.
362
+ const stopListening = client.on('update', (event) => {
363
+ if (!openReportId || event?.bugId !== openReportId) return;
364
+ // Already on screen, so already read — and marking it here, before the
365
+ // client draws its notifications, is what keeps a card from appearing over
366
+ // the thread that is about to show the same thing.
367
+ client.markReportRead(openReportId);
368
+ refreshThread(openReportId);
369
+ });
370
+ // Stopped with the panel: a frame arriving after it has gone would redraw a
371
+ // panel that is no longer on the page. The assistant's poll too — it reads
372
+ // the thread out of the detached panel, finds it still there, and would go
373
+ // on asking every three seconds for as long as the analysis ran.
374
+ ctx.onDestroy(() => {
375
+ stopListening();
376
+ clearTimeout(assistantTimer);
377
+ });
378
+
379
+ scrim.querySelector('[data-act="history"]')?.addEventListener('click', showHistory);
380
+
381
+ ctx.succeed = succeed;
382
+ ctx.refreshThread = refreshThread;
383
+ ctx.openReportId = () => openReportId;
384
+ }
package/src/session.js CHANGED
@@ -22,6 +22,8 @@
22
22
  // mounts two projects' widgets is two independent sign-ins, and signing out of
23
23
  // one leaves the other alone.
24
24
 
25
+ import { tooManyMessage } from './busy.js';
26
+
25
27
  const KEY_PREFIX = 'bugcatch.session.';
26
28
 
27
29
  const storageKey = (projectId) => `${KEY_PREFIX}${projectId}`;
@@ -91,12 +93,15 @@ function headers({ apiKey, token }) {
91
93
  }
92
94
 
93
95
  async function readError(res) {
96
+ let message = '';
94
97
  try {
95
98
  const body = await res.json();
96
- const message = Array.isArray(body.message) ? body.message.join(', ') : body.message;
97
- if (message) return message;
99
+ message = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
98
100
  } catch { /* non-JSON error */ }
99
- return `Request failed (HTTP ${res.status})`;
101
+ // Asking for codes too fast. The server's sentence says so; a proxy's page
102
+ // would not, and "Request failed (HTTP 429)" reads as a broken form.
103
+ if (res.status === 429) return tooManyMessage(message);
104
+ return message || `Request failed (HTTP ${res.status})`;
100
105
  }
101
106
 
102
107
  /**
@@ -0,0 +1,6 @@
1
+ // The call code as a script of its own, for the script-tag build: run, it
2
+ // hands its exports to the loader that added it (lazy.js beside this file).
3
+ import * as call from '../call.js';
4
+ import { registerChunk } from '../chunks.js';
5
+
6
+ registerChunk('call', call);
@@ -0,0 +1,98 @@
1
+ // The script-tag build's loader: ../lazy.js's exports, fetched with <script> tags
2
+ // from the place this script itself was loaded from.
3
+ //
4
+ // Vite cannot split a UMD bundle, so the parts are built as scripts of their
5
+ // own — bugcatch-panel.umd.js and bugcatch-call.umd.js, see vite.config.js —
6
+ // that register their exports when they run (panel.js and call.js beside
7
+ // this file, through ../chunks.js). This adds the script, waits for it to
8
+ // load, and reads them back.
9
+ //
10
+ // # From where
11
+ //
12
+ // Beside this file, found while it is still running: document.currentScript is
13
+ // only set during a script's first run, so it is read here, at the top, and
14
+ // never again. That covers the CDN, which serves dist/ as a directory, and a
15
+ // copy of dist/ on the site's own server. It cannot cover a copy with no
16
+ // address of its own — the bundle pasted into an inline <script>, or
17
+ // concatenated into one of the site's files — and there init({ chunkBase })
18
+ // says where the parts are.
19
+
20
+ import {
21
+ registeredChunk, chunkUnavailable, once, CHUNK_VERSION,
22
+ } from '../chunks.js';
23
+
24
+ const self = typeof document !== 'undefined' ? document.currentScript : null;
25
+ const loadedFrom = self?.src || null;
26
+ // Carried over to the scripts this adds, so a page that let this one in lets
27
+ // them in the same way: its CSP nonce, and the CORS and referrer settings the
28
+ // page chose for this tag.
29
+ const nonce = self?.nonce || '';
30
+ const crossOrigin = self?.getAttribute('crossorigin') ?? null;
31
+ const referrerPolicy = self?.referrerPolicy || '';
32
+
33
+ /**
34
+ * The folder the parts are in, with a trailing slash — or null when there is
35
+ * no way to know. `loadedFrom` is this script's own address; `baseURI` the
36
+ * page's, which a relative `chunkBase` is read against.
37
+ *
38
+ * A bare package address, https://unpkg.com/@sphoro/bugcatch-web-sdk@0.17.0,
39
+ * is answered by the CDN with this file but names no folder to be beside, so
40
+ * its parts are looked for under dist/, which is where that file lives.
41
+ */
42
+ export function chunkFolder({ chunkBase, loadedFrom: from, baseURI }) {
43
+ if (chunkBase) return new URL(String(chunkBase).replace(/\/*$/, '/'), baseURI).href;
44
+ if (!from) return null;
45
+ const url = new URL(from);
46
+ if (!/\.js$/i.test(url.pathname)) return new URL(`${url.pathname.replace(/\/*$/, '/')}dist/`, url).href;
47
+ return new URL('.', url).href;
48
+ }
49
+
50
+ function loadScript(name, chunkBase) {
51
+ const arrived = registeredChunk(name);
52
+ if (arrived) return Promise.resolve(arrived);
53
+
54
+ const folder = chunkFolder({ chunkBase, loadedFrom, baseURI: document.baseURI });
55
+ if (!folder) {
56
+ return Promise.reject(chunkUnavailable(`bugcatch-${name}.umd.js could not be loaded: this copy of `
57
+ + 'the SDK was not loaded from an address, so there is nowhere to look for it. Set '
58
+ + 'init({ chunkBase }) to the folder that holds it.'));
59
+ }
60
+ // The version in the address as well as in the registry: a file cached from
61
+ // an older release, under an address that does not change between releases,
62
+ // is fetched afresh rather than found stale.
63
+ const src = `${folder}bugcatch-${name}.umd.js?v=${encodeURIComponent(CHUNK_VERSION)}`;
64
+
65
+ return new Promise((resolve, reject) => {
66
+ const script = document.createElement('script');
67
+ script.src = src;
68
+ script.async = true;
69
+ if (nonce) script.nonce = nonce;
70
+ if (crossOrigin !== null) script.crossOrigin = crossOrigin;
71
+ if (referrerPolicy) script.referrerPolicy = referrerPolicy;
72
+ const settle = () => {
73
+ script.onload = null;
74
+ script.onerror = null;
75
+ script.remove();
76
+ };
77
+ script.onload = () => {
78
+ settle();
79
+ const exports = registeredChunk(name);
80
+ if (exports) resolve(exports);
81
+ else {
82
+ reject(chunkUnavailable(`${src} loaded, but not as version ${CHUNK_VERSION} of the SDK — `
83
+ + 'serve every file in dist/ from the same release.'));
84
+ }
85
+ };
86
+ script.onerror = () => {
87
+ settle();
88
+ reject(chunkUnavailable(`could not load ${src}. The files in dist/ are served together: `
89
+ + 'a self-hosted copy needs all of them (or init({ chunkBase }) pointing at them), and a '
90
+ + 'Content-Security-Policy has to allow them in script-src.'));
91
+ };
92
+ (document.head || document.documentElement).appendChild(script);
93
+ });
94
+ }
95
+
96
+ export const loadPanel = once((chunkBase) => loadScript('panel', chunkBase));
97
+
98
+ export const loadCall = once((chunkBase) => loadScript('call', chunkBase));
@@ -0,0 +1,6 @@
1
+ // The report panel as a script of its own, for the script-tag build: run, it
2
+ // hands its exports to the loader that added it (lazy.js beside this file).
3
+ import * as panel from '../panel/index.js';
4
+ import { registerChunk } from '../chunks.js';
5
+
6
+ registerChunk('panel', panel);
package/src/updates.js CHANGED
@@ -11,6 +11,8 @@
11
11
  // let anyone read anyone's reports. Holding this token is proof of having
12
12
  // filed them.
13
13
 
14
+ import { tooManyMessage } from './busy.js';
15
+
14
16
  const TOKEN_KEY = 'bugcatch.watch';
15
17
  const BACKOFF_MS = [2000, 5000, 15000, 30000, 60000];
16
18
 
@@ -308,6 +310,7 @@ export async function sendMessage({ apiBase, bugId, body, attachments }) {
308
310
  const problem = await res.json();
309
311
  detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
310
312
  } catch { /* non-JSON error */ }
313
+ if (res.status === 429) throw new Error(tooManyMessage(detail));
311
314
  throw new Error(detail || `[BugCatch] could not send that (HTTP ${res.status}).`);
312
315
  }
313
316
  return res.json();
@@ -384,7 +387,7 @@ export async function callKindsForProject({ apiBase, apiKey, projectId }) {
384
387
  * call could be placed — which is a success for the report and a failure only
385
388
  * for the call, and the caller is expected to tell them apart.
386
389
  */
387
- export async function reportByCall({ apiBase, apiKey, token, payload }) {
390
+ export async function reportByCall({ apiBase, apiKey, token, payload, watchToken }) {
388
391
  const res = await fetch(`${apiBase}/ingest/calls`, {
389
392
  method: 'POST',
390
393
  headers: {
@@ -396,7 +399,9 @@ export async function reportByCall({ apiBase, apiKey, token, payload }) {
396
399
  // is saying, an inch below the message, who they are signed in as.
397
400
  ...(token ? { 'X-BugCatch-Session': token } : {}),
398
401
  },
399
- body: JSON.stringify(payload),
402
+ // The watch token this browser holds, so the one filing hands back covers
403
+ // the reports it already could — see withHeldToken in index.js.
404
+ body: JSON.stringify(watchToken ? { ...payload, watchToken } : payload),
400
405
  });
401
406
 
402
407
  if (!res.ok) {
@@ -406,7 +411,8 @@ export async function reportByCall({ apiBase, apiKey, token, payload }) {
406
411
  detail = Array.isArray(problem.message) ? problem.message.join(', ') : problem.message || '';
407
412
  } catch { /* non-JSON error */ }
408
413
  const err = new Error(
409
- detail || `[BugCatch] the call could not be started (HTTP ${res.status}).`,
414
+ res.status === 429 ? tooManyMessage(detail)
415
+ : detail || `[BugCatch] the call could not be started (HTTP ${res.status}).`,
410
416
  );
411
417
  // Carried so a session the server has stopped recognising can be told from
412
418
  // every other refusal and forgotten, rather than pressing Call into the
@@ -1020,12 +1026,18 @@ export async function matchKnownIssues({ apiBase, apiKey, projectId, q, sessionT
1020
1026
  export async function joinKnownIssue({
1021
1027
  apiBase, apiKey, projectId, bugId, reporterEmail, reporterName, metadata, logs, sessionToken,
1022
1028
  }) {
1029
+ // The token this browser already holds goes back with the join, so the one
1030
+ // that comes back still covers the reports it filed as well as this issue.
1031
+ const held = readToken();
1023
1032
  const res = await fetch(
1024
1033
  `${apiBase}/ingest/known-issues/${encodeURIComponent(bugId)}/join`,
1025
1034
  {
1026
1035
  method: 'POST',
1027
1036
  headers: knownIssueHeaders(apiKey, sessionToken),
1028
- body: JSON.stringify({ projectId, reporterEmail, reporterName, metadata, logs }),
1037
+ body: JSON.stringify({
1038
+ projectId, reporterEmail, reporterName, metadata, logs,
1039
+ ...(held ? { watchToken: held } : {}),
1040
+ }),
1029
1041
  },
1030
1042
  );
1031
1043
  if (!res.ok) throw new Error(await readJoinError(res));
@@ -1043,12 +1055,13 @@ function knownIssueHeaders(apiKey, sessionToken) {
1043
1055
  }
1044
1056
 
1045
1057
  async function readJoinError(res) {
1058
+ let message = '';
1046
1059
  try {
1047
1060
  const body = await res.json();
1048
- const message = Array.isArray(body.message) ? body.message.join(', ') : body.message;
1049
- if (message) return message;
1061
+ message = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
1050
1062
  } catch { /* non-JSON error */ }
1051
- return `Could not add you to this issue (HTTP ${res.status})`;
1063
+ if (res.status === 429) return tooManyMessage(message);
1064
+ return message || `Could not add you to this issue (HTTP ${res.status})`;
1052
1065
  }
1053
1066
 
1054
1067
  export async function verifyFix({ apiBase, bugId, ...feedback }) {