@holdyapp/sdk 1.1.0 → 1.6.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 (50) hide show
  1. package/README.md +53 -1
  2. package/dist/arm.d.ts +5 -0
  3. package/dist/arm.d.ts.map +1 -0
  4. package/dist/changes.d.ts +42 -0
  5. package/dist/changes.d.ts.map +1 -0
  6. package/dist/core.d.ts +17 -6
  7. package/dist/core.d.ts.map +1 -1
  8. package/dist/friction.d.ts +91 -0
  9. package/dist/friction.d.ts.map +1 -0
  10. package/dist/holdy.js +6 -1
  11. package/dist/holdy.js.map +4 -4
  12. package/dist/index.cjs +871 -26
  13. package/dist/index.cjs.map +4 -4
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.mjs +871 -26
  17. package/dist/index.mjs.map +4 -4
  18. package/dist/run.d.ts +37 -0
  19. package/dist/run.d.ts.map +1 -0
  20. package/dist/server.cjs +127 -23
  21. package/dist/server.cjs.map +3 -3
  22. package/dist/server.d.ts +14 -0
  23. package/dist/server.d.ts.map +1 -1
  24. package/dist/server.mjs +124 -22
  25. package/dist/server.mjs.map +3 -3
  26. package/dist/skeleton.d.ts +50 -0
  27. package/dist/skeleton.d.ts.map +1 -0
  28. package/dist/types.d.ts +2 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/web.d.ts +26 -0
  31. package/dist/web.d.ts.map +1 -1
  32. package/dist/web.mjs +824 -26
  33. package/dist/web.mjs.map +4 -4
  34. package/package.json +1 -1
  35. package/src/arm.test.ts +47 -0
  36. package/src/arm.ts +25 -0
  37. package/src/changes.test.ts +54 -0
  38. package/src/changes.ts +357 -0
  39. package/src/core.test.ts +78 -4
  40. package/src/core.ts +95 -31
  41. package/src/friction.test.ts +223 -0
  42. package/src/friction.ts +419 -0
  43. package/src/index.ts +1 -0
  44. package/src/run.test.ts +47 -0
  45. package/src/run.ts +80 -0
  46. package/src/server.ts +17 -0
  47. package/src/skeleton.test.ts +60 -0
  48. package/src/skeleton.ts +173 -0
  49. package/src/types.ts +2 -0
  50. package/src/web.ts +71 -1
