@hanzo/event 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hz.test.ts CHANGED
@@ -42,6 +42,9 @@ interface StubOptions {
42
42
  navigator?: Record<string, unknown>
43
43
  /** Seed localStorage (e.g. an explicit hz_consent choice). */
44
44
  storage?: Record<string, string>
45
+ /** The cookie jar. Pass one in to model a browser that already carries an id —
46
+ * from another *.hanzo.ai surface, or from the npm client on this same page. */
47
+ jar?: Map<string, string>
45
48
  /** Let navigator.sendBeacon succeed, so the beacon path is the one measured. */
46
49
  beacon?: boolean
47
50
  }
@@ -54,16 +57,20 @@ type Api = { track(n: string, p?: unknown): void; flush(): void }
54
57
  * descriptor is an accessor with no setter, so the plain assignment this
55
58
  * harness used threw — and every hz.js test failed on a current runtime,
56
59
  * leaving the shipped file with no executed coverage again. */
57
- function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefined } {
60
+ function runSnippet(opts: StubOptions = {}): {
61
+ posts: Post[]
62
+ api: Api | undefined
63
+ local: Map<string, string>
64
+ jar: Map<string, string>
65
+ } {
58
66
  const posts: Post[] = []
59
- const store = (seed: Record<string, string> = {}) => {
60
- const m = new Map<string, string>(Object.entries(seed))
61
- return {
62
- getItem: (k: string) => m.get(k) ?? null,
63
- setItem: (k: string, v: string) => void m.set(k, v),
64
- removeItem: (k: string) => void m.delete(k),
65
- }
66
- }
67
+ const local = new Map<string, string>(Object.entries(opts.storage ?? {}))
68
+ const jar = opts.jar ?? new Map<string, string>()
69
+ const store = (m: Map<string, string>) => ({
70
+ getItem: (k: string) => m.get(k) ?? null,
71
+ setItem: (k: string, v: string) => void m.set(k, v),
72
+ removeItem: (k: string) => void m.delete(k),
73
+ })
67
74
  const attrs: Record<string, string> = { 'data-product': 'test', ...opts.attrs }
68
75
  const g = globalThis as unknown as Record<string, unknown>
69
76
  const define = (name: string, value: unknown) =>
@@ -78,6 +85,16 @@ function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefin
78
85
  })
79
86
  define('document', {
80
87
  currentScript: { getAttribute: (a: string) => attrs[a] ?? null },
88
+ // A real jar: the anonymous id is a cookie now, so a stub with no `cookie`
89
+ // would leave the whole identity chain unexecuted by these tests.
90
+ get cookie(): string {
91
+ return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
92
+ },
93
+ set cookie(raw: string) {
94
+ const first = raw.split(';')[0]
95
+ const eq = first.indexOf('=')
96
+ if (eq > 0) jar.set(first.slice(0, eq).trim(), first.slice(eq + 1).trim())
97
+ },
81
98
  referrer: '',
82
99
  addEventListener: () => {},
83
100
  documentElement: { scrollHeight: 1000 },
@@ -104,8 +121,8 @@ function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefin
104
121
  }
105
122
  },
106
123
  )
107
- define('localStorage', store(opts.storage))
108
- define('sessionStorage', store())
124
+ define('localStorage', store(local))
125
+ define('sessionStorage', store(new Map()))
109
126
  define('history', { pushState: () => {}, replaceState: () => {} })
110
127
  define('addEventListener', () => {})
111
128
  define('PerformanceObserver', undefined)
@@ -122,7 +139,7 @@ function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefin
122
139
  define('window', g)
123
140
 
124
141
  new Function(SRC)()
125
- return { posts, api: (g.window as { hanzo?: Api }).hanzo }
142
+ return { posts, api: (g.window as { hanzo?: Api }).hanzo, local, jar }
126
143
  }
127
144
 
128
145
  /** Every event across every transmission, in order. */
