@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.
- package/README.md +53 -1
- package/dist/arm.d.ts +5 -0
- package/dist/arm.d.ts.map +1 -0
- package/dist/changes.d.ts +42 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/core.d.ts +17 -6
- package/dist/core.d.ts.map +1 -1
- package/dist/friction.d.ts +91 -0
- package/dist/friction.d.ts.map +1 -0
- package/dist/holdy.js +6 -1
- package/dist/holdy.js.map +4 -4
- package/dist/index.cjs +871 -26
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +871 -26
- package/dist/index.mjs.map +4 -4
- package/dist/run.d.ts +37 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/server.cjs +127 -23
- package/dist/server.cjs.map +3 -3
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.mjs +124 -22
- package/dist/server.mjs.map +3 -3
- package/dist/skeleton.d.ts +50 -0
- package/dist/skeleton.d.ts.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/web.d.ts +26 -0
- package/dist/web.d.ts.map +1 -1
- package/dist/web.mjs +824 -26
- package/dist/web.mjs.map +4 -4
- package/package.json +1 -1
- package/src/arm.test.ts +47 -0
- package/src/arm.ts +25 -0
- package/src/changes.test.ts +54 -0
- package/src/changes.ts +357 -0
- package/src/core.test.ts +78 -4
- package/src/core.ts +95 -31
- package/src/friction.test.ts +223 -0
- package/src/friction.ts +419 -0
- package/src/index.ts +1 -0
- package/src/run.test.ts +47 -0
- package/src/run.ts +80 -0
- package/src/server.ts +17 -0
- package/src/skeleton.test.ts +60 -0
- package/src/skeleton.ts +173 -0
- package/src/types.ts +2 -0
- 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
|
+
})
|
package/src/friction.ts
ADDED
|
@@ -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'
|
package/src/run.test.ts
ADDED
|
@@ -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
|
+
})
|