@@ -0,0 +1,223 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ RageClickDetector,
4
+ NavLoopDetector,
5
+ isDeadClickTarget,
6
+ describeElement,
7
+ stripRequestUrl,
8
+ errorMessage,
9
+ installFrictionCapture,
10
+ classifyRequest,
11
+ documentStatus,
12
+ isAbortError,
13
+ isIgnoredUrl,
14
+ isFirstPartyRequest,
15
+ registrableDomain,
16
+ type ElementLike,
17
+ } from './friction'
18
+
19
+ describe('classifyRequest', () => {
20
+ it('status ≥ 400 ou sem resposta é erro, mesmo quando é rápido', () => {
21
+ expect(classifyRequest({ status: 404, durationMs: 120, slowMs: 3000 })).toBe('http_error')
22
+ expect(classifyRequest({ status: 500, durationMs: 5000, slowMs: 3000 })).toBe('http_error')
23
+ expect(classifyRequest({ status: null, durationMs: 40, slowMs: 3000 })).toBe('http_error')
24
+ })
25
+
26
+ it('lento com resposta ok segue lento; abortado e opaco nunca são erro', () => {
27
+ expect(classifyRequest({ status: 200, durationMs: 4000, slowMs: 3000 })).toBe('slow_request')
28
+ expect(classifyRequest({ status: 204, durationMs: 90, slowMs: 3000 })).toBeNull()
29
+ expect(classifyRequest({ status: null, durationMs: 10, slowMs: 3000, aborted: true })).toBeNull()
30
+ expect(classifyRequest({ status: 0, durationMs: 10, slowMs: 3000, opaque: true })).toBeNull()
31
+ expect(classifyRequest({ status: 0, durationMs: 3500, slowMs: 3000, opaque: true })).toBe('slow_request')
32
+ })
33
+ })
34
+
35
+ describe('registrableDomain', () => {
36
+ it('reduz o host aos dois últimos rótulos', () => {
37
+ expect(registrableDomain('app.acme.com')).toBe('acme.com')
38
+ expect(registrableDomain('acme.com')).toBe('acme.com')
39
+ expect(registrableDomain('a.b.c.acme.com')).toBe('acme.com')
40
+ expect(registrableDomain('localhost')).toBe('localhost')
41
+ })
42
+ })
43
+
44
+ describe('isFirstPartyRequest', () => {
45
+ const page = 'https://app.acme.com/relatorios'
46
+
47
+ it('mesmo host e mesmo domínio contam como produto', () => {
48
+ expect(isFirstPartyRequest('https://app.acme.com/api/v1/x', page)).toBe(true)
49
+ expect(isFirstPartyRequest('https://api.acme.com/v1/x', page)).toBe(true)
50
+ expect(isFirstPartyRequest('/api/v1/x', page)).toBe(true)
51
+ })
52
+
53
+ it('terceiro não é fricção do produto — foi o que inflou a fricção em 2026-09-17', () => {
54
+ expect(isFirstPartyRequest('https://www.google-analytics.com/g/collect', page)).toBe(false)
55
+ expect(isFirstPartyRequest('https://www.google.com/g/collect', page)).toBe(false)
56
+ expect(isFirstPartyRequest('https://cdn.segment.io/analytics.js', page)).toBe(false)
57
+ })
58
+
59
+ it('apiOrigins recupera a API que mora em outro domínio', () => {
60
+ expect(isFirstPartyRequest('https://acme-api.io/v1/x', page)).toBe(false)
61
+ expect(isFirstPartyRequest('https://acme-api.io/v1/x', page, ['https://acme-api.io'])).toBe(true)
62
+ })
63
+
64
+ it('localhost e porta diferente seguem sendo o próprio produto', () => {
65
+ expect(isFirstPartyRequest('http://localhost:4000/api', 'http://localhost:3000/x')).toBe(true)
66
+ })
67
+
68
+ it('esquema que não é http não é julgado', () => {
69
+ expect(isFirstPartyRequest('data:text/plain,oi', page)).toBe(true)
70
+ expect(isFirstPartyRequest('blob:https://outro.com/abc', page)).toBe(true)
71
+ })
72
+
73
+ it('url impossível de parsear volta como própria, nunca descartada em silêncio', () => {
74
+ expect(isFirstPartyRequest(':::', page)).toBe(true)
75
+ })
76
+ })
77
+
78
+ describe('isIgnoredUrl', () => {
79
+ it('ignora pelo prefixo e nunca por prefixo vazio', () => {
80
+ expect(isIgnoredUrl('https://holdy.chat/api/v1/events/bulk', ['https://holdy.chat/api/v1'])).toBe(true)
81
+ expect(isIgnoredUrl('https://app.cliente.com/files/a.pdf', ['https://holdy.chat/api/v1'])).toBe(false)
82
+ expect(isIgnoredUrl('https://app.cliente.com/x', [''])).toBe(false)
83
+ })
84
+ })
85
+
86
+ describe('documentStatus', () => {
87
+ it('lê o status da navegação e cala quando o browser não sabe', () => {
88
+ expect(documentStatus({ getEntriesByType: () => [{ responseStatus: 404 }] })).toBe(404)
89
+ expect(documentStatus({ getEntriesByType: () => [{ responseStatus: 0 }] })).toBeNull()
90
+ expect(documentStatus({ getEntriesByType: () => [] })).toBeNull()
91
+ expect(
92
+ documentStatus({
93
+ getEntriesByType: () => {
94
+ throw new Error('sem suporte')
95
+ },
96
+ }),
97
+ ).toBeNull()
98
+ expect(documentStatus(undefined)).toBeNull()
99
+ })
100
+ })
101
+
102
+ describe('isAbortError', () => {
103
+ it('reconhece o cancelamento pelo nome', () => {
104
+ expect(isAbortError({ name: 'AbortError' })).toBe(true)
105
+ expect(isAbortError(new TypeError('Failed to fetch'))).toBe(false)
106
+ expect(isAbortError(null)).toBe(false)
107
+ })
108
+ })
109
+
110
+ function el(
111
+ tag: string,
112
+ attrs: Record<string, string | null> = {},
113
+ extra: Partial<ElementLike> = {},
114
+ ): ElementLike {
115
+ return {
116
+ tagName: tag.toUpperCase(),
117
+ getAttribute: (name) => attrs[name] ?? null,
118
+ ...extra,
119
+ }
120
+ }
121
+
122
+ describe('RageClickDetector', () => {
123
+ it('dispara na 3ª batida dentro de 1s e 30px, depois zera', () => {
124
+ const d = new RageClickDetector()
125
+ expect(d.push(100, 100, 0)).toBe(false)
126
+ expect(d.push(105, 102, 300)).toBe(false)
127
+ expect(d.push(110, 98, 700)).toBe(true)
128
+ expect(d.push(110, 98, 800)).toBe(false)
129
+ })
130
+
131
+ it('cliques espalhados ou lentos não são raiva', () => {
132
+ const d = new RageClickDetector()
133
+ d.push(0, 0, 0)
134
+ d.push(200, 200, 100)
135
+ expect(d.push(0, 0, 200)).toBe(false)
136
+ d.push(0, 0, 5000)
137
+ expect(d.push(0, 0, 5100)).toBe(false)
138
+ })
139
+ })
140
+
141
+ describe('NavLoopDetector', () => {
142
+ it('A→B→A→B em menos de 60s é loop; devolve o par', () => {
143
+ const d = new NavLoopDetector()
144
+ expect(d.push('/a', 0)).toBeNull()
145
+ expect(d.push('/b', 1000)).toBeNull()
146
+ expect(d.push('/a', 2000)).toBeNull()
147
+ expect(d.push('/b', 3000)).toEqual({ from: '/a', to: '/b' })
148
+ expect(d.push('/a', 4000)).toBeNull()
149
+ })
150
+
151
+ it('A→A→A→A não é loop, e visitas velhas caem da janela', () => {
152
+ const d = new NavLoopDetector()
153
+ ;['/a', '/a', '/a'].forEach((p, i) => d.push(p, i * 1000))
154
+ expect(d.push('/a', 3000)).toBeNull()
155
+ const slow = new NavLoopDetector(10_000)
156
+ slow.push('/a', 0)
157
+ slow.push('/b', 1000)
158
+ slow.push('/a', 2000)
159
+ expect(slow.push('/b', 20_000)).toBeNull()
160
+ })
161
+ })
162
+
163
+ describe('isDeadClickTarget', () => {
164
+ it('botão desabilitado, aria-disabled e âncora sem href são cliques mortos', () => {
165
+ expect(isDeadClickTarget(el('button', {}, { disabled: true }))).toBe(true)
166
+ expect(isDeadClickTarget(el('button', { disabled: '' }))).toBe(true)
167
+ expect(isDeadClickTarget(el('div', { role: 'button', 'aria-disabled': 'true' }))).toBe(true)
168
+ expect(isDeadClickTarget(el('a', { href: null }))).toBe(true)
169
+ expect(isDeadClickTarget(el('a', { href: '#' }))).toBe(true)
170
+ })
171
+
172
+ it('controle habilitado e texto solto não são', () => {
173
+ expect(isDeadClickTarget(el('button', {}, { disabled: false }))).toBe(false)
174
+ expect(isDeadClickTarget(el('a', { href: '/x' }))).toBe(false)
175
+ expect(isDeadClickTarget(el('p'))).toBe(false)
176
+ expect(isDeadClickTarget(null)).toBe(false)
177
+ })
178
+
179
+ it('resolve pelo ancestral interativo (clique no ícone dentro do botão)', () => {
180
+ const button = el('button', {}, { disabled: true })
181
+ const icon = el('svg', {}, { closest: () => button })
182
+ expect(isDeadClickTarget(icon)).toBe(true)
183
+ })
184
+ })
185
+
186
+ describe('describeElement', () => {
187
+ it('tag + id + texto colapsado e cortado em 60', () => {
188
+ const long = 'Convidar time '.repeat(10)
189
+ const out = describeElement(el('button', { name: 'invite' }, { id: 'cta', textContent: long }))
190
+ expect(out.element).toBe('button')
191
+ expect(out.element_id).toBe('cta')
192
+ expect(out.element_name).toBe('invite')
193
+ expect(out.element_text.length).toBeLessThanOrEqual(60)
194
+ expect(out.element_text.startsWith('Convidar time Convidar')).toBe(true)
195
+ })
196
+
197
+ it('aria-label vence o texto', () => {
198
+ const out = describeElement(el('button', { 'aria-label': 'Fechar' }, { textContent: '×' }))
199
+ expect(out.element_text).toBe('Fechar')
200
+ })
201
+ })
202
+
203
+ describe('stripRequestUrl / errorMessage', () => {
204
+ it('tira query e hash, resolve relativa contra a base', () => {
205
+ expect(stripRequestUrl('https://api.x.com/v1/users?limit=5#top')).toBe('https://api.x.com/v1/users')
206
+ expect(stripRequestUrl('/api/items?id=1', 'https://app.x.com/page')).toBe('https://app.x.com/api/items')
207
+ expect(stripRequestUrl('not a url ?x=1')).toBe('not a url ')
208
+ })
209
+
210
+ it('mensagem de erro de qualquer forma de reason', () => {
211
+ expect(errorMessage(new Error('boom'))).toBe('boom')
212
+ expect(errorMessage('str')).toBe('str')
213
+ expect(errorMessage({ code: 1 })).toBe('{"code":1}')
214
+ })
215
+ })
216
+
217
+ describe('installFrictionCapture fora do browser', () => {
218
+ it('é no-op e nunca lança', () => {
219
+ const capture = installFrictionCapture({ onFriction: () => undefined })
220
+ expect(() => capture.onNavigate('/x')).not.toThrow()
221
+ expect(() => capture.teardown()).not.toThrow()
222
+ })
223
+ })
@@ -0,0 +1,419 @@
1
+ export type FrictionKind =
2
+ | 'dead_click'
3
+ | 'rage_click'
4
+ | 'form_abandon'
5
+ | 'js_error'
6
+ | 'slow_request'
7
+ | 'nav_loop'
8
+ | 'http_error'
9
+
10
+ export interface FrictionSignal {
11
+ kind: FrictionKind
12
+ properties: Record<string, unknown>
13
+ }
14
+
15
+ export interface FrictionCaptureOptions {
16
+ onFriction: (signal: FrictionSignal) => void
17
+ slowRequestMs?: number
18
+ rageClicks?: number
19
+ rageWindowMs?: number
20
+ rageRadiusPx?: number
21
+ navLoopWindowMs?: number
22
+ ignoreUrls?: string[]
23
+ /**
24
+ * Origens que contam como o próprio produto além do domínio da página —
25
+ * pro caso em que a API mora em outro domínio (`acme-api.io` servindo
26
+ * `acme.com`). O subdomínio já entra sozinho.
27
+ */
28
+ apiOrigins?: string[]
29
+ }
30
+
31
+ export interface FrictionCapture {
32
+ onNavigate: (path: string) => void
33
+ teardown: () => void
34
+ }
35
+
36
+ const INSTALLED_FLAG = '__holdy_friction__'
37
+ const INTERACTIVE_SELECTOR =
38
+ 'button, a, input, select, textarea, summary, [role="button"], [role="link"], [role="menuitem"], [role="tab"]'
39
+ const INTERACTIVE_TAGS = new Set(['button', 'a', 'input', 'select', 'textarea', 'summary'])
40
+ const FORM_CONTROL_TAGS = new Set(['input', 'select', 'textarea'])
41
+
42
+ export class RageClickDetector {
43
+ private clicks: Array<{ x: number; y: number; t: number }> = []
44
+
45
+ constructor(
46
+ private readonly threshold = 3,
47
+ private readonly windowMs = 1000,
48
+ private readonly radiusPx = 30,
49
+ ) {}
50
+
51
+ push(x: number, y: number, t: number): boolean {
52
+ this.clicks = this.clicks.filter(
53
+ (c) => t - c.t <= this.windowMs && Math.hypot(c.x - x, c.y - y) <= this.radiusPx,
54
+ )
55
+ this.clicks.push({ x, y, t })
56
+ if (this.clicks.length >= this.threshold) {
57
+ this.clicks = []
58
+ return true
59
+ }
60
+ return false
61
+ }
62
+ }
63
+
64
+ export class NavLoopDetector {
65
+ private visits: Array<{ path: string; t: number }> = []
66
+
67
+ constructor(private readonly windowMs = 60_000) {}
68
+
69
+ push(path: string, t: number): { from: string; to: string } | null {
70
+ this.visits = [...this.visits, { path, t }].filter((v) => t - v.t <= this.windowMs).slice(-4)
71
+ if (this.visits.length < 4) return null
72
+ const [a, b, c, d] = this.visits.map((v) => v.path)
73
+ if (a === c && b === d && a !== b) {
74
+ this.visits = []
75
+ return { from: a, to: b }
76
+ }
77
+ return null
78
+ }
79
+ }
80
+
81
+ export interface ElementLike {
82
+ tagName: string
83
+ id?: string
84
+ disabled?: boolean
85
+ textContent?: string | null
86
+ getAttribute(name: string): string | null
87
+ closest?(selector: string): ElementLike | null
88
+ }
89
+
90
+ export function resolveInteractive(el: ElementLike | null): ElementLike | null {
91
+ if (!el) return null
92
+ const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null
93
+ if (viaClosest) return viaClosest
94
+ const tag = el.tagName.toLowerCase()
95
+ if (INTERACTIVE_TAGS.has(tag)) return el
96
+ const role = el.getAttribute('role')
97
+ if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') return el
98
+ return null
99
+ }
100
+
101
+ export function isDeadClickTarget(el: ElementLike | null): boolean {
102
+ const target = resolveInteractive(el)
103
+ if (!target) return false
104
+ if (target.disabled === true) return true
105
+ if (target.getAttribute('disabled') !== null) return true
106
+ if (target.getAttribute('aria-disabled') === 'true') return true
107
+ const tag = target.tagName.toLowerCase()
108
+ if (tag === 'a') {
109
+ const href = target.getAttribute('href')
110
+ if (!href || href === '#' || href.startsWith('javascript:')) return true
111
+ }
112
+ return false
113
+ }
114
+
115
+ export function describeElement(el: ElementLike | null): Record<string, string> {
116
+ const target = resolveInteractive(el) ?? el
117
+ if (!target) return {}
118
+ const out: Record<string, string> = { element: target.tagName.toLowerCase() }
119
+ if (target.id) out.element_id = target.id
120
+ const role = target.getAttribute('role')
121
+ if (role) out.element_role = role
122
+ const label = target.getAttribute('aria-label') ?? target.textContent ?? ''
123
+ const text = label.replace(/\s+/g, ' ').trim().slice(0, 60)
124
+ if (text) out.element_text = text
125
+ const name = target.getAttribute('name') ?? target.getAttribute('data-testid')
126
+ if (name) out.element_name = name
127
+ return out
128
+ }
129
+
130
+ export function stripRequestUrl(input: string, base?: string): string {
131
+ try {
132
+ const url = new URL(input, base)
133
+ return `${url.origin}${url.pathname}`
134
+ } catch {
135
+ return input.split('?')[0].split('#')[0]
136
+ }
137
+ }
138
+
139
+ export function errorMessage(reason: unknown): string {
140
+ if (reason instanceof Error) return reason.message
141
+ if (typeof reason === 'string') return reason
142
+ try {
143
+ return JSON.stringify(reason)
144
+ } catch {
145
+ return String(reason)
146
+ }
147
+ }
148
+
149
+ export function isAbortError(err: unknown): boolean {
150
+ return typeof err === 'object' && err !== null && (err as { name?: unknown }).name === 'AbortError'
151
+ }
152
+
153
+ export function isIgnoredUrl(url: string, ignore: readonly string[]): boolean {
154
+ return ignore.some((prefix) => prefix.length > 0 && url.startsWith(prefix))
155
+ }
156
+
157
+ /**
158
+ * Domínio registrável aproximado — os dois últimos rótulos do host.
159
+ *
160
+ * `app.acme.com` e `api.acme.com` → `acme.com`: produto que fala com a própria
161
+ * API em outro subdomínio continua sendo primeira parte. Sem lista de sufixos
162
+ * públicos, `foo.co.uk` e `bar.co.uk` colidem — e a colisão erra pro lado
163
+ * seguro, incluindo como próprio o que hoje já é incluído.
164
+ */
165
+ export function registrableDomain(host: string): string {
166
+ const labels = host.toLowerCase().split('.').filter(Boolean)
167
+ if (labels.length <= 2) return labels.join('.')
168
+ return labels.slice(-2).join('.')
169
+ }
170
+
171
+ /**
172
+ * A requisição é do produto de quem instalou, ou de um terceiro?
173
+ *
174
+ * Fricção é fricção DO PRODUTO. Um `google-analytics.com/g/collect` morto por
175
+ * ad blocker não é o produto falhando — é o navegador do visitante recusando
176
+ * um script que nem é seu. Em 2026-09-17 esses dois domínios respondiam por 36
177
+ * dos eventos de fricção da janela: num cliente com GA instalado e usuários com
178
+ * bloqueador, isso infla a fricção e pode abrir oportunidade em cima de nada.
179
+ *
180
+ * Localhost e IP entram pela comparação de host exato — em desenvolvimento não
181
+ * há domínio registrável que valha.
182
+ */
183
+ export function isFirstPartyRequest(url: string, pageUrl: string, extraOrigins: readonly string[] = []): boolean {
184
+ let target: URL
185
+ let page: URL
186
+ try {
187
+ target = new URL(url, pageUrl)
188
+ page = new URL(pageUrl)
189
+ } catch {
190
+ return true
191
+ }
192
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') return true
193
+ if (target.host === page.host) return true
194
+ if (extraOrigins.some((o) => o.length > 0 && target.origin === o)) return true
195
+ const targetDomain = registrableDomain(target.hostname)
196
+ return targetDomain.length > 0 && targetDomain === registrableDomain(page.hostname)
197
+ }
198
+
199
+ export interface RequestOutcome {
200
+ status: number | null
201
+ durationMs: number
202
+ slowMs: number
203
+ aborted?: boolean
204
+ opaque?: boolean
205
+ }
206
+
207
+ export function classifyRequest(outcome: RequestOutcome): 'http_error' | 'slow_request' | null {
208
+ if (outcome.aborted) return null
209
+ if (!outcome.opaque && (outcome.status === null || outcome.status >= 400)) return 'http_error'
210
+ return outcome.durationMs >= outcome.slowMs ? 'slow_request' : null
211
+ }
212
+
213
+ export function documentStatus(
214
+ perf: { getEntriesByType?: (type: string) => unknown[] } | undefined,
215
+ ): number | null {
216
+ try {
217
+ const entry = perf?.getEntriesByType?.('navigation')?.[0] as { responseStatus?: unknown } | undefined
218
+ const status = entry?.responseStatus
219
+ return typeof status === 'number' && status > 0 ? status : null
220
+ } catch {
221
+ return null
222
+ }
223
+ }
224
+
225
+ interface FormTouch {
226
+ form: string
227
+ fields: Set<string>
228
+ }
229
+
230
+ function formKey(form: HTMLFormElement): string {
231
+ return (
232
+ form.id ||
233
+ form.getAttribute('name') ||
234
+ form.getAttribute('data-testid') ||
235
+ stripRequestUrl(form.getAttribute('action') ?? '', location.href).replace(location.origin, '') ||
236
+ 'form'
237
+ )
238
+ }
239
+
240
+ function currentPath(): string {
241
+ return typeof location === 'undefined' ? '/' : location.pathname || '/'
242
+ }
243
+
244
+ export function installFrictionCapture(opts: FrictionCaptureOptions): FrictionCapture {
245
+ const noop: FrictionCapture = { onNavigate: () => undefined, teardown: () => undefined }
246
+ if (typeof window === 'undefined' || typeof document === 'undefined') return noop
247
+
248
+ const w = window as unknown as Record<string, boolean | undefined>
249
+ if (w[INSTALLED_FLAG]) return noop
250
+ w[INSTALLED_FLAG] = true
251
+
252
+ const emit = (kind: FrictionKind, properties: Record<string, unknown>): void => {
253
+ try {
254
+ opts.onFriction({ kind, properties: { path: currentPath(), ...properties } })
255
+ } catch {
256
+ return
257
+ }
258
+ }
259
+
260
+ const cleanups: Array<() => void> = []
261
+ const rage = new RageClickDetector(opts.rageClicks, opts.rageWindowMs, opts.rageRadiusPx)
262
+ const navLoop = new NavLoopDetector(opts.navLoopWindowMs)
263
+ const slowMs = opts.slowRequestMs ?? 3000
264
+ const touched = new Map<HTMLFormElement, FormTouch>()
265
+
266
+ const onClick = (ev: MouseEvent): void => {
267
+ const el = ev.target instanceof Element ? (ev.target as unknown as ElementLike) : null
268
+ if (isDeadClickTarget(el)) emit('dead_click', describeElement(el))
269
+ if (rage.push(ev.clientX, ev.clientY, ev.timeStamp || Date.now())) {
270
+ emit('rage_click', describeElement(el))
271
+ }
272
+ }
273
+ document.addEventListener('click', onClick, true)
274
+ cleanups.push(() => document.removeEventListener('click', onClick, true))
275
+
276
+ const onError = (ev: ErrorEvent): void => {
277
+ emit('js_error', {
278
+ message: String(ev.message ?? '').slice(0, 200),
279
+ ...(ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {}),
280
+ ...(ev.lineno ? { line: ev.lineno } : {}),
281
+ })
282
+ }
283
+ window.addEventListener('error', onError)
284
+ cleanups.push(() => window.removeEventListener('error', onError))
285
+
286
+ const onRejection = (ev: PromiseRejectionEvent): void => {
287
+ emit('js_error', { message: errorMessage(ev.reason).slice(0, 200), unhandled_rejection: true })
288
+ }
289
+ window.addEventListener('unhandledrejection', onRejection)
290
+ cleanups.push(() => window.removeEventListener('unhandledrejection', onRejection))
291
+
292
+ const ignore = (opts.ignoreUrls ?? []).map((url) => stripRequestUrl(url, location.href))
293
+ const apiOrigins = (opts.apiOrigins ?? []).map((o) => {
294
+ try {
295
+ return new URL(o, location.href).origin
296
+ } catch {
297
+ return ''
298
+ }
299
+ })
300
+ const reportRequest = (
301
+ url: string,
302
+ method: string,
303
+ started: number,
304
+ status: number | null,
305
+ flags: { aborted?: boolean; opaque?: boolean } = {},
306
+ ): void => {
307
+ const target = stripRequestUrl(url, location.href)
308
+ if (isIgnoredUrl(target, ignore)) return
309
+ if (!isFirstPartyRequest(target, location.href, apiOrigins)) return
310
+ const durationMs = performance.now() - started
311
+ const kind = classifyRequest({ status, durationMs, slowMs, ...flags })
312
+ if (!kind) return
313
+ emit(kind, {
314
+ url: target,
315
+ method,
316
+ duration_ms: Math.round(durationMs),
317
+ ...(flags.opaque ? {} : status !== null ? { status } : { failed: true }),
318
+ })
319
+ }
320
+
321
+ const originalFetch = window.fetch
322
+ if (typeof originalFetch === 'function') {
323
+ const patched: typeof window.fetch = async (input, init) => {
324
+ const started = performance.now()
325
+ const url =
326
+ typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
327
+ const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase()
328
+ try {
329
+ const res = await originalFetch(input, init)
330
+ reportRequest(url, method, started, res.status, {
331
+ opaque: res.type === 'opaque' || res.type === 'opaqueredirect',
332
+ })
333
+ return res
334
+ } catch (err) {
335
+ reportRequest(url, method, started, null, { aborted: isAbortError(err) })
336
+ throw err
337
+ }
338
+ }
339
+ window.fetch = patched
340
+ cleanups.push(() => {
341
+ window.fetch = originalFetch
342
+ })
343
+ }
344
+
345
+ const xhrProto = typeof XMLHttpRequest === 'function' ? XMLHttpRequest.prototype : null
346
+ if (xhrProto) {
347
+ const originalOpen = xhrProto.open
348
+ const originalSend = xhrProto.send
349
+ const requests = new WeakMap<XMLHttpRequest, { method: string; url: string }>()
350
+ xhrProto.open = function (this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
351
+ requests.set(this, { method: String(method).toUpperCase(), url: String(url) })
352
+ return (originalOpen as (...args: unknown[]) => void).apply(this, [method, url, ...rest])
353
+ } as typeof xhrProto.open
354
+ xhrProto.send = function (this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
355
+ const request = requests.get(this)
356
+ if (request) {
357
+ const started = performance.now()
358
+ let aborted = false
359
+ this.addEventListener('abort', () => {
360
+ aborted = true
361
+ })
362
+ this.addEventListener('loadend', () => {
363
+ reportRequest(request.url, request.method, started, this.status === 0 ? null : this.status, { aborted })
364
+ })
365
+ }
366
+ return originalSend.call(this, body)
367
+ }
368
+ cleanups.push(() => {
369
+ xhrProto.open = originalOpen
370
+ xhrProto.send = originalSend
371
+ })
372
+ }
373
+
374
+ const loadStatus = documentStatus(typeof performance === 'undefined' ? undefined : performance)
375
+ if (loadStatus !== null && loadStatus >= 400) {
376
+ emit('http_error', { document: true, status: loadStatus, url: stripRequestUrl(location.href) })
377
+ }
378
+
379
+ const onFocusIn = (ev: FocusEvent): void => {
380
+ const el = ev.target as HTMLElement | null
381
+ if (!el || !FORM_CONTROL_TAGS.has(el.tagName.toLowerCase())) return
382
+ const form = (el as HTMLInputElement).form
383
+ if (!form) return
384
+ const entry = touched.get(form) ?? { form: formKey(form), fields: new Set<string>() }
385
+ const field = (el as HTMLInputElement).name || el.id || el.tagName.toLowerCase()
386
+ entry.fields.add(field)
387
+ touched.set(form, entry)
388
+ }
389
+ document.addEventListener('focusin', onFocusIn, true)
390
+ cleanups.push(() => document.removeEventListener('focusin', onFocusIn, true))
391
+
392
+ const onSubmit = (ev: Event): void => {
393
+ if (ev.target instanceof HTMLFormElement) touched.delete(ev.target)
394
+ }
395
+ document.addEventListener('submit', onSubmit, true)
396
+ cleanups.push(() => document.removeEventListener('submit', onSubmit, true))
397
+
398
+ const flushAbandons = (): void => {
399
+ for (const entry of touched.values()) {
400
+ emit('form_abandon', { form: entry.form, fields_touched: entry.fields.size })
401
+ }
402
+ touched.clear()
403
+ }
404
+ window.addEventListener('pagehide', flushAbandons)
405
+ cleanups.push(() => window.removeEventListener('pagehide', flushAbandons))
406
+
407
+ return {
408
+ onNavigate: (path: string) => {
409
+ flushAbandons()
410
+ const loop = navLoop.push(path, Date.now())
411
+ if (loop) emit('nav_loop', { from: loop.from, to: loop.to })
412
+ },
413
+ teardown: () => {
414
+ for (const cleanup of cleanups) cleanup()
415
+ touched.clear()
416
+ w[INSTALLED_FLAG] = false
417
+ },
418
+ }
419
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Holdy, HoldyCore, DEFAULT_BASE_URL } from './server'
2
2
  export { HoldyWeb, initHoldy } from './web'