@@ -170,6 +187,44 @@ describe('hz.js', () => {
170
187
  expect(pv!.libraryVersion).toBe(PKG.version)
171
188
  })
172
189
 
190
+ // ── identity ──────────────────────────────────────────────────────────────
191
+ // This file used to mint into `hz_id`, a key nothing else read or wrote, so a
192
+ // page carrying both this tag and the npm client sent two anonymous ids for one
193
+ // visitor — and every surface counted them as two people. It now runs the same
194
+ // chain, from the same file, against the same key.
195
+
196
+ const SEEDED = '01920000-0000-7000-8000-0000000000cc'
197
+ const LEGACY = '01920000-0000-7000-8000-0000000000dd'
198
+
199
+ it('is the same person as every other Hanzo client on the browser', () => {
200
+ // The cookie the npm client (or another *.hanzo.ai surface) already wrote.
201
+ const r = runSnippet({ jar: new Map([['hz_anon_id', SEEDED]]) })
202
+ r.api!.track('checkout_started')
203
+ r.api!.flush()
204
+ const sent = sentOf(r)
205
+ expect(sent.length).toBeGreaterThan(0)
206
+ for (const ev of sent) expect(ev.anonymousId).toBe(SEEDED)
207
+ })
208
+
209
+ it('adopts the `hz_id` it used to mint rather than making a stranger', () => {
210
+ // Every browser that has ever loaded this tag holds one of these. Minting
211
+ // over it would detach a returning visitor from their own history.
212
+ const r = runSnippet({ storage: { hz_id: LEGACY } })
213
+ r.api!.flush()
214
+ for (const ev of sentOf(r)) expect(ev.anonymousId).toBe(LEGACY)
215
+ expect(r.jar.get('hz_anon_id')).toBe(LEGACY) // carried onto the shared key
216
+ expect(r.local.get('hz_anon_id')).toBe(LEGACY)
217
+ })
218
+
219
+ it('writes the one key, in the durable place, and no longer mints its own', () => {
220
+ const r = runSnippet()
221
+ r.api!.flush()
222
+ const id = sentOf(r)[0].anonymousId
223
+ expect(r.jar.get('hz_anon_id')).toBe(id) // the cookie outlives the ORIGIN
224
+ expect(r.local.get('hz_anon_id')).toBe(id)
225
+ expect(r.local.has('hz_id')).toBe(false)
226
+ })
227
+
173
228
  // ── the publishable key ───────────────────────────────────────────────────
174
229
  // Through 0.3.11 this file could present none at all: no header, no query. A
175
230
  // keyed static surface therefore sent UNATTRIBUTED writes, which the door
