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.
@@ -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;AAkCD,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
+ {"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;IAwB/B,OAAO,CAAC,SAAS;YAcH,YAAY;IAmC1B,OAAO,CAAC,WAAW;IAgBnB,OAAO,CAAC,WAAW;IAkBnB,OAAO,CAAC,OAAO;IAiBf,OAAO,IAAI,IAAI;CAYhB"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makethisbetter",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Make This Better JS Widget SDK — embed user feedback collection in any website",
5
5
  "type": "module",
6
6
  "main": "./dist/makethisbetter.cjs.js",
@@ -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 pendingObservers: MutationObserver[] = []
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
- for (const obs of this.pendingObservers) obs.disconnect()
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
- let mutationSeen = false
283
- const observer = new MutationObserver(() => {
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
- observer.disconnect()
297
- const idx = this.pendingObservers.indexOf(observer)
298
- if (idx >= 0) this.pendingObservers.splice(idx, 1)
290
+ this.pendingDomChecks--
291
+ const mutated = this.lastMutationAt >= clickTime
292
+ if (this.pendingDomChecks === 0) this.disconnectDomObserver()
299
293
 
300
- if (!mutationSeen && this.active) {
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
@@ -14,7 +14,12 @@ export interface RecordingResult {
14
14
  }
15
15
 
16
16
  const MAX_DURATION_S = 60
17
- const RRWEB_CDN = 'https://cdn.jsdelivr.net/npm/rrweb@2/dist/rrweb.min.js'
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)
@@ -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()