3
3
  export type { HoldyWebOptions } from './web'
4
+ export type { FrictionKind, FrictionSignal, FrictionCaptureOptions } from './friction'
4
5
  export type { HoldyOptions, Properties, TrackOverrides, CanonicalEvent } from './types'
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { runEventName, withRun, type TrackFn } from './run'
3
+ import type { TrackOverrides } from './types'
4
+
5
+ function recorder() {
6
+ const calls: Array<{ name: string; properties?: Record<string, unknown>; overrides?: TrackOverrides }> = []
7
+ const track: TrackFn = (name, properties, overrides) => calls.push({ name, properties, overrides })
8
+ return { calls, track }
9
+ }
10
+
11
+ describe('withRun', () => {
12
+ it('carimba a mesma ai_run_id em início, passos e fim, com a conta em cada evento', async () => {
13
+ const { calls, track } = recorder()
14
+ const out = await withRun(track, { name: 'support_agent', accountKey: 'acme' }, async (run) => {
15
+ run.track('ticket_drafted', { ai_tokens: 1200 })
16
+ return 'ok'
17
+ })
18
+ expect(out).toBe('ok')
19
+ expect(calls.map((c) => c.name)).toEqual(['support_agent.started', 'ticket_drafted', 'support_agent.completed'])
20
+ const ids = new Set(calls.map((c) => c.properties?.ai_run_id))
21
+ expect(ids.size).toBe(1)
22
+ expect(calls.every((c) => c.overrides?.accountKey === 'acme')).toBe(true)
23
+ expect(calls[1].properties?.ai_tokens).toBe(1200)
24
+ })
25
+
26
+ it('o erro propaga e sai um .failed só com o nome do erro', async () => {
27
+ const { calls, track } = recorder()
28
+ await expect(
29
+ withRun(track, { name: 'agent', runId: 'trace-77' }, () => {
30
+ throw new TypeError('dado do cliente')
31
+ }),
32
+ ).rejects.toThrow(TypeError)
33
+ const failed = calls.find((c) => c.name === 'agent.failed')
34
+ expect(failed?.properties?.error).toBe('TypeError')
35
+ expect(calls.every((c) => c.properties?.ai_run_id === 'trace-77')).toBe(true)
36
+ })
37
+
38
+ it('a chave da execução não é sobrescrita por propriedade do passo', async () => {
39
+ const { calls, track } = recorder()
40
+ await withRun(track, { name: 'agent', runId: 'r1' }, (run) => run.track('step', { ai_run_id: 'outro' }))
41
+ expect(calls.every((c) => c.properties?.ai_run_id === 'r1')).toBe(true)
42
+ })
43
+
44
+ it('não duplica o sufixo quando o nome termina em ponto', () => {
45
+ expect(runEventName('agent.', 'started')).toBe('agent.started')
46
+ })
47
+ })