@hanzo/event 0.3.14 → 0.3.16
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/LICENSE.md +21 -0
- package/dist/index.cjs +125 -87
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.mjs +125 -87
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +122 -86
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +122 -86
- package/dist/react.mjs.map +1 -1
- package/hz.js +188 -27
- package/package.json +10 -10
- package/src/anon.d.ts +14 -0
- package/src/anon.js +199 -0
- package/src/anon.test.ts +80 -0
- package/src/core.ts +8 -1
- package/src/hz.test.ts +67 -12
- package/src/storage.test.ts +28 -0
- package/src/storage.ts +14 -99
- package/src/uid.ts +9 -35
- package/src/version.ts +1 -1
package/hz.js
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
var LIB = 'hz.js'
|
|
58
|
-
var VERSION = '0.3.
|
|
58
|
+
var VERSION = '0.3.16'
|
|
59
59
|
var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
|
|
60
60
|
var product = s.getAttribute('data-product') || location.hostname
|
|
61
61
|
var capture = s.getAttribute('data-capture') !== '0'
|
|
@@ -69,42 +69,203 @@
|
|
|
69
69
|
// fine in the browser, and filed nothing.
|
|
70
70
|
var key = s.getAttribute('data-ingest-key') || ''
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
72
|
+
// ── the shared anonymous-identity chain ───────────────────────────────────
|
|
73
|
+
// COPIED VERBATIM from @hanzo/event's src/anon.js, markers and all, for the same
|
|
74
|
+
// reason `clean` restates scrub.ts: this file has no bundler and cannot import
|
|
75
|
+
// anything. It is the ONE chain and the ONE key, so a page carrying this tag and
|
|
76
|
+
// the npm client resolves to one person; src/anon.test.ts fails on a byte of
|
|
77
|
+
// drift between the two copies. EDIT src/anon.js, never this copy, and do not
|
|
78
|
+
// reformat it — the indentation is part of the byte comparison.
|
|
79
|
+
|
|
80
|
+
/* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
|
|
81
|
+
|
|
82
|
+
/** The ONE anonymous-id key, on every surface and in every distribution. */
|
|
83
|
+
var HZ_ANON_KEY = 'hz_anon_id'
|
|
84
|
+
|
|
85
|
+
/** hz.js used to write `hz_id` — a SECOND identity space, so the one-paste tag
|
|
86
|
+
* and the npm client were two different people on one page. It is READ and never
|
|
87
|
+
* written: an id already in the wild is ADOPTED into the shared identity, because
|
|
88
|
+
* minting over one detaches a returning visitor from their own history. */
|
|
89
|
+
var HZ_ANON_LEGACY_KEY = 'hz_id'
|
|
90
|
+
|
|
91
|
+
/** The registrable domain the cookie is scoped to, so docs, cloud, console,
|
|
92
|
+
* studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
|
|
93
|
+
* ORIGIN-scoped, which is what made one journey arrive as several strangers. */
|
|
94
|
+
var HZ_ANON_DOMAIN = 'hanzo.ai'
|
|
95
|
+
|
|
96
|
+
/** Two years, rewritten on every read, so the cookie rolls forward with the
|
|
97
|
+
* visitor instead of expiring two years after first touch. Safari caps a
|
|
98
|
+
* SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
|
|
99
|
+
* what keeps a returning Safari visitor: each read re-arms the 7-day window. */
|
|
100
|
+
var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
|
|
101
|
+
|
|
102
|
+
/** Last resort for a browser that refuses cookies AND localStorage: without it
|
|
103
|
+
* every event in a page load would mint an id of its own. */
|
|
104
|
+
var hzAnonMemo
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
|
|
108
|
+
*
|
|
109
|
+
* It has to be v7, and this is the only minter any distribution may use. The
|
|
110
|
+
* session rollups on the event plane derive a session's start instant FROM THE ID
|
|
111
|
+
* and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
|
|
112
|
+
* is not merely unordered there — it is DISCARDED, silently, and the rollup stays
|
|
113
|
+
* empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
|
|
114
|
+
*/
|
|
115
|
+
function hzUuidv7(now) {
|
|
116
|
+
var b = new Uint8Array(16)
|
|
117
|
+
var i
|
|
118
|
+
var c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
119
|
+
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
|
|
120
|
+
else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
|
|
121
|
+
var t = Math.floor(now === undefined ? Date.now() : now)
|
|
122
|
+
for (i = 5; i >= 0; i--) {
|
|
123
|
+
b[i] = t % 256
|
|
124
|
+
t = Math.floor(t / 256)
|
|
125
|
+
}
|
|
126
|
+
b[6] = 0x70 | (b[6] & 0x0f) // version 7
|
|
127
|
+
b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
|
|
128
|
+
var h = ''
|
|
129
|
+
for (i = 0; i < 16; i++) {
|
|
130
|
+
h += (b[i] + 0x100).toString(16).slice(1)
|
|
131
|
+
if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
|
|
132
|
+
}
|
|
133
|
+
return h
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The cookie jar, or null wherever there is no document to read one from. */
|
|
137
|
+
function hzAnonJar() {
|
|
138
|
+
try {
|
|
139
|
+
if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
|
|
140
|
+
return document
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return null // sandboxed frame with an opaque origin
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** localStorage, or null when the browser refuses it (Safari private mode). */
|
|
147
|
+
function hzAnonStore() {
|
|
148
|
+
try {
|
|
149
|
+
if (typeof window === 'undefined' || !window.localStorage) return null
|
|
150
|
+
return window.localStorage
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One stored value, or '' — a jar can read as well as refuse to. */
|
|
157
|
+
function hzAnonItem(store, name) {
|
|
158
|
+
try {
|
|
159
|
+
return (store && store.getItem(name)) || ''
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return ''
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The value of cookie `name`, or ''. */
|
|
166
|
+
function hzAnonCookie(name) {
|
|
167
|
+
var d = hzAnonJar()
|
|
168
|
+
if (!d) return ''
|
|
169
|
+
var parts = d.cookie.split(';')
|
|
170
|
+
for (var i = 0; i < parts.length; i++) {
|
|
171
|
+
var eq = parts[i].indexOf('=')
|
|
172
|
+
if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
|
|
173
|
+
var v = parts[i].slice(eq + 1).trim()
|
|
174
|
+
if (!v) continue
|
|
175
|
+
try {
|
|
176
|
+
return decodeURIComponent(v)
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return v // not percent-encoded — take it as written
|
|
88
179
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
180
|
+
}
|
|
181
|
+
return ''
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Writes `name` on the registrable domain, for as long as the browser allows. */
|
|
185
|
+
function hzAnonWrite(name, value) {
|
|
186
|
+
var d = hzAnonJar()
|
|
187
|
+
if (!d) return
|
|
188
|
+
var host = ''
|
|
189
|
+
var secure = false
|
|
190
|
+
try {
|
|
191
|
+
if (typeof window !== 'undefined' && window.location) {
|
|
192
|
+
host = window.location.hostname || ''
|
|
193
|
+
// A Secure cookie is refused outright by a non-secure origin, which would
|
|
194
|
+
// strand http://localhost dev on the localStorage path.
|
|
195
|
+
secure = window.location.protocol === 'https:'
|
|
95
196
|
}
|
|
96
|
-
|
|
197
|
+
} catch (e) {
|
|
198
|
+
/* location unreachable — write a host-only, non-secure cookie */
|
|
199
|
+
}
|
|
200
|
+
// encodeURIComponent leaves a UUID byte-identical while making any value that is
|
|
201
|
+
// not one unable to forge a `;` and inject an attribute.
|
|
202
|
+
var c = name + '=' + encodeURIComponent(value)
|
|
203
|
+
c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
|
|
204
|
+
// Off hanzo.ai (localhost, previews, other registrable domains) the attribute
|
|
205
|
+
// would be rejected and the whole cookie dropped, so it stays host-only there.
|
|
206
|
+
// Prefixing both sides with '.' matches the domain itself and its subdomains
|
|
207
|
+
// while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
|
|
208
|
+
if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
|
|
209
|
+
c += '; Domain=' + HZ_ANON_DOMAIN
|
|
97
210
|
}
|
|
211
|
+
if (secure) c += '; Secure'
|
|
212
|
+
try {
|
|
213
|
+
d.cookie = c
|
|
214
|
+
} catch (e) {
|
|
215
|
+
/* cookies refused — localStorage still carries the id */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* hzAnonId returns the stable anonymous id for this browser, '' during SSR.
|
|
221
|
+
*
|
|
222
|
+
* Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
|
|
223
|
+
* only a browser holding none of them is given a new one:
|
|
224
|
+
*
|
|
225
|
+
* cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
|
|
226
|
+
*
|
|
227
|
+
* Minting over an id resets a returning visitor and detaches them from their own
|
|
228
|
+
* history, so the order is the migration: the cookie is the shared home, the two
|
|
229
|
+
* localStorage keys are what the three implementations wrote before it existed,
|
|
230
|
+
* and each is read until nothing is left to adopt.
|
|
231
|
+
*
|
|
232
|
+
* localStorage keeps being written, so a rollback finds everyone where it left
|
|
233
|
+
* them, and a browser that refuses cookies still holds one id per origin.
|
|
234
|
+
*/
|
|
235
|
+
function hzAnonId() {
|
|
236
|
+
if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
|
|
237
|
+
var s = hzAnonStore()
|
|
238
|
+
var id =
|
|
239
|
+
hzAnonCookie(HZ_ANON_KEY) ||
|
|
240
|
+
hzAnonItem(s, HZ_ANON_KEY) ||
|
|
241
|
+
hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
|
|
242
|
+
hzAnonMemo ||
|
|
243
|
+
hzUuidv7()
|
|
244
|
+
hzAnonMemo = id
|
|
245
|
+
hzAnonWrite(HZ_ANON_KEY, id)
|
|
246
|
+
try {
|
|
247
|
+
if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
|
|
248
|
+
} catch (e) {
|
|
249
|
+
/* quota exhausted, or a private-mode jar that reads but refuses writes */
|
|
250
|
+
}
|
|
251
|
+
return id
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
|
|
255
|
+
|
|
98
256
|
function stored(store, key) {
|
|
99
257
|
try {
|
|
100
258
|
var v = store.getItem(key)
|
|
101
|
-
if (!v) store.setItem(key, (v =
|
|
259
|
+
if (!v) store.setItem(key, (v = hzUuidv7()))
|
|
102
260
|
return v
|
|
103
261
|
} catch (e) {
|
|
104
262
|
return 'anon'
|
|
105
263
|
}
|
|
106
264
|
}
|
|
107
|
-
|
|
265
|
+
// This used to mint into a localStorage key of its own, so the tag and
|
|
266
|
+
// @hanzo/event counted one visitor as two people. The chain adopts that old id
|
|
267
|
+
// where it exists rather than orphaning it.
|
|
268
|
+
var anon = hzAnonId()
|
|
108
269
|
var sid = stored(sessionStorage, 'hz_sid')
|
|
109
270
|
var person = null
|
|
110
271
|
try {
|
|
@@ -177,7 +338,7 @@
|
|
|
177
338
|
// server cannot tell which distribution emitted it.
|
|
178
339
|
function send(kind, event, props) {
|
|
179
340
|
queue.push({
|
|
180
|
-
messageId:
|
|
341
|
+
messageId: hzUuidv7(),
|
|
181
342
|
type: kind,
|
|
182
343
|
event: event,
|
|
183
344
|
timestamp: new Date().toISOString(),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Hanzo Event
|
|
3
|
+
"version": "0.3.16",
|
|
4
|
+
"description": "Hanzo Event — the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes — the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org/",
|
|
7
7
|
"access": "public",
|
|
@@ -37,13 +37,6 @@
|
|
|
37
37
|
"funnel",
|
|
38
38
|
"hanzo"
|
|
39
39
|
],
|
|
40
|
-
"scripts": {
|
|
41
|
-
"build": "tsup",
|
|
42
|
-
"dev": "tsup --watch",
|
|
43
|
-
"test": "vitest run",
|
|
44
|
-
"typecheck": "tsc --noEmit",
|
|
45
|
-
"clean": "rm -rf dist"
|
|
46
|
-
},
|
|
47
40
|
"exports": {
|
|
48
41
|
".": {
|
|
49
42
|
"import": {
|
|
@@ -81,5 +74,12 @@
|
|
|
81
74
|
"tsup": "^8.5.1",
|
|
82
75
|
"typescript": "^5.9.3",
|
|
83
76
|
"vitest": "^4.1.0"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"build": "tsup",
|
|
80
|
+
"dev": "tsup --watch",
|
|
81
|
+
"test": "vitest run",
|
|
82
|
+
"typecheck": "tsc --noEmit",
|
|
83
|
+
"clean": "rm -rf dist"
|
|
84
84
|
}
|
|
85
|
-
}
|
|
85
|
+
}
|
package/src/anon.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Types for anon.js, which is hand-written ES5 rather than TypeScript because
|
|
2
|
+
// hz.js and the door's hosted tag inline it VERBATIM and neither has a compiler.
|
|
3
|
+
// The declarations are here so the bundled client still imports it typed.
|
|
4
|
+
|
|
5
|
+
/** Mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch milliseconds. */
|
|
6
|
+
export declare function hzUuidv7(now?: number): string
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The stable anonymous id for this browser, or '' during SSR.
|
|
10
|
+
*
|
|
11
|
+
* cookie · localStorage `hz_anon_id` · localStorage `hz_id` · in-memory · mint —
|
|
12
|
+
* every existing id is adopted, and only a browser holding none is given a new one.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hzAnonId(): string
|
package/src/anon.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/*! anon.js — THE anonymous-identity chain. ONE implementation, three distributions.
|
|
2
|
+
*
|
|
3
|
+
* One browser is ONE person on every Hanzo surface, whichever client a page
|
|
4
|
+
* happens to have loaded. There were three implementations writing TWO keys —
|
|
5
|
+
* `hz_anon_id` (the npm client, the hosted tag) and `hz_id` (hz.js) — so the same
|
|
6
|
+
* visitor was several people depending on which snippet the surface shipped.
|
|
7
|
+
*
|
|
8
|
+
* The three call sites:
|
|
9
|
+
* 1. src/storage.ts — the bundled npm client; IMPORTS this file.
|
|
10
|
+
* 2. hz.js — the no-build script tag; INLINES the marked region.
|
|
11
|
+
* 3. hanzoai/cloud apps/analytics/tag.js — the tag the door hosts at
|
|
12
|
+
* /v1/event.js; vendors this file and its tag.go serves the marked region
|
|
13
|
+
* with the tag as one asset, so the door holds no second copy either.
|
|
14
|
+
*
|
|
15
|
+
* (2) and (3) have no bundler and cannot import anything, which is why the chain
|
|
16
|
+
* lives in a file that is plain ES5 rather than in a .ts: the region between the
|
|
17
|
+
* BEGIN and END markers is COPIED VERBATIM, and src/anon.test.ts fails if hz.js's
|
|
18
|
+
* copy is so much as a byte different. Keep the region ES5, dependency-free,
|
|
19
|
+
* `hz`-prefixed (it is spliced into other people's scopes) and unformatted — a
|
|
20
|
+
* reformat of one copy is a diff against the other.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
|
|
24
|
+
|
|
25
|
+
/** The ONE anonymous-id key, on every surface and in every distribution. */
|
|
26
|
+
var HZ_ANON_KEY = 'hz_anon_id'
|
|
27
|
+
|
|
28
|
+
/** hz.js used to write `hz_id` — a SECOND identity space, so the one-paste tag
|
|
29
|
+
* and the npm client were two different people on one page. It is READ and never
|
|
30
|
+
* written: an id already in the wild is ADOPTED into the shared identity, because
|
|
31
|
+
* minting over one detaches a returning visitor from their own history. */
|
|
32
|
+
var HZ_ANON_LEGACY_KEY = 'hz_id'
|
|
33
|
+
|
|
34
|
+
/** The registrable domain the cookie is scoped to, so docs, cloud, console,
|
|
35
|
+
* studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
|
|
36
|
+
* ORIGIN-scoped, which is what made one journey arrive as several strangers. */
|
|
37
|
+
var HZ_ANON_DOMAIN = 'hanzo.ai'
|
|
38
|
+
|
|
39
|
+
/** Two years, rewritten on every read, so the cookie rolls forward with the
|
|
40
|
+
* visitor instead of expiring two years after first touch. Safari caps a
|
|
41
|
+
* SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
|
|
42
|
+
* what keeps a returning Safari visitor: each read re-arms the 7-day window. */
|
|
43
|
+
var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
|
|
44
|
+
|
|
45
|
+
/** Last resort for a browser that refuses cookies AND localStorage: without it
|
|
46
|
+
* every event in a page load would mint an id of its own. */
|
|
47
|
+
var hzAnonMemo
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
|
|
51
|
+
*
|
|
52
|
+
* It has to be v7, and this is the only minter any distribution may use. The
|
|
53
|
+
* session rollups on the event plane derive a session's start instant FROM THE ID
|
|
54
|
+
* and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
|
|
55
|
+
* is not merely unordered there — it is DISCARDED, silently, and the rollup stays
|
|
56
|
+
* empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
|
|
57
|
+
*/
|
|
58
|
+
function hzUuidv7(now) {
|
|
59
|
+
var b = new Uint8Array(16)
|
|
60
|
+
var i
|
|
61
|
+
var c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
62
|
+
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
|
|
63
|
+
else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
|
|
64
|
+
var t = Math.floor(now === undefined ? Date.now() : now)
|
|
65
|
+
for (i = 5; i >= 0; i--) {
|
|
66
|
+
b[i] = t % 256
|
|
67
|
+
t = Math.floor(t / 256)
|
|
68
|
+
}
|
|
69
|
+
b[6] = 0x70 | (b[6] & 0x0f) // version 7
|
|
70
|
+
b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
|
|
71
|
+
var h = ''
|
|
72
|
+
for (i = 0; i < 16; i++) {
|
|
73
|
+
h += (b[i] + 0x100).toString(16).slice(1)
|
|
74
|
+
if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
|
|
75
|
+
}
|
|
76
|
+
return h
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The cookie jar, or null wherever there is no document to read one from. */
|
|
80
|
+
function hzAnonJar() {
|
|
81
|
+
try {
|
|
82
|
+
if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
|
|
83
|
+
return document
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return null // sandboxed frame with an opaque origin
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** localStorage, or null when the browser refuses it (Safari private mode). */
|
|
90
|
+
function hzAnonStore() {
|
|
91
|
+
try {
|
|
92
|
+
if (typeof window === 'undefined' || !window.localStorage) return null
|
|
93
|
+
return window.localStorage
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One stored value, or '' — a jar can read as well as refuse to. */
|
|
100
|
+
function hzAnonItem(store, name) {
|
|
101
|
+
try {
|
|
102
|
+
return (store && store.getItem(name)) || ''
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return ''
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The value of cookie `name`, or ''. */
|
|
109
|
+
function hzAnonCookie(name) {
|
|
110
|
+
var d = hzAnonJar()
|
|
111
|
+
if (!d) return ''
|
|
112
|
+
var parts = d.cookie.split(';')
|
|
113
|
+
for (var i = 0; i < parts.length; i++) {
|
|
114
|
+
var eq = parts[i].indexOf('=')
|
|
115
|
+
if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
|
|
116
|
+
var v = parts[i].slice(eq + 1).trim()
|
|
117
|
+
if (!v) continue
|
|
118
|
+
try {
|
|
119
|
+
return decodeURIComponent(v)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return v // not percent-encoded — take it as written
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return ''
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Writes `name` on the registrable domain, for as long as the browser allows. */
|
|
128
|
+
function hzAnonWrite(name, value) {
|
|
129
|
+
var d = hzAnonJar()
|
|
130
|
+
if (!d) return
|
|
131
|
+
var host = ''
|
|
132
|
+
var secure = false
|
|
133
|
+
try {
|
|
134
|
+
if (typeof window !== 'undefined' && window.location) {
|
|
135
|
+
host = window.location.hostname || ''
|
|
136
|
+
// A Secure cookie is refused outright by a non-secure origin, which would
|
|
137
|
+
// strand http://localhost dev on the localStorage path.
|
|
138
|
+
secure = window.location.protocol === 'https:'
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
/* location unreachable — write a host-only, non-secure cookie */
|
|
142
|
+
}
|
|
143
|
+
// encodeURIComponent leaves a UUID byte-identical while making any value that is
|
|
144
|
+
// not one unable to forge a `;` and inject an attribute.
|
|
145
|
+
var c = name + '=' + encodeURIComponent(value)
|
|
146
|
+
c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
|
|
147
|
+
// Off hanzo.ai (localhost, previews, other registrable domains) the attribute
|
|
148
|
+
// would be rejected and the whole cookie dropped, so it stays host-only there.
|
|
149
|
+
// Prefixing both sides with '.' matches the domain itself and its subdomains
|
|
150
|
+
// while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
|
|
151
|
+
if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
|
|
152
|
+
c += '; Domain=' + HZ_ANON_DOMAIN
|
|
153
|
+
}
|
|
154
|
+
if (secure) c += '; Secure'
|
|
155
|
+
try {
|
|
156
|
+
d.cookie = c
|
|
157
|
+
} catch (e) {
|
|
158
|
+
/* cookies refused — localStorage still carries the id */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* hzAnonId returns the stable anonymous id for this browser, '' during SSR.
|
|
164
|
+
*
|
|
165
|
+
* Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
|
|
166
|
+
* only a browser holding none of them is given a new one:
|
|
167
|
+
*
|
|
168
|
+
* cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
|
|
169
|
+
*
|
|
170
|
+
* Minting over an id resets a returning visitor and detaches them from their own
|
|
171
|
+
* history, so the order is the migration: the cookie is the shared home, the two
|
|
172
|
+
* localStorage keys are what the three implementations wrote before it existed,
|
|
173
|
+
* and each is read until nothing is left to adopt.
|
|
174
|
+
*
|
|
175
|
+
* localStorage keeps being written, so a rollback finds everyone where it left
|
|
176
|
+
* them, and a browser that refuses cookies still holds one id per origin.
|
|
177
|
+
*/
|
|
178
|
+
function hzAnonId() {
|
|
179
|
+
if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
|
|
180
|
+
var s = hzAnonStore()
|
|
181
|
+
var id =
|
|
182
|
+
hzAnonCookie(HZ_ANON_KEY) ||
|
|
183
|
+
hzAnonItem(s, HZ_ANON_KEY) ||
|
|
184
|
+
hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
|
|
185
|
+
hzAnonMemo ||
|
|
186
|
+
hzUuidv7()
|
|
187
|
+
hzAnonMemo = id
|
|
188
|
+
hzAnonWrite(HZ_ANON_KEY, id)
|
|
189
|
+
try {
|
|
190
|
+
if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
|
|
191
|
+
} catch (e) {
|
|
192
|
+
/* quota exhausted, or a private-mode jar that reads but refuses writes */
|
|
193
|
+
}
|
|
194
|
+
return id
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
|
|
198
|
+
|
|
199
|
+
export { hzAnonId, hzUuidv7 }
|
package/src/anon.test.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The anonymous-identity chain is ONE implementation with three call sites, and
|
|
2
|
+
// two of them cannot import it: hz.js and the tag hanzoai/cloud hosts at
|
|
3
|
+
// /v1/event.js have no bundler, so they carry the marked region of anon.js
|
|
4
|
+
// VERBATIM. That is the drift risk this file exists to remove — three snippets
|
|
5
|
+
// that agree the day they are written and disagree a quarter later, which is
|
|
6
|
+
// exactly how the same browser came to hold two different anonymous ids under two
|
|
7
|
+
// different key names.
|
|
8
|
+
//
|
|
9
|
+
// The behavioural contract is proven where each distribution runs it:
|
|
10
|
+
// storage.test.ts (the npm client) and hz.test.ts (the script tag). This file
|
|
11
|
+
// proves the copies are the SAME TEXT, which is the only thing that keeps those
|
|
12
|
+
// two suites testing one implementation instead of two.
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest'
|
|
15
|
+
import { readFileSync } from 'node:fs'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
const read = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8')
|
|
19
|
+
|
|
20
|
+
const BEGIN = '/* ── BEGIN hz anon chain'
|
|
21
|
+
const END = '/* ── END hz anon chain'
|
|
22
|
+
|
|
23
|
+
/** Where the shared region starts and ends in a file that carries it. */
|
|
24
|
+
function span(src: string, what: string): [number, number] {
|
|
25
|
+
const b = src.indexOf(BEGIN)
|
|
26
|
+
const e = src.indexOf(END)
|
|
27
|
+
expect(b, `${what} has no BEGIN marker`).toBeGreaterThanOrEqual(0)
|
|
28
|
+
expect(e, `${what} has no END marker`).toBeGreaterThan(b)
|
|
29
|
+
return [b, src.indexOf('\n', e) + 1]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The shared region, markers included. */
|
|
33
|
+
const region = (src: string, what: string): string => src.slice(...span(src, what))
|
|
34
|
+
|
|
35
|
+
/** Everything that is NOT the shared region. */
|
|
36
|
+
function outside(src: string, what: string): string {
|
|
37
|
+
const [b, e] = span(src, what)
|
|
38
|
+
return src.slice(0, b) + src.slice(e)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ANON = read('./anon.js')
|
|
42
|
+
const HZ = read('../hz.js')
|
|
43
|
+
|
|
44
|
+
describe('the shared anon chain', () => {
|
|
45
|
+
it('is in hz.js as the same text, byte for byte', () => {
|
|
46
|
+
// Not "equivalent" — IDENTICAL. Anything weaker (normalising whitespace,
|
|
47
|
+
// comparing behaviour) permits the copies to say different things about which
|
|
48
|
+
// key they read, which is the bug. To change the chain, edit src/anon.js and
|
|
49
|
+
// re-splice; never patch a copy.
|
|
50
|
+
expect(region(HZ, 'hz.js')).toBe(region(ANON, 'anon.js'))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('names one key, and hz.js holds no identity code of its own', () => {
|
|
54
|
+
// hz.js used to mint into hz_id — a second identity space, so the one-paste
|
|
55
|
+
// tag and the npm client were two people on one page. Neither key may be
|
|
56
|
+
// NAMED outside the shared region: a snippet that spells a key is a snippet
|
|
57
|
+
// that has an opinion about identity, and there is one opinion now.
|
|
58
|
+
const rest = outside(HZ, 'hz.js')
|
|
59
|
+
expect(rest).not.toContain("'hz_anon_id'")
|
|
60
|
+
expect(rest).not.toContain("'hz_id'")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('is inlineable: the region imports nothing and declares no ES6', () => {
|
|
64
|
+
// It is spliced into two files that have no bundler and into pages the client
|
|
65
|
+
// does not control, so it must be ES5, self-contained and hz-prefixed.
|
|
66
|
+
const r = region(ANON, 'anon.js')
|
|
67
|
+
expect(r).not.toMatch(/\b(?:import|export|const|let|class|=>)\b|=>/)
|
|
68
|
+
for (const [, name] of r.matchAll(/^(?:function|var) ([A-Za-z_$][\w$]*)/gm)) {
|
|
69
|
+
expect(name, `${name} would collide in a host page's scope`).toMatch(/^(?:hz|HZ_)/)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('ships in the published package, for the door to vendor', () => {
|
|
74
|
+
// hanzoai/cloud vendors this file whole and its tag.go serves the region with
|
|
75
|
+
// the tag as one asset, so the third call site holds no second copy either.
|
|
76
|
+
const pkg = JSON.parse(read('../package.json')) as { files: string[] }
|
|
77
|
+
expect(pkg.files).toContain('src')
|
|
78
|
+
expect(pkg.files).toContain('hz.js')
|
|
79
|
+
})
|
|
80
|
+
})
|
package/src/core.ts
CHANGED
|
@@ -123,7 +123,14 @@ function normalizeError(err: unknown): Exception {
|
|
|
123
123
|
return { type: n.name, message: n.message, stack: n.stack }
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
// React Native defines a `window` but no `document` (and no `window.location`),
|
|
127
|
+
// so `typeof window !== 'undefined'` alone took the browser path there and
|
|
128
|
+
// `init()` threw on `window.location.search`. Requiring `document` makes RN read
|
|
129
|
+
// as non-browser: `init()` returns early (skips attribution + the visibility/
|
|
130
|
+
// unload listeners RN doesn't have), while `capture()`/`flush()` — which are NOT
|
|
131
|
+
// gated on this and post through plain `fetch` — still emit. So the client is
|
|
132
|
+
// inert-safe under SSR/Node and live on RN, without a second transport.
|
|
133
|
+
const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
127
134
|
|
|
128
135
|
/** serializeBatch stringifies a batch, salvaging what it can. `properties` is
|
|
129
136
|
* arbitrary caller data — a DOM node, a React synthetic event, an axios error are
|