@hanzo/event 0.3.32 → 0.3.33
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 +17 -19
- package/dist/index.cjs +14 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +14 -14
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +14 -14
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +14 -14
- package/dist/react.mjs.map +1 -1
- package/package.json +1 -2
- package/src/anon.d.ts +1 -1
- package/src/anon.js +18 -18
- package/src/anon.test.ts +17 -38
- package/src/stack.ts +144 -0
- package/src/storage.test.ts +2 -2
- package/src/storage.ts +1 -1
- package/src/uid.ts +1 -1
- package/src/version.ts +1 -1
- package/hz.js +0 -511
- package/src/hz.test.ts +0 -376
package/src/hz.test.ts
DELETED
|
@@ -1,376 +0,0 @@
|
|
|
1
|
-
// hz.js is the no-build distribution — 300 lines of shipped client that no test
|
|
2
|
-
// had ever executed. It restates, by hand, what the bundled client imports, so the
|
|
3
|
-
// two can drift; this runs the real file against a minimal browser stub and reads
|
|
4
|
-
// what it actually posts — batch, URL and headers, because the credential is not
|
|
5
|
-
// in the body and a test that reads only the batch cannot see it.
|
|
6
|
-
|
|
7
|
-
import { describe, expect, it, beforeEach } from 'vitest'
|
|
8
|
-
import { readFileSync } from 'node:fs'
|
|
9
|
-
import { fileURLToPath } from 'node:url'
|
|
10
|
-
|
|
11
|
-
const SRC = readFileSync(fileURLToPath(new URL('../hz.js', import.meta.url)), 'utf8')
|
|
12
|
-
const PKG = JSON.parse(
|
|
13
|
-
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
|
14
|
-
) as { version: string }
|
|
15
|
-
|
|
16
|
-
/** The event plane's session-rollup admission gate, transcribed from its own SQL. */
|
|
17
|
-
const versionNibble = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 76n) & 15n
|
|
18
|
-
const embeddedMs = (id: string): bigint => BigInt('0x' + id.replace(/-/g, '')) >> 80n
|
|
19
|
-
|
|
20
|
-
interface WireEvent {
|
|
21
|
-
messageId: string
|
|
22
|
-
sessionId: string
|
|
23
|
-
anonymousId: string
|
|
24
|
-
type: string
|
|
25
|
-
event?: string
|
|
26
|
-
library: string
|
|
27
|
-
libraryVersion: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** One recorded transmission: which transport carried it, where, under what headers
|
|
31
|
-
* and — the fact that decides whether a cross-origin unload beacon is sent at all
|
|
32
|
-
* — the content type its body was labelled with. */
|
|
33
|
-
interface Post {
|
|
34
|
-
via: 'beacon' | 'fetch'
|
|
35
|
-
url: string
|
|
36
|
-
headers: Record<string, string>
|
|
37
|
-
contentType: string
|
|
38
|
-
batch: WireEvent[]
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// The three CORS-safelisted request content types. A POST whose body carries one
|
|
42
|
-
// is a SIMPLE request and goes immediately; anything else is PREFLIGHTED, and an
|
|
43
|
-
// unloading document never gets the preflight's second round trip. This tag runs
|
|
44
|
-
// on a customer's own domain, so its beacon is always cross-origin.
|
|
45
|
-
const CORS_SAFELISTED = new Set([
|
|
46
|
-
'text/plain',
|
|
47
|
-
'application/x-www-form-urlencoded',
|
|
48
|
-
'multipart/form-data',
|
|
49
|
-
])
|
|
50
|
-
|
|
51
|
-
interface StubOptions {
|
|
52
|
-
/** data-* attributes on the <script> tag. */
|
|
53
|
-
attrs?: Record<string, string>
|
|
54
|
-
/** navigator fields — doNotTrack, globalPrivacyControl, msDoNotTrack. */
|
|
55
|
-
navigator?: Record<string, unknown>
|
|
56
|
-
/** Seed localStorage (e.g. an explicit hz_consent choice). */
|
|
57
|
-
storage?: Record<string, string>
|
|
58
|
-
/** The cookie jar. Pass one in to model a browser that already carries an id —
|
|
59
|
-
* from another *.hanzo.ai surface, or from the npm client on this same page. */
|
|
60
|
-
jar?: Map<string, string>
|
|
61
|
-
/** Let navigator.sendBeacon succeed, so the beacon path is the one measured. */
|
|
62
|
-
beacon?: boolean
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
type Api = { track(n: string, p?: unknown): void; flush(): void }
|
|
66
|
-
|
|
67
|
-
/** Runs hz.js against a stub browser and returns everything it posted.
|
|
68
|
-
*
|
|
69
|
-
* Globals are DEFINED, not assigned: Node ≥ 21 ships a real `navigator` whose
|
|
70
|
-
* descriptor is an accessor with no setter, so the plain assignment this
|
|
71
|
-
* harness used threw — and every hz.js test failed on a current runtime,
|
|
72
|
-
* leaving the shipped file with no executed coverage again. */
|
|
73
|
-
function runSnippet(opts: StubOptions = {}): {
|
|
74
|
-
posts: Post[]
|
|
75
|
-
api: Api | undefined
|
|
76
|
-
local: Map<string, string>
|
|
77
|
-
jar: Map<string, string>
|
|
78
|
-
fire: (type: string) => void
|
|
79
|
-
hide: () => void
|
|
80
|
-
} {
|
|
81
|
-
const posts: Post[] = []
|
|
82
|
-
const listeners = new Map<string, (() => void)[]>()
|
|
83
|
-
const local = new Map<string, string>(Object.entries(opts.storage ?? {}))
|
|
84
|
-
const jar = opts.jar ?? new Map<string, string>()
|
|
85
|
-
const store = (m: Map<string, string>) => ({
|
|
86
|
-
getItem: (k: string) => m.get(k) ?? null,
|
|
87
|
-
setItem: (k: string, v: string) => void m.set(k, v),
|
|
88
|
-
removeItem: (k: string) => void m.delete(k),
|
|
89
|
-
})
|
|
90
|
-
const attrs: Record<string, string> = { 'data-product': 'test', ...opts.attrs }
|
|
91
|
-
const g = globalThis as unknown as Record<string, unknown>
|
|
92
|
-
const define = (name: string, value: unknown) =>
|
|
93
|
-
Object.defineProperty(g, name, { value, configurable: true, writable: true })
|
|
94
|
-
|
|
95
|
-
define('location', {
|
|
96
|
-
href: 'https://x.test/p?a=1',
|
|
97
|
-
pathname: '/p',
|
|
98
|
-
search: '?a=1',
|
|
99
|
-
hostname: 'x.test',
|
|
100
|
-
host: 'x.test',
|
|
101
|
-
})
|
|
102
|
-
define('document', {
|
|
103
|
-
currentScript: { getAttribute: (a: string) => attrs[a] ?? null },
|
|
104
|
-
// A real jar: the anonymous id is a cookie now, so a stub with no `cookie`
|
|
105
|
-
// would leave the whole identity chain unexecuted by these tests.
|
|
106
|
-
get cookie(): string {
|
|
107
|
-
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
108
|
-
},
|
|
109
|
-
set cookie(raw: string) {
|
|
110
|
-
const first = raw.split(';')[0]
|
|
111
|
-
const eq = first.indexOf('=')
|
|
112
|
-
if (eq > 0) jar.set(first.slice(0, eq).trim(), first.slice(eq + 1).trim())
|
|
113
|
-
},
|
|
114
|
-
referrer: '',
|
|
115
|
-
addEventListener: () => {},
|
|
116
|
-
documentElement: { scrollHeight: 1000 },
|
|
117
|
-
visibilityState: 'visible',
|
|
118
|
-
createElement: () => ({}),
|
|
119
|
-
head: { appendChild: () => {} },
|
|
120
|
-
})
|
|
121
|
-
define('navigator', {
|
|
122
|
-
doNotTrack: '0',
|
|
123
|
-
...opts.navigator,
|
|
124
|
-
sendBeacon: opts.beacon
|
|
125
|
-
? (url: string, blob: { body: string; type: string }) => {
|
|
126
|
-
posts.push({
|
|
127
|
-
via: 'beacon',
|
|
128
|
-
url,
|
|
129
|
-
headers: {},
|
|
130
|
-
contentType: blob.type,
|
|
131
|
-
batch: JSON.parse(blob.body).batch,
|
|
132
|
-
})
|
|
133
|
-
return true
|
|
134
|
-
}
|
|
135
|
-
: undefined,
|
|
136
|
-
})
|
|
137
|
-
define(
|
|
138
|
-
'Blob',
|
|
139
|
-
class {
|
|
140
|
-
body: string
|
|
141
|
-
type: string
|
|
142
|
-
constructor(parts: string[], opts?: { type?: string }) {
|
|
143
|
-
this.body = parts.join('')
|
|
144
|
-
this.type = opts?.type ?? ''
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
|
-
)
|
|
148
|
-
define('localStorage', store(local))
|
|
149
|
-
define('sessionStorage', store(new Map()))
|
|
150
|
-
define('history', { pushState: () => {}, replaceState: () => {} })
|
|
151
|
-
// Listeners are kept, not discarded: the unload flush IS a listener, so a stub
|
|
152
|
-
// that drops them leaves the only path the drop-off signal travels unexecuted.
|
|
153
|
-
define('addEventListener', (type: string, fn: () => void) => {
|
|
154
|
-
const seen = listeners.get(type)
|
|
155
|
-
if (seen) seen.push(fn)
|
|
156
|
-
else listeners.set(type, [fn])
|
|
157
|
-
})
|
|
158
|
-
define('PerformanceObserver', undefined)
|
|
159
|
-
define('hzDNT', undefined)
|
|
160
|
-
define('doNotTrack', undefined)
|
|
161
|
-
// The stub `window` IS globalThis, so a public API installed by an earlier run
|
|
162
|
-
// would still be there — and "it refused" would be indistinguishable from
|
|
163
|
-
// "the previous run's API answered".
|
|
164
|
-
define('hanzo', undefined)
|
|
165
|
-
define('fetch', (url: string, init: { body: string; headers: Record<string, string> }) => {
|
|
166
|
-
posts.push({
|
|
167
|
-
via: 'fetch',
|
|
168
|
-
url,
|
|
169
|
-
headers: init.headers,
|
|
170
|
-
contentType: init.headers['content-type'] ?? '',
|
|
171
|
-
batch: JSON.parse(init.body).batch,
|
|
172
|
-
})
|
|
173
|
-
return Promise.resolve()
|
|
174
|
-
})
|
|
175
|
-
define('window', g)
|
|
176
|
-
|
|
177
|
-
new Function(SRC)()
|
|
178
|
-
/** Raise an event the page would raise, so its listeners actually run. */
|
|
179
|
-
const fire = (type: string) => {
|
|
180
|
-
for (const fn of listeners.get(type) ?? []) fn()
|
|
181
|
-
}
|
|
182
|
-
/** Hide the document, as a browser does before it takes one away. */
|
|
183
|
-
const hide = () => {
|
|
184
|
-
;(g.document as { visibilityState: string }).visibilityState = 'hidden'
|
|
185
|
-
fire('visibilitychange')
|
|
186
|
-
}
|
|
187
|
-
return { posts, api: (g.window as { hanzo?: Api }).hanzo, local, jar, fire, hide }
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Every event across every transmission, in order. */
|
|
191
|
-
const sentOf = (r: { posts: Post[] }): WireEvent[] => r.posts.flatMap((p) => p.batch)
|
|
192
|
-
|
|
193
|
-
describe('hz.js', () => {
|
|
194
|
-
let run: ReturnType<typeof runSnippet>
|
|
195
|
-
beforeEach(() => {
|
|
196
|
-
run = runSnippet()
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
// A visitor who leaves mid-funnel is the drop-off signal, and hz.js only gets
|
|
200
|
-
// to report it from a teardown listener. Neither signal fires in every browser
|
|
201
|
-
// on every path, so the tag listens for both.
|
|
202
|
-
it('flushes what is queued when the tab is hidden', () => {
|
|
203
|
-
run.api!.track('plan_clicked')
|
|
204
|
-
run.hide()
|
|
205
|
-
expect(sentOf(run).map((e) => e.event)).toContain('plan_clicked')
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
it('flushes what is queued on pagehide, which visibilitychange need not precede', () => {
|
|
209
|
-
run.api!.track('checkout_started')
|
|
210
|
-
run.fire('pagehide')
|
|
211
|
-
expect(sentOf(run).map((e) => e.event)).toContain('checkout_started')
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
it('sends nothing a second time when both teardown signals arrive', () => {
|
|
215
|
-
run.api!.track('plan_clicked')
|
|
216
|
-
run.hide()
|
|
217
|
-
run.fire('pagehide')
|
|
218
|
-
const clicks = sentOf(run).filter((e) => e.event === 'plan_clicked')
|
|
219
|
-
expect(clicks).toHaveLength(1)
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it('mints session ids the plane admits', () => {
|
|
223
|
-
run.api!.track('checkout_started')
|
|
224
|
-
run.api!.flush()
|
|
225
|
-
const sent = sentOf(run)
|
|
226
|
-
expect(sent.length).toBeGreaterThan(0)
|
|
227
|
-
const before = Date.now()
|
|
228
|
-
for (const ev of sent) {
|
|
229
|
-
expect(versionNibble(ev.sessionId)).toBe(7n)
|
|
230
|
-
expect(versionNibble(ev.messageId)).toBe(7n)
|
|
231
|
-
expect(versionNibble(ev.anonymousId)).toBe(7n)
|
|
232
|
-
// The embedded instant is the real mint time, not a constant.
|
|
233
|
-
expect(Number(embeddedMs(ev.sessionId))).toBeGreaterThan(before - 60_000)
|
|
234
|
-
expect(Number(embeddedMs(ev.sessionId))).toBeLessThanOrEqual(Date.now())
|
|
235
|
-
}
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
it('holds one session id across every event it emits', () => {
|
|
239
|
-
run.api!.track('a')
|
|
240
|
-
run.api!.track('b')
|
|
241
|
-
run.api!.flush()
|
|
242
|
-
const sent = sentOf(run)
|
|
243
|
-
expect(new Set(sent.map((e) => e.sessionId)).size).toBe(1)
|
|
244
|
-
expect(new Set(sent.map((e) => e.messageId)).size).toBe(sent.length)
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
it('emits the auto pageview on load and stamps the library', () => {
|
|
248
|
-
run.api!.flush()
|
|
249
|
-
const pv = sentOf(run).find((e) => e.type === 'pageview')
|
|
250
|
-
expect(pv).toBeDefined()
|
|
251
|
-
expect(pv!.library).toBe('hz.js')
|
|
252
|
-
// The stamped version IS the package version. It had drifted to 0.3.9 against
|
|
253
|
-
// a published 0.3.11, so every static-site row in the warehouse was dated to a
|
|
254
|
-
// release three patches old — including the ones that changed what it sends.
|
|
255
|
-
expect(pv!.libraryVersion).toBe(PKG.version)
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
// ── identity ──────────────────────────────────────────────────────────────
|
|
259
|
-
// This file used to mint into `hz_id`, a key nothing else read or wrote, so a
|
|
260
|
-
// page carrying both this tag and the npm client sent two anonymous ids for one
|
|
261
|
-
// visitor — and every surface counted them as two people. It now runs the same
|
|
262
|
-
// chain, from the same file, against the same key.
|
|
263
|
-
|
|
264
|
-
const SEEDED = '01920000-0000-7000-8000-0000000000cc'
|
|
265
|
-
const LEGACY = '01920000-0000-7000-8000-0000000000dd'
|
|
266
|
-
|
|
267
|
-
it('is the same person as every other Hanzo client on the browser', () => {
|
|
268
|
-
// The cookie the npm client (or another *.hanzo.ai surface) already wrote.
|
|
269
|
-
const r = runSnippet({ jar: new Map([['hz_anon_id', SEEDED]]) })
|
|
270
|
-
r.api!.track('checkout_started')
|
|
271
|
-
r.api!.flush()
|
|
272
|
-
const sent = sentOf(r)
|
|
273
|
-
expect(sent.length).toBeGreaterThan(0)
|
|
274
|
-
for (const ev of sent) expect(ev.anonymousId).toBe(SEEDED)
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
it('adopts the `hz_id` it used to mint rather than making a stranger', () => {
|
|
278
|
-
// Every browser that has ever loaded this tag holds one of these. Minting
|
|
279
|
-
// over it would detach a returning visitor from their own history.
|
|
280
|
-
const r = runSnippet({ storage: { hz_id: LEGACY } })
|
|
281
|
-
r.api!.flush()
|
|
282
|
-
for (const ev of sentOf(r)) expect(ev.anonymousId).toBe(LEGACY)
|
|
283
|
-
expect(r.jar.get('hz_anon_id')).toBe(LEGACY) // carried onto the shared key
|
|
284
|
-
expect(r.local.get('hz_anon_id')).toBe(LEGACY)
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
it('writes the one key, in the durable place, and no longer mints its own', () => {
|
|
288
|
-
const r = runSnippet()
|
|
289
|
-
r.api!.flush()
|
|
290
|
-
const id = sentOf(r)[0].anonymousId
|
|
291
|
-
expect(r.jar.get('hz_anon_id')).toBe(id) // the cookie outlives the ORIGIN
|
|
292
|
-
expect(r.local.get('hz_anon_id')).toBe(id)
|
|
293
|
-
expect(r.local.has('hz_id')).toBe(false)
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
// ── the publishable key ───────────────────────────────────────────────────
|
|
297
|
-
// Through 0.3.11 this file could present none at all: no header, no query. A
|
|
298
|
-
// keyed static surface therefore sent UNATTRIBUTED writes, which the door
|
|
299
|
-
// refuses — silently, because nothing here reads the response.
|
|
300
|
-
|
|
301
|
-
// The fetch presents it on the SAME carrier the beacon does, and stays a CORS
|
|
302
|
-
// simple request doing so. This file's whole job is to run on a customer's own
|
|
303
|
-
// page, where every send is cross-origin: an Authorization header or a JSON
|
|
304
|
-
// content type would preflight the POST, and an origin that does not pass the
|
|
305
|
-
// preflight loses the batch that splice has already emptied. A simple request is
|
|
306
|
-
// sent from any origin — the browser asks permission to READ a cross-origin
|
|
307
|
-
// response, never to send one, and nothing here reads it.
|
|
308
|
-
it('presents the publishable key on the query, as a simple request', () => {
|
|
309
|
-
const r = runSnippet({ attrs: { 'data-publishable-key': 'pk-abc123' } })
|
|
310
|
-
r.api!.flush()
|
|
311
|
-
const post = r.posts.at(-1)!
|
|
312
|
-
expect(post.via).toBe('fetch')
|
|
313
|
-
expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
|
|
314
|
-
expect(post.headers.authorization).toBeUndefined()
|
|
315
|
-
expect(post.headers['content-type']).toBe('text/plain')
|
|
316
|
-
// Content-Type is safelisted only for three values; every other header is
|
|
317
|
-
// outside the safelist by construction, so the count is the check.
|
|
318
|
-
expect(Object.keys(post.headers)).toEqual(['content-type'])
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
it('still reads the retiring data-ingest-key, on a headerless beacon', () => {
|
|
322
|
-
const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' }, beacon: true })
|
|
323
|
-
r.api!.flush()
|
|
324
|
-
const post = r.posts.at(-1)!
|
|
325
|
-
expect(post.via).toBe('beacon')
|
|
326
|
-
expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
it('labels the beacon body a CORS-simple type, so the unload POST is sent', () => {
|
|
330
|
-
// The tag is installed on a customer's own domain, so every send here is
|
|
331
|
-
// cross-origin. A non-safelisted body type preflights, and an unloading
|
|
332
|
-
// document gets no second round trip — the batch would simply never leave.
|
|
333
|
-
// The credential is in the query for the same reason: a beacon sets no
|
|
334
|
-
// headers, and an Authorization header preflights whatever the type is.
|
|
335
|
-
const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' }, beacon: true })
|
|
336
|
-
r.api!.flush()
|
|
337
|
-
const post = r.posts.at(-1)!
|
|
338
|
-
expect(post.via).toBe('beacon')
|
|
339
|
-
expect(CORS_SAFELISTED.has(post.contentType)).toBe(true)
|
|
340
|
-
})
|
|
341
|
-
|
|
342
|
-
it('sends no credential when no key is declared — no baked literal', () => {
|
|
343
|
-
// The key is the surface's own, stamped into the tag by its deploy from KMS;
|
|
344
|
-
// a bare tag carries nothing, so it is honestly keyless rather than borrowing
|
|
345
|
-
// a hardcoded org credential.
|
|
346
|
-
run.api!.flush()
|
|
347
|
-
const post = run.posts.at(-1)!
|
|
348
|
-
expect(post.headers.authorization).toBeUndefined()
|
|
349
|
-
expect(post.url).not.toContain('ingest_key')
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
// ── consent ───────────────────────────────────────────────────────────────
|
|
353
|
-
|
|
354
|
-
it('refuses under Global Privacy Control', () => {
|
|
355
|
-
const r = runSnippet({ navigator: { globalPrivacyControl: true } })
|
|
356
|
-
expect(r.api).toBeUndefined()
|
|
357
|
-
expect(r.posts).toEqual([])
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
it('refuses under Do Not Track, in each of its spellings', () => {
|
|
361
|
-
for (const nav of [{ doNotTrack: '1' }, { doNotTrack: 'yes' }, { msDoNotTrack: '1' }]) {
|
|
362
|
-
expect(runSnippet({ navigator: nav }).api).toBeUndefined()
|
|
363
|
-
}
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
it('refuses on a stored denial', () => {
|
|
367
|
-
expect(runSnippet({ storage: { hz_consent: 'denied' } }).api).toBeUndefined()
|
|
368
|
-
})
|
|
369
|
-
|
|
370
|
-
it('an explicit grant outranks the browser signal', () => {
|
|
371
|
-
const r = runSnippet({ navigator: { doNotTrack: '1' }, storage: { hz_consent: 'granted' } })
|
|
372
|
-
expect(r.api).toBeDefined()
|
|
373
|
-
r.api!.flush()
|
|
374
|
-
expect(sentOf(r).length).toBeGreaterThan(0)
|
|
375
|
-
})
|
|
376
|
-
})
|