@obelism/numbers-sdk 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +101 -2
- package/dist/index.d.mts +135 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +278 -0
- package/dist/index.mjs +274 -0
- package/dist/script.global.js +1 -0
- package/package.json +61 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Obelism
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Obelism numbers SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Page views, events and clicks on tagged elements, sent to
|
|
4
|
+
[Obelism numbers](https://numbers.obelism.studio): the events every Obelism
|
|
5
|
+
product shares, and their dashboard. The improve SDK sends its events
|
|
6
|
+
through this client, so a site with improve already has it; this package is
|
|
7
|
+
for numbers on its own.
|
|
8
|
+
|
|
9
|
+
## Script tag
|
|
10
|
+
|
|
11
|
+
No bundler needed:
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<script
|
|
15
|
+
defer
|
|
16
|
+
src="https://numbers.obelism.studio/script.js"
|
|
17
|
+
data-organization="org_…"
|
|
18
|
+
data-environment="production"
|
|
19
|
+
></script>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
It sends a page view on load and on every client-side navigation, a `click`
|
|
23
|
+
for clicks on (or inside) an element with `data-numbers-id="…"`, and puts the
|
|
24
|
+
client on `window.numbers`:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
numbers.track('sign_up', 'footer_form')
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`data-endpoint="https://…"` sends somewhere else, e.g. a proxy on your own
|
|
31
|
+
domain. npm CDNs serve the same file from
|
|
32
|
+
`@obelism/numbers-sdk/dist/script.global.js`.
|
|
33
|
+
|
|
34
|
+
## Module
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm i @obelism/numbers-sdk
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createNumbers } from '@obelism/numbers-sdk'
|
|
42
|
+
|
|
43
|
+
const numbers = createNumbers({
|
|
44
|
+
organizationId: 'org_…',
|
|
45
|
+
environment: 'production', // default; or 'develop', 'staging'
|
|
46
|
+
// endpoint: 'https://numbers.obelism.studio/api/log',
|
|
47
|
+
// disableWarnings: true,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Page views now and on every navigation, and clicks on tagged elements.
|
|
51
|
+
const stop = numbers.autoTrack() // { pageViews?: boolean, clicks?: boolean }
|
|
52
|
+
|
|
53
|
+
numbers.track('add_to_cart', 'product_page', {
|
|
54
|
+
value: 59.95,
|
|
55
|
+
currency: 'EUR',
|
|
56
|
+
params: { ecommerce: { items: [{ item_id: 'sku_1' }] } },
|
|
57
|
+
})
|
|
58
|
+
numbers.track('view_item', 'list', { dedupeKey: 'sku_1' }) // once per key
|
|
59
|
+
numbers.pageView() // by hand, without autoTrack
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The client is safe to create during server rendering: outside the browser
|
|
63
|
+
every call does nothing and returns `null`.
|
|
64
|
+
|
|
65
|
+
### Events
|
|
66
|
+
|
|
67
|
+
An event is a name and a scope. Use snake_case names, the GA4 recommended
|
|
68
|
+
ones where they fit (`sign_up`, `add_to_cart`, `purchase`): the client warns
|
|
69
|
+
once about any other. The scope is the stable second half of its identity,
|
|
70
|
+
where or what it is about (`homepage_hero_signup`); it defaults to the page's
|
|
71
|
+
path. Every event carries the page's path and UTM tags, the first page view
|
|
72
|
+
also the referrer when it is another site. Query strings never leave the
|
|
73
|
+
browser.
|
|
74
|
+
|
|
75
|
+
`value` and `currency` are summed into revenue per variant in improve.
|
|
76
|
+
`params` are stored as JSON. `track` returns `null` when it sent nothing,
|
|
77
|
+
otherwise a promise of the response (`undefined` when the request failed; it
|
|
78
|
+
never rejects).
|
|
79
|
+
|
|
80
|
+
### Tagged elements
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<section data-numbers-id="hero_signup">…</section>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A click on or inside it is a `click` event with scope `hero_signup`. numbers
|
|
87
|
+
shows the most clicked elements; Content tags its blocks with their id, so
|
|
88
|
+
its visual editor shows each block's click rate.
|
|
89
|
+
|
|
90
|
+
### Visitors
|
|
91
|
+
|
|
92
|
+
A visitor is a random id in the `visitorId` cookie (`visi_` and 26
|
|
93
|
+
characters, 30 days from the last visit), the same cookie the improve SDK
|
|
94
|
+
uses. `numbers.visitor()` returns `{ id, recurring }`. The device categories
|
|
95
|
+
(pointer, device, browser, os, screen size) come from the user agent; pass
|
|
96
|
+
`device` to `createNumbers` (or call `setDevice`) to use your own.
|
|
97
|
+
|
|
98
|
+
### Limits
|
|
99
|
+
|
|
100
|
+
Strings are capped to what numbers accepts (256 characters, currency 8),
|
|
101
|
+
and the client warns about a request over 8 KB, which numbers refuses. After
|
|
102
|
+
a `429` it stops sending for the `Retry-After`, or a minute.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
type Environment = 'develop' | 'staging' | 'production';
|
|
2
|
+
/**
|
|
3
|
+
* The visitor's device categories, as improve's audiences know them. The
|
|
4
|
+
* client works them out from the user agent; an SDK that parses it more
|
|
5
|
+
* thoroughly (improve's) passes its own.
|
|
6
|
+
*/
|
|
7
|
+
type Device = {
|
|
8
|
+
pointer: 'coarse' | 'fine';
|
|
9
|
+
device: string;
|
|
10
|
+
browser: string;
|
|
11
|
+
os: string;
|
|
12
|
+
};
|
|
13
|
+
type NumbersOptions = {
|
|
14
|
+
organizationId: string;
|
|
15
|
+
/** Default `production`. */
|
|
16
|
+
environment?: Environment;
|
|
17
|
+
/**
|
|
18
|
+
* Where events go. Default `https://numbers.obelism.studio/api/log`; point
|
|
19
|
+
* it at a proxy on your own domain to get past ad blockers.
|
|
20
|
+
*/
|
|
21
|
+
endpoint?: string;
|
|
22
|
+
/** The device categories, when you parse the user agent yourself. */
|
|
23
|
+
device?: Device;
|
|
24
|
+
/** Silence the development warnings (e.g. a name that isn't snake_case). */
|
|
25
|
+
disableWarnings?: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Event names GA4 / Google Tag Manager recommend. Reusing them lines your
|
|
29
|
+
* numbers up with any GA4 tagging you have.
|
|
30
|
+
*
|
|
31
|
+
* @see https://support.google.com/analytics/answer/9267735
|
|
32
|
+
*/
|
|
33
|
+
type RecommendedEventName = 'page_view' | 'view_item' | 'view_item_list' | 'select_item' | 'add_to_cart' | 'remove_from_cart' | 'view_cart' | 'begin_checkout' | 'add_payment_info' | 'add_shipping_info' | 'purchase' | 'refund' | 'sign_up' | 'login' | 'search' | 'select_content' | 'share' | 'generate_lead';
|
|
34
|
+
/** An event's name: snake_case, any string, the GA4 ones suggested. */
|
|
35
|
+
type EventName = RecommendedEventName | (string & {});
|
|
36
|
+
type TrackOptions = {
|
|
37
|
+
/** A numeric value, e.g. an order total (GA4 `value`). */
|
|
38
|
+
value?: number;
|
|
39
|
+
/** ISO 4217 currency for `value`, e.g. `'EUR'`. */
|
|
40
|
+
currency?: string;
|
|
41
|
+
/** Extra parameters, stored as JSON (e.g. a GA4 `ecommerce` object). */
|
|
42
|
+
params?: Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* Sends the event once per client for this event and key, e.g. an item id
|
|
45
|
+
* for a `view_item` fired every render. Not sent; without it every call
|
|
46
|
+
* sends.
|
|
47
|
+
*/
|
|
48
|
+
dedupeKey?: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* A visitor's assignment to a test's or flag's variant, for SDKs that run
|
|
52
|
+
* them (improve's). numbers attributes the visitor's events to it.
|
|
53
|
+
*/
|
|
54
|
+
type Exposure = {
|
|
55
|
+
subjectKind: 'test' | 'flag';
|
|
56
|
+
subjectId: string;
|
|
57
|
+
variant: string;
|
|
58
|
+
};
|
|
59
|
+
/** Fields every request carries. */
|
|
60
|
+
type BeaconCommon<T extends 'event' | 'exposure'> = {
|
|
61
|
+
type: T;
|
|
62
|
+
organizationId: string;
|
|
63
|
+
environment: Environment;
|
|
64
|
+
visitorId: string;
|
|
65
|
+
pointer: string;
|
|
66
|
+
device: string;
|
|
67
|
+
screen: string;
|
|
68
|
+
browser: string;
|
|
69
|
+
os: string;
|
|
70
|
+
visitor: 'new' | 'recurring';
|
|
71
|
+
};
|
|
72
|
+
/** The page an event happened on. Paths leave without their query string. */
|
|
73
|
+
type PageContext = {
|
|
74
|
+
path?: string;
|
|
75
|
+
referrer?: string;
|
|
76
|
+
utmSource?: string;
|
|
77
|
+
utmMedium?: string;
|
|
78
|
+
utmCampaign?: string;
|
|
79
|
+
};
|
|
80
|
+
type EventBeacon = BeaconCommon<'event'> & PageContext & {
|
|
81
|
+
event: string;
|
|
82
|
+
scope: string;
|
|
83
|
+
value?: number;
|
|
84
|
+
currency?: string;
|
|
85
|
+
params?: Record<string, unknown>;
|
|
86
|
+
};
|
|
87
|
+
type ExposureBeacon = BeaconCommon<'exposure'> & Exposure;
|
|
88
|
+
|
|
89
|
+
type Visitor = {
|
|
90
|
+
id: string;
|
|
91
|
+
/** Whether the visitor had a cookie already: `recurring` on the server. */
|
|
92
|
+
recurring: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type AutoTrackOptions = {
|
|
96
|
+
/** A page view now and on every client-side navigation. Default on. */
|
|
97
|
+
pageViews?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* A `click` event, scoped to the id, for every click on or inside an
|
|
100
|
+
* element with `data-numbers-id="…"`. Default on.
|
|
101
|
+
*/
|
|
102
|
+
clicks?: boolean;
|
|
103
|
+
};
|
|
104
|
+
/** What a send resolves to: the response, or nothing when it failed. */
|
|
105
|
+
type Sent = Promise<Response | undefined>;
|
|
106
|
+
type NumbersClient = {
|
|
107
|
+
readonly organizationId: string;
|
|
108
|
+
readonly environment: Environment;
|
|
109
|
+
/** The visitor, from the cookie or new. `null` outside the browser. */
|
|
110
|
+
visitor: () => Visitor | null;
|
|
111
|
+
/** Replaces the device categories, e.g. from a fuller user-agent parser. */
|
|
112
|
+
setDevice: (device: Device) => void;
|
|
113
|
+
setEndpoint: (endpoint: string) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Sends an event. `scope` is the stable second half of its identity, e.g.
|
|
116
|
+
* `'homepage_hero_signup'` (default: the page's path). `null` when nothing
|
|
117
|
+
* was sent: outside the browser, deduplicated, or paused after a 429.
|
|
118
|
+
*/
|
|
119
|
+
track: (event: EventName, scope?: string, options?: TrackOptions) => Sent | null;
|
|
120
|
+
/** Sends a page view of the current page. */
|
|
121
|
+
pageView: () => Sent | null;
|
|
122
|
+
/** Sends a test's or flag's assignment, once per subject per client. */
|
|
123
|
+
expose: (exposure: Exposure) => Sent | null;
|
|
124
|
+
/** Starts page views and click tracking; returns a function that stops them. */
|
|
125
|
+
autoTrack: (options?: AutoTrackOptions) => () => void;
|
|
126
|
+
};
|
|
127
|
+
declare const createNumbers: ({ organizationId, environment, endpoint, device: givenDevice, disableWarnings, }: NumbersOptions) => NumbersClient;
|
|
128
|
+
|
|
129
|
+
declare const DEFAULT_ENDPOINT = "https://numbers.obelism.studio/api/log";
|
|
130
|
+
|
|
131
|
+
declare const detectDevice: (userAgent: string) => Device;
|
|
132
|
+
|
|
133
|
+
export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
|
|
134
|
+
export type { AutoTrackOptions, BeaconCommon, Device, Environment, EventBeacon, EventName, Exposure, ExposureBeacon, NumbersClient, NumbersOptions, PageContext, RecommendedEventName, Sent, TrackOptions, Visitor };
|
|
135
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","sources":["../src/types.ts","../src/visitor.ts","../src/client.ts","../src/constants.ts","../src/device.ts"],"mappings":"AAAA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA,KAAK,MAAM;AACX;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;AACnB;AACA;AACA,kBAAkB,WAAW;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,oBAAoB;AACzB;AACA,KAAK,SAAS,GAAG,oBAAoB;AACrC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,QAAQ;AACb;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA,iBAAiB,WAAW;AAC5B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW,GAAG,YAAY,YAAY,WAAW;AACtD;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA,KAAK,cAAc,GAAG,YAAY,eAAe,QAAQ;;ACtFzD,KAAK,OAAO;AACZ;AACA;AACA;AACA;;ACFA,KAAK,gBAAgB;AACrB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,IAAI,GAAG,OAAO,CAAC,QAAQ;AAC5B,KAAK,aAAa;AAClB;AACA,0BAA0B,WAAW;AACrC;AACA,mBAAmB,OAAO;AAC1B;AACA,wBAAwB,MAAM;AAC9B;AACA;AACA;AACA;AACA;AACA;AACA,mBAAmB,SAAS,4BAA4B,YAAY,KAAK,IAAI;AAC7E;AACA,oBAAoB,IAAI;AACxB;AACA,uBAAuB,QAAQ,KAAK,IAAI;AACxC;AACA,0BAA0B,gBAAgB;AAC1C;AACA,cAAc,aAAa,qFAAqF,cAAc,KAAK,aAAa;;AClChJ,cAAc,gBAAgB;;ACC9B,cAAc,YAAY,yBAAyB,MAAM;;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
type Environment = 'develop' | 'staging' | 'production';
|
|
2
|
+
/**
|
|
3
|
+
* The visitor's device categories, as improve's audiences know them. The
|
|
4
|
+
* client works them out from the user agent; an SDK that parses it more
|
|
5
|
+
* thoroughly (improve's) passes its own.
|
|
6
|
+
*/
|
|
7
|
+
type Device = {
|
|
8
|
+
pointer: 'coarse' | 'fine';
|
|
9
|
+
device: string;
|
|
10
|
+
browser: string;
|
|
11
|
+
os: string;
|
|
12
|
+
};
|
|
13
|
+
type NumbersOptions = {
|
|
14
|
+
organizationId: string;
|
|
15
|
+
/** Default `production`. */
|
|
16
|
+
environment?: Environment;
|
|
17
|
+
/**
|
|
18
|
+
* Where events go. Default `https://numbers.obelism.studio/api/log`; point
|
|
19
|
+
* it at a proxy on your own domain to get past ad blockers.
|
|
20
|
+
*/
|
|
21
|
+
endpoint?: string;
|
|
22
|
+
/** The device categories, when you parse the user agent yourself. */
|
|
23
|
+
device?: Device;
|
|
24
|
+
/** Silence the development warnings (e.g. a name that isn't snake_case). */
|
|
25
|
+
disableWarnings?: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Event names GA4 / Google Tag Manager recommend. Reusing them lines your
|
|
29
|
+
* numbers up with any GA4 tagging you have.
|
|
30
|
+
*
|
|
31
|
+
* @see https://support.google.com/analytics/answer/9267735
|
|
32
|
+
*/
|
|
33
|
+
type RecommendedEventName = 'page_view' | 'view_item' | 'view_item_list' | 'select_item' | 'add_to_cart' | 'remove_from_cart' | 'view_cart' | 'begin_checkout' | 'add_payment_info' | 'add_shipping_info' | 'purchase' | 'refund' | 'sign_up' | 'login' | 'search' | 'select_content' | 'share' | 'generate_lead';
|
|
34
|
+
/** An event's name: snake_case, any string, the GA4 ones suggested. */
|
|
35
|
+
type EventName = RecommendedEventName | (string & {});
|
|
36
|
+
type TrackOptions = {
|
|
37
|
+
/** A numeric value, e.g. an order total (GA4 `value`). */
|
|
38
|
+
value?: number;
|
|
39
|
+
/** ISO 4217 currency for `value`, e.g. `'EUR'`. */
|
|
40
|
+
currency?: string;
|
|
41
|
+
/** Extra parameters, stored as JSON (e.g. a GA4 `ecommerce` object). */
|
|
42
|
+
params?: Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* Sends the event once per client for this event and key, e.g. an item id
|
|
45
|
+
* for a `view_item` fired every render. Not sent; without it every call
|
|
46
|
+
* sends.
|
|
47
|
+
*/
|
|
48
|
+
dedupeKey?: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* A visitor's assignment to a test's or flag's variant, for SDKs that run
|
|
52
|
+
* them (improve's). numbers attributes the visitor's events to it.
|
|
53
|
+
*/
|
|
54
|
+
type Exposure = {
|
|
55
|
+
subjectKind: 'test' | 'flag';
|
|
56
|
+
subjectId: string;
|
|
57
|
+
variant: string;
|
|
58
|
+
};
|
|
59
|
+
/** Fields every request carries. */
|
|
60
|
+
type BeaconCommon<T extends 'event' | 'exposure'> = {
|
|
61
|
+
type: T;
|
|
62
|
+
organizationId: string;
|
|
63
|
+
environment: Environment;
|
|
64
|
+
visitorId: string;
|
|
65
|
+
pointer: string;
|
|
66
|
+
device: string;
|
|
67
|
+
screen: string;
|
|
68
|
+
browser: string;
|
|
69
|
+
os: string;
|
|
70
|
+
visitor: 'new' | 'recurring';
|
|
71
|
+
};
|
|
72
|
+
/** The page an event happened on. Paths leave without their query string. */
|
|
73
|
+
type PageContext = {
|
|
74
|
+
path?: string;
|
|
75
|
+
referrer?: string;
|
|
76
|
+
utmSource?: string;
|
|
77
|
+
utmMedium?: string;
|
|
78
|
+
utmCampaign?: string;
|
|
79
|
+
};
|
|
80
|
+
type EventBeacon = BeaconCommon<'event'> & PageContext & {
|
|
81
|
+
event: string;
|
|
82
|
+
scope: string;
|
|
83
|
+
value?: number;
|
|
84
|
+
currency?: string;
|
|
85
|
+
params?: Record<string, unknown>;
|
|
86
|
+
};
|
|
87
|
+
type ExposureBeacon = BeaconCommon<'exposure'> & Exposure;
|
|
88
|
+
|
|
89
|
+
type Visitor = {
|
|
90
|
+
id: string;
|
|
91
|
+
/** Whether the visitor had a cookie already: `recurring` on the server. */
|
|
92
|
+
recurring: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type AutoTrackOptions = {
|
|
96
|
+
/** A page view now and on every client-side navigation. Default on. */
|
|
97
|
+
pageViews?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* A `click` event, scoped to the id, for every click on or inside an
|
|
100
|
+
* element with `data-numbers-id="…"`. Default on.
|
|
101
|
+
*/
|
|
102
|
+
clicks?: boolean;
|
|
103
|
+
};
|
|
104
|
+
/** What a send resolves to: the response, or nothing when it failed. */
|
|
105
|
+
type Sent = Promise<Response | undefined>;
|
|
106
|
+
type NumbersClient = {
|
|
107
|
+
readonly organizationId: string;
|
|
108
|
+
readonly environment: Environment;
|
|
109
|
+
/** The visitor, from the cookie or new. `null` outside the browser. */
|
|
110
|
+
visitor: () => Visitor | null;
|
|
111
|
+
/** Replaces the device categories, e.g. from a fuller user-agent parser. */
|
|
112
|
+
setDevice: (device: Device) => void;
|
|
113
|
+
setEndpoint: (endpoint: string) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Sends an event. `scope` is the stable second half of its identity, e.g.
|
|
116
|
+
* `'homepage_hero_signup'` (default: the page's path). `null` when nothing
|
|
117
|
+
* was sent: outside the browser, deduplicated, or paused after a 429.
|
|
118
|
+
*/
|
|
119
|
+
track: (event: EventName, scope?: string, options?: TrackOptions) => Sent | null;
|
|
120
|
+
/** Sends a page view of the current page. */
|
|
121
|
+
pageView: () => Sent | null;
|
|
122
|
+
/** Sends a test's or flag's assignment, once per subject per client. */
|
|
123
|
+
expose: (exposure: Exposure) => Sent | null;
|
|
124
|
+
/** Starts page views and click tracking; returns a function that stops them. */
|
|
125
|
+
autoTrack: (options?: AutoTrackOptions) => () => void;
|
|
126
|
+
};
|
|
127
|
+
declare const createNumbers: ({ organizationId, environment, endpoint, device: givenDevice, disableWarnings, }: NumbersOptions) => NumbersClient;
|
|
128
|
+
|
|
129
|
+
declare const DEFAULT_ENDPOINT = "https://numbers.obelism.studio/api/log";
|
|
130
|
+
|
|
131
|
+
declare const detectDevice: (userAgent: string) => Device;
|
|
132
|
+
|
|
133
|
+
export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
|
|
134
|
+
export type { AutoTrackOptions, BeaconCommon, Device, Environment, EventBeacon, EventName, Exposure, ExposureBeacon, NumbersClient, NumbersOptions, PageContext, RecommendedEventName, Sent, TrackOptions, Visitor };
|
|
135
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sources":["../src/types.ts","../src/visitor.ts","../src/client.ts","../src/constants.ts","../src/device.ts"],"mappings":"AAAA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA,KAAK,MAAM;AACX;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;AACnB;AACA;AACA,kBAAkB,WAAW;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,oBAAoB;AACzB;AACA,KAAK,SAAS,GAAG,oBAAoB;AACrC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,QAAQ;AACb;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA,iBAAiB,WAAW;AAC5B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW,GAAG,YAAY,YAAY,WAAW;AACtD;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA,KAAK,cAAc,GAAG,YAAY,eAAe,QAAQ;;ACtFzD,KAAK,OAAO;AACZ;AACA;AACA;AACA;;ACFA,KAAK,gBAAgB;AACrB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,IAAI,GAAG,OAAO,CAAC,QAAQ;AAC5B,KAAK,aAAa;AAClB;AACA,0BAA0B,WAAW;AACrC;AACA,mBAAmB,OAAO;AAC1B;AACA,wBAAwB,MAAM;AAC9B;AACA;AACA;AACA;AACA;AACA;AACA,mBAAmB,SAAS,4BAA4B,YAAY,KAAK,IAAI;AAC7E;AACA,oBAAoB,IAAI;AACxB;AACA,uBAAuB,QAAQ,KAAK,IAAI;AACxC;AACA,0BAA0B,gBAAgB;AAC1C;AACA,cAAc,aAAa,qFAAqF,cAAc,KAAK,aAAa;;AClChJ,cAAc,gBAAgB;;ACC9B,cAAc,YAAY,yBAAyB,MAAM;;","names":[]}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ENDPOINT = 'https://numbers.obelism.studio/api/log';
|
|
4
|
+
// The visitor cookie and id format, shared with the improve SDK so a visitor
|
|
5
|
+
// is the same one in both: `visi_` and 26 characters.
|
|
6
|
+
const VISITOR_COOKIE = 'visitorId';
|
|
7
|
+
const VISITOR_ID_PREFIX = 'visi';
|
|
8
|
+
const VISITOR_ID_SEPARATOR = '_';
|
|
9
|
+
const VISITOR_ID_LENGTH = 26;
|
|
10
|
+
const VISITOR_COOKIE_DAYS = 30;
|
|
11
|
+
// numbers' limits (`POST /api/log`): a longer string is a 400 and a bigger
|
|
12
|
+
// body a 413, and either loses the event, so the client caps what it can.
|
|
13
|
+
const MAX_FIELD_LENGTH = 256;
|
|
14
|
+
const MAX_CURRENCY_LENGTH = 8;
|
|
15
|
+
const MAX_URL_LENGTH = 1024;
|
|
16
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
17
|
+
// How long to stop sending after a 429 without a `Retry-After`.
|
|
18
|
+
const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
// improve's audience categories from the user agent, the short version
|
|
21
|
+
// (improve's SDK uses ua-parser-js; pass its result as `device` to match it
|
|
22
|
+
// exactly).
|
|
23
|
+
const detectDevice = (userAgent)=>{
|
|
24
|
+
const ua = userAgent.toLowerCase();
|
|
25
|
+
const device = /ipad|tablet|android(?!.*mobile)/.test(ua) ? 'tablet' : /mobi|iphone|ipod|android/.test(ua) ? 'mobile' : 'desktop';
|
|
26
|
+
const browser = /edg\//.test(ua) ? 'edge' : /samsungbrowser/.test(ua) ? 'samsung internet' : /instagram|fban|fbav|tiktok|snapchat|wechat|line\//.test(ua) ? 'social' : /firefox|fxios/.test(ua) ? 'firefox' : /chrome|crios|chromium/.test(ua) ? 'chrome' : /safari/.test(ua) ? 'safari' : 'other';
|
|
27
|
+
const os = /iphone|ipad|ipod/.test(ua) ? 'ios' : /android/.test(ua) ? 'android' : /windows/.test(ua) ? 'windows' : /mac os/.test(ua) ? 'mac os' : 'unix';
|
|
28
|
+
return {
|
|
29
|
+
pointer: device === 'desktop' ? 'fine' : 'coarse',
|
|
30
|
+
device,
|
|
31
|
+
browser,
|
|
32
|
+
os
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
const getScreenSize = ()=>{
|
|
36
|
+
const width = window.innerWidth;
|
|
37
|
+
if (width <= 768) return 'small';
|
|
38
|
+
if (width <= 1024) return 'medium';
|
|
39
|
+
if (width <= 1200) return 'large';
|
|
40
|
+
return 'huge';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const utm = (params, name)=>params.get(name)?.slice(0, MAX_FIELD_LENGTH) || undefined;
|
|
44
|
+
// The page now: its path (without the query string, which numbers drops too)
|
|
45
|
+
// and the UTM tags.
|
|
46
|
+
const getPageContext = ()=>{
|
|
47
|
+
const params = new URLSearchParams(location.search);
|
|
48
|
+
const context = {
|
|
49
|
+
path: location.pathname.slice(0, MAX_URL_LENGTH)
|
|
50
|
+
};
|
|
51
|
+
const utmSource = utm(params, 'utm_source');
|
|
52
|
+
const utmMedium = utm(params, 'utm_medium');
|
|
53
|
+
const utmCampaign = utm(params, 'utm_campaign');
|
|
54
|
+
if (utmSource) context.utmSource = utmSource;
|
|
55
|
+
if (utmMedium) context.utmMedium = utmMedium;
|
|
56
|
+
if (utmCampaign) context.utmCampaign = utmCampaign;
|
|
57
|
+
return context;
|
|
58
|
+
};
|
|
59
|
+
// Where the visit came from: another site's origin and path, never this
|
|
60
|
+
// site's own pages or a query string.
|
|
61
|
+
const getExternalReferrer = ()=>{
|
|
62
|
+
if (!document.referrer) return undefined;
|
|
63
|
+
try {
|
|
64
|
+
const referrer = new URL(document.referrer);
|
|
65
|
+
if (referrer.host === location.host) return undefined;
|
|
66
|
+
return `${referrer.origin}${referrer.pathname}`.slice(0, MAX_URL_LENGTH);
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
73
|
+
const readCookie = (name)=>{
|
|
74
|
+
const row = document.cookie.split('; ').find((entry)=>entry.split('=')[0] === name);
|
|
75
|
+
return row ? row.split('=')[1] ?? '' : '';
|
|
76
|
+
};
|
|
77
|
+
const writeCookie = (name, value)=>{
|
|
78
|
+
const expires = new Date(Date.now() + VISITOR_COOKIE_DAYS * 86_400_000);
|
|
79
|
+
document.cookie = `${name}=${value};path=/;expires=${expires.toUTCString()};SameSite=Lax` + (location.protocol === 'https:' ? ';Secure' : '');
|
|
80
|
+
};
|
|
81
|
+
// The improve SDK's check, so an id it (or its middleware) made stays valid.
|
|
82
|
+
const isVisitorId = (value)=>{
|
|
83
|
+
const parts = value.split(VISITOR_ID_SEPARATOR);
|
|
84
|
+
return parts.length === 2 && parts[0] === VISITOR_ID_PREFIX && parts[1].length === VISITOR_ID_LENGTH;
|
|
85
|
+
};
|
|
86
|
+
const createVisitorId = ()=>{
|
|
87
|
+
const random = new Uint8Array(VISITOR_ID_LENGTH);
|
|
88
|
+
crypto.getRandomValues(random);
|
|
89
|
+
let id = '';
|
|
90
|
+
for (const byte of random)id += CHARACTERS[byte % CHARACTERS.length];
|
|
91
|
+
return `${VISITOR_ID_PREFIX}${VISITOR_ID_SEPARATOR}${id}`;
|
|
92
|
+
};
|
|
93
|
+
// The visitor in the cookie, or a new one; either way the cookie is set
|
|
94
|
+
// again, so it lasts 30 days from the last visit.
|
|
95
|
+
const loadVisitor = ()=>{
|
|
96
|
+
const existing = readCookie(VISITOR_COOKIE);
|
|
97
|
+
const recurring = isVisitorId(existing);
|
|
98
|
+
const id = recurring ? existing : createVisitorId();
|
|
99
|
+
writeCookie(VISITOR_COOKIE, id);
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
recurring
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const SNAKE_CASE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
|
|
107
|
+
const parseRetryAfterMs = (header)=>{
|
|
108
|
+
if (!header) return undefined;
|
|
109
|
+
const seconds = Number(header);
|
|
110
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
111
|
+
const date = Date.parse(header);
|
|
112
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
113
|
+
};
|
|
114
|
+
const isBrowser = ()=>typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
115
|
+
const createNumbers = ({ organizationId, environment = 'production', endpoint = DEFAULT_ENDPOINT, device: givenDevice, disableWarnings = false })=>{
|
|
116
|
+
let visitor = null;
|
|
117
|
+
let device = givenDevice;
|
|
118
|
+
let url = endpoint;
|
|
119
|
+
// Epoch ms until which sending is paused after a 429.
|
|
120
|
+
let pausedUntil = 0;
|
|
121
|
+
let firstPageView = true;
|
|
122
|
+
const sentKeys = new Set();
|
|
123
|
+
const exposed = new Set();
|
|
124
|
+
const warned = new Set();
|
|
125
|
+
const warn = (key, message)=>{
|
|
126
|
+
if (disableWarnings || warned.has(key)) return;
|
|
127
|
+
warned.add(key);
|
|
128
|
+
if (typeof console !== 'undefined') console.warn(`[numbers] ${message}`);
|
|
129
|
+
};
|
|
130
|
+
const getVisitor = ()=>{
|
|
131
|
+
if (!isBrowser()) return null;
|
|
132
|
+
visitor ??= loadVisitor();
|
|
133
|
+
return visitor;
|
|
134
|
+
};
|
|
135
|
+
const common = (type, current)=>{
|
|
136
|
+
device ??= detectDevice(navigator.userAgent);
|
|
137
|
+
return {
|
|
138
|
+
type,
|
|
139
|
+
organizationId,
|
|
140
|
+
environment,
|
|
141
|
+
visitorId: current.id,
|
|
142
|
+
pointer: device.pointer,
|
|
143
|
+
device: device.device,
|
|
144
|
+
screen: getScreenSize(),
|
|
145
|
+
browser: device.browser,
|
|
146
|
+
os: device.os,
|
|
147
|
+
visitor: current.recurring ? 'recurring' : 'new'
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
// A `keepalive` fetch, so it outlives a navigation. Never rejects.
|
|
151
|
+
const send = (body, label)=>{
|
|
152
|
+
const serialized = JSON.stringify(body);
|
|
153
|
+
if (typeof TextEncoder !== 'undefined' && new TextEncoder().encode(serialized).length > MAX_BODY_BYTES) warn(`size:${label}`, `"${label}" is over ${MAX_BODY_BYTES} bytes and will be refused: trim its \`params\`.`);
|
|
154
|
+
return fetch(url, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: {
|
|
157
|
+
'Content-Type': 'application/json'
|
|
158
|
+
},
|
|
159
|
+
body: serialized,
|
|
160
|
+
keepalive: true
|
|
161
|
+
}).then((response)=>{
|
|
162
|
+
// Over the Organization's limit: back off, by Retry-After when given.
|
|
163
|
+
if (response.status === 429) pausedUntil = Date.now() + (parseRetryAfterMs(response.headers.get('Retry-After')) ?? RATE_LIMIT_COOLDOWN_MS);
|
|
164
|
+
return response;
|
|
165
|
+
}, ()=>undefined);
|
|
166
|
+
};
|
|
167
|
+
const paused = ()=>Date.now() < pausedUntil;
|
|
168
|
+
const sendEvent = (event, scope, options, referrer)=>{
|
|
169
|
+
const current = getVisitor();
|
|
170
|
+
if (!current || paused()) return null;
|
|
171
|
+
const { value, currency, params, dedupeKey } = options;
|
|
172
|
+
if (dedupeKey !== undefined) {
|
|
173
|
+
const key = `${event}:${dedupeKey}`;
|
|
174
|
+
if (sentKeys.has(key)) return null;
|
|
175
|
+
sentKeys.add(key);
|
|
176
|
+
}
|
|
177
|
+
const body = {
|
|
178
|
+
...common('event', current),
|
|
179
|
+
...getPageContext(),
|
|
180
|
+
event: event.slice(0, MAX_FIELD_LENGTH),
|
|
181
|
+
scope: scope.slice(0, MAX_FIELD_LENGTH)
|
|
182
|
+
};
|
|
183
|
+
if (referrer) body.referrer = referrer;
|
|
184
|
+
if (typeof value === 'number' && Number.isFinite(value)) body.value = value;
|
|
185
|
+
if (currency) body.currency = currency.slice(0, MAX_CURRENCY_LENGTH);
|
|
186
|
+
// numbers refuses `params` that aren't a plain object, and the event
|
|
187
|
+
// with it: leave them out instead.
|
|
188
|
+
if (params && typeof params === 'object' && !Array.isArray(params)) body.params = params;
|
|
189
|
+
return send(body, event);
|
|
190
|
+
};
|
|
191
|
+
const track = (event, scope, options = {})=>{
|
|
192
|
+
if (!isBrowser()) return null;
|
|
193
|
+
if (!SNAKE_CASE.test(event)) warn(`name:${event}`, `"${event}" isn't snake_case. Prefer GA4-style names like "add_to_cart" or "sign_up"; \`disableWarnings: true\` silences this.`);
|
|
194
|
+
return sendEvent(event, scope || location.pathname, options);
|
|
195
|
+
};
|
|
196
|
+
const pageView = ()=>{
|
|
197
|
+
if (!isBrowser()) return null;
|
|
198
|
+
// Where the visit came from belongs to its first page view only.
|
|
199
|
+
const referrer = firstPageView ? getExternalReferrer() : undefined;
|
|
200
|
+
firstPageView = false;
|
|
201
|
+
return sendEvent('page_view', location.pathname, {}, referrer);
|
|
202
|
+
};
|
|
203
|
+
const expose = (exposure)=>{
|
|
204
|
+
const current = getVisitor();
|
|
205
|
+
if (!current || paused()) return null;
|
|
206
|
+
const key = `${exposure.subjectKind}:${exposure.subjectId}`;
|
|
207
|
+
if (exposed.has(key)) return null;
|
|
208
|
+
exposed.add(key);
|
|
209
|
+
return send({
|
|
210
|
+
...common('exposure', current),
|
|
211
|
+
...exposure
|
|
212
|
+
}, key);
|
|
213
|
+
};
|
|
214
|
+
const autoTrack = ({ pageViews = true, clicks = true } = {})=>{
|
|
215
|
+
if (!isBrowser()) return ()=>{};
|
|
216
|
+
const stops = [];
|
|
217
|
+
if (pageViews) {
|
|
218
|
+
// One page view per path: replaceState for a query or hash change
|
|
219
|
+
// isn't a new page.
|
|
220
|
+
let lastPath = null;
|
|
221
|
+
const onNavigate = ()=>{
|
|
222
|
+
if (location.pathname === lastPath) return;
|
|
223
|
+
lastPath = location.pathname;
|
|
224
|
+
pageView();
|
|
225
|
+
};
|
|
226
|
+
const wrap = (name)=>{
|
|
227
|
+
const original = history[name];
|
|
228
|
+
const wrapped = function(...args) {
|
|
229
|
+
const result = original.apply(this, args);
|
|
230
|
+
onNavigate();
|
|
231
|
+
return result;
|
|
232
|
+
};
|
|
233
|
+
history[name] = wrapped;
|
|
234
|
+
stops.push(()=>{
|
|
235
|
+
// Leave a wrapper someone added after ours in place.
|
|
236
|
+
if (history[name] === wrapped) history[name] = original;
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
wrap('pushState');
|
|
240
|
+
wrap('replaceState');
|
|
241
|
+
window.addEventListener('popstate', onNavigate);
|
|
242
|
+
stops.push(()=>window.removeEventListener('popstate', onNavigate));
|
|
243
|
+
onNavigate();
|
|
244
|
+
}
|
|
245
|
+
if (clicks) {
|
|
246
|
+
// Captured, so a handler that stops the click still counts it.
|
|
247
|
+
const onClick = (event)=>{
|
|
248
|
+
const target = event.target;
|
|
249
|
+
const id = target?.closest?.('[data-numbers-id]')?.getAttribute('data-numbers-id');
|
|
250
|
+
if (id) track('click', id);
|
|
251
|
+
};
|
|
252
|
+
document.addEventListener('click', onClick, true);
|
|
253
|
+
stops.push(()=>document.removeEventListener('click', onClick, true));
|
|
254
|
+
}
|
|
255
|
+
return ()=>{
|
|
256
|
+
for (const stop of stops)stop();
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
organizationId,
|
|
261
|
+
environment,
|
|
262
|
+
visitor: getVisitor,
|
|
263
|
+
setDevice: (next)=>{
|
|
264
|
+
device = next;
|
|
265
|
+
},
|
|
266
|
+
setEndpoint: (next)=>{
|
|
267
|
+
url = next;
|
|
268
|
+
},
|
|
269
|
+
track,
|
|
270
|
+
pageView,
|
|
271
|
+
expose,
|
|
272
|
+
autoTrack
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
exports.DEFAULT_ENDPOINT = DEFAULT_ENDPOINT;
|
|
277
|
+
exports.createNumbers = createNumbers;
|
|
278
|
+
exports.detectDevice = detectDevice;
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
const DEFAULT_ENDPOINT = 'https://numbers.obelism.studio/api/log';
|
|
2
|
+
// The visitor cookie and id format, shared with the improve SDK so a visitor
|
|
3
|
+
// is the same one in both: `visi_` and 26 characters.
|
|
4
|
+
const VISITOR_COOKIE = 'visitorId';
|
|
5
|
+
const VISITOR_ID_PREFIX = 'visi';
|
|
6
|
+
const VISITOR_ID_SEPARATOR = '_';
|
|
7
|
+
const VISITOR_ID_LENGTH = 26;
|
|
8
|
+
const VISITOR_COOKIE_DAYS = 30;
|
|
9
|
+
// numbers' limits (`POST /api/log`): a longer string is a 400 and a bigger
|
|
10
|
+
// body a 413, and either loses the event, so the client caps what it can.
|
|
11
|
+
const MAX_FIELD_LENGTH = 256;
|
|
12
|
+
const MAX_CURRENCY_LENGTH = 8;
|
|
13
|
+
const MAX_URL_LENGTH = 1024;
|
|
14
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
15
|
+
// How long to stop sending after a 429 without a `Retry-After`.
|
|
16
|
+
const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
// improve's audience categories from the user agent, the short version
|
|
19
|
+
// (improve's SDK uses ua-parser-js; pass its result as `device` to match it
|
|
20
|
+
// exactly).
|
|
21
|
+
const detectDevice = (userAgent)=>{
|
|
22
|
+
const ua = userAgent.toLowerCase();
|
|
23
|
+
const device = /ipad|tablet|android(?!.*mobile)/.test(ua) ? 'tablet' : /mobi|iphone|ipod|android/.test(ua) ? 'mobile' : 'desktop';
|
|
24
|
+
const browser = /edg\//.test(ua) ? 'edge' : /samsungbrowser/.test(ua) ? 'samsung internet' : /instagram|fban|fbav|tiktok|snapchat|wechat|line\//.test(ua) ? 'social' : /firefox|fxios/.test(ua) ? 'firefox' : /chrome|crios|chromium/.test(ua) ? 'chrome' : /safari/.test(ua) ? 'safari' : 'other';
|
|
25
|
+
const os = /iphone|ipad|ipod/.test(ua) ? 'ios' : /android/.test(ua) ? 'android' : /windows/.test(ua) ? 'windows' : /mac os/.test(ua) ? 'mac os' : 'unix';
|
|
26
|
+
return {
|
|
27
|
+
pointer: device === 'desktop' ? 'fine' : 'coarse',
|
|
28
|
+
device,
|
|
29
|
+
browser,
|
|
30
|
+
os
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const getScreenSize = ()=>{
|
|
34
|
+
const width = window.innerWidth;
|
|
35
|
+
if (width <= 768) return 'small';
|
|
36
|
+
if (width <= 1024) return 'medium';
|
|
37
|
+
if (width <= 1200) return 'large';
|
|
38
|
+
return 'huge';
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const utm = (params, name)=>params.get(name)?.slice(0, MAX_FIELD_LENGTH) || undefined;
|
|
42
|
+
// The page now: its path (without the query string, which numbers drops too)
|
|
43
|
+
// and the UTM tags.
|
|
44
|
+
const getPageContext = ()=>{
|
|
45
|
+
const params = new URLSearchParams(location.search);
|
|
46
|
+
const context = {
|
|
47
|
+
path: location.pathname.slice(0, MAX_URL_LENGTH)
|
|
48
|
+
};
|
|
49
|
+
const utmSource = utm(params, 'utm_source');
|
|
50
|
+
const utmMedium = utm(params, 'utm_medium');
|
|
51
|
+
const utmCampaign = utm(params, 'utm_campaign');
|
|
52
|
+
if (utmSource) context.utmSource = utmSource;
|
|
53
|
+
if (utmMedium) context.utmMedium = utmMedium;
|
|
54
|
+
if (utmCampaign) context.utmCampaign = utmCampaign;
|
|
55
|
+
return context;
|
|
56
|
+
};
|
|
57
|
+
// Where the visit came from: another site's origin and path, never this
|
|
58
|
+
// site's own pages or a query string.
|
|
59
|
+
const getExternalReferrer = ()=>{
|
|
60
|
+
if (!document.referrer) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const referrer = new URL(document.referrer);
|
|
63
|
+
if (referrer.host === location.host) return undefined;
|
|
64
|
+
return `${referrer.origin}${referrer.pathname}`.slice(0, MAX_URL_LENGTH);
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
71
|
+
const readCookie = (name)=>{
|
|
72
|
+
const row = document.cookie.split('; ').find((entry)=>entry.split('=')[0] === name);
|
|
73
|
+
return row ? row.split('=')[1] ?? '' : '';
|
|
74
|
+
};
|
|
75
|
+
const writeCookie = (name, value)=>{
|
|
76
|
+
const expires = new Date(Date.now() + VISITOR_COOKIE_DAYS * 86_400_000);
|
|
77
|
+
document.cookie = `${name}=${value};path=/;expires=${expires.toUTCString()};SameSite=Lax` + (location.protocol === 'https:' ? ';Secure' : '');
|
|
78
|
+
};
|
|
79
|
+
// The improve SDK's check, so an id it (or its middleware) made stays valid.
|
|
80
|
+
const isVisitorId = (value)=>{
|
|
81
|
+
const parts = value.split(VISITOR_ID_SEPARATOR);
|
|
82
|
+
return parts.length === 2 && parts[0] === VISITOR_ID_PREFIX && parts[1].length === VISITOR_ID_LENGTH;
|
|
83
|
+
};
|
|
84
|
+
const createVisitorId = ()=>{
|
|
85
|
+
const random = new Uint8Array(VISITOR_ID_LENGTH);
|
|
86
|
+
crypto.getRandomValues(random);
|
|
87
|
+
let id = '';
|
|
88
|
+
for (const byte of random)id += CHARACTERS[byte % CHARACTERS.length];
|
|
89
|
+
return `${VISITOR_ID_PREFIX}${VISITOR_ID_SEPARATOR}${id}`;
|
|
90
|
+
};
|
|
91
|
+
// The visitor in the cookie, or a new one; either way the cookie is set
|
|
92
|
+
// again, so it lasts 30 days from the last visit.
|
|
93
|
+
const loadVisitor = ()=>{
|
|
94
|
+
const existing = readCookie(VISITOR_COOKIE);
|
|
95
|
+
const recurring = isVisitorId(existing);
|
|
96
|
+
const id = recurring ? existing : createVisitorId();
|
|
97
|
+
writeCookie(VISITOR_COOKIE, id);
|
|
98
|
+
return {
|
|
99
|
+
id,
|
|
100
|
+
recurring
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const SNAKE_CASE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
|
|
105
|
+
const parseRetryAfterMs = (header)=>{
|
|
106
|
+
if (!header) return undefined;
|
|
107
|
+
const seconds = Number(header);
|
|
108
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
109
|
+
const date = Date.parse(header);
|
|
110
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
111
|
+
};
|
|
112
|
+
const isBrowser = ()=>typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
113
|
+
const createNumbers = ({ organizationId, environment = 'production', endpoint = DEFAULT_ENDPOINT, device: givenDevice, disableWarnings = false })=>{
|
|
114
|
+
let visitor = null;
|
|
115
|
+
let device = givenDevice;
|
|
116
|
+
let url = endpoint;
|
|
117
|
+
// Epoch ms until which sending is paused after a 429.
|
|
118
|
+
let pausedUntil = 0;
|
|
119
|
+
let firstPageView = true;
|
|
120
|
+
const sentKeys = new Set();
|
|
121
|
+
const exposed = new Set();
|
|
122
|
+
const warned = new Set();
|
|
123
|
+
const warn = (key, message)=>{
|
|
124
|
+
if (disableWarnings || warned.has(key)) return;
|
|
125
|
+
warned.add(key);
|
|
126
|
+
if (typeof console !== 'undefined') console.warn(`[numbers] ${message}`);
|
|
127
|
+
};
|
|
128
|
+
const getVisitor = ()=>{
|
|
129
|
+
if (!isBrowser()) return null;
|
|
130
|
+
visitor ??= loadVisitor();
|
|
131
|
+
return visitor;
|
|
132
|
+
};
|
|
133
|
+
const common = (type, current)=>{
|
|
134
|
+
device ??= detectDevice(navigator.userAgent);
|
|
135
|
+
return {
|
|
136
|
+
type,
|
|
137
|
+
organizationId,
|
|
138
|
+
environment,
|
|
139
|
+
visitorId: current.id,
|
|
140
|
+
pointer: device.pointer,
|
|
141
|
+
device: device.device,
|
|
142
|
+
screen: getScreenSize(),
|
|
143
|
+
browser: device.browser,
|
|
144
|
+
os: device.os,
|
|
145
|
+
visitor: current.recurring ? 'recurring' : 'new'
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
// A `keepalive` fetch, so it outlives a navigation. Never rejects.
|
|
149
|
+
const send = (body, label)=>{
|
|
150
|
+
const serialized = JSON.stringify(body);
|
|
151
|
+
if (typeof TextEncoder !== 'undefined' && new TextEncoder().encode(serialized).length > MAX_BODY_BYTES) warn(`size:${label}`, `"${label}" is over ${MAX_BODY_BYTES} bytes and will be refused: trim its \`params\`.`);
|
|
152
|
+
return fetch(url, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/json'
|
|
156
|
+
},
|
|
157
|
+
body: serialized,
|
|
158
|
+
keepalive: true
|
|
159
|
+
}).then((response)=>{
|
|
160
|
+
// Over the Organization's limit: back off, by Retry-After when given.
|
|
161
|
+
if (response.status === 429) pausedUntil = Date.now() + (parseRetryAfterMs(response.headers.get('Retry-After')) ?? RATE_LIMIT_COOLDOWN_MS);
|
|
162
|
+
return response;
|
|
163
|
+
}, ()=>undefined);
|
|
164
|
+
};
|
|
165
|
+
const paused = ()=>Date.now() < pausedUntil;
|
|
166
|
+
const sendEvent = (event, scope, options, referrer)=>{
|
|
167
|
+
const current = getVisitor();
|
|
168
|
+
if (!current || paused()) return null;
|
|
169
|
+
const { value, currency, params, dedupeKey } = options;
|
|
170
|
+
if (dedupeKey !== undefined) {
|
|
171
|
+
const key = `${event}:${dedupeKey}`;
|
|
172
|
+
if (sentKeys.has(key)) return null;
|
|
173
|
+
sentKeys.add(key);
|
|
174
|
+
}
|
|
175
|
+
const body = {
|
|
176
|
+
...common('event', current),
|
|
177
|
+
...getPageContext(),
|
|
178
|
+
event: event.slice(0, MAX_FIELD_LENGTH),
|
|
179
|
+
scope: scope.slice(0, MAX_FIELD_LENGTH)
|
|
180
|
+
};
|
|
181
|
+
if (referrer) body.referrer = referrer;
|
|
182
|
+
if (typeof value === 'number' && Number.isFinite(value)) body.value = value;
|
|
183
|
+
if (currency) body.currency = currency.slice(0, MAX_CURRENCY_LENGTH);
|
|
184
|
+
// numbers refuses `params` that aren't a plain object, and the event
|
|
185
|
+
// with it: leave them out instead.
|
|
186
|
+
if (params && typeof params === 'object' && !Array.isArray(params)) body.params = params;
|
|
187
|
+
return send(body, event);
|
|
188
|
+
};
|
|
189
|
+
const track = (event, scope, options = {})=>{
|
|
190
|
+
if (!isBrowser()) return null;
|
|
191
|
+
if (!SNAKE_CASE.test(event)) warn(`name:${event}`, `"${event}" isn't snake_case. Prefer GA4-style names like "add_to_cart" or "sign_up"; \`disableWarnings: true\` silences this.`);
|
|
192
|
+
return sendEvent(event, scope || location.pathname, options);
|
|
193
|
+
};
|
|
194
|
+
const pageView = ()=>{
|
|
195
|
+
if (!isBrowser()) return null;
|
|
196
|
+
// Where the visit came from belongs to its first page view only.
|
|
197
|
+
const referrer = firstPageView ? getExternalReferrer() : undefined;
|
|
198
|
+
firstPageView = false;
|
|
199
|
+
return sendEvent('page_view', location.pathname, {}, referrer);
|
|
200
|
+
};
|
|
201
|
+
const expose = (exposure)=>{
|
|
202
|
+
const current = getVisitor();
|
|
203
|
+
if (!current || paused()) return null;
|
|
204
|
+
const key = `${exposure.subjectKind}:${exposure.subjectId}`;
|
|
205
|
+
if (exposed.has(key)) return null;
|
|
206
|
+
exposed.add(key);
|
|
207
|
+
return send({
|
|
208
|
+
...common('exposure', current),
|
|
209
|
+
...exposure
|
|
210
|
+
}, key);
|
|
211
|
+
};
|
|
212
|
+
const autoTrack = ({ pageViews = true, clicks = true } = {})=>{
|
|
213
|
+
if (!isBrowser()) return ()=>{};
|
|
214
|
+
const stops = [];
|
|
215
|
+
if (pageViews) {
|
|
216
|
+
// One page view per path: replaceState for a query or hash change
|
|
217
|
+
// isn't a new page.
|
|
218
|
+
let lastPath = null;
|
|
219
|
+
const onNavigate = ()=>{
|
|
220
|
+
if (location.pathname === lastPath) return;
|
|
221
|
+
lastPath = location.pathname;
|
|
222
|
+
pageView();
|
|
223
|
+
};
|
|
224
|
+
const wrap = (name)=>{
|
|
225
|
+
const original = history[name];
|
|
226
|
+
const wrapped = function(...args) {
|
|
227
|
+
const result = original.apply(this, args);
|
|
228
|
+
onNavigate();
|
|
229
|
+
return result;
|
|
230
|
+
};
|
|
231
|
+
history[name] = wrapped;
|
|
232
|
+
stops.push(()=>{
|
|
233
|
+
// Leave a wrapper someone added after ours in place.
|
|
234
|
+
if (history[name] === wrapped) history[name] = original;
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
wrap('pushState');
|
|
238
|
+
wrap('replaceState');
|
|
239
|
+
window.addEventListener('popstate', onNavigate);
|
|
240
|
+
stops.push(()=>window.removeEventListener('popstate', onNavigate));
|
|
241
|
+
onNavigate();
|
|
242
|
+
}
|
|
243
|
+
if (clicks) {
|
|
244
|
+
// Captured, so a handler that stops the click still counts it.
|
|
245
|
+
const onClick = (event)=>{
|
|
246
|
+
const target = event.target;
|
|
247
|
+
const id = target?.closest?.('[data-numbers-id]')?.getAttribute('data-numbers-id');
|
|
248
|
+
if (id) track('click', id);
|
|
249
|
+
};
|
|
250
|
+
document.addEventListener('click', onClick, true);
|
|
251
|
+
stops.push(()=>document.removeEventListener('click', onClick, true));
|
|
252
|
+
}
|
|
253
|
+
return ()=>{
|
|
254
|
+
for (const stop of stops)stop();
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
organizationId,
|
|
259
|
+
environment,
|
|
260
|
+
visitor: getVisitor,
|
|
261
|
+
setDevice: (next)=>{
|
|
262
|
+
device = next;
|
|
263
|
+
},
|
|
264
|
+
setEndpoint: (next)=>{
|
|
265
|
+
url = next;
|
|
266
|
+
},
|
|
267
|
+
track,
|
|
268
|
+
pageView,
|
|
269
|
+
expose,
|
|
270
|
+
autoTrack
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
!/*! Obelism numbers (@obelism/numbers-sdk), MIT. https://numbers.obelism.studio */function(){"use strict";let e="https://numbers.obelism.studio/api/log",t="visitorId",r="visi",n=(e,t)=>e.get(t)?.slice(0,256)||void 0,i="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",o=/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/,a=()=>"u">typeof window&&"u">typeof document;((s,l)=>{if(!l||s.numbers)return;let u=l.dataset.organization;if(!u)return;let c=(({organizationId:s,environment:l="production",endpoint:u=e,device:c,disableWarnings:d=!1})=>{let p=null,m=c,h=u,f=0,w=!0,b=new Set,g=new Set,v=new Set,y=(e,t)=>{!(d||v.has(e))&&(v.add(e),"u">typeof console&&console.warn(`[numbers] ${t}`))},$=()=>a()?(p??(p=(()=>{let e,n,o,a=(e=document.cookie.split("; ").find(e=>e.split("=")[0]===t))?e.split("=")[1]??"":"",s=2===(n=a.split("_")).length&&n[0]===r&&26===n[1].length,l=s?a:(()=>{let e=new Uint8Array(26);crypto.getRandomValues(e);let t="";for(let r of e)t+=i[r%i.length];return`${r}_${t}`})();return o=new Date(Date.now()+2592e6),document.cookie=`${t}=${l};path=/;expires=${o.toUTCString()};SameSite=Lax`+("https:"===location.protocol?";Secure":""),{id:l,recurring:s}})()),p):null,S=(e,t)=>{let r,n,i;return m??(r=navigator.userAgent.toLowerCase(),n=/ipad|tablet|android(?!.*mobile)/.test(r)?"tablet":/mobi|iphone|ipod|android/.test(r)?"mobile":"desktop",m={pointer:"desktop"===n?"fine":"coarse",device:n,browser:/edg\//.test(r)?"edge":/samsungbrowser/.test(r)?"samsung internet":/instagram|fban|fbav|tiktok|snapchat|wechat|line\//.test(r)?"social":/firefox|fxios/.test(r)?"firefox":/chrome|crios|chromium/.test(r)?"chrome":/safari/.test(r)?"safari":"other",os:/iphone|ipad|ipod/.test(r)?"ios":/android/.test(r)?"android":/windows/.test(r)?"windows":/mac os/.test(r)?"mac os":"unix"}),{type:e,organizationId:s,environment:l,visitorId:t.id,pointer:m.pointer,device:m.device,screen:(i=window.innerWidth)<=768?"small":i<=1024?"medium":i<=1200?"large":"huge",browser:m.browser,os:m.os,visitor:t.recurring?"recurring":"new"}},k=(e,t)=>{let r=JSON.stringify(e);return"u">typeof TextEncoder&&new TextEncoder().encode(r).length>8192&&y(`size:${t}`,`"${t}" is over 8192 bytes and will be refused: trim its \`params\`.`),fetch(h,{method:"POST",headers:{"Content-Type":"application/json"},body:r,keepalive:!0}).then(e=>(429===e.status&&(f=Date.now()+((e=>{if(!e)return;let t=Number(e);if(Number.isFinite(t))return Math.max(0,1e3*t);let r=Date.parse(e);return Number.isNaN(r)?void 0:Math.max(0,r-Date.now())})(e.headers.get("Retry-After"))??6e4)),e),()=>void 0)},x=(e,t,r,i)=>{let o,a,s,l,u,c=$();if(!c||Date.now()<f)return null;let{value:d,currency:p,params:m,dedupeKey:h}=r;if(void 0!==h){let t=`${e}:${h}`;if(b.has(t))return null;b.add(t)}let w={...S("event",c),...(o=new URLSearchParams(location.search),a={path:location.pathname.slice(0,1024)},s=n(o,"utm_source"),l=n(o,"utm_medium"),u=n(o,"utm_campaign"),s&&(a.utmSource=s),l&&(a.utmMedium=l),u&&(a.utmCampaign=u),a),event:e.slice(0,256),scope:t.slice(0,256)};return i&&(w.referrer=i),"number"==typeof d&&Number.isFinite(d)&&(w.value=d),p&&(w.currency=p.slice(0,8)),m&&"object"==typeof m&&!Array.isArray(m)&&(w.params=m),k(w,e)},_=(e,t,r={})=>a()?(o.test(e)||y(`name:${e}`,`"${e}" isn't snake_case. Prefer GA4-style names like "add_to_cart" or "sign_up"; \`disableWarnings: true\` silences this.`),x(e,t||location.pathname,r)):null,L=()=>{if(!a())return null;let e=w?(()=>{if(document.referrer)try{let e=new URL(document.referrer);if(e.host===location.host)return;return`${e.origin}${e.pathname}`.slice(0,1024)}catch{return}})():void 0;return w=!1,x("page_view",location.pathname,{},e)};return{organizationId:s,environment:l,visitor:$,setDevice:e=>{m=e},setEndpoint:e=>{h=e},track:_,pageView:L,expose:e=>{let t=$();if(!t||Date.now()<f)return null;let r=`${e.subjectKind}:${e.subjectId}`;return g.has(r)?null:(g.add(r),k({...S("exposure",t),...e},r))},autoTrack:({pageViews:e=!0,clicks:t=!0}={})=>{if(!a())return()=>{};let r=[];if(e){let e=null,t=()=>{location.pathname!==e&&(e=location.pathname,L())},n=e=>{let n=history[e],i=function(...e){let r=n.apply(this,e);return t(),r};history[e]=i,r.push(()=>{history[e]===i&&(history[e]=n)})};n("pushState"),n("replaceState"),window.addEventListener("popstate",t),r.push(()=>window.removeEventListener("popstate",t)),t()}if(t){let e=e=>{let t=e.target,r=t?.closest?.("[data-numbers-id]")?.getAttribute("data-numbers-id");r&&_("click",r)};document.addEventListener("click",e,!0),r.push(()=>document.removeEventListener("click",e,!0))}return()=>{for(let e of r)e()}}}})({organizationId:u,environment:l.dataset.environment||"production",endpoint:l.dataset.endpoint||new URL("/api/log",l.src||location.href).href,disableWarnings:!0});return s.numbers=c,c.autoTrack()})(window,document.currentScript)}();
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@obelism/numbers-sdk",
|
|
3
|
+
"description": "Obelism numbers SDK: page views, events and clicks on tagged elements, sent to numbers.obelism.studio",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"analytics",
|
|
7
|
+
"events",
|
|
8
|
+
"page-views",
|
|
9
|
+
"typescript",
|
|
10
|
+
"obelism"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Obelism <hello@obelism.studio> (https://www.obelism.studio)",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/Obelism/sdk",
|
|
17
|
+
"directory": "packages/numbers"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"default": "./dist/index.mjs"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "bunchee && rollup --config rollup.script.config.mjs",
|
|
37
|
+
"dev": "bunchee --watch --no-clean",
|
|
38
|
+
"test": "vitest",
|
|
39
|
+
"test:coverage": "vitest run --coverage",
|
|
40
|
+
"test:ci": "vitest run",
|
|
41
|
+
"lint": "npm-run-all --parallel lint:*",
|
|
42
|
+
"lint:oxlint": "oxlint",
|
|
43
|
+
"lint:types": "tsc --noEmit",
|
|
44
|
+
"clean": "rm -rf ./node_modules && rm -rf ./dist && rm -rf ./.turbo"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@obelism/sdk-typescript-config": "^0.0.0",
|
|
48
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
49
|
+
"@typescript/typescript6": "^6.0.2",
|
|
50
|
+
"bunchee": "^7.0.2",
|
|
51
|
+
"jsdom": "^30.1.2",
|
|
52
|
+
"npm-run-all2": "^9.0.2",
|
|
53
|
+
"oxlint": "^1.88.0",
|
|
54
|
+
"rollup": "^4.64.7",
|
|
55
|
+
"rollup-plugin-swc3": "^0.12.1",
|
|
56
|
+
"typescript": "^7.0.2",
|
|
57
|
+
"vitest": "^5.0.4"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20"
|
|
61
|
+
}
|
|
62
|
+
}
|