@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
package/src/changes.ts
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { changeArm, matchesPathTemplate, type ChangeArm } from './arm'
|
|
2
|
+
|
|
3
|
+
export type ChangeAnchor =
|
|
4
|
+
| { kind: 'selector'; value: string }
|
|
5
|
+
| { kind: 'text'; value: string }
|
|
6
|
+
| { kind: 'page' }
|
|
7
|
+
|
|
8
|
+
export interface ProductChange {
|
|
9
|
+
id: string
|
|
10
|
+
kind: 'copy' | 'guidance'
|
|
11
|
+
path: string
|
|
12
|
+
anchor: ChangeAnchor
|
|
13
|
+
expected_text: string | null
|
|
14
|
+
content: { text: string }
|
|
15
|
+
holdout_pct: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProductChangesOptions {
|
|
19
|
+
baseUrl: string
|
|
20
|
+
apiKey: string
|
|
21
|
+
getAccountKey: () => string | null
|
|
22
|
+
track: (name: string, properties: Record<string, unknown>) => void
|
|
23
|
+
debug?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProductChangesHandle {
|
|
27
|
+
onNavigate(): void
|
|
28
|
+
refresh(): Promise<void>
|
|
29
|
+
teardown(): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const REFRESH_MS = 5 * 60 * 1000
|
|
33
|
+
const NAV_REFRESH_MS = 60 * 1000
|
|
34
|
+
const SCAN_DEBOUNCE_MS = 250
|
|
35
|
+
const SEEN_KEY = 'holdy_changes_seen'
|
|
36
|
+
const DISMISSED_KEY = 'holdy_changes_dismissed'
|
|
37
|
+
const TIP_GAP_PX = 8
|
|
38
|
+
|
|
39
|
+
const TIP_CSS = `:host{all:initial}
|
|
40
|
+
.tip{position:fixed;box-sizing:border-box;max-width:280px;padding:10px 12px;border-radius:8px;background:#111;color:#fff;font:13px/1.45 system-ui,-apple-system,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.18);pointer-events:auto;display:flex;gap:10px;align-items:flex-start}
|
|
41
|
+
.tip p{margin:0;flex:1}
|
|
42
|
+
.tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
|
|
43
|
+
.tip button:hover,.tip button:focus-visible{opacity:1}
|
|
44
|
+
@media (prefers-color-scheme:dark){.tip{background:#fff;color:#111}}`
|
|
45
|
+
|
|
46
|
+
export function normalizeText(text: string): string {
|
|
47
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseChanges(body: unknown): ProductChange[] {
|
|
51
|
+
const list = (body as { changes?: unknown } | null)?.changes
|
|
52
|
+
return Array.isArray(list) ? list.filter(isChange) : []
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isAnchor(value: unknown): value is ChangeAnchor {
|
|
56
|
+
const a = value as { kind?: unknown; value?: unknown } | null
|
|
57
|
+
if (a?.kind === 'page') return true
|
|
58
|
+
return (a?.kind === 'selector' || a?.kind === 'text') && typeof a.value === 'string' && a.value.length > 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isChange(value: unknown): value is ProductChange {
|
|
62
|
+
const c = value as ProductChange | null
|
|
63
|
+
return (
|
|
64
|
+
typeof c?.id === 'string' &&
|
|
65
|
+
(c.kind === 'copy' || c.kind === 'guidance') &&
|
|
66
|
+
typeof c.path === 'string' &&
|
|
67
|
+
isAnchor(c.anchor) &&
|
|
68
|
+
typeof c.content?.text === 'string' &&
|
|
69
|
+
typeof c.holdout_pct === 'number'
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function changesForPage(
|
|
74
|
+
changes: readonly ProductChange[],
|
|
75
|
+
path: string,
|
|
76
|
+
accountKey: string | null,
|
|
77
|
+
): Array<{ change: ProductChange; arm: ChangeArm }> {
|
|
78
|
+
if (!accountKey) return []
|
|
79
|
+
return changes
|
|
80
|
+
.filter((c) => matchesPathTemplate(c.path, path))
|
|
81
|
+
.map((change) => ({ change, arm: changeArm(change.id, accountKey, change.holdout_pct) }))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function storage(kind: 'local' | 'session'): Storage | undefined {
|
|
85
|
+
try {
|
|
86
|
+
return kind === 'local' ? window.localStorage : window.sessionStorage
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readSet(store: Storage | undefined, key: string): Set<string> {
|
|
93
|
+
try {
|
|
94
|
+
return new Set(JSON.parse(store?.getItem(key) ?? '[]') as string[])
|
|
95
|
+
} catch {
|
|
96
|
+
return new Set()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function addToSet(store: Storage | undefined, key: string, value: string): void {
|
|
101
|
+
try {
|
|
102
|
+
const set = readSet(store, key)
|
|
103
|
+
set.add(value)
|
|
104
|
+
store?.setItem(key, JSON.stringify([...set].slice(-200)))
|
|
105
|
+
} catch {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function query(selector: string): Element | null {
|
|
111
|
+
try {
|
|
112
|
+
return document.querySelector(selector)
|
|
113
|
+
} catch {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const TEXT_ANCHOR_TAGS = 'button,a,[role="button"],label,summary,h1,h2,h3,h4,li,td,th,p,span,strong'
|
|
119
|
+
const TEXT_ANCHOR_SCAN_LIMIT = 3000
|
|
120
|
+
|
|
121
|
+
export function findByText(value: string): Element | null {
|
|
122
|
+
const wanted = normalizeText(value)
|
|
123
|
+
let best: Element | null = null
|
|
124
|
+
let bestLength = Number.POSITIVE_INFINITY
|
|
125
|
+
const nodes = document.querySelectorAll(TEXT_ANCHOR_TAGS)
|
|
126
|
+
const total = Math.min(nodes.length, TEXT_ANCHOR_SCAN_LIMIT)
|
|
127
|
+
for (let i = 0; i < total; i += 1) {
|
|
128
|
+
const el = nodes[i]
|
|
129
|
+
const raw = el.textContent ?? ''
|
|
130
|
+
if (raw.length >= bestLength || normalizeText(raw) !== wanted) continue
|
|
131
|
+
best = el
|
|
132
|
+
bestLength = raw.length
|
|
133
|
+
}
|
|
134
|
+
return best
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function findAnchor(anchor: ChangeAnchor): Element | null {
|
|
138
|
+
if (anchor.kind === 'page') return null
|
|
139
|
+
return anchor.kind === 'selector' ? query(anchor.value) : findByText(anchor.value)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function findTextNode(el: Element, match: (text: string) => boolean): Text | null {
|
|
143
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
|
144
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
145
|
+
if (match(normalizeText(node.textContent ?? ''))) return node as Text
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface AppliedCopy {
|
|
151
|
+
node: Text
|
|
152
|
+
original: string
|
|
153
|
+
next: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function installProductChanges(opts: ProductChangesOptions): ProductChangesHandle {
|
|
157
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
158
|
+
return { onNavigate: () => undefined, refresh: async () => undefined, teardown: () => undefined }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let changes: ProductChange[] = []
|
|
162
|
+
let lastFetch = 0
|
|
163
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
164
|
+
let root: ShadowRoot | null = null
|
|
165
|
+
let host: HTMLElement | null = null
|
|
166
|
+
const copies = new Map<string, AppliedCopy>()
|
|
167
|
+
const tips = new Map<string, () => void>()
|
|
168
|
+
|
|
169
|
+
const log = (message: string): void => {
|
|
170
|
+
if (opts.debug) console.warn(`[holdy] ${message}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const expose = (change: ProductChange, arm: ChangeArm): void => {
|
|
174
|
+
const key = `${change.id}:${arm}`
|
|
175
|
+
const session = storage('session')
|
|
176
|
+
if (readSet(session, SEEN_KEY).has(key)) return
|
|
177
|
+
addToSet(session, SEEN_KEY, key)
|
|
178
|
+
opts.track('$change_exposure', { change_id: change.id, arm, kind: change.kind })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const eligibleCopyNode = (el: Element, change: ProductChange): Text | null => {
|
|
182
|
+
const expected = change.expected_text ? normalizeText(change.expected_text) : null
|
|
183
|
+
const next = normalizeText(change.content.text)
|
|
184
|
+
return findTextNode(el, (text) => text.length > 0 && (text === next || expected === null || text === expected))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const applyCopy = (el: Element, change: ProductChange): boolean => {
|
|
188
|
+
const next = normalizeText(change.content.text)
|
|
189
|
+
const current = copies.get(change.id)
|
|
190
|
+
if (current && current.node.isConnected && normalizeText(current.node.textContent ?? '') === next) return true
|
|
191
|
+
const node = eligibleCopyNode(el, change)
|
|
192
|
+
if (!node) return false
|
|
193
|
+
const text = node.textContent ?? ''
|
|
194
|
+
if (normalizeText(text) !== next) {
|
|
195
|
+
copies.set(change.id, { node, original: text, next: change.content.text })
|
|
196
|
+
node.textContent = text.replace(text.trim(), change.content.text)
|
|
197
|
+
}
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const restoreCopy = (id: string): void => {
|
|
202
|
+
const applied = copies.get(id)
|
|
203
|
+
if (!applied) return
|
|
204
|
+
if (applied.node.isConnected && normalizeText(applied.node.textContent ?? '') === normalizeText(applied.next)) {
|
|
205
|
+
applied.node.textContent = applied.original
|
|
206
|
+
}
|
|
207
|
+
copies.delete(id)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ensureRoot = (): ShadowRoot | null => {
|
|
211
|
+
if (root) return root
|
|
212
|
+
if (!document.body) return null
|
|
213
|
+
host = document.createElement('holdy-changes')
|
|
214
|
+
host.setAttribute('data-holdy-root', '1')
|
|
215
|
+
host.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none'
|
|
216
|
+
root = host.attachShadow({ mode: 'closed' })
|
|
217
|
+
const style = document.createElement('style')
|
|
218
|
+
style.textContent = TIP_CSS
|
|
219
|
+
root.appendChild(style)
|
|
220
|
+
document.body.appendChild(host)
|
|
221
|
+
return root
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const showTip = (anchor: Element | null, change: ProductChange): boolean => {
|
|
225
|
+
if (tips.has(change.id)) return true
|
|
226
|
+
if (readSet(storage('local'), DISMISSED_KEY).has(change.id)) return true
|
|
227
|
+
const shadow = ensureRoot()
|
|
228
|
+
if (!shadow) return false
|
|
229
|
+
|
|
230
|
+
const card = document.createElement('div')
|
|
231
|
+
card.className = 'tip'
|
|
232
|
+
card.setAttribute('role', 'tooltip')
|
|
233
|
+
const text = document.createElement('p')
|
|
234
|
+
text.textContent = change.content.text
|
|
235
|
+
const close = document.createElement('button')
|
|
236
|
+
close.type = 'button'
|
|
237
|
+
close.setAttribute('aria-label', 'Fechar')
|
|
238
|
+
close.textContent = '×'
|
|
239
|
+
card.append(text, close)
|
|
240
|
+
card.style.top = '-9999px'
|
|
241
|
+
card.style.left = '-9999px'
|
|
242
|
+
shadow.appendChild(card)
|
|
243
|
+
|
|
244
|
+
const remove = (): void => {
|
|
245
|
+
card.remove()
|
|
246
|
+
window.removeEventListener('scroll', position, true)
|
|
247
|
+
window.removeEventListener('resize', position)
|
|
248
|
+
tips.delete(change.id)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function position(): void {
|
|
252
|
+
if (!anchor) {
|
|
253
|
+
const corner = card.getBoundingClientRect()
|
|
254
|
+
card.style.top = `${Math.max(8, window.innerHeight - corner.height - 16)}px`
|
|
255
|
+
card.style.left = `${Math.max(8, window.innerWidth - corner.width - 16)}px`
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (!anchor.isConnected) {
|
|
259
|
+
remove()
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
const a = anchor.getBoundingClientRect()
|
|
263
|
+
const c = card.getBoundingClientRect()
|
|
264
|
+
let top = a.bottom + TIP_GAP_PX
|
|
265
|
+
if (top + c.height > window.innerHeight - 8) top = a.top - c.height - TIP_GAP_PX
|
|
266
|
+
const left = Math.min(Math.max(8, a.left + a.width / 2 - c.width / 2), window.innerWidth - c.width - 8)
|
|
267
|
+
card.style.top = `${Math.max(8, top)}px`
|
|
268
|
+
card.style.left = `${Math.max(8, left)}px`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
close.addEventListener('click', () => {
|
|
272
|
+
addToSet(storage('local'), DISMISSED_KEY, change.id)
|
|
273
|
+
opts.track('$change_dismissed', { change_id: change.id, kind: change.kind })
|
|
274
|
+
remove()
|
|
275
|
+
})
|
|
276
|
+
tips.set(change.id, remove)
|
|
277
|
+
requestAnimationFrame(position)
|
|
278
|
+
window.addEventListener('scroll', position, true)
|
|
279
|
+
window.addEventListener('resize', position)
|
|
280
|
+
return true
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const scan = (): void => {
|
|
284
|
+
timer = null
|
|
285
|
+
const active = changesForPage(changes, location.pathname, opts.getAccountKey())
|
|
286
|
+
const activeIds = new Set(active.map((a) => a.change.id))
|
|
287
|
+
for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id)
|
|
288
|
+
for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove()
|
|
289
|
+
|
|
290
|
+
for (const { change, arm } of active) {
|
|
291
|
+
const el = findAnchor(change.anchor)
|
|
292
|
+
if (!el && change.anchor.kind !== 'page') continue
|
|
293
|
+
if (change.kind === 'copy') {
|
|
294
|
+
if (!el) continue
|
|
295
|
+
const shown = arm === 'change' ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null
|
|
296
|
+
if (shown) expose(change, arm)
|
|
297
|
+
} else if (arm === 'holdout' || showTip(el, change)) {
|
|
298
|
+
expose(change, arm)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const schedule = (): void => {
|
|
304
|
+
if (timer === null) timer = setTimeout(scan, SCAN_DEBOUNCE_MS)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const refresh = async (): Promise<void> => {
|
|
308
|
+
lastFetch = Date.now()
|
|
309
|
+
try {
|
|
310
|
+
const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
|
|
311
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` },
|
|
312
|
+
})
|
|
313
|
+
if (!res.ok) {
|
|
314
|
+
log(`changes ${res.status}`)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
changes = parseChanges(await res.json())
|
|
318
|
+
schedule()
|
|
319
|
+
} catch (err) {
|
|
320
|
+
log(`changes network error: ${(err as Error).message}`)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const observer =
|
|
325
|
+
typeof MutationObserver !== 'undefined'
|
|
326
|
+
? new MutationObserver(() => {
|
|
327
|
+
if (changes.length > 0 || copies.size > 0 || tips.size > 0) schedule()
|
|
328
|
+
})
|
|
329
|
+
: null
|
|
330
|
+
const observe = (): void => {
|
|
331
|
+
if (document.body) observer?.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
332
|
+
}
|
|
333
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', observe, { once: true })
|
|
334
|
+
else observe()
|
|
335
|
+
|
|
336
|
+
const interval = setInterval(() => void refresh(), REFRESH_MS)
|
|
337
|
+
void refresh()
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
onNavigate: () => {
|
|
341
|
+
if (Date.now() - lastFetch > NAV_REFRESH_MS) void refresh()
|
|
342
|
+
schedule()
|
|
343
|
+
},
|
|
344
|
+
refresh,
|
|
345
|
+
teardown: () => {
|
|
346
|
+
clearInterval(interval)
|
|
347
|
+
if (timer !== null) clearTimeout(timer)
|
|
348
|
+
observer?.disconnect()
|
|
349
|
+
for (const id of [...copies.keys()]) restoreCopy(id)
|
|
350
|
+
for (const remove of [...tips.values()]) remove()
|
|
351
|
+
host?.remove()
|
|
352
|
+
host = null
|
|
353
|
+
root = null
|
|
354
|
+
changes = []
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
}
|
package/src/core.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ interface SentBatch {
|
|
|
7
7
|
events: Array<Record<string, unknown>>
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
function mockFetch(status = 202) {
|
|
10
|
+
function mockFetch(status = 202, headers: Record<string, string> = {}) {
|
|
11
11
|
const sent: SentBatch[] = []
|
|
12
12
|
const fn = vi.fn(async (url: unknown, init?: RequestInit) => {
|
|
13
13
|
sent.push({
|
|
@@ -15,7 +15,7 @@ function mockFetch(status = 202) {
|
|
|
15
15
|
auth: (init?.headers as Record<string, string> | undefined)?.Authorization,
|
|
16
16
|
events: (JSON.parse(String(init?.body)) as { events: Array<Record<string, unknown>> }).events,
|
|
17
17
|
})
|
|
18
|
-
return { ok: status < 400, status } as Response
|
|
18
|
+
return { ok: status < 400, status, headers: new Headers(headers) } as Response
|
|
19
19
|
})
|
|
20
20
|
vi.stubGlobal('fetch', fn)
|
|
21
21
|
return { sent, fn }
|
|
@@ -34,6 +34,7 @@ function makeClient(opts: Partial<ConstructorParameters<typeof HoldyCore>[1]> =
|
|
|
34
34
|
describe('HoldyCore', () => {
|
|
35
35
|
afterEach(() => {
|
|
36
36
|
vi.unstubAllGlobals()
|
|
37
|
+
vi.restoreAllMocks()
|
|
37
38
|
})
|
|
38
39
|
|
|
39
40
|
it('track enfileira envelope canônico e flush envia pra /events/bulk com Bearer', async () => {
|
|
@@ -111,17 +112,90 @@ describe('HoldyCore', () => {
|
|
|
111
112
|
expect(sent[0].events).toHaveLength(2)
|
|
112
113
|
})
|
|
113
114
|
|
|
114
|
-
it('
|
|
115
|
+
it('todo evento sai com external_id, o mesmo quando o lote é reenviado', async () => {
|
|
116
|
+
const failing = mockFetch(503)
|
|
117
|
+
const holdy = makeClient()
|
|
118
|
+
holdy.track('a')
|
|
119
|
+
holdy.track('b')
|
|
120
|
+
await holdy.flush()
|
|
121
|
+
const ids = failing.sent[0].events.map((e) => e.external_id)
|
|
122
|
+
expect(ids.every((id) => typeof id === 'string' && id.length > 0)).toBe(true)
|
|
123
|
+
expect(new Set(ids).size).toBe(2)
|
|
124
|
+
|
|
125
|
+
vi.unstubAllGlobals()
|
|
126
|
+
const retry = mockFetch()
|
|
127
|
+
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000)
|
|
128
|
+
await holdy.flush()
|
|
129
|
+
expect(retry.sent[0].events.map((e) => e.external_id)).toEqual(ids)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('5xx re-enfileira e espera o backoff antes de tentar de novo', async () => {
|
|
115
133
|
const { sent } = mockFetch(500)
|
|
116
134
|
const holdy = makeClient()
|
|
117
135
|
holdy.track('x')
|
|
136
|
+
const t0 = Date.now()
|
|
137
|
+
vi.spyOn(Date, 'now').mockReturnValue(t0)
|
|
118
138
|
await holdy.flush()
|
|
119
139
|
expect(holdy.pending).toBe(1)
|
|
140
|
+
|
|
141
|
+
await holdy.flush()
|
|
120
142
|
expect(sent).toHaveLength(1)
|
|
121
143
|
|
|
122
|
-
vi.
|
|
144
|
+
vi.spyOn(Date, 'now').mockReturnValue(t0 + 61_000)
|
|
145
|
+
await holdy.flush()
|
|
146
|
+
expect(sent).toHaveLength(2)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('429 respeita Retry-After e não descarta o lote', async () => {
|
|
150
|
+
const { sent } = mockFetch(429, { 'Retry-After': '30' })
|
|
151
|
+
const holdy = makeClient()
|
|
152
|
+
holdy.track('x')
|
|
153
|
+
const t0 = Date.now()
|
|
154
|
+
vi.spyOn(Date, 'now').mockReturnValue(t0)
|
|
155
|
+
await holdy.flush()
|
|
156
|
+
expect(holdy.pending).toBe(1)
|
|
157
|
+
|
|
158
|
+
vi.spyOn(Date, 'now').mockReturnValue(t0 + 29_000)
|
|
159
|
+
await holdy.flush()
|
|
160
|
+
expect(sent).toHaveLength(1)
|
|
161
|
+
|
|
162
|
+
vi.spyOn(Date, 'now').mockReturnValue(t0 + 30_001)
|
|
163
|
+
await holdy.flush()
|
|
164
|
+
expect(sent).toHaveLength(2)
|
|
165
|
+
expect(holdy.pending).toBe(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('4xx de payload descarta o lote', async () => {
|
|
123
169
|
mockFetch(400)
|
|
170
|
+
const holdy = makeClient()
|
|
171
|
+
holdy.track('x')
|
|
172
|
+
await holdy.flush()
|
|
173
|
+
expect(holdy.pending).toBe(0)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('chave inválida avisa uma vez no console, mesmo sem debug', async () => {
|
|
177
|
+
mockFetch(401)
|
|
178
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
179
|
+
const holdy = makeClient()
|
|
180
|
+
holdy.track('a')
|
|
181
|
+
await holdy.flush()
|
|
182
|
+
holdy.track('b')
|
|
183
|
+
await holdy.flush()
|
|
184
|
+
expect(holdy.pending).toBe(0)
|
|
185
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('shutdown envia mesmo durante o backoff', async () => {
|
|
189
|
+
mockFetch(500)
|
|
190
|
+
const holdy = makeClient()
|
|
191
|
+
holdy.track('x')
|
|
124
192
|
await holdy.flush()
|
|
193
|
+
expect(holdy.pending).toBe(1)
|
|
194
|
+
|
|
195
|
+
vi.unstubAllGlobals()
|
|
196
|
+
const ok = mockFetch()
|
|
197
|
+
await holdy.shutdown()
|
|
198
|
+
expect(ok.sent).toHaveLength(1)
|
|
125
199
|
expect(holdy.pending).toBe(0)
|
|
126
200
|
})
|
|
127
201
|
|
package/src/core.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Core do @holdyapp/sdk · fila + flush pra POST /api/v1/events/bulk.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Todo evento sai com `external_id` próprio, então reenviar um lote nunca
|
|
5
|
+
* duplica (dedupe server-side por source + external_id + occurred_at).
|
|
6
|
+
* 429 respeita Retry-After; 5xx e rede esperam com backoff exponencial; o lote
|
|
7
|
+
* volta pra frente da fila em vez de sumir. Chave inválida ou origem recusada
|
|
8
|
+
* avisam uma vez no console, mesmo sem debug. Fila é bounded: acima de
|
|
9
|
+
* MAX_QUEUE dropa os mais antigos (telemetria nunca derruba o app do cliente —
|
|
10
|
+
* nenhum método público lança).
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import type { CanonicalEvent, HoldyOptions, Properties, TrackOverrides } from './types'
|
|
@@ -13,10 +17,13 @@ const DEFAULT_FLUSH_AT = 20
|
|
|
13
17
|
const DEFAULT_FLUSH_INTERVAL_MS = 5000
|
|
14
18
|
const MAX_QUEUE = 1000
|
|
15
19
|
const BULK_LIMIT = 500
|
|
20
|
+
const MAX_BACKOFF_MS = 60_000
|
|
21
|
+
|
|
22
|
+
type SendOutcome = { kind: 'sent' } | { kind: 'dropped' } | { kind: 'retry'; afterMs: number | null }
|
|
16
23
|
|
|
17
24
|
export class HoldyCore {
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
protected readonly apiKey: string
|
|
26
|
+
protected readonly baseUrl: string
|
|
20
27
|
private readonly flushAt: number
|
|
21
28
|
private readonly debug: boolean
|
|
22
29
|
private readonly source: 'sdk_web' | 'sdk_server'
|
|
@@ -24,6 +31,9 @@ export class HoldyCore {
|
|
|
24
31
|
private queue: CanonicalEvent[] = []
|
|
25
32
|
private timer: ReturnType<typeof setInterval> | null = null
|
|
26
33
|
private flushing: Promise<void> | null = null
|
|
34
|
+
private retryAt = 0
|
|
35
|
+
private failures = 0
|
|
36
|
+
private readonly warned = new Set<string>()
|
|
27
37
|
|
|
28
38
|
protected userId: string | null = null
|
|
29
39
|
protected anonymousId: string | null = null
|
|
@@ -69,6 +79,7 @@ export class HoldyCore {
|
|
|
69
79
|
event: name,
|
|
70
80
|
occurred_at: occurredAt,
|
|
71
81
|
source: this.source,
|
|
82
|
+
external_id: newEventId(),
|
|
72
83
|
...(userId ? { user_id: userId } : {}),
|
|
73
84
|
...(this.anonymousId ? { anonymous_id: this.anonymousId } : {}),
|
|
74
85
|
properties: properties ?? {},
|
|
@@ -76,7 +87,7 @@ export class HoldyCore {
|
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
this.queue.push(event)
|
|
79
|
-
|
|
90
|
+
this.trimQueue()
|
|
80
91
|
if (this.queue.length >= this.flushAt) void this.flush()
|
|
81
92
|
}
|
|
82
93
|
|
|
@@ -92,16 +103,9 @@ export class HoldyCore {
|
|
|
92
103
|
this.track('$group', traits ?? {})
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
/** Drena a fila. Nunca lança — falha re-enfileira e
|
|
106
|
+
/** Drena a fila. Nunca lança — falha re-enfileira e espera antes de tentar de novo. */
|
|
96
107
|
async flush(): Promise<void> {
|
|
97
|
-
|
|
98
|
-
if (this.flushing) return this.flushing
|
|
99
|
-
if (this.queue.length === 0) return
|
|
100
|
-
|
|
101
|
-
this.flushing = this.drain().finally(() => {
|
|
102
|
-
this.flushing = null
|
|
103
|
-
})
|
|
104
|
-
return this.flushing
|
|
108
|
+
return this.startFlush(false)
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
/** Flush final + para o timer. Chamar em shutdown do server / unload do web. */
|
|
@@ -110,22 +114,51 @@ export class HoldyCore {
|
|
|
110
114
|
clearInterval(this.timer)
|
|
111
115
|
this.timer = null
|
|
112
116
|
}
|
|
113
|
-
await this.
|
|
117
|
+
await this.startFlush(true)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private startFlush(force: boolean): Promise<void> {
|
|
121
|
+
// Serializa flushes concorrentes (timer + flushAt + manual).
|
|
122
|
+
if (this.flushing) return this.flushing
|
|
123
|
+
if (this.queue.length === 0) return Promise.resolve()
|
|
124
|
+
if (!force && Date.now() < this.retryAt) return Promise.resolve()
|
|
125
|
+
|
|
126
|
+
this.flushing = this.drain().finally(() => {
|
|
127
|
+
this.flushing = null
|
|
128
|
+
})
|
|
129
|
+
return this.flushing
|
|
114
130
|
}
|
|
115
131
|
|
|
116
132
|
private async drain(): Promise<void> {
|
|
117
133
|
while (this.queue.length > 0) {
|
|
118
134
|
const batch = this.queue.splice(0, BULK_LIMIT)
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
// Devolve pra frente da fila e para — próximo flush re-tenta (dedupe-safe).
|
|
135
|
+
const outcome = await this.send(batch)
|
|
136
|
+
if (outcome.kind === 'retry') {
|
|
122
137
|
this.queue.unshift(...batch)
|
|
138
|
+
this.trimQueue()
|
|
139
|
+
this.scheduleRetry(outcome.afterMs)
|
|
123
140
|
return
|
|
124
141
|
}
|
|
142
|
+
this.failures = 0
|
|
143
|
+
this.retryAt = 0
|
|
125
144
|
}
|
|
126
145
|
}
|
|
127
146
|
|
|
128
|
-
private
|
|
147
|
+
private scheduleRetry(afterMs: number | null): void {
|
|
148
|
+
this.failures += 1
|
|
149
|
+
if (afterMs !== null) {
|
|
150
|
+
this.retryAt = Date.now() + afterMs
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** (this.failures - 1))
|
|
154
|
+
this.retryAt = Date.now() + backoff + Math.floor(Math.random() * backoff * 0.2)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private trimQueue(): void {
|
|
158
|
+
if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async send(events: CanonicalEvent[]): Promise<SendOutcome> {
|
|
129
162
|
try {
|
|
130
163
|
const res = await fetch(`${this.baseUrl}/events/bulk`, {
|
|
131
164
|
method: 'POST',
|
|
@@ -136,19 +169,29 @@ export class HoldyCore {
|
|
|
136
169
|
body: JSON.stringify({ events }),
|
|
137
170
|
...(this.source === 'sdk_web' ? { keepalive: true } : {}),
|
|
138
171
|
})
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
172
|
+
if (res.ok) return { kind: 'sent' }
|
|
173
|
+
|
|
174
|
+
if (res.status === 429) {
|
|
175
|
+
this.log('rate limited · will retry after Retry-After')
|
|
176
|
+
return { kind: 'retry', afterMs: retryAfterMs(res) }
|
|
177
|
+
}
|
|
178
|
+
if (res.status >= 500) {
|
|
179
|
+
this.log(`bulk failed ${res.status} · will retry with backoff`)
|
|
180
|
+
return { kind: 'retry', afterMs: null }
|
|
147
181
|
}
|
|
148
|
-
|
|
182
|
+
if (res.status === 401) {
|
|
183
|
+
this.warnOnce('401', 'API key inválida ou revogada · eventos descartados. Confira a chave em Configurações › SDK.')
|
|
184
|
+
return { kind: 'dropped' }
|
|
185
|
+
}
|
|
186
|
+
if (res.status === 403) {
|
|
187
|
+
this.warnOnce('403', 'Origem não permitida ou chave sem escopo events:write · eventos descartados.')
|
|
188
|
+
return { kind: 'dropped' }
|
|
189
|
+
}
|
|
190
|
+
this.log(`bulk rejected ${res.status} · batch dropped`)
|
|
191
|
+
return { kind: 'dropped' }
|
|
149
192
|
} catch (err) {
|
|
150
|
-
this.log(`bulk network error: ${(err as Error).message} · will retry`)
|
|
151
|
-
return
|
|
193
|
+
this.log(`bulk network error: ${(err as Error).message} · will retry with backoff`)
|
|
194
|
+
return { kind: 'retry', afterMs: null }
|
|
152
195
|
}
|
|
153
196
|
}
|
|
154
197
|
|
|
@@ -156,6 +199,12 @@ export class HoldyCore {
|
|
|
156
199
|
if (this.debug) console.warn(`[holdy] ${msg}`)
|
|
157
200
|
}
|
|
158
201
|
|
|
202
|
+
private warnOnce(key: string, msg: string): void {
|
|
203
|
+
if (this.warned.has(key)) return
|
|
204
|
+
this.warned.add(key)
|
|
205
|
+
console.warn(`[holdy] ${msg}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
159
208
|
/**
|
|
160
209
|
* Ambiente que o SDK observa sozinho (url, referrer, browser, utm…).
|
|
161
210
|
* Vazio no core; `HoldyWeb` preenche. O server não infere ambiente — não
|
|
@@ -170,3 +219,18 @@ export class HoldyCore {
|
|
|
170
219
|
return this.queue.length
|
|
171
220
|
}
|
|
172
221
|
}
|
|
222
|
+
|
|
223
|
+
function retryAfterMs(res: Response): number | null {
|
|
224
|
+
const raw = typeof res.headers?.get === 'function' ? res.headers.get('Retry-After') : null
|
|
225
|
+
if (!raw) return null
|
|
226
|
+
const seconds = Number(raw)
|
|
227
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null
|
|
228
|
+
return Math.min(MAX_BACKOFF_MS, seconds * 1000)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function newEventId(): string {
|
|
232
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
|
|
233
|
+
if (typeof c?.randomUUID === 'function') return c.randomUUID()
|
|
234
|
+
const rand = () => Math.random().toString(36).slice(2, 12)
|
|
235
|
+
return `${Date.now().toString(36)}-${rand()}-${rand()}`
|
|
236
|
+
}
|