@hanzo/event 0.3.13 → 0.3.14
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/dist/index.cjs +63 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +63 -7
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +63 -7
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +63 -7
- package/dist/react.mjs.map +1 -1
- package/hz.js +1 -1
- package/package.json +1 -1
- package/src/storage.test.ts +257 -0
- package/src/storage.ts +100 -7
- package/src/version.ts +1 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// The anonymous id is the join key for the whole pre-signup journey, and it used
|
|
2
|
+
// to live in localStorage — which is ORIGIN-scoped, so docs, cloud and console
|
|
3
|
+
// each minted their own for the same person (463 anonymous identities carried 545
|
|
4
|
+
// events in a week). It is now a cookie on the registrable domain. These tests
|
|
5
|
+
// pin the two properties that migration turns on: that one jar yields ONE id
|
|
6
|
+
// across surfaces, and that an existing localStorage id is ADOPTED rather than
|
|
7
|
+
// minted over — minting there resets every returning visitor.
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
const ANON = 'hz_anon_id'
|
|
12
|
+
const LEGACY = '01920000-0000-7000-8000-0000000000aa'
|
|
13
|
+
const OTHER = '01920000-0000-7000-8000-0000000000bb'
|
|
14
|
+
|
|
15
|
+
const g = globalThis as Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
interface Browser {
|
|
18
|
+
/** Every raw `document.cookie = …` write, so attributes can be asserted. */
|
|
19
|
+
writes: string[]
|
|
20
|
+
/** name → raw cookie value, shared between surfaces to model one browser. */
|
|
21
|
+
jar: Map<string, string>
|
|
22
|
+
store: Map<string, string> | undefined
|
|
23
|
+
restore: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A minimal browser. `jar` can be passed in to model two *.hanzo.ai surfaces
|
|
27
|
+
* reading the same cookie store, which is the whole point of the change. */
|
|
28
|
+
function browser(
|
|
29
|
+
opts: {
|
|
30
|
+
href?: string
|
|
31
|
+
jar?: Map<string, string>
|
|
32
|
+
storage?: Record<string, string>
|
|
33
|
+
noStorage?: boolean
|
|
34
|
+
refuseCookies?: boolean
|
|
35
|
+
noDocument?: boolean
|
|
36
|
+
} = {},
|
|
37
|
+
): Browser {
|
|
38
|
+
const had = { window: 'window' in g, document: 'document' in g }
|
|
39
|
+
const prev = { window: g.window, document: g.document }
|
|
40
|
+
const jar = opts.jar ?? new Map<string, string>()
|
|
41
|
+
const writes: string[] = []
|
|
42
|
+
const store = opts.noStorage ? undefined : new Map(Object.entries(opts.storage ?? {}))
|
|
43
|
+
const url = new URL(opts.href ?? 'https://docs.hanzo.ai/guide')
|
|
44
|
+
|
|
45
|
+
g.window = {
|
|
46
|
+
location: {
|
|
47
|
+
href: url.href,
|
|
48
|
+
hostname: url.hostname,
|
|
49
|
+
protocol: url.protocol,
|
|
50
|
+
pathname: url.pathname,
|
|
51
|
+
search: url.search,
|
|
52
|
+
},
|
|
53
|
+
localStorage: store && {
|
|
54
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
55
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
56
|
+
},
|
|
57
|
+
addEventListener: () => {},
|
|
58
|
+
}
|
|
59
|
+
if (opts.noDocument) {
|
|
60
|
+
delete g.document
|
|
61
|
+
} else {
|
|
62
|
+
g.document = {
|
|
63
|
+
get cookie(): string {
|
|
64
|
+
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
65
|
+
},
|
|
66
|
+
set cookie(raw: string) {
|
|
67
|
+
writes.push(raw)
|
|
68
|
+
if (opts.refuseCookies) return // a jar that accepts the write and drops it
|
|
69
|
+
const eq = raw.split(';')[0].indexOf('=')
|
|
70
|
+
if (eq < 0) return
|
|
71
|
+
jar.set(raw.slice(0, eq).trim(), raw.split(';')[0].slice(eq + 1).trim())
|
|
72
|
+
},
|
|
73
|
+
referrer: '',
|
|
74
|
+
visibilityState: 'visible',
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
writes,
|
|
80
|
+
jar,
|
|
81
|
+
store,
|
|
82
|
+
restore: () => {
|
|
83
|
+
if (had.window) g.window = prev.window
|
|
84
|
+
else delete g.window
|
|
85
|
+
if (had.document) g.document = prev.document
|
|
86
|
+
else delete g.document
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A fresh module instance — the in-memory fallback is module state, and each
|
|
92
|
+
* surface in these tests is a separately loaded copy of the client. */
|
|
93
|
+
const load = async () => {
|
|
94
|
+
vi.resetModules()
|
|
95
|
+
return await import('./storage')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isUuidV7 = (id: string) =>
|
|
99
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id)
|
|
100
|
+
|
|
101
|
+
describe('anonId', () => {
|
|
102
|
+
it('adopts an existing localStorage id into the cookie instead of minting', async () => {
|
|
103
|
+
// THE migration invariant. Minting here would hand every returning visitor a
|
|
104
|
+
// new identity and detach them from their own history.
|
|
105
|
+
const b = browser({ storage: { [ANON]: LEGACY } })
|
|
106
|
+
try {
|
|
107
|
+
const { anonId } = await load()
|
|
108
|
+
expect(anonId()).toBe(LEGACY)
|
|
109
|
+
expect(b.jar.get(ANON)).toBe(LEGACY)
|
|
110
|
+
expect(b.store!.get(ANON)).toBe(LEGACY)
|
|
111
|
+
} finally {
|
|
112
|
+
b.restore()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('lets the cookie win over a divergent origin-local id', async () => {
|
|
117
|
+
// docs already minted its own before the migration; the shared cookie is now
|
|
118
|
+
// the source of truth and localStorage converges onto it.
|
|
119
|
+
const b = browser({ jar: new Map([[ANON, OTHER]]), storage: { [ANON]: LEGACY } })
|
|
120
|
+
try {
|
|
121
|
+
const { anonId } = await load()
|
|
122
|
+
expect(anonId()).toBe(OTHER)
|
|
123
|
+
expect(b.store!.get(ANON)).toBe(OTHER)
|
|
124
|
+
} finally {
|
|
125
|
+
b.restore()
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('mints a v7 id when neither store holds one, and writes both', async () => {
|
|
130
|
+
const b = browser()
|
|
131
|
+
try {
|
|
132
|
+
const { anonId } = await load()
|
|
133
|
+
const id = anonId()!
|
|
134
|
+
expect(isUuidV7(id)).toBe(true)
|
|
135
|
+
expect(b.jar.get(ANON)).toBe(id)
|
|
136
|
+
expect(b.store!.get(ANON)).toBe(id)
|
|
137
|
+
expect(anonId()).toBe(id) // stable across calls
|
|
138
|
+
} finally {
|
|
139
|
+
b.restore()
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('is the SAME id on a second *.hanzo.ai surface sharing the jar', async () => {
|
|
144
|
+
// The goal: one visitor is one person from marketing through checkout.
|
|
145
|
+
const shared = new Map<string, string>()
|
|
146
|
+
const docs = browser({ href: 'https://docs.hanzo.ai/guide', jar: shared })
|
|
147
|
+
let first: string
|
|
148
|
+
try {
|
|
149
|
+
first = (await load()).anonId()!
|
|
150
|
+
} finally {
|
|
151
|
+
docs.restore()
|
|
152
|
+
}
|
|
153
|
+
// A different origin: its own empty localStorage, the same cookie jar.
|
|
154
|
+
const cloud = browser({ href: 'https://cloud.hanzo.ai/', jar: shared })
|
|
155
|
+
try {
|
|
156
|
+
expect((await load()).anonId()).toBe(first)
|
|
157
|
+
} finally {
|
|
158
|
+
cloud.restore()
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('returns undefined during SSR rather than minting a server-side id', async () => {
|
|
163
|
+
const had = { window: 'window' in g, document: 'document' in g }
|
|
164
|
+
const prev = { window: g.window, document: g.document }
|
|
165
|
+
delete g.window
|
|
166
|
+
delete g.document
|
|
167
|
+
try {
|
|
168
|
+
const { anonId } = await load()
|
|
169
|
+
expect(anonId()).toBeUndefined()
|
|
170
|
+
} finally {
|
|
171
|
+
if (had.window) g.window = prev.window
|
|
172
|
+
if (had.document) g.document = prev.document
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('scopes the cookie to the registrable domain, secure and long-lived', async () => {
|
|
177
|
+
const b = browser({ href: 'https://cloud.hanzo.ai/billing' })
|
|
178
|
+
try {
|
|
179
|
+
const { anonId } = await load()
|
|
180
|
+
anonId()
|
|
181
|
+
const w = b.writes[0]
|
|
182
|
+
expect(w).toContain('Domain=hanzo.ai')
|
|
183
|
+
expect(w).toContain('Path=/')
|
|
184
|
+
expect(w).toContain('SameSite=Lax')
|
|
185
|
+
expect(w).toContain('Secure')
|
|
186
|
+
expect(w).toContain(`Max-Age=${2 * 365 * 24 * 60 * 60}`)
|
|
187
|
+
} finally {
|
|
188
|
+
b.restore()
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('omits Domain and Secure off hanzo.ai, where both would drop the cookie', async () => {
|
|
193
|
+
const b = browser({ href: 'http://localhost:3000/' })
|
|
194
|
+
try {
|
|
195
|
+
const { anonId } = await load()
|
|
196
|
+
const id = anonId()!
|
|
197
|
+
expect(b.writes[0]).not.toContain('Domain=')
|
|
198
|
+
expect(b.writes[0]).not.toContain('Secure')
|
|
199
|
+
expect(b.jar.get(ANON)).toBe(id) // still durable, just host-only
|
|
200
|
+
} finally {
|
|
201
|
+
b.restore()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('falls back to localStorage when cookies are refused', async () => {
|
|
206
|
+
const b = browser({ refuseCookies: true, storage: { [ANON]: LEGACY } })
|
|
207
|
+
try {
|
|
208
|
+
const { anonId } = await load()
|
|
209
|
+
expect(anonId()).toBe(LEGACY)
|
|
210
|
+
expect(anonId()).toBe(LEGACY)
|
|
211
|
+
expect(b.jar.size).toBe(0)
|
|
212
|
+
} finally {
|
|
213
|
+
b.restore()
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('holds one id in memory when both cookies and localStorage are refused', async () => {
|
|
218
|
+
const b = browser({ refuseCookies: true, noStorage: true })
|
|
219
|
+
try {
|
|
220
|
+
const { anonId } = await load()
|
|
221
|
+
const id = anonId()!
|
|
222
|
+
expect(isUuidV7(id)).toBe(true)
|
|
223
|
+
expect(anonId()).toBe(id) // one identity per page load, not one per event
|
|
224
|
+
} finally {
|
|
225
|
+
b.restore()
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('survives a browser with no document at all', async () => {
|
|
230
|
+
const b = browser({ noDocument: true, storage: { [ANON]: LEGACY } })
|
|
231
|
+
try {
|
|
232
|
+
const { anonId } = await load()
|
|
233
|
+
expect(anonId()).toBe(LEGACY)
|
|
234
|
+
} finally {
|
|
235
|
+
b.restore()
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('sessionId', () => {
|
|
241
|
+
// Deliberately unchanged by the anon migration: a session is origin-local and
|
|
242
|
+
// rotates on a 30-minute idle window.
|
|
243
|
+
it('stays in localStorage and rotates after the idle window', async () => {
|
|
244
|
+
const b = browser()
|
|
245
|
+
try {
|
|
246
|
+
const { sessionId } = await load()
|
|
247
|
+
const t0 = Date.now()
|
|
248
|
+
const a = sessionId(t0)
|
|
249
|
+
// The window runs from the LAST call, not from the session's start.
|
|
250
|
+
expect(sessionId(t0 + 60_000)).toBe(a)
|
|
251
|
+
expect(sessionId(t0 + 60_000 + 31 * 60_000)).not.toBe(a)
|
|
252
|
+
expect(b.jar.has('hz_session')).toBe(false)
|
|
253
|
+
} finally {
|
|
254
|
+
b.restore()
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
})
|
package/src/storage.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// SSR-safe browser storage for stable identifiers and first-touch state. Every
|
|
2
2
|
// accessor no-ops (returns undefined) when there is no window/localStorage, so the
|
|
3
3
|
// client imports cleanly in a Next.js server component.
|
|
4
|
+
//
|
|
5
|
+
// The anonymous id is the one value that must outlive an ORIGIN, so it lives in a
|
|
6
|
+
// cookie on the registrable domain; everything else here stays origin-local.
|
|
4
7
|
|
|
5
8
|
import type { Attribution, Cohort } from './types'
|
|
6
9
|
import { uuidv7 } from './uid'
|
|
@@ -15,6 +18,14 @@ const KEY = {
|
|
|
15
18
|
/** 30-minute inactivity window defines a session (PostHog/GA convention). */
|
|
16
19
|
const SESSION_TTL_MS = 30 * 60 * 1000
|
|
17
20
|
|
|
21
|
+
/** The registrable domain the anon cookie is scoped to, so docs, cloud, console,
|
|
22
|
+
* studio, pay, id and www all read the ONE id. */
|
|
23
|
+
const ANON_DOMAIN = 'hanzo.ai'
|
|
24
|
+
|
|
25
|
+
/** Two years, refreshed on every read — the cookie rolls forward with the visitor
|
|
26
|
+
* instead of expiring a fixed two years after first touch. */
|
|
27
|
+
const ANON_MAX_AGE_S = 2 * 365 * 24 * 60 * 60
|
|
28
|
+
|
|
18
29
|
function ls(): Storage | undefined {
|
|
19
30
|
try {
|
|
20
31
|
if (typeof window === 'undefined' || !window.localStorage) return undefined
|
|
@@ -24,16 +35,98 @@ function ls(): Storage | undefined {
|
|
|
24
35
|
}
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
/**
|
|
38
|
+
/** The cookie jar, or undefined wherever there is no document to read one from. */
|
|
39
|
+
function jar(): Document | undefined {
|
|
40
|
+
try {
|
|
41
|
+
if (typeof document === 'undefined' || typeof document.cookie !== 'string') return undefined
|
|
42
|
+
return document
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined // sandboxed frame with an opaque origin
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getCookie(name: string): string | undefined {
|
|
49
|
+
const d = jar()
|
|
50
|
+
if (!d) return undefined
|
|
51
|
+
for (const part of d.cookie.split(';')) {
|
|
52
|
+
const eq = part.indexOf('=')
|
|
53
|
+
if (eq < 0 || part.slice(0, eq).trim() !== name) continue
|
|
54
|
+
const v = part.slice(eq + 1).trim()
|
|
55
|
+
if (!v) continue
|
|
56
|
+
try {
|
|
57
|
+
return decodeURIComponent(v)
|
|
58
|
+
} catch {
|
|
59
|
+
return v // not percent-encoded — take it as written
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setCookie(name: string, value: string): void {
|
|
66
|
+
const d = jar()
|
|
67
|
+
if (!d) return
|
|
68
|
+
let host = ''
|
|
69
|
+
let secure = false
|
|
70
|
+
try {
|
|
71
|
+
if (typeof window !== 'undefined' && window.location) {
|
|
72
|
+
host = window.location.hostname || ''
|
|
73
|
+
// A Secure cookie is refused outright by a non-secure origin, which would
|
|
74
|
+
// strand http://localhost dev on the localStorage path.
|
|
75
|
+
secure = window.location.protocol === 'https:'
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
/* location unreachable — write a host-only, non-secure cookie */
|
|
79
|
+
}
|
|
80
|
+
const attrs = [
|
|
81
|
+
// encodeURIComponent leaves a UUID byte-identical while making any value that
|
|
82
|
+
// is not one unable to forge a `;` and inject an attribute.
|
|
83
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
84
|
+
'Path=/',
|
|
85
|
+
`Max-Age=${ANON_MAX_AGE_S}`,
|
|
86
|
+
'SameSite=Lax',
|
|
87
|
+
]
|
|
88
|
+
// Off hanzo.ai (localhost, previews, other registrable domains) the attribute
|
|
89
|
+
// would be rejected and the cookie dropped, so the cookie stays host-only there.
|
|
90
|
+
if (host === ANON_DOMAIN || host.endsWith(`.${ANON_DOMAIN}`)) attrs.push(`Domain=${ANON_DOMAIN}`)
|
|
91
|
+
if (secure) attrs.push('Secure')
|
|
92
|
+
try {
|
|
93
|
+
d.cookie = attrs.join('; ')
|
|
94
|
+
} catch {
|
|
95
|
+
/* cookies refused — localStorage still carries the id */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Last resort for a browser that refuses cookies AND localStorage: without it
|
|
100
|
+
* every event in a page load would mint its own id. */
|
|
101
|
+
let memAnon: string | undefined
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stable anonymous id, shared by every *.hanzo.ai surface.
|
|
105
|
+
*
|
|
106
|
+
* It lives in a first-party cookie on the registrable domain because
|
|
107
|
+
* localStorage is ORIGIN-scoped: docs, cloud and console each minted their own
|
|
108
|
+
* id for the same person, so one marketing → docs → signup → checkout journey
|
|
109
|
+
* arrived as several strangers — 463 anonymous identities carried 545 events in
|
|
110
|
+
* a week, about 1.2 events each.
|
|
111
|
+
*
|
|
112
|
+
* Resolution is strictly ADDITIVE: cookie, else the localStorage id this package
|
|
113
|
+
* has always written (ADOPTED into the cookie, never minted over — minting there
|
|
114
|
+
* would reset every returning visitor and detach their history), else mint.
|
|
115
|
+
* localStorage keeps being written, so a rollback finds everyone where it left
|
|
116
|
+
* them.
|
|
117
|
+
*/
|
|
28
118
|
export function anonId(): string | undefined {
|
|
119
|
+
if (typeof window === 'undefined') return undefined // SSR / prerender
|
|
29
120
|
const s = ls()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
s.setItem(KEY.anon,
|
|
121
|
+
const id = getCookie(KEY.anon) || s?.getItem(KEY.anon) || memAnon || uuidv7()
|
|
122
|
+
memAnon = id
|
|
123
|
+
setCookie(KEY.anon, id)
|
|
124
|
+
try {
|
|
125
|
+
if (s && s.getItem(KEY.anon) !== id) s.setItem(KEY.anon, id)
|
|
126
|
+
} catch {
|
|
127
|
+
/* quota exhausted, or a private-mode jar that reads but refuses writes */
|
|
35
128
|
}
|
|
36
|
-
return
|
|
129
|
+
return id
|
|
37
130
|
}
|
|
38
131
|
|
|
39
132
|
interface SessionState {
|
package/src/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// The library version, stamped on every event (`libraryVersion`) and on the
|
|
2
2
|
// Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
|
|
3
3
|
// `core.ts` — core imports sentry, so the reverse would be an import cycle.
|
|
4
|
-
export const VERSION = '0.3.
|
|
4
|
+
export const VERSION = '0.3.14'
|