@@ -0,0 +1,285 @@
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("adopts hz.js's `hz_id` when there is no canonical id to find", async () => {
117
+ // The no-build tag minted into a key of its own, so a browser that met hz.js
118
+ // first already carries an identity — under a different name. Minting here
119
+ // would make that visitor a stranger the moment they reach a bundled surface,
120
+ // which is precisely the split this migration closes.
121
+ const b = browser({ storage: { hz_id: LEGACY } })
122
+ try {
123
+ const { anonId } = await load()
124
+ expect(anonId()).toBe(LEGACY)
125
+ expect(b.jar.get(ANON)).toBe(LEGACY)
126
+ expect(b.store!.get(ANON)).toBe(LEGACY) // and converged onto the one key
127
+ } finally {
128
+ b.restore()
129
+ }
130
+ })
131
+
132
+ it('prefers the canonical id when a divergent hz_id is also present', async () => {
133
+ // Both clients ran on this origin before the merge and counted the visitor
134
+ // twice. One of the two has to win, and it is the key everything else uses.
135
+ const b = browser({ storage: { [ANON]: LEGACY, hz_id: OTHER } })
136
+ try {
137
+ const { anonId } = await load()
138
+ expect(anonId()).toBe(LEGACY)
139
+ } finally {
140
+ b.restore()
141
+ }
142
+ })
143
+
144
+ it('lets the cookie win over a divergent origin-local id', async () => {
145
+ // docs already minted its own before the migration; the shared cookie is now
146
+ // the source of truth and localStorage converges onto it.
147
+ const b = browser({ jar: new Map([[ANON, OTHER]]), storage: { [ANON]: LEGACY } })
148
+ try {
149
+ const { anonId } = await load()
150
+ expect(anonId()).toBe(OTHER)
151
+ expect(b.store!.get(ANON)).toBe(OTHER)
152
+ } finally {
153
+ b.restore()
154
+ }
155
+ })
156
+
157
+ it('mints a v7 id when neither store holds one, and writes both', async () => {
158
+ const b = browser()
159
+ try {
160
+ const { anonId } = await load()
161
+ const id = anonId()!
162
+ expect(isUuidV7(id)).toBe(true)
163
+ expect(b.jar.get(ANON)).toBe(id)
164
+ expect(b.store!.get(ANON)).toBe(id)
165
+ expect(anonId()).toBe(id) // stable across calls
166
+ } finally {
167
+ b.restore()
168
+ }
169
+ })
170
+
171
+ it('is the SAME id on a second *.hanzo.ai surface sharing the jar', async () => {
172
+ // The goal: one visitor is one person from marketing through checkout.
173
+ const shared = new Map<string, string>()
174
+ const docs = browser({ href: 'https://docs.hanzo.ai/guide', jar: shared })
175
+ let first: string
176
+ try {
177
+ first = (await load()).anonId()!
178
+ } finally {
179
+ docs.restore()
180
+ }
181
+ // A different origin: its own empty localStorage, the same cookie jar.
182
+ const cloud = browser({ href: 'https://cloud.hanzo.ai/', jar: shared })
183
+ try {
184
+ expect((await load()).anonId()).toBe(first)
185
+ } finally {
186
+ cloud.restore()
187
+ }
188
+ })
189
+
190
+ it('returns undefined during SSR rather than minting a server-side id', async () => {
191
+ const had = { window: 'window' in g, document: 'document' in g }
192
+ const prev = { window: g.window, document: g.document }
193
+ delete g.window
194
+ delete g.document
195
+ try {
196
+ const { anonId } = await load()
197
+ expect(anonId()).toBeUndefined()
198
+ } finally {
199
+ if (had.window) g.window = prev.window
200
+ if (had.document) g.document = prev.document
201
+ }
202
+ })
203
+
204
+ it('scopes the cookie to the registrable domain, secure and long-lived', async () => {
205
+ const b = browser({ href: 'https://cloud.hanzo.ai/billing' })
206
+ try {
207
+ const { anonId } = await load()
208
+ anonId()
209
+ const w = b.writes[0]
210
+ expect(w).toContain('Domain=hanzo.ai')
211
+ expect(w).toContain('Path=/')
212
+ expect(w).toContain('SameSite=Lax')
213
+ expect(w).toContain('Secure')
214
+ expect(w).toContain(`Max-Age=${2 * 365 * 24 * 60 * 60}`)
215
+ } finally {
216
+ b.restore()
217
+ }
218
+ })
219
+
220
+ it('omits Domain and Secure off hanzo.ai, where both would drop the cookie', async () => {
221
+ const b = browser({ href: 'http://localhost:3000/' })
222
+ try {
223
+ const { anonId } = await load()
224
+ const id = anonId()!
225
+ expect(b.writes[0]).not.toContain('Domain=')
226
+ expect(b.writes[0]).not.toContain('Secure')
227
+ expect(b.jar.get(ANON)).toBe(id) // still durable, just host-only
228
+ } finally {
229
+ b.restore()
230
+ }
231
+ })
232
+
233
+ it('falls back to localStorage when cookies are refused', async () => {
234
+ const b = browser({ refuseCookies: true, storage: { [ANON]: LEGACY } })
235
+ try {
236
+ const { anonId } = await load()
237
+ expect(anonId()).toBe(LEGACY)
238
+ expect(anonId()).toBe(LEGACY)
239
+ expect(b.jar.size).toBe(0)
240
+ } finally {
241
+ b.restore()
242
+ }
243
+ })
244
+
245
+ it('holds one id in memory when both cookies and localStorage are refused', async () => {
246
+ const b = browser({ refuseCookies: true, noStorage: true })
247
+ try {
248
+ const { anonId } = await load()
249
+ const id = anonId()!
250
+ expect(isUuidV7(id)).toBe(true)
251
+ expect(anonId()).toBe(id) // one identity per page load, not one per event
252
+ } finally {
253
+ b.restore()
254
+ }
255
+ })
256
+
257
+ it('survives a browser with no document at all', async () => {
258
+ const b = browser({ noDocument: true, storage: { [ANON]: LEGACY } })
259
+ try {
260
+ const { anonId } = await load()
261
+ expect(anonId()).toBe(LEGACY)
262
+ } finally {
263
+ b.restore()
264
+ }
265
+ })
266
+ })
267
+
268
+ describe('sessionId', () => {
269
+ // Deliberately unchanged by the anon migration: a session is origin-local and
270
+ // rotates on a 30-minute idle window.
271
+ it('stays in localStorage and rotates after the idle window', async () => {
272
+ const b = browser()
273
+ try {
274
+ const { sessionId } = await load()
275
+ const t0 = Date.now()
276
+ const a = sessionId(t0)
277
+ // The window runs from the LAST call, not from the session's start.
278
+ expect(sessionId(t0 + 60_000)).toBe(a)
279
+ expect(sessionId(t0 + 60_000 + 31 * 60_000)).not.toBe(a)
280
+ expect(b.jar.has('hz_session')).toBe(false)
281
+ } finally {
282
+ b.restore()
283
+ }
284
+ })
285
+ })
package/src/storage.ts CHANGED
@@ -1,12 +1,16 @@
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, and it is also
6
+ // the one value the OTHER two distributions must agree with, so its chain lives in
7
+ // ./anon.js and this file only calls it. Everything else here stays origin-local.
4
8
 
