makethisbetter 1.1.1 → 1.1.2
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/README.md +2 -2
- package/dist/api/client.d.ts.map +1 -1
- package/dist/context/frustration.d.ts +5 -1
- package/dist/context/frustration.d.ts.map +1 -1
- package/dist/makethisbetter.cjs.js +4 -4
- package/dist/makethisbetter.cjs.js.map +1 -1
- package/dist/makethisbetter.esm.js +94 -87
- package/dist/makethisbetter.esm.js.map +1 -1
- package/dist/makethisbetter.js +3 -3
- package/dist/makethisbetter.js.map +1 -1
- package/dist/record/session.d.ts.map +1 -1
- package/dist/widget/controller.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api/client.test.ts +33 -0
- package/src/api/client.ts +11 -0
- package/src/context/frustration.test.ts +24 -0
- package/src/context/frustration.ts +31 -19
- package/src/record/session.ts +8 -1
- package/src/widget/controller.ts +5 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/record/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAEvD,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAQzC,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,UAAU,EAAE,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;CACjB;
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/record/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAEvD,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAQzC,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,UAAU,EAAE,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAyCD,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAmB;IACjC,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,SAAS,CAAI;IACrB,OAAO,CAAC,KAAK,CAA6C;IAC1D,OAAO,CAAC,YAAY,CAAyC;IAC7D,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,YAAY,CAAC,CAAY;IACjC,OAAO,CAAC,OAAO,CAAQ;gBAEX,MAAM,EAAE,eAAe,EAAE,YAAY,CAAC,EAAE,MAAM,IAAI;IAKxD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA2B5B,IAAI,IAAI,eAAe;IAavB,WAAW,IAAI,MAAM;IAKrB,OAAO,IAAI,IAAI;IAUf,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,eAAe;CAOxB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../src/widget/controller.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,oBAAoB,EAAc,MAAM,UAAU,CAAA;AAIhE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,GAAG,CAA2B;IACtC,OAAO,CAAC,GAAG,CAAwB;IACnC,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,KAAK,CAA4B;IACzC,OAAO,CAAC,OAAO,CAA2B;IAC1C,OAAO,CAAC,OAAO,CAA2B;IAC1C,OAAO,CAAC,iBAAiB,CAAqC;IAC9D,OAAO,CAAC,SAAS,CAAW;IAC5B,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,mBAAmB,CAAmC;IAC9D,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,IAAI,CAAe;IAC3B,OAAO,CAAC,iBAAiB,CAA0B;IACnD,OAAO,CAAC,gBAAgB,CAAgC;gBAE5C,MAAM,EAAE,oBAAoB;IAcxC,OAAO,CAAC,OAAO;IAgBf,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,iBAAiB;IAYzB,OAAO,CAAC,wBAAwB;IAKhC,OAAO,CAAC,mBAAmB;IAiB3B,OAAO,CAAC,uBAAuB;
|
|
1
|
+
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../src/widget/controller.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,oBAAoB,EAAc,MAAM,UAAU,CAAA;AAIhE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,GAAG,CAA2B;IACtC,OAAO,CAAC,GAAG,CAAwB;IACnC,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,KAAK,CAA4B;IACzC,OAAO,CAAC,OAAO,CAA2B;IAC1C,OAAO,CAAC,OAAO,CAA2B;IAC1C,OAAO,CAAC,iBAAiB,CAAqC;IAC9D,OAAO,CAAC,SAAS,CAAW;IAC5B,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,mBAAmB,CAAmC;IAC9D,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,IAAI,CAAe;IAC3B,OAAO,CAAC,iBAAiB,CAA0B;IACnD,OAAO,CAAC,gBAAgB,CAAgC;gBAE5C,MAAM,EAAE,oBAAoB;IAcxC,OAAO,CAAC,OAAO;IAgBf,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,iBAAiB;IAYzB,OAAO,CAAC,wBAAwB;IAKhC,OAAO,CAAC,mBAAmB;IAiB3B,OAAO,CAAC,uBAAuB;IA6B/B,OAAO,CAAC,SAAS;YAcH,YAAY;IAmC1B,OAAO,CAAC,WAAW;IAgBnB,OAAO,CAAC,WAAW;IAkBnB,OAAO,CAAC,OAAO;IAiBf,OAAO,IAAI,IAAI;CAYhB"}
|
package/package.json
CHANGED
package/src/api/client.test.ts
CHANGED
|
@@ -179,4 +179,37 @@ describe('ApiClient', () => {
|
|
|
179
179
|
const client = new ApiClient('key', 'https://api.example.com', 0)
|
|
180
180
|
await expect(client.submitFeedback(mockPayload, null)).rejects.toThrow()
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
it('sends an idempotency key and reuses it across retries', async () => {
|
|
184
|
+
const calls: RequestInit[] = []
|
|
185
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
186
|
+
calls.push(init!)
|
|
187
|
+
if (calls.length === 1) return new Response('oops', { status: 500 })
|
|
188
|
+
return new Response(JSON.stringify({ id: 'fb_1' }), { status: 201 })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const client = new ApiClient('proj_test123', 'https://api.example.com', 0)
|
|
192
|
+
await client.submitFeedback(mockPayload, null)
|
|
193
|
+
|
|
194
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
|
195
|
+
const key1 = (calls[0].headers as Record<string, string>)['X-Idempotency-Key']
|
|
196
|
+
const key2 = (calls[1].headers as Record<string, string>)['X-Idempotency-Key']
|
|
197
|
+
expect(key1).toBeTruthy()
|
|
198
|
+
expect(key2).toBe(key1)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('uses a fresh idempotency key for each separate submission', async () => {
|
|
202
|
+
const keys: string[] = []
|
|
203
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
204
|
+
keys.push((init!.headers as Record<string, string>)['X-Idempotency-Key'])
|
|
205
|
+
return new Response(JSON.stringify({ id: 'fb_1' }), { status: 201 })
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const client = new ApiClient('proj_test123', 'https://api.example.com')
|
|
209
|
+
await client.submitFeedback(mockPayload, null)
|
|
210
|
+
await client.submitFeedback(mockPayload, null)
|
|
211
|
+
|
|
212
|
+
expect(keys[0]).toBeTruthy()
|
|
213
|
+
expect(keys[0]).not.toBe(keys[1])
|
|
214
|
+
})
|
|
182
215
|
})
|
package/src/api/client.ts
CHANGED
|
@@ -61,6 +61,10 @@ export class ApiClient {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const headers = await this.authHeaders()
|
|
64
|
+
// One key per submission, reused across retries: if the first POST
|
|
65
|
+
// reached the server but the response was lost, the retry must not
|
|
66
|
+
// create a second feedback.
|
|
67
|
+
headers['X-Idempotency-Key'] = generateIdempotencyKey()
|
|
64
68
|
const res = await this.fetchWithRetry(`${this.apiUrl}/widget/feedbacks`, {
|
|
65
69
|
method: 'POST',
|
|
66
70
|
headers,
|
|
@@ -115,3 +119,10 @@ function delay(ms: number): Promise<void> {
|
|
|
115
119
|
if (ms <= 0) return Promise.resolve()
|
|
116
120
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
117
121
|
}
|
|
122
|
+
|
|
123
|
+
function generateIdempotencyKey(): string {
|
|
124
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
125
|
+
return crypto.randomUUID()
|
|
126
|
+
}
|
|
127
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
|
|
128
|
+
}
|
|
@@ -441,5 +441,29 @@ describe('FrustrationDetector', () => {
|
|
|
441
441
|
|
|
442
442
|
document.body.removeChild(div)
|
|
443
443
|
})
|
|
444
|
+
|
|
445
|
+
it('shares one MutationObserver across overlapping clicks and disconnects when done', () => {
|
|
446
|
+
const observeSpy = vi.spyOn(MutationObserver.prototype, 'observe')
|
|
447
|
+
const disconnectSpy = vi.spyOn(MutationObserver.prototype, 'disconnect')
|
|
448
|
+
makeDetector()
|
|
449
|
+
const div = document.createElement('div')
|
|
450
|
+
div.style.cursor = 'pointer'
|
|
451
|
+
div.textContent = 'fake button'
|
|
452
|
+
document.body.appendChild(div)
|
|
453
|
+
|
|
454
|
+
div.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
455
|
+
vi.advanceTimersByTime(100)
|
|
456
|
+
div.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
457
|
+
|
|
458
|
+
expect(observeSpy).toHaveBeenCalledTimes(1)
|
|
459
|
+
expect(disconnectSpy).not.toHaveBeenCalled()
|
|
460
|
+
|
|
461
|
+
vi.advanceTimersByTime(500)
|
|
462
|
+
expect(disconnectSpy).toHaveBeenCalledTimes(1)
|
|
463
|
+
|
|
464
|
+
document.body.removeChild(div)
|
|
465
|
+
observeSpy.mockRestore()
|
|
466
|
+
disconnectSpy.mockRestore()
|
|
467
|
+
})
|
|
444
468
|
})
|
|
445
469
|
})
|
|
@@ -63,7 +63,9 @@ export class FrustrationDetector {
|
|
|
63
63
|
private boundSubmitHandler: ((e: Event) => void) | null = null
|
|
64
64
|
private boundInvalidHandler: ((e: Event) => void) | null = null
|
|
65
65
|
private errorPageUrls = new Set<string>()
|
|
66
|
-
private
|
|
66
|
+
private domObserver: MutationObserver | null = null
|
|
67
|
+
private pendingDomChecks = 0
|
|
68
|
+
private lastMutationAt = -1
|
|
67
69
|
|
|
68
70
|
constructor(onFrustration: (event: FrustrationEvent) => void) {
|
|
69
71
|
this.onFrustration = onFrustration
|
|
@@ -95,8 +97,7 @@ export class FrustrationDetector {
|
|
|
95
97
|
this.stopFormTracking()
|
|
96
98
|
this.errorPageUrls.clear()
|
|
97
99
|
|
|
98
|
-
|
|
99
|
-
this.pendingObservers = []
|
|
100
|
+
this.disconnectDomObserver()
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
private startNavigationTracking(): void {
|
|
@@ -276,28 +277,21 @@ export class FrustrationDetector {
|
|
|
276
277
|
}
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
// One shared observer for all in-flight dead-click checks, connected only
|
|
281
|
+
// while at least one check is pending — a full-page observer per click was
|
|
282
|
+
// too heavy on mutation-busy pages.
|
|
279
283
|
private checkDeadClickDom(target: Element, clickTime: number): void {
|
|
280
284
|
if (!this.looksInteractive(target)) return
|
|
281
285
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
mutationSeen = true
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
observer.observe(document.body, {
|
|
288
|
-
childList: true,
|
|
289
|
-
subtree: true,
|
|
290
|
-
attributes: true,
|
|
291
|
-
characterData: true,
|
|
292
|
-
})
|
|
293
|
-
this.pendingObservers.push(observer)
|
|
286
|
+
this.pendingDomChecks++
|
|
287
|
+
this.ensureDomObserver()
|
|
294
288
|
|
|
295
289
|
setTimeout(() => {
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
if (
|
|
290
|
+
this.pendingDomChecks--
|
|
291
|
+
const mutated = this.lastMutationAt >= clickTime
|
|
292
|
+
if (this.pendingDomChecks === 0) this.disconnectDomObserver()
|
|
299
293
|
|
|
300
|
-
if (!
|
|
294
|
+
if (!mutated && this.active) {
|
|
301
295
|
this.emitIfReady({
|
|
302
296
|
signal: 'dead_click_dom',
|
|
303
297
|
target: describeElement(target),
|
|
@@ -307,6 +301,24 @@ export class FrustrationDetector {
|
|
|
307
301
|
}, DEAD_CLICK_DOM_DELAY_MS)
|
|
308
302
|
}
|
|
309
303
|
|
|
304
|
+
private ensureDomObserver(): void {
|
|
305
|
+
if (this.domObserver) return
|
|
306
|
+
this.domObserver = new MutationObserver(() => {
|
|
307
|
+
this.lastMutationAt = Date.now()
|
|
308
|
+
})
|
|
309
|
+
this.domObserver.observe(document.body, {
|
|
310
|
+
childList: true,
|
|
311
|
+
subtree: true,
|
|
312
|
+
attributes: true,
|
|
313
|
+
characterData: true,
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private disconnectDomObserver(): void {
|
|
318
|
+
this.domObserver?.disconnect()
|
|
319
|
+
this.domObserver = null
|
|
320
|
+
}
|
|
321
|
+
|
|
310
322
|
private looksInteractive(el: Element): boolean {
|
|
311
323
|
const style = window.getComputedStyle(el)
|
|
312
324
|
if (style.cursor === 'pointer') return true
|
package/src/record/session.ts
CHANGED
|
@@ -14,7 +14,12 @@ export interface RecordingResult {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const MAX_DURATION_S = 60
|
|
17
|
-
|
|
17
|
+
// Pinned version + SRI: this script executes on customers' pages, so a
|
|
18
|
+
// floating tag or unverified content would be a supply-chain hole. Bumping
|
|
19
|
+
// the version requires recomputing the hash from the exact CDN file:
|
|
20
|
+
// curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
|
21
|
+
const RRWEB_CDN = 'https://cdn.jsdelivr.net/npm/rrweb@2.1.0/dist/rrweb.min.js'
|
|
22
|
+
const RRWEB_SRI = 'sha384-6qZKf9GE/E2uhA8IdLwXsQiLN/jVx8mhA0i04rQ1aHLrs2URo3ivHlSbaccG59zt'
|
|
18
23
|
|
|
19
24
|
let rrwebLoaded: Promise<RRWebRecordFn> | null = null
|
|
20
25
|
|
|
@@ -35,6 +40,8 @@ function loadViaCdn(): Promise<RRWebRecordFn> {
|
|
|
35
40
|
if (win.rrweb?.record) { resolve(win.rrweb.record); return }
|
|
36
41
|
const script = document.createElement('script')
|
|
37
42
|
script.src = RRWEB_CDN
|
|
43
|
+
script.integrity = RRWEB_SRI
|
|
44
|
+
script.crossOrigin = 'anonymous'
|
|
38
45
|
script.onload = () => {
|
|
39
46
|
const w = globalThis as unknown as Window & { rrweb?: { record: RRWebRecordFn } }
|
|
40
47
|
if (w.rrweb?.record) resolve(w.rrweb.record)
|
package/src/widget/controller.ts
CHANGED
|
@@ -122,6 +122,11 @@ export class WidgetController {
|
|
|
122
122
|
if (!this.recordingManager) return
|
|
123
123
|
this.recordingManager.stop()
|
|
124
124
|
this.showPopup(window.innerWidth / 2, window.innerHeight / 2, this.messages.record.timer_label)
|
|
125
|
+
}).catch(() => {
|
|
126
|
+
// rrweb failed to load (network / SRI mismatch) — fall back to markup
|
|
127
|
+
if (this.mode !== 'recording') return
|
|
128
|
+
this.toolbar?.setMode('markup')
|
|
129
|
+
this.handleToolbarModeChange('markup')
|
|
125
130
|
})
|
|
126
131
|
} else {
|
|
127
132
|
this.recordingManager?.destroy()
|