@holdyapp/sdk 1.1.0 → 1.5.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/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('5xx re-enfileira (retry dedupe-safe) · 4xx dropa (evita loop)', async () => {
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.unstubAllGlobals()
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
- * Retry de batch inteiro é seguro (partial accept + dedupe server-side).
5
- * Fila é bounded: acima de MAX_QUEUE dropa os mais antigos (telemetria nunca
6
- * derruba o app do cliente — nenhum método público lança).
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
- private readonly apiKey: string
19
- private readonly baseUrl: string
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
- if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE)
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 loga em debug. */
106
+ /** Drena a fila. Nunca lança — falha re-enfileira e espera antes de tentar de novo. */
96
107
  async flush(): Promise<void> {
97
- // Serializa flushes concorrentes (timer + flushAt + manual).
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.flush()
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 ok = await this.send(batch)
120
- if (!ok) {
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 async send(events: CanonicalEvent[]): Promise<boolean> {
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 (!res.ok) {
140
- // 4xx: batch estruturalmente inválido não re-tenta (evita loop infinito).
141
- if (res.status >= 400 && res.status < 500) {
142
- this.log(`bulk rejected ${res.status} · batch dropped`)
143
- return true
144
- }
145
- this.log(`bulk failed ${res.status} · will retry`)
146
- return false
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
- return true
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 false
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
+ }