9
+ import { hzAnonId } from './anon.js'
5
10
  import type { Attribution, Cohort } from './types'
6
11
  import { uuidv7 } from './uid'
7
12
 
8
13
  const KEY = {
9
- anon: 'hz_anon_id',
10
14
  session: 'hz_session',
11
15
  firstTouch: 'hz_first_touch',
12
16
  cohort: 'hz_cohort',
@@ -24,16 +28,20 @@ function ls(): Storage | undefined {
24
28
  }
25
29
  }
26
30
 
27
- /** Stable anonymous id, minted once per browser and reused across sessions. */
31
+ /**
32
+ * Stable anonymous id, shared by every *.hanzo.ai surface AND by every Hanzo
33
+ * client on the page — the npm client, hz.js and the hosted tag all run the one
34
+ * chain in ./anon.js, so which snippet a surface loaded no longer decides who the
35
+ * visitor is.
36
+ *
37
+ * It lives in a first-party cookie on the registrable domain because localStorage
38
+ * is ORIGIN-scoped: docs, cloud and console each minted their own id for the same
39
+ * person, so one marketing → docs → signup → checkout journey arrived as several
40
+ * strangers — 463 anonymous identities carried 545 events in a week, about 1.2
41
+ * events each.
42
+ */
28
43
  export function anonId(): string | undefined {
29
- const s = ls()
30
- if (!s) return undefined
31
- let v = s.getItem(KEY.anon)
32
- if (!v) {
33
- v = uuidv7()
34
- s.setItem(KEY.anon, v)
35
- }
36
- return v
44
+ return hzAnonId() || undefined // '' during SSR / prerender
37
45
  }
38
46
 
