@holdyapp/sdk 0.2.0 → 1.0.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 +20 -147
- package/dist/auto-capture.d.ts +12 -16
- package/dist/auto-capture.d.ts.map +1 -0
- package/dist/browser.d.ts +23 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/core.d.ts +42 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/holdy.js +2 -0
- package/dist/holdy.js.map +7 -0
- package/dist/index.cjs.js +422 -0
- package/dist/index.cjs.js.map +7 -0
- package/dist/index.d.ts +5 -46
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +399 -0
- package/dist/index.mjs.map +7 -0
- package/dist/server.cjs.js +171 -0
- package/dist/server.cjs.js.map +7 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.mjs +148 -0
- package/dist/server.mjs.map +7 -0
- package/dist/types.d.ts +41 -109
- package/dist/types.d.ts.map +1 -0
- package/dist/web-context.d.ts +51 -0
- package/dist/web-context.d.ts.map +1 -0
- package/dist/web.d.ts +41 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.mjs +389 -0
- package/dist/web.mjs.map +7 -0
- package/package.json +25 -21
- package/src/auto-capture.ts +51 -78
- package/src/browser.ts +55 -0
- package/src/core.test.ts +147 -0
- package/src/core.ts +172 -0
- package/src/index.ts +4 -427
- package/src/server.ts +19 -0
- package/src/types.ts +38 -162
- package/src/web-context.test.ts +71 -0
- package/src/web-context.ts +227 -0
- package/src/web.ts +108 -0
- package/LICENSE +0 -21
- package/dist/anchor.d.ts +0 -8
- package/dist/holdy.cjs.js +0 -255
- package/dist/holdy.cjs.js.map +0 -7
- package/dist/holdy.esm.js +0 -255
- package/dist/holdy.esm.js.map +0 -7
- package/dist/holdy.iife.js +0 -255
- package/dist/holdy.iife.js.map +0 -7
- package/dist/outcome.d.ts +0 -19
- package/dist/queue.d.ts +0 -54
- package/dist/render/kinds/banner.d.ts +0 -3
- package/dist/render/kinds/checklist.d.ts +0 -19
- package/dist/render/kinds/cta.d.ts +0 -8
- package/dist/render/kinds/modal.d.ts +0 -16
- package/dist/render/kinds/shared.d.ts +0 -16
- package/dist/render/kinds/spotlight.d.ts +0 -17
- package/dist/render/kinds/tooltip.d.ts +0 -3
- package/dist/render/root.d.ts +0 -3
- package/dist/render/styles.d.ts +0 -5
- package/dist/store.d.ts +0 -36
- package/dist/transport.d.ts +0 -59
- package/src/anchor.ts +0 -72
- package/src/outcome.ts +0 -51
- package/src/queue.ts +0 -296
- package/src/render/kinds/banner.ts +0 -52
- package/src/render/kinds/checklist.ts +0 -175
- package/src/render/kinds/cta.ts +0 -53
- package/src/render/kinds/modal.ts +0 -129
- package/src/render/kinds/shared.ts +0 -67
- package/src/render/kinds/spotlight.ts +0 -176
- package/src/render/kinds/tooltip.ts +0 -82
- package/src/render/root.ts +0 -69
- package/src/render/styles.ts +0 -280
- package/src/store.ts +0 -106
- package/src/transport.ts +0 -182
package/src/auto-capture.ts
CHANGED
|
@@ -1,129 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-capture
|
|
2
|
+
* Auto-capture de navegação · @holdyapp/sdk/web.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Emite pageview no carregamento e a cada mudança de rota, inclusive nas que
|
|
5
|
+
* não recarregam a página (SPA). Sem isto, instrumentar exige que o cliente
|
|
6
|
+
* chame `page()` em cada tela — e o primeiro dado só aparece depois de um
|
|
7
|
+
* trabalho que ele ainda não fez.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* · visibility_change · página voltou a ficar visível
|
|
10
|
-
*
|
|
11
|
-
* Tudo client-side · não precisa que o customer declare nada. Permite que
|
|
12
|
-
* o widget de install do Holdy mostre "✓ conectado" segundos após o user
|
|
13
|
-
* carregar a página.
|
|
14
|
-
*
|
|
15
|
-
* Idempotente · re-rodar `installAutoCapture` é no-op via guard global.
|
|
16
|
-
* Cleanup ao destroy via array de unsubscribers retornado.
|
|
9
|
+
* Idempotente: reinstalar é no-op (guard global). O retorno desfaz o patch de
|
|
10
|
+
* `history`, então `destroy()` devolve o browser como estava.
|
|
17
11
|
*/
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
(path: string, title: string | undefined, referrer: string | undefined, kind: 'initial_load' | 'route_change' | 'visibility_change'): void
|
|
21
|
-
}
|
|
13
|
+
const INSTALLED_FLAG = '__holdy_autocapture__'
|
|
22
14
|
|
|
23
|
-
|
|
15
|
+
/** pushState seguido de replaceState é uma mudança lógica só. */
|
|
24
16
|
const ROUTE_DEBOUNCE_MS = 50
|
|
25
17
|
|
|
18
|
+
export type NavigationKind = 'initial_load' | 'route_change'
|
|
19
|
+
|
|
20
|
+
export interface AutoCaptureOptions {
|
|
21
|
+
onNavigate: (kind: NavigationKind) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
26
24
|
interface GlobalWithFlag {
|
|
27
25
|
[INSTALLED_FLAG]?: boolean
|
|
28
|
-
history?: History
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
export function installAutoCapture(
|
|
28
|
+
export function installAutoCapture(opts: AutoCaptureOptions): () => void {
|
|
32
29
|
if (typeof window === 'undefined' || typeof history === 'undefined') {
|
|
33
30
|
return () => undefined
|
|
34
31
|
}
|
|
32
|
+
|
|
35
33
|
const w = window as unknown as GlobalWithFlag
|
|
36
|
-
if (w[INSTALLED_FLAG])
|
|
37
|
-
return () => undefined
|
|
38
|
-
}
|
|
34
|
+
if (w[INSTALLED_FLAG]) return () => undefined
|
|
39
35
|
w[INSTALLED_FLAG] = true
|
|
40
36
|
|
|
41
37
|
const cleanups: Array<() => void> = []
|
|
42
38
|
let lastPath = currentPath()
|
|
43
|
-
let
|
|
39
|
+
let pending: ReturnType<typeof setTimeout> | null = null
|
|
44
40
|
|
|
45
|
-
function
|
|
41
|
+
function emit(kind: NavigationKind): void {
|
|
46
42
|
try {
|
|
47
|
-
|
|
48
|
-
const title = typeof document !== 'undefined' ? document.title : undefined
|
|
49
|
-
const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined
|
|
50
|
-
emit(path, title, referrer, kind)
|
|
43
|
+
opts.onNavigate(kind)
|
|
51
44
|
} catch {
|
|
52
|
-
//
|
|
45
|
+
// Telemetria nunca derruba a app do cliente.
|
|
53
46
|
}
|
|
54
47
|
}
|
|
55
48
|
|
|
56
49
|
function scheduleRouteEmit(): void {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
if (pending) clearTimeout(pending)
|
|
51
|
+
pending = setTimeout(() => {
|
|
52
|
+
pending = null
|
|
60
53
|
const path = currentPath()
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
// Só conta como pageview se a rota mudou de fato — replaceState de
|
|
55
|
+
// estado interno (filtro, scroll) não é navegação.
|
|
56
|
+
if (path === lastPath) return
|
|
57
|
+
lastPath = path
|
|
58
|
+
emit('route_change')
|
|
66
59
|
}, ROUTE_DEBOUNCE_MS)
|
|
67
60
|
}
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
emit('initial_load')
|
|
63
|
+
|
|
64
|
+
const originalPush = history.pushState
|
|
65
|
+
const originalReplace = history.replaceState
|
|
71
66
|
|
|
72
|
-
// 2) Monkeypatch history.pushState/replaceState (SPA SDR navigation)
|
|
73
|
-
const origPush = history.pushState
|
|
74
|
-
const origReplace = history.replaceState
|
|
75
67
|
history.pushState = function patchedPush(...args: Parameters<History['pushState']>) {
|
|
76
|
-
const result =
|
|
68
|
+
const result = originalPush.apply(this, args)
|
|
77
69
|
scheduleRouteEmit()
|
|
78
70
|
return result
|
|
79
71
|
}
|
|
80
72
|
history.replaceState = function patchedReplace(...args: Parameters<History['replaceState']>) {
|
|
81
|
-
const result =
|
|
73
|
+
const result = originalReplace.apply(this, args)
|
|
82
74
|
scheduleRouteEmit()
|
|
83
75
|
return result
|
|
84
76
|
}
|
|
85
77
|
cleanups.push(() => {
|
|
86
|
-
history.pushState =
|
|
87
|
-
history.replaceState =
|
|
78
|
+
history.pushState = originalPush
|
|
79
|
+
history.replaceState = originalReplace
|
|
88
80
|
})
|
|
89
81
|
|
|
90
|
-
//
|
|
91
|
-
const
|
|
92
|
-
window.addEventListener('popstate',
|
|
93
|
-
cleanups.push(() => window.removeEventListener('popstate',
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
window.
|
|
98
|
-
cleanups.push(() => window.removeEventListener('hashchange', hashHandler))
|
|
99
|
-
|
|
100
|
-
// 5) visibility_change · tab voltou a ficar visível depois de >5min
|
|
101
|
-
let hiddenAt: number | null = null
|
|
102
|
-
const visHandler = (): void => {
|
|
103
|
-
if (typeof document === 'undefined') return
|
|
104
|
-
if (document.visibilityState === 'hidden') {
|
|
105
|
-
hiddenAt = Date.now()
|
|
106
|
-
} else if (document.visibilityState === 'visible' && hiddenAt) {
|
|
107
|
-
const elapsed = Date.now() - hiddenAt
|
|
108
|
-
hiddenAt = null
|
|
109
|
-
// Re-emit só se ficou hidden >5min · evita flood quando user troca aba rapidinho
|
|
110
|
-
if (elapsed > 5 * 60_000) safeEmit('visibility_change')
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
if (typeof document !== 'undefined') {
|
|
114
|
-
document.addEventListener('visibilitychange', visHandler)
|
|
115
|
-
cleanups.push(() => document.removeEventListener('visibilitychange', visHandler))
|
|
116
|
-
}
|
|
82
|
+
// Voltar/avançar do browser e routers baseados em hash.
|
|
83
|
+
const onPop = (): void => scheduleRouteEmit()
|
|
84
|
+
window.addEventListener('popstate', onPop)
|
|
85
|
+
cleanups.push(() => window.removeEventListener('popstate', onPop))
|
|
86
|
+
|
|
87
|
+
const onHash = (): void => scheduleRouteEmit()
|
|
88
|
+
window.addEventListener('hashchange', onHash)
|
|
89
|
+
cleanups.push(() => window.removeEventListener('hashchange', onHash))
|
|
117
90
|
|
|
118
|
-
// Final cleanup function · usado pelo destroy()
|
|
119
91
|
return () => {
|
|
120
|
-
if (
|
|
121
|
-
for (const
|
|
92
|
+
if (pending) clearTimeout(pending)
|
|
93
|
+
for (const cleanup of cleanups) cleanup()
|
|
122
94
|
w[INSTALLED_FLAG] = false
|
|
123
95
|
}
|
|
124
96
|
}
|
|
125
97
|
|
|
98
|
+
/** Rota comparável · path + query, sem hash de âncora. */
|
|
126
99
|
function currentPath(): string {
|
|
127
|
-
if (typeof
|
|
128
|
-
return
|
|
100
|
+
if (typeof location === 'undefined') return '/'
|
|
101
|
+
return `${location.pathname}${location.search}${location.hash}`
|
|
129
102
|
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entrada do bundle IIFE · global `Holdy` pra instalação por `<script>`.
|
|
3
|
+
*
|
|
4
|
+
* Existe pro cliente que não tem bundler (ou não quer mexer no build) poder
|
|
5
|
+
* instrumentar colando duas linhas no HTML. A API é a mesma de `@holdyapp/sdk/web`
|
|
6
|
+
* em forma de funções soltas, porque `Holdy.track(...)` é o que se espera de um
|
|
7
|
+
* global.
|
|
8
|
+
*
|
|
9
|
+
* Chamar antes de `init` não lança: telemetria não derruba a página do cliente.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { HoldyWeb, type HoldyWebOptions } from './web'
|
|
13
|
+
import type { Properties } from './types'
|
|
14
|
+
|
|
15
|
+
let instance: HoldyWeb | null = null
|
|
16
|
+
|
|
17
|
+
export function init(opts: HoldyWebOptions): HoldyWeb {
|
|
18
|
+
// Idempotente: script duplicado na página não duplica evento.
|
|
19
|
+
if (!instance) instance = new HoldyWeb(opts)
|
|
20
|
+
return instance
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function track(name: string, properties?: Properties): void {
|
|
24
|
+
instance?.track(name, properties)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function identify(userId: string, traits?: Properties): void {
|
|
28
|
+
instance?.identify(userId, traits)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function group(accountKey: string, traits?: Properties): void {
|
|
32
|
+
instance?.group(accountKey, traits)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function page(name?: string, properties?: Properties): void {
|
|
36
|
+
instance?.page(name, properties)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function reset(): void {
|
|
40
|
+
instance?.reset()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function flush(): Promise<void> {
|
|
44
|
+
await instance?.flush()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function shutdown(): Promise<void> {
|
|
48
|
+
await instance?.shutdown()
|
|
49
|
+
instance = null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Instância corrente · útil pra depurar no console do browser. */
|
|
53
|
+
export function getInstance(): HoldyWeb | null {
|
|
54
|
+
return instance
|
|
55
|
+
}
|
package/src/core.test.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { HoldyCore } from './core'
|
|
3
|
+
|
|
4
|
+
interface SentBatch {
|
|
5
|
+
url: string
|
|
6
|
+
auth: string | undefined
|
|
7
|
+
events: Array<Record<string, unknown>>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function mockFetch(status = 202) {
|
|
11
|
+
const sent: SentBatch[] = []
|
|
12
|
+
const fn = vi.fn(async (url: unknown, init?: RequestInit) => {
|
|
13
|
+
sent.push({
|
|
14
|
+
url: String(url),
|
|
15
|
+
auth: (init?.headers as Record<string, string> | undefined)?.Authorization,
|
|
16
|
+
events: (JSON.parse(String(init?.body)) as { events: Array<Record<string, unknown>> }).events,
|
|
17
|
+
})
|
|
18
|
+
return { ok: status < 400, status } as Response
|
|
19
|
+
})
|
|
20
|
+
vi.stubGlobal('fetch', fn)
|
|
21
|
+
return { sent, fn }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeClient(opts: Partial<ConstructorParameters<typeof HoldyCore>[1]> = {}) {
|
|
25
|
+
return new HoldyCore('sdk_server', {
|
|
26
|
+
apiKey: 'hol_test_key',
|
|
27
|
+
baseUrl: 'https://example.test/api/v1',
|
|
28
|
+
flushIntervalMs: 0,
|
|
29
|
+
flushAt: 100,
|
|
30
|
+
...opts,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('HoldyCore', () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.unstubAllGlobals()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('track enfileira envelope canônico e flush envia pra /events/bulk com Bearer', async () => {
|
|
40
|
+
const { sent } = mockFetch()
|
|
41
|
+
const holdy = makeClient()
|
|
42
|
+
holdy.track('invite_sent', { plan: 'pro' })
|
|
43
|
+
expect(holdy.pending).toBe(1)
|
|
44
|
+
|
|
45
|
+
await holdy.flush()
|
|
46
|
+
expect(sent).toHaveLength(1)
|
|
47
|
+
expect(sent[0].url).toBe('https://example.test/api/v1/events/bulk')
|
|
48
|
+
expect(sent[0].auth).toBe('Bearer hol_test_key')
|
|
49
|
+
expect(sent[0].events[0]).toMatchObject({
|
|
50
|
+
event: 'invite_sent',
|
|
51
|
+
source: 'sdk_server',
|
|
52
|
+
properties: { plan: 'pro' },
|
|
53
|
+
})
|
|
54
|
+
expect(holdy.pending).toBe(0)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('identify emite $identify e persiste user_id nos próximos events', async () => {
|
|
58
|
+
const { sent } = mockFetch()
|
|
59
|
+
const holdy = makeClient()
|
|
60
|
+
holdy.identify('user_42', { role: 'admin' })
|
|
61
|
+
holdy.track('pageview')
|
|
62
|
+
await holdy.flush()
|
|
63
|
+
|
|
64
|
+
expect(sent[0].events[0]).toMatchObject({
|
|
65
|
+
event: '$identify',
|
|
66
|
+
user_id: 'user_42',
|
|
67
|
+
properties: { role: 'admin' },
|
|
68
|
+
})
|
|
69
|
+
expect(sent[0].events[1]).toMatchObject({ event: 'pageview', user_id: 'user_42' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('group emite $group e associa context.account_external_id aos próximos', async () => {
|
|
73
|
+
const { sent } = mockFetch()
|
|
74
|
+
const holdy = makeClient()
|
|
75
|
+
holdy.group('acme-inc', { plan: 'scale' })
|
|
76
|
+
holdy.track('feature_used')
|
|
77
|
+
await holdy.flush()
|
|
78
|
+
|
|
79
|
+
expect(sent[0].events[0]).toMatchObject({
|
|
80
|
+
event: '$group',
|
|
81
|
+
context: { account_external_id: 'acme-inc' },
|
|
82
|
+
properties: { plan: 'scale' },
|
|
83
|
+
})
|
|
84
|
+
expect(sent[0].events[1]).toMatchObject({
|
|
85
|
+
event: 'feature_used',
|
|
86
|
+
context: { account_external_id: 'acme-inc' },
|
|
87
|
+
})
|
|
88
|
+
// account_id (uuid Holdy) nunca sai da porta — Data layer resolve
|
|
89
|
+
expect('account_id' in sent[0].events[1]).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('overrides stateless (server): userId/accountKey por chamada', async () => {
|
|
93
|
+
const { sent } = mockFetch()
|
|
94
|
+
const holdy = makeClient()
|
|
95
|
+
holdy.track('sync_done', {}, { userId: 'u9', accountKey: 'org-77' })
|
|
96
|
+
await holdy.flush()
|
|
97
|
+
|
|
98
|
+
expect(sent[0].events[0]).toMatchObject({
|
|
99
|
+
user_id: 'u9',
|
|
100
|
+
context: { account_external_id: 'org-77' },
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('flushAt dispara flush automático', async () => {
|
|
105
|
+
const { sent } = mockFetch()
|
|
106
|
+
const holdy = makeClient({ flushAt: 2 })
|
|
107
|
+
holdy.track('a')
|
|
108
|
+
expect(sent).toHaveLength(0)
|
|
109
|
+
holdy.track('b')
|
|
110
|
+
await vi.waitFor(() => expect(sent).toHaveLength(1))
|
|
111
|
+
expect(sent[0].events).toHaveLength(2)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('5xx re-enfileira (retry dedupe-safe) · 4xx dropa (evita loop)', async () => {
|
|
115
|
+
const { sent } = mockFetch(500)
|
|
116
|
+
const holdy = makeClient()
|
|
117
|
+
holdy.track('x')
|
|
118
|
+
await holdy.flush()
|
|
119
|
+
expect(holdy.pending).toBe(1)
|
|
120
|
+
expect(sent).toHaveLength(1)
|
|
121
|
+
|
|
122
|
+
vi.unstubAllGlobals()
|
|
123
|
+
mockFetch(400)
|
|
124
|
+
await holdy.flush()
|
|
125
|
+
expect(holdy.pending).toBe(0)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('network error nunca lança — re-enfileira', async () => {
|
|
129
|
+
vi.stubGlobal('fetch', vi.fn(async () => {
|
|
130
|
+
throw new Error('ECONNREFUSED')
|
|
131
|
+
}))
|
|
132
|
+
const holdy = makeClient()
|
|
133
|
+
holdy.track('x')
|
|
134
|
+
await expect(holdy.flush()).resolves.toBeUndefined()
|
|
135
|
+
expect(holdy.pending).toBe(1)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('batches > 500 são fatiados', async () => {
|
|
139
|
+
const { sent } = mockFetch()
|
|
140
|
+
const holdy = makeClient({ flushAt: 10000 })
|
|
141
|
+
for (let i = 0; i < 750; i++) holdy.track(`e${i}`)
|
|
142
|
+
await holdy.flush()
|
|
143
|
+
expect(sent).toHaveLength(2)
|
|
144
|
+
expect(sent[0].events).toHaveLength(500)
|
|
145
|
+
expect(sent[1].events).toHaveLength(250)
|
|
146
|
+
})
|
|
147
|
+
})
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core do @holdyapp/sdk · fila + flush pra POST /api/v1/events/bulk.
|
|
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).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CanonicalEvent, HoldyOptions, Properties, TrackOverrides } from './types'
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_BASE_URL = 'https://holdy.chat/api/v1'
|
|
12
|
+
const DEFAULT_FLUSH_AT = 20
|
|
13
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 5000
|
|
14
|
+
const MAX_QUEUE = 1000
|
|
15
|
+
const BULK_LIMIT = 500
|
|
16
|
+
|
|
17
|
+
export class HoldyCore {
|
|
18
|
+
private readonly apiKey: string
|
|
19
|
+
private readonly baseUrl: string
|
|
20
|
+
private readonly flushAt: number
|
|
21
|
+
private readonly debug: boolean
|
|
22
|
+
private readonly source: 'sdk_web' | 'sdk_server'
|
|
23
|
+
|
|
24
|
+
private queue: CanonicalEvent[] = []
|
|
25
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
26
|
+
private flushing: Promise<void> | null = null
|
|
27
|
+
|
|
28
|
+
protected userId: string | null = null
|
|
29
|
+
protected anonymousId: string | null = null
|
|
30
|
+
protected accountKey: string | null = null
|
|
31
|
+
|
|
32
|
+
constructor(source: 'sdk_web' | 'sdk_server', opts: HoldyOptions) {
|
|
33
|
+
this.source = source
|
|
34
|
+
this.apiKey = opts.apiKey
|
|
35
|
+
this.accountKey = opts.accountKey ?? null
|
|
36
|
+
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '')
|
|
37
|
+
this.flushAt = Math.max(1, opts.flushAt ?? DEFAULT_FLUSH_AT)
|
|
38
|
+
this.debug = opts.debug ?? false
|
|
39
|
+
|
|
40
|
+
const intervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS
|
|
41
|
+
if (intervalMs > 0) {
|
|
42
|
+
this.timer = setInterval(() => void this.flush(), intervalMs)
|
|
43
|
+
// Node: não segura o processo vivo só pelo timer do SDK.
|
|
44
|
+
if (typeof (this.timer as { unref?: () => void }).unref === 'function') {
|
|
45
|
+
;(this.timer as unknown as { unref: () => void }).unref()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
track(name: string, properties?: Properties, overrides?: TrackOverrides): void {
|
|
51
|
+
if (!name || typeof name !== 'string') return
|
|
52
|
+
|
|
53
|
+
const userId = overrides?.userId ?? this.userId ?? undefined
|
|
54
|
+
const accountKey = overrides?.accountKey ?? this.accountKey ?? undefined
|
|
55
|
+
const occurredAt = overrides?.occurredAt
|
|
56
|
+
? new Date(overrides.occurredAt).toISOString()
|
|
57
|
+
: new Date().toISOString()
|
|
58
|
+
|
|
59
|
+
// Ordem: ambiente observado pelo SDK → context da chamada → carrier de
|
|
60
|
+
// account. O que o caller passou vence o inferido; o carrier vence tudo,
|
|
61
|
+
// porque é o que a Data layer usa pra resolver a conta.
|
|
62
|
+
const context: Properties = {
|
|
63
|
+
...this.environmentContext(),
|
|
64
|
+
...(overrides?.context ?? {}),
|
|
65
|
+
}
|
|
66
|
+
if (accountKey) context.account_external_id = accountKey
|
|
67
|
+
|
|
68
|
+
const event: CanonicalEvent = {
|
|
69
|
+
event: name,
|
|
70
|
+
occurred_at: occurredAt,
|
|
71
|
+
source: this.source,
|
|
72
|
+
...(userId ? { user_id: userId } : {}),
|
|
73
|
+
...(this.anonymousId ? { anonymous_id: this.anonymousId } : {}),
|
|
74
|
+
properties: properties ?? {},
|
|
75
|
+
...(Object.keys(context).length > 0 ? { context } : {}),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.queue.push(event)
|
|
79
|
+
if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE)
|
|
80
|
+
if (this.queue.length >= this.flushAt) void this.flush()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
identify(userId: string, traits?: Properties): void {
|
|
84
|
+
if (!userId || typeof userId !== 'string') return
|
|
85
|
+
this.userId = userId
|
|
86
|
+
this.track('$identify', traits ?? {})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
group(accountKey: string, traits?: Properties): void {
|
|
90
|
+
if (!accountKey || typeof accountKey !== 'string') return
|
|
91
|
+
this.accountKey = accountKey
|
|
92
|
+
this.track('$group', traits ?? {})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Drena a fila. Nunca lança — falha re-enfileira e loga em debug. */
|
|
96
|
+
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
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Flush final + para o timer. Chamar em shutdown do server / unload do web. */
|
|
108
|
+
async shutdown(): Promise<void> {
|
|
109
|
+
if (this.timer) {
|
|
110
|
+
clearInterval(this.timer)
|
|
111
|
+
this.timer = null
|
|
112
|
+
}
|
|
113
|
+
await this.flush()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async drain(): Promise<void> {
|
|
117
|
+
while (this.queue.length > 0) {
|
|
118
|
+
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).
|
|
122
|
+
this.queue.unshift(...batch)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async send(events: CanonicalEvent[]): Promise<boolean> {
|
|
129
|
+
try {
|
|
130
|
+
const res = await fetch(`${this.baseUrl}/events/bulk`, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: {
|
|
133
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
134
|
+
'Content-Type': 'application/json',
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({ events }),
|
|
137
|
+
...(this.source === 'sdk_web' ? { keepalive: true } : {}),
|
|
138
|
+
})
|
|
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
|
|
147
|
+
}
|
|
148
|
+
return true
|
|
149
|
+
} catch (err) {
|
|
150
|
+
this.log(`bulk network error: ${(err as Error).message} · will retry`)
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private log(msg: string): void {
|
|
156
|
+
if (this.debug) console.warn(`[holdy] ${msg}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Ambiente que o SDK observa sozinho (url, referrer, browser, utm…).
|
|
161
|
+
* Vazio no core; `HoldyWeb` preenche. O server não infere ambiente — não
|
|
162
|
+
* existe "página atual" num worker.
|
|
163
|
+
*/
|
|
164
|
+
protected environmentContext(): Properties {
|
|
165
|
+
return {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Interno · exposto pra testes. */
|
|
169
|
+
get pending(): number {
|
|
170
|
+
return this.queue.length
|
|
171
|
+
}
|
|
172
|
+
}
|