@hanzo/event 0.3.10 → 0.3.12
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 +15 -0
- package/dist/{core-BCEdNp8H.d.cts → core-BZraSvcr.d.cts} +6 -3
- package/dist/{core-BCEdNp8H.d.ts → core-BZraSvcr.d.ts} +6 -3
- package/dist/index.cjs +18 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.mjs +18 -10
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +18 -10
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +18 -10
- package/dist/react.mjs.map +1 -1
- package/hz.js +45 -4
- package/package.json +1 -1
- package/src/core.test.ts +33 -4
- package/src/core.ts +29 -11
- package/src/hz.test.ts +166 -36
- package/src/types.ts +6 -3
- package/src/version.ts +1 -1
package/src/core.test.ts
CHANGED
|
@@ -360,26 +360,26 @@ describe('Analytics capture', () => {
|
|
|
360
360
|
// The failure this closes is silent: a surface with no key attributes nothing
|
|
361
361
|
// for a logged-out visitor, the door refuses the write, and the page shows no
|
|
362
362
|
// sign of it. The key must resolve from the env exactly as the DSN does.
|
|
363
|
-
process.env.
|
|
363
|
+
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-from-env'
|
|
364
364
|
try {
|
|
365
365
|
const a = mk() // no key in config
|
|
366
366
|
a.capture('x')
|
|
367
367
|
a.flush(true)
|
|
368
368
|
expect(tx.sent[0].ingestKey).toBe('pk-live-from-env')
|
|
369
369
|
} finally {
|
|
370
|
-
delete process.env.
|
|
370
|
+
delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
|
|
371
371
|
}
|
|
372
372
|
})
|
|
373
373
|
|
|
374
374
|
it('prefers an explicit ingest key over the build env', () => {
|
|
375
|
-
process.env.
|
|
375
|
+
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-from-env'
|
|
376
376
|
try {
|
|
377
377
|
const a = mk({ ingestKey: 'pk-live-explicit' })
|
|
378
378
|
a.capture('x')
|
|
379
379
|
a.flush(true)
|
|
380
380
|
expect(tx.sent[0].ingestKey).toBe('pk-live-explicit')
|
|
381
381
|
} finally {
|
|
382
|
-
delete process.env.
|
|
382
|
+
delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
|
|
383
383
|
}
|
|
384
384
|
})
|
|
385
385
|
|
|
@@ -390,6 +390,35 @@ describe('Analytics capture', () => {
|
|
|
390
390
|
expect(tx.sent[0].ingestKey).toBeUndefined()
|
|
391
391
|
})
|
|
392
392
|
|
|
393
|
+
it('a signed-in bearer WINS over a key from the build env', () => {
|
|
394
|
+
// The leak this closes: one console bundle is served to several brands, and a
|
|
395
|
+
// pk- names ONE org. If an env-sourced key displaced the bearer, every
|
|
396
|
+
// signed-in user's events would re-file under whichever org minted the key.
|
|
397
|
+
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-one-org'
|
|
398
|
+
try {
|
|
399
|
+
const a = mk({ getToken: () => 'jwt-of-a-real-person' })
|
|
400
|
+
a.capture('x')
|
|
401
|
+
a.flush()
|
|
402
|
+
expect(tx.sent[0].token).toBe('jwt-of-a-real-person')
|
|
403
|
+
expect(tx.sent[0].ingestKey).toBeUndefined()
|
|
404
|
+
} finally {
|
|
405
|
+
delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('an anonymous visitor still rides the key', () => {
|
|
410
|
+
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-one-org'
|
|
411
|
+
try {
|
|
412
|
+
const a = mk({ getToken: () => undefined }) // logged out
|
|
413
|
+
a.capture('x')
|
|
414
|
+
a.flush()
|
|
415
|
+
expect(tx.sent[0].ingestKey).toBe('pk-live-one-org')
|
|
416
|
+
expect(tx.sent[0].token).toBeUndefined()
|
|
417
|
+
} finally {
|
|
418
|
+
delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
|
|
393
422
|
it('setCohort rides subsequent events', () => {
|
|
394
423
|
const a = mk()
|
|
395
424
|
a.setCohort({ signupWeek: '2026-W29', channel: 'paid', refCode: 'REF9' })
|
package/src/core.ts
CHANGED
|
@@ -232,14 +232,21 @@ export class Analytics {
|
|
|
232
232
|
captureErrors: true,
|
|
233
233
|
...config,
|
|
234
234
|
// The publishable key resolves the SAME way the DSN below does: an explicit
|
|
235
|
-
// config wins, else the inlined build-time env.
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
235
|
+
// config wins, else the inlined build-time env.
|
|
236
|
+
//
|
|
237
|
+
// NEXT_PUBLIC_EVENT_INGEST_KEY is that env, and it is the name the fleet
|
|
238
|
+
// ALREADY carries end to end — KMS holds deploy/EVENT_INGEST_KEY, each
|
|
239
|
+
// Dockerfile takes it as the EVENT_INGEST_KEY build-arg and re-exports it
|
|
240
|
+
// with the NEXT_PUBLIC_ prefix Next needs to inline it. Reading anything
|
|
241
|
+
// else here would add a fourth spelling of one value.
|
|
242
|
+
//
|
|
243
|
+
// Without this the key was the one piece of wiring a surface could not
|
|
244
|
+
// declare the way it declares every other piece, so a surface that shipped
|
|
245
|
+
// without passing it in code sent its beacons unattributed — and an
|
|
246
|
+
// unattributed write is refused (401 ingest_key_required), which is silent
|
|
247
|
+
// in the page and invisible until you read the warehouse and find the host
|
|
248
|
+
// missing entirely.
|
|
249
|
+
ingestKey: config.ingestKey ?? readEnv('NEXT_PUBLIC_EVENT_INGEST_KEY'),
|
|
243
250
|
}
|
|
244
251
|
this.transport = config.transport ?? new DefaultTransport()
|
|
245
252
|
// Error plane, most specific source first: an explicit DSN wins, then the
|
|
@@ -408,9 +415,20 @@ export class Analytics {
|
|
|
408
415
|
this.queue = []
|
|
409
416
|
this.clearTimer()
|
|
410
417
|
|
|
411
|
-
|
|
412
|
-
//
|
|
413
|
-
|
|
418
|
+
// A publishable key and a bearer JWT are mutually exclusive doors, and the
|
|
419
|
+
// BEARER WINS. It names a real principal and resolves to THAT person's org;
|
|
420
|
+
// a pk- names one org for everybody holding it. So the key is what attributes
|
|
421
|
+
// a visitor nobody has vouched for, and it must never displace someone who
|
|
422
|
+
// has been.
|
|
423
|
+
//
|
|
424
|
+
// The precedence used to run the other way, which was survivable only while
|
|
425
|
+
// the key had to be passed in code. Once it also resolves from the build env,
|
|
426
|
+
// key-wins means setting one variable silently blanks every signed-in user's
|
|
427
|
+
// token and re-files their events under whichever org minted the key — on a
|
|
428
|
+
// console served to several brands from one bundle, that is a cross-tenant
|
|
429
|
+
// leak introduced by an env var.
|
|
430
|
+
const token = this.cfg.getToken?.() ?? undefined
|
|
431
|
+
const key = token ? undefined : this.cfg.ingestKey?.trim() || undefined
|
|
414
432
|
// Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
|
|
415
433
|
// Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.
|
|
416
434
|
const useBeacon = beacon && !token
|
package/src/hz.test.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// hz.js is the no-build distribution — 300 lines of shipped client that no test
|
|
2
2
|
// had ever executed. It restates, by hand, what the bundled client imports, so the
|
|
3
3
|
// two can drift; this runs the real file against a minimal browser stub and reads
|
|
4
|
-
//
|
|
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.
|
|
5
6
|
|
|
6
7
|
import { describe, expect, it, beforeEach } from 'vitest'
|
|
7
8
|
import { readFileSync } from 'node:fs'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
9
10
|
|
|
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 }
|
|
11
15
|
|
|
12
16
|
/** The event plane's session-rollup admission gate, transcribed from its own SQL. */
|
|
13
17
|
const versionNibble = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 76n) & 15n
|
|
@@ -23,39 +27,107 @@ interface WireEvent {
|
|
|
23
27
|
libraryVersion: string
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
/** One recorded transmission: which transport carried it, where, under what headers. */
|
|
31
|
+
interface Post {
|
|
32
|
+
via: 'beacon' | 'fetch'
|
|
33
|
+
url: string
|
|
34
|
+
headers: Record<string, string>
|
|
35
|
+
batch: WireEvent[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface StubOptions {
|
|
39
|
+
/** data-* attributes on the <script> tag. */
|
|
40
|
+
attrs?: Record<string, string>
|
|
41
|
+
/** navigator fields — doNotTrack, globalPrivacyControl, msDoNotTrack. */
|
|
42
|
+
navigator?: Record<string, unknown>
|
|
43
|
+
/** Seed localStorage (e.g. an explicit hz_consent choice). */
|
|
44
|
+
storage?: Record<string, string>
|
|
45
|
+
/** Let navigator.sendBeacon succeed, so the beacon path is the one measured. */
|
|
46
|
+
beacon?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Api = { track(n: string, p?: unknown): void; flush(): void }
|
|
50
|
+
|
|
51
|
+
/** Runs hz.js against a stub browser and returns everything it posted.
|
|
52
|
+
*
|
|
53
|
+
* Globals are DEFINED, not assigned: Node ≥ 21 ships a real `navigator` whose
|
|
54
|
+
* descriptor is an accessor with no setter, so the plain assignment this
|
|
55
|
+
* harness used threw — and every hz.js test failed on a current runtime,
|
|
56
|
+
* leaving the shipped file with no executed coverage again. */
|
|
57
|
+
function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefined } {
|
|
58
|
+
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
|
+
}
|
|
32
66
|
}
|
|
33
|
-
const
|
|
34
|
-
g
|
|
35
|
-
|
|
36
|
-
|
|
67
|
+
const attrs: Record<string, string> = { 'data-product': 'test', ...opts.attrs }
|
|
68
|
+
const g = globalThis as unknown as Record<string, unknown>
|
|
69
|
+
const define = (name: string, value: unknown) =>
|
|
70
|
+
Object.defineProperty(g, name, { value, configurable: true, writable: true })
|
|
71
|
+
|
|
72
|
+
define('location', {
|
|
73
|
+
href: 'https://x.test/p?a=1',
|
|
74
|
+
pathname: '/p',
|
|
75
|
+
search: '?a=1',
|
|
76
|
+
hostname: 'x.test',
|
|
77
|
+
host: 'x.test',
|
|
78
|
+
})
|
|
79
|
+
define('document', {
|
|
80
|
+
currentScript: { getAttribute: (a: string) => attrs[a] ?? null },
|
|
37
81
|
referrer: '',
|
|
38
82
|
addEventListener: () => {},
|
|
39
83
|
documentElement: { scrollHeight: 1000 },
|
|
40
84
|
visibilityState: 'visible',
|
|
41
85
|
createElement: () => ({}),
|
|
42
86
|
head: { appendChild: () => {} },
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
87
|
+
})
|
|
88
|
+
define('navigator', {
|
|
89
|
+
doNotTrack: '0',
|
|
90
|
+
...opts.navigator,
|
|
91
|
+
sendBeacon: opts.beacon
|
|
92
|
+
? (url: string, blob: { body: string }) => {
|
|
93
|
+
posts.push({ via: 'beacon', url, headers: {}, batch: JSON.parse(blob.body).batch })
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
: undefined,
|
|
97
|
+
})
|
|
98
|
+
define(
|
|
99
|
+
'Blob',
|
|
100
|
+
class {
|
|
101
|
+
body: string
|
|
102
|
+
constructor(parts: string[]) {
|
|
103
|
+
this.body = parts.join('')
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
define('localStorage', store(opts.storage))
|
|
108
|
+
define('sessionStorage', store())
|
|
109
|
+
define('history', { pushState: () => {}, replaceState: () => {} })
|
|
110
|
+
define('addEventListener', () => {})
|
|
111
|
+
define('PerformanceObserver', undefined)
|
|
112
|
+
define('hzDNT', undefined)
|
|
113
|
+
define('doNotTrack', undefined)
|
|
114
|
+
// The stub `window` IS globalThis, so a public API installed by an earlier run
|
|
115
|
+
// would still be there — and "it refused" would be indistinguishable from
|
|
116
|
+
// "the previous run's API answered".
|
|
117
|
+
define('hanzo', undefined)
|
|
118
|
+
define('fetch', (url: string, init: { body: string; headers: Record<string, string> }) => {
|
|
119
|
+
posts.push({ via: 'fetch', url, headers: init.headers, batch: JSON.parse(init.body).batch })
|
|
52
120
|
return Promise.resolve()
|
|
53
|
-
}
|
|
54
|
-
|
|
121
|
+
})
|
|
122
|
+
define('window', g)
|
|
123
|
+
|
|
55
124
|
new Function(SRC)()
|
|
56
|
-
return {
|
|
125
|
+
return { posts, api: (g.window as { hanzo?: Api }).hanzo }
|
|
57
126
|
}
|
|
58
127
|
|
|
128
|
+
/** Every event across every transmission, in order. */
|
|
129
|
+
const sentOf = (r: { posts: Post[] }): WireEvent[] => r.posts.flatMap((p) => p.batch)
|
|
130
|
+
|
|
59
131
|
describe('hz.js', () => {
|
|
60
132
|
let run: ReturnType<typeof runSnippet>
|
|
61
133
|
beforeEach(() => {
|
|
@@ -63,11 +135,12 @@ describe('hz.js', () => {
|
|
|
63
135
|
})
|
|
64
136
|
|
|
65
137
|
it('mints session ids the plane admits', () => {
|
|
66
|
-
run.api
|
|
67
|
-
run.api
|
|
68
|
-
|
|
138
|
+
run.api!.track('checkout_started')
|
|
139
|
+
run.api!.flush()
|
|
140
|
+
const sent = sentOf(run)
|
|
141
|
+
expect(sent.length).toBeGreaterThan(0)
|
|
69
142
|
const before = Date.now()
|
|
70
|
-
for (const ev of
|
|
143
|
+
for (const ev of sent) {
|
|
71
144
|
expect(versionNibble(ev.sessionId)).toBe(7n)
|
|
72
145
|
expect(versionNibble(ev.messageId)).toBe(7n)
|
|
73
146
|
expect(versionNibble(ev.anonymousId)).toBe(7n)
|
|
@@ -78,19 +151,76 @@ describe('hz.js', () => {
|
|
|
78
151
|
})
|
|
79
152
|
|
|
80
153
|
it('holds one session id across every event it emits', () => {
|
|
81
|
-
run.api
|
|
82
|
-
run.api
|
|
83
|
-
run.api
|
|
84
|
-
const
|
|
85
|
-
expect(
|
|
86
|
-
expect(new Set(
|
|
154
|
+
run.api!.track('a')
|
|
155
|
+
run.api!.track('b')
|
|
156
|
+
run.api!.flush()
|
|
157
|
+
const sent = sentOf(run)
|
|
158
|
+
expect(new Set(sent.map((e) => e.sessionId)).size).toBe(1)
|
|
159
|
+
expect(new Set(sent.map((e) => e.messageId)).size).toBe(sent.length)
|
|
87
160
|
})
|
|
88
161
|
|
|
89
162
|
it('emits the auto pageview on load and stamps the library', () => {
|
|
90
|
-
run.api
|
|
91
|
-
const pv = run.
|
|
163
|
+
run.api!.flush()
|
|
164
|
+
const pv = sentOf(run).find((e) => e.type === 'pageview')
|
|
92
165
|
expect(pv).toBeDefined()
|
|
93
166
|
expect(pv!.library).toBe('hz.js')
|
|
94
|
-
|
|
167
|
+
// The stamped version IS the package version. It had drifted to 0.3.9 against
|
|
168
|
+
// a published 0.3.11, so every static-site row in the warehouse was dated to a
|
|
169
|
+
// release three patches old — including the ones that changed what it sends.
|
|
170
|
+
expect(pv!.libraryVersion).toBe(PKG.version)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ── the publishable key ───────────────────────────────────────────────────
|
|
174
|
+
// Through 0.3.11 this file could present none at all: no header, no query. A
|
|
175
|
+
// keyed static surface therefore sent UNATTRIBUTED writes, which the door
|
|
176
|
+
// refuses — silently, because nothing here reads the response.
|
|
177
|
+
|
|
178
|
+
it('presents the ingest key as a bearer on fetch', () => {
|
|
179
|
+
const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' } })
|
|
180
|
+
r.api!.flush()
|
|
181
|
+
const post = r.posts.at(-1)!
|
|
182
|
+
expect(post.via).toBe('fetch')
|
|
183
|
+
expect(post.headers.authorization).toBe('Bearer pk-abc123')
|
|
184
|
+
expect(post.url).toBe('https://api.hanzo.ai/v1/event')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('presents the ingest key in the query on a headerless beacon', () => {
|
|
188
|
+
const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' }, beacon: true })
|
|
189
|
+
r.api!.flush()
|
|
190
|
+
const post = r.posts.at(-1)!
|
|
191
|
+
expect(post.via).toBe('beacon')
|
|
192
|
+
expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('sends no credential when no key is declared', () => {
|
|
196
|
+
run.api!.flush()
|
|
197
|
+
const post = run.posts.at(-1)!
|
|
198
|
+
expect(post.headers.authorization).toBeUndefined()
|
|
199
|
+
expect(post.url).not.toContain('ingest_key')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// ── consent ───────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
it('refuses under Global Privacy Control', () => {
|
|
205
|
+
const r = runSnippet({ navigator: { globalPrivacyControl: true } })
|
|
206
|
+
expect(r.api).toBeUndefined()
|
|
207
|
+
expect(r.posts).toEqual([])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('refuses under Do Not Track, in each of its spellings', () => {
|
|
211
|
+
for (const nav of [{ doNotTrack: '1' }, { doNotTrack: 'yes' }, { msDoNotTrack: '1' }]) {
|
|
212
|
+
expect(runSnippet({ navigator: nav }).api).toBeUndefined()
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('refuses on a stored denial', () => {
|
|
217
|
+
expect(runSnippet({ storage: { hz_consent: 'denied' } }).api).toBeUndefined()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('an explicit grant outranks the browser signal', () => {
|
|
221
|
+
const r = runSnippet({ navigator: { doNotTrack: '1' }, storage: { hz_consent: 'granted' } })
|
|
222
|
+
expect(r.api).toBeDefined()
|
|
223
|
+
r.api!.flush()
|
|
224
|
+
expect(sentOf(r).length).toBeGreaterThan(0)
|
|
95
225
|
})
|
|
96
226
|
})
|
package/src/types.ts
CHANGED
|
@@ -120,9 +120,12 @@ export interface AnalyticsConfig {
|
|
|
120
120
|
* a reading principal — so it is safe to ship in a bundle. Mint one per org with
|
|
121
121
|
* POST /v1/keys {"type":"publishable"}.
|
|
122
122
|
*
|
|
123
|
-
* Omit it and the client reads
|
|
124
|
-
*
|
|
125
|
-
*
|
|
123
|
+
* Omit it and the client reads NEXT_PUBLIC_EVENT_INGEST_KEY from the inlined
|
|
124
|
+
* build env, the same way `dsn` falls back — so a surface declares BOTH planes
|
|
125
|
+
* in its build and neither needs code to switch on. That is the ONE spelling
|
|
126
|
+
* the fleet already carries: KMS holds deploy/EVENT_INGEST_KEY, and each
|
|
127
|
+
* Dockerfile takes EVENT_INGEST_KEY as a build-arg and re-exports it with the
|
|
128
|
+
* NEXT_PUBLIC_ prefix that makes Next inline it.
|
|
126
129
|
*
|
|
127
130
|
* A surface with no key at all still reports for whoever is SIGNED IN (the
|
|
128
131
|
* session credential attributes them), and drops every logged-out visitor: the
|
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.12'
|