39
47
  interface SessionState {
package/src/uid.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  // The ONE id minter for this client — UUIDv7 (RFC 9562 §5.7).
2
2
  //
3
+ // The implementation is `hzUuidv7` in ./anon.js and this file only re-exports it.
4
+ // It lives there because the anonymous-id chain has to mint too, and that chain is
5
+ // inlined verbatim by two distributions that have no bundler (hz.js, and the tag
6
+ // the door hosts) — a minter here as well would be a second implementation, and
7
+ // the version nibble it produces is exactly the thing that must never diverge.
8
+ //
3
9
  // WHY NOT crypto.randomUUID(): it mints v4, whose 122 bits are pure entropy and
4
10
  // carry no time. The session rollups on the event plane derive a session's start
5
11
  // instant FROM THE ID — their PARTITION BY and ORDER BY are
@@ -21,22 +27,8 @@
21
27
  // Never returns a non-UUID shape. The old minters fell back to `'a-' + base36`
22
28
  // when crypto was absent, and the plane casts a session id with
23
29
  // accurateCastOrNull(…, 'UUID') — a shape that does not parse becomes NULL and is
24
- // dropped by the same gate, so the fallback failed exactly like v4 did. Here only
25
- // the ENTROPY degrades without crypto; the shape is always a valid v7 UUID.
26
-
27
- /** 00..ff, so formatting is a lookup rather than 16 padStart calls. */
28
- const HEX: string[] = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).slice(1))
29
-
30
- /** Cryptographic randomness when the host has it, Math.random when it does not. */
31
- function fill(b: Uint8Array): Uint8Array {
32
- const c = typeof crypto !== 'undefined' ? crypto : undefined
33
- if (c && typeof c.getRandomValues === 'function') {
34
- c.getRandomValues(b)
35
- return b
36
- }
37
- for (let i = 0; i < b.length; i++) b[i] = (Math.random() * 256) | 0
38
- return b
39
- }
30
+ // dropped by the same gate, so the fallback failed exactly like v4 did. Only the
31
+ // ENTROPY degrades without crypto; the shape is always a valid v7 UUID.
40
32
 
41
33
  /**
42
34
  * uuidv7 mints a time-ordered UUIDv7 for `now` (epoch milliseconds).
@@ -44,25 +36,7 @@ function fill(b: Uint8Array): Uint8Array {
44
36
  * Two ids minted in the same millisecond sort arbitrarily between themselves; ids
45
37
  * from different milliseconds sort by time, lexically and numerically alike.
46
38
  */
47
- export function uuidv7(now: number = Date.now()): string {
48
- const b = fill(new Uint8Array(16))
49
- // 48-bit big-endian timestamp. Milliseconds stay exact well past year 10000, so
50
- // the arithmetic never leaves the safe-integer range.
51
- let t = Math.floor(now)
52
- for (let i = 5; i >= 0; i--) {
53
- b[i] = t % 256
54
- t = Math.floor(t / 256)
55
- }
56
- b[6] = 0x70 | (b[6] & 0x0f) // version 7
57
- b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
58
- return (
59
- HEX[b[0]] + HEX[b[1]] + HEX[b[2]] + HEX[b[3]] + '-' +
60
- HEX[b[4]] + HEX[b[5]] + '-' +
61
- HEX[b[6]] + HEX[b[7]] + '-' +
62
- HEX[b[8]] + HEX[b[9]] + '-' +
63
- HEX[b[10]] + HEX[b[11]] + HEX[b[12]] + HEX[b[13]] + HEX[b[14]] + HEX[b[15]]
64
- )
65
- }
39
+ export { hzUuidv7 as uuidv7 } from './anon.js'
66
40
 
67
41
  /** The millisecond timestamp a v7 id was minted at — the inverse of uuidv7. */
68
42
  export function uuidv7Time(id: string): number {
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.13'
4
+ export const VERSION = '0.3.15'