@stetcms/analytics 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 +202 -0
- package/README.md +288 -0
- package/dist/client.d.ts +20 -0
- package/dist/client.js +111 -0
- package/dist/events-DnYdjoR6.js +57 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +4 -0
- package/dist/plan-CftGOQuT.d.ts +57 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.js +175 -0
- package/dist/sync.d.ts +23 -0
- package/dist/sync.js +46 -0
- package/dist/types-CVzbMMoR.d.ts +39 -0
- package/dist/wire-J3xnrDYj.js +143 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# @stetcms/analytics
|
|
2
|
+
|
|
3
|
+
Product analytics for apps built on Stet: pageviews and your own typed events,
|
|
4
|
+
routed through your backend instead of a third-party endpoint.
|
|
5
|
+
|
|
6
|
+
- No cookies, so no consent banner.
|
|
7
|
+
- No third-party origin in the page, so nothing for a blocker to match on.
|
|
8
|
+
- Readers' addresses and user agents never leave your infrastructure.
|
|
9
|
+
- One tracking plan types the browser calls, validates the server ones, and
|
|
10
|
+
becomes the event list your content team builds dashboards from.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @stetcms/analytics
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 1. Declare the tracking plan
|
|
19
|
+
|
|
20
|
+
The plan goes in `stet.config.ts` at the project root, the one file
|
|
21
|
+
`@stetcms/vite` and the `stet` CLI read for everything. Props take any
|
|
22
|
+
[Standard Schema](https://standardschema.dev) validator, so Zod, Valibot and
|
|
23
|
+
ArkType all work.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { defineAnalytics, event } from '@stetcms/analytics';
|
|
27
|
+
import { defineStet } from '@stetcms/config';
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
|
|
30
|
+
export default defineStet({
|
|
31
|
+
analytics: defineAnalytics({
|
|
32
|
+
events: {
|
|
33
|
+
signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
34
|
+
checkout: {
|
|
35
|
+
started: event(),
|
|
36
|
+
completed: event({ total: z.number(), coupon: z.string().optional() }),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Nested events track under dot-joined names (`checkout.completed`). The file
|
|
44
|
+
carries no secrets: `STET_API_KEY` and `STET_ORIGIN` are read from the
|
|
45
|
+
environment, so it is safe to commit.
|
|
46
|
+
|
|
47
|
+
## 2. Mount the route
|
|
48
|
+
|
|
49
|
+
One route in your own app. It validates against the plan, adds what only your
|
|
50
|
+
backend can see, and forwards to Stet with your organization API key.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { createAnalyticsHandler } from '@stetcms/analytics/server';
|
|
54
|
+
import config from '../stet.config';
|
|
55
|
+
|
|
56
|
+
export const POST = createAnalyticsHandler(config.analytics, {
|
|
57
|
+
// Called per request: whatever you return is attached to every event in the
|
|
58
|
+
// batch, and overrules anything the browser claimed.
|
|
59
|
+
context: async (request) => ({ userId: (await session(request))?.userId }),
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 3. Track from the browser
|
|
64
|
+
|
|
65
|
+
The client only ever talks to the route above, so it needs no key.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { createAnalytics } from '@stetcms/analytics/client';
|
|
69
|
+
import type config from '../stet.config';
|
|
70
|
+
|
|
71
|
+
export const analytics = createAnalytics<(typeof config)['analytics']>({
|
|
72
|
+
endpoint: '/api/analytics',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
analytics.track('checkout.completed', { total: 42 });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`track` is typed from the plan: a misspelled name, a missing prop or a wrong
|
|
79
|
+
type fails the build. It never throws and never rejects, so a tracking mistake
|
|
80
|
+
cannot break the page. Events batch and flush every two seconds, when twenty
|
|
81
|
+
are queued, and when the page is hidden or unloaded.
|
|
82
|
+
|
|
83
|
+
## Pageviews
|
|
84
|
+
|
|
85
|
+
On a site where every navigation is a real page load — plain HTML, Astro
|
|
86
|
+
without view transitions, Rails, Django — leave `autoPageviews` on its default
|
|
87
|
+
and you are done.
|
|
88
|
+
|
|
89
|
+
**In a single-page app, turn it off and let your router say what a navigation
|
|
90
|
+
is.** The fallback patches `history.pushState`, which cannot see the
|
|
91
|
+
`replaceState` that routers use for redirects and search-parameter changes, so
|
|
92
|
+
it both misses views and reports the wrong URL for others. Your router already
|
|
93
|
+
knows; ask it.
|
|
94
|
+
|
|
95
|
+
A repeated view of the same URL counts once either way, so the double-invoked
|
|
96
|
+
effects of React Strict Mode do not inflate anything.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
export const analytics = createAnalytics<(typeof config)['analytics']>({
|
|
100
|
+
endpoint: '/api/analytics',
|
|
101
|
+
autoPageviews: false,
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then mount one of these **at the root**, so every page is counted rather than
|
|
106
|
+
only the routes that happen to import the client.
|
|
107
|
+
|
|
108
|
+
**Pass the router's URL, never let `pageview()` read `window.location`.** By
|
|
109
|
+
the time your effect or callback runs, the router has advanced and
|
|
110
|
+
`window.location` may not have, so a bare `analytics.pageview()` labels the
|
|
111
|
+
view with the _previous_ page — and the same-URL guard then quietly drops
|
|
112
|
+
every other one. Every snippet below passes it explicitly for that reason.
|
|
113
|
+
|
|
114
|
+
### TanStack Router
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { useLocation } from '@tanstack/react-router';
|
|
118
|
+
import { useEffect } from 'react';
|
|
119
|
+
|
|
120
|
+
export function usePageviews() {
|
|
121
|
+
const location = useLocation();
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
analytics.pageview(`${window.location.origin}${location.href}`);
|
|
124
|
+
}, [location.href]);
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Next.js (App Router)
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
'use client';
|
|
132
|
+
|
|
133
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
134
|
+
import { useEffect } from 'react';
|
|
135
|
+
|
|
136
|
+
export function Pageviews() {
|
|
137
|
+
const pathname = usePathname();
|
|
138
|
+
const searchParams = useSearchParams();
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const query = searchParams.toString();
|
|
141
|
+
analytics.pageview(`${window.location.origin}${pathname}${query === '' ? '' : `?${query}`}`);
|
|
142
|
+
}, [pathname, searchParams]);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Render it in `app/layout.tsx`. `useSearchParams` opts the subtree into client
|
|
148
|
+
rendering, so wrap it in `<Suspense>` to keep the rest of the layout static.
|
|
149
|
+
|
|
150
|
+
### React Router
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { useEffect } from 'react';
|
|
154
|
+
import { useLocation } from 'react-router';
|
|
155
|
+
|
|
156
|
+
export function Pageviews() {
|
|
157
|
+
const location = useLocation();
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
analytics.pageview(`${window.location.origin}${location.pathname}${location.search}`);
|
|
160
|
+
}, [location.pathname, location.search]);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### SvelteKit
|
|
166
|
+
|
|
167
|
+
In `+layout.svelte`:
|
|
168
|
+
|
|
169
|
+
```svelte
|
|
170
|
+
<script>
|
|
171
|
+
import { afterNavigate } from '$app/navigation';
|
|
172
|
+
afterNavigate(({ to }) => analytics.pageview(to?.url.href));
|
|
173
|
+
</script>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Nuxt
|
|
177
|
+
|
|
178
|
+
In a client-only plugin, `plugins/analytics.client.ts`:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
export default defineNuxtPlugin(() => {
|
|
182
|
+
useRouter().afterEach((to) => {
|
|
183
|
+
analytics.pageview(`${window.location.origin}${to.fullPath}`);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Astro
|
|
189
|
+
|
|
190
|
+
Astro navigations are full page loads, so the default is already correct and
|
|
191
|
+
there is nothing to add. With view transitions enabled the client script runs
|
|
192
|
+
only once, so listen instead — and here a bare call _is_ right, because the
|
|
193
|
+
event fires after the swap, when `window.location` is already the new page:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
document.addEventListener('astro:page-load', () => analytics.pageview());
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Context vs metadata
|
|
200
|
+
|
|
201
|
+
Two different trust models, worth keeping straight:
|
|
202
|
+
|
|
203
|
+
- **`metadata`** is derived by the handler from the request. The browser never
|
|
204
|
+
sends it and cannot influence it.
|
|
205
|
+
- **`context`** is merged. The browser proposes, and your handler overrules it
|
|
206
|
+
per key.
|
|
207
|
+
|
|
208
|
+
So a key your handler does not set stays exactly as the browser sent it, and
|
|
209
|
+
anyone can post whatever they like to your route. **Treat a context key as
|
|
210
|
+
trustworthy only if your handler sets it.** `userId` in the example above is
|
|
211
|
+
safe because the handler resolves it from the session; had it come from the
|
|
212
|
+
browser, it would be a claim rather than a fact.
|
|
213
|
+
|
|
214
|
+
Returning `undefined` for a key leaves the browser's value in place rather than
|
|
215
|
+
deleting it, so a missing session reads as "unknown". Pass `null` to state
|
|
216
|
+
positively that there is nobody signed in.
|
|
217
|
+
|
|
218
|
+
## Default metadata
|
|
219
|
+
|
|
220
|
+
Derived by the handler from the request the browser made to you. The raw
|
|
221
|
+
address and user agent are used and discarded there; only what is listed here
|
|
222
|
+
is forwarded.
|
|
223
|
+
|
|
224
|
+
| Field | From |
|
|
225
|
+
| --------------------------- | ----------------------------------------------------------------------- |
|
|
226
|
+
| `country`, `region`, `city` | `request.cf` on Cloudflare, `cf-ipcountry` or `x-vercel-ip-*` elsewhere |
|
|
227
|
+
| `browser`, `os`, `device` | `sec-ch-ua*` client hints, falling back to the agent string |
|
|
228
|
+
| `visitor` | `SHA-256(date, salt, address, user agent)`, truncated |
|
|
229
|
+
|
|
230
|
+
The visitor digest covers the date, so the same reader is a different id
|
|
231
|
+
tomorrow: uniques are countable within a day and nothing can be joined across
|
|
232
|
+
days, by anyone, including us. `salt` defaults to the request's host, which
|
|
233
|
+
also stops ids being comparable across sites you run.
|
|
234
|
+
|
|
235
|
+
Traffic that announces itself as automation is answered `200` and discarded,
|
|
236
|
+
because a `4xx` only teaches a crawler to retry.
|
|
237
|
+
|
|
238
|
+
## API
|
|
239
|
+
|
|
240
|
+
### `@stetcms/analytics`
|
|
241
|
+
|
|
242
|
+
`defineAnalytics`, `event`, `validateEvent`, `flattenEvents`, `resolveEvent`,
|
|
243
|
+
`parseClientBatch`, and the types behind them. Isomorphic.
|
|
244
|
+
|
|
245
|
+
### `@stetcms/analytics/sync`
|
|
246
|
+
|
|
247
|
+
`syncTrackingPlan(options)` publishes the plan to Stet. Called for you by
|
|
248
|
+
`@stetcms/vite` on dev-server start and at the end of a build, and by
|
|
249
|
+
`stet sync`; you rarely call it yourself.
|
|
250
|
+
|
|
251
|
+
### `@stetcms/analytics/client`
|
|
252
|
+
|
|
253
|
+
`createAnalytics(options)` → `{ track, pageview, setContext, flush }`.
|
|
254
|
+
|
|
255
|
+
| Option | Default | Meaning |
|
|
256
|
+
| --------------- | -------- | ------------------------------------------ |
|
|
257
|
+
| `endpoint` | required | The route you mounted the handler on |
|
|
258
|
+
| `context` | `{}` | Props attached to every batch |
|
|
259
|
+
| `flushInterval` | `2000` | Milliseconds a partial batch waits |
|
|
260
|
+
| `maxBatchSize` | `20` | Send immediately once this many are queued |
|
|
261
|
+
| `autoPageviews` | `true` | Pageviews on load and history navigation |
|
|
262
|
+
|
|
263
|
+
### `@stetcms/analytics/server`
|
|
264
|
+
|
|
265
|
+
`createAnalyticsHandler(plan, options)` → `(request: Request) => Promise<Response>`.
|
|
266
|
+
|
|
267
|
+
| Option | Default | Meaning |
|
|
268
|
+
| --------- | ----------------------------- | ---------------------------------------------------------------- |
|
|
269
|
+
| `context` | `{}` | Props, or a function of the request, that win over the browser's |
|
|
270
|
+
| `origin` | `STET_ORIGIN`, then the cloud | Stet deployment to forward to |
|
|
271
|
+
| `apiKey` | `STET_API_KEY` | Organization API key |
|
|
272
|
+
| `salt` | the request's host | Mixed into the visitor digest |
|
|
273
|
+
| `onError` | `console.error` | Called when a batch cannot be forwarded |
|
|
274
|
+
|
|
275
|
+
Responses: `200 { accepted }` on success, `400` for a malformed batch or an
|
|
276
|
+
event that does not match the plan, `502` when Stet could not be reached. An
|
|
277
|
+
event outside the plan fails loudly rather than being dropped quietly, so a
|
|
278
|
+
typo surfaces the first time it runs.
|
|
279
|
+
|
|
280
|
+
## Development
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
pnpm test # Vitest
|
|
284
|
+
pnpm tc # Type check
|
|
285
|
+
pnpm build # vp pack
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`examples/tanstack` runs this package against a local Stet; see its README.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { s as TrackArgs, t as AnalyticsTypes } from "./types-CVzbMMoR.js";
|
|
2
|
+
|
|
3
|
+
//#region src/client.d.ts
|
|
4
|
+
type AnalyticsOptions = {
|
|
5
|
+
/** Path or URL of the route you mounted the handler on, e.g. `/api/analytics`. */endpoint: string; /** Props attached to every event in a batch. Your server's values win over these. */
|
|
6
|
+
context?: Record<string, unknown>; /** Milliseconds a partial batch waits before being sent. Default 2000. */
|
|
7
|
+
flushInterval?: number; /** Send as soon as this many events are queued. Default 20. */
|
|
8
|
+
maxBatchSize?: number; /** Record a pageview on load and on history navigation. Default true. */
|
|
9
|
+
autoPageviews?: boolean; /** Override fetch, e.g. in tests. */
|
|
10
|
+
fetch?: typeof globalThis.fetch;
|
|
11
|
+
};
|
|
12
|
+
type Analytics<TPlan extends AnalyticsTypes> = {
|
|
13
|
+
/** Record an event from the tracking plan. Never throws and never rejects. */track: <K extends keyof TPlan['$types']['events'] & string>(name: K, ...args: TrackArgs<TPlan['$types']['events'][K]>) => void; /** Record a pageview. Called for you unless `autoPageviews` is off. */
|
|
14
|
+
pageview: (url?: string) => void; /** Merge props into the context sent with every later batch. */
|
|
15
|
+
setContext: (context: Record<string, unknown>) => void; /** Send anything queued now. Resolves once the request settles. */
|
|
16
|
+
flush: () => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
declare function createAnalytics<TPlan extends AnalyticsTypes>(options: AnalyticsOptions): Analytics<TPlan>;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { Analytics, AnalyticsOptions, type AnalyticsTypes, createAnalytics };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { n as PAGEVIEW } from "./events-DnYdjoR6.js";
|
|
2
|
+
//#region src/client.ts
|
|
3
|
+
/**
|
|
4
|
+
* Browser client. Talks only to the route you mounted in your own app with
|
|
5
|
+
* `createAnalyticsHandler` from `@stetcms/analytics/server` — no keys in the
|
|
6
|
+
* bundle, no third-party origin, nothing for an ad blocker to recognise.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { createAnalytics } from '@stetcms/analytics/client';
|
|
10
|
+
* import type plan from '../stet.config';
|
|
11
|
+
*
|
|
12
|
+
* export const analytics = createAnalytics<typeof plan>({ endpoint: '/api/analytics' });
|
|
13
|
+
* analytics.track('checkout.completed', { total: 42 });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
function createAnalytics(options) {
|
|
17
|
+
const isBrowser = typeof window !== "undefined";
|
|
18
|
+
const flushInterval = options.flushInterval ?? 2e3;
|
|
19
|
+
const maxBatchSize = options.maxBatchSize ?? 20;
|
|
20
|
+
const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
21
|
+
let context = { ...options.context };
|
|
22
|
+
let queue = [];
|
|
23
|
+
let timer;
|
|
24
|
+
let lastPageviewUrl;
|
|
25
|
+
async function flush() {
|
|
26
|
+
if (timer !== void 0) {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
timer = void 0;
|
|
29
|
+
}
|
|
30
|
+
if (queue.length === 0) return;
|
|
31
|
+
const events = queue;
|
|
32
|
+
queue = [];
|
|
33
|
+
const batch = {
|
|
34
|
+
context,
|
|
35
|
+
events
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
await fetcher(options.endpoint, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify(batch),
|
|
42
|
+
keepalive: true
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
queue = [...events, ...queue];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function enqueue(event) {
|
|
49
|
+
queue.push(event);
|
|
50
|
+
if (queue.length >= maxBatchSize) {
|
|
51
|
+
flush();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
timer ??= setTimeout(() => void flush(), flushInterval);
|
|
55
|
+
}
|
|
56
|
+
function envelope() {
|
|
57
|
+
if (!isBrowser) return {};
|
|
58
|
+
return {
|
|
59
|
+
url: window.location.href,
|
|
60
|
+
...document.referrer === "" ? {} : { referrer: document.referrer }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function track(name, props) {
|
|
64
|
+
enqueue({
|
|
65
|
+
name,
|
|
66
|
+
props: props ?? {},
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
...envelope()
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function pageview(url) {
|
|
72
|
+
const envelopeUrl = url ?? (isBrowser ? window.location.href : void 0);
|
|
73
|
+
if (envelopeUrl !== void 0 && envelopeUrl === lastPageviewUrl) return;
|
|
74
|
+
lastPageviewUrl = envelopeUrl;
|
|
75
|
+
enqueue({
|
|
76
|
+
name: PAGEVIEW,
|
|
77
|
+
props: {},
|
|
78
|
+
timestamp: Date.now(),
|
|
79
|
+
...envelope(),
|
|
80
|
+
...envelopeUrl === void 0 ? {} : { url: envelopeUrl }
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (isBrowser) {
|
|
84
|
+
window.addEventListener("pagehide", () => void flush());
|
|
85
|
+
document.addEventListener("visibilitychange", () => {
|
|
86
|
+
if (document.visibilityState === "hidden") flush();
|
|
87
|
+
});
|
|
88
|
+
if (options.autoPageviews ?? true) {
|
|
89
|
+
pageview();
|
|
90
|
+
const pushState = window.history.pushState.bind(window.history);
|
|
91
|
+
window.history.pushState = (...args) => {
|
|
92
|
+
pushState(...args);
|
|
93
|
+
pageview();
|
|
94
|
+
};
|
|
95
|
+
window.addEventListener("popstate", () => pageview());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
track,
|
|
100
|
+
pageview,
|
|
101
|
+
setContext: (next) => {
|
|
102
|
+
context = {
|
|
103
|
+
...context,
|
|
104
|
+
...next
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
flush
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
export { createAnalytics };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/events.ts
|
|
2
|
+
/** The pageview every browser client sends. */
|
|
3
|
+
const PAGEVIEW = "$pageview";
|
|
4
|
+
/**
|
|
5
|
+
* Events the client sends for itself. `$`-prefixed so a tracking plan can
|
|
6
|
+
* never collide with one; their context (url, referrer) rides the envelope
|
|
7
|
+
* rather than the props, so they carry no schema of their own.
|
|
8
|
+
*/
|
|
9
|
+
const BUILT_IN_EVENTS = /* @__PURE__ */ new Set([PAGEVIEW]);
|
|
10
|
+
/**
|
|
11
|
+
* Declares an event and the props it carries. Props are any Standard Schema
|
|
12
|
+
* validator, so the schema library is yours to pick (Zod, Valibot, ArkType).
|
|
13
|
+
* Events group by nesting, and nested events track under dot-joined names:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* events: {
|
|
17
|
+
* signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
18
|
+
* checkout: { started: event(), completed: event({ total: z.number() }) },
|
|
19
|
+
* }
|
|
20
|
+
* // → 'signup', 'checkout.started', 'checkout.completed'
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
function event(props) {
|
|
24
|
+
return {
|
|
25
|
+
$event: true,
|
|
26
|
+
props: props ?? {}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function isEventDefinition(value) {
|
|
30
|
+
return typeof value === "object" && value !== null && value.$event === true;
|
|
31
|
+
}
|
|
32
|
+
/** Walks a dot-joined name (`checkout.completed`) to its definition. */
|
|
33
|
+
function resolveEvent(events, name) {
|
|
34
|
+
let node = events;
|
|
35
|
+
for (const part of name.split(".")) {
|
|
36
|
+
if (isEventDefinition(node)) return;
|
|
37
|
+
const next = node[part];
|
|
38
|
+
if (next === void 0) return;
|
|
39
|
+
node = next;
|
|
40
|
+
}
|
|
41
|
+
return isEventDefinition(node) ? node : void 0;
|
|
42
|
+
}
|
|
43
|
+
/** Flattens a nested plan to `[{ name: 'checkout.completed', props: ['total'] }]`. */
|
|
44
|
+
function flattenEvents(events, prefix = "") {
|
|
45
|
+
const flat = [];
|
|
46
|
+
for (const [key, value] of Object.entries(events)) {
|
|
47
|
+
const name = prefix === "" ? key : `${prefix}.${key}`;
|
|
48
|
+
if (isEventDefinition(value)) flat.push({
|
|
49
|
+
name,
|
|
50
|
+
props: Object.keys(value.props)
|
|
51
|
+
});
|
|
52
|
+
else flat.push(...flattenEvents(value, name));
|
|
53
|
+
}
|
|
54
|
+
return flat;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { isEventDefinition as a, flattenEvents as i, PAGEVIEW as n, resolveEvent as o, event as r, BUILT_IN_EVENTS as t };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { a as InferProps, i as EventsShape, n as EventDefinition, o as PropsShape, r as EventsRecord, s as TrackArgs, t as AnalyticsTypes } from "./types-CVzbMMoR.js";
|
|
2
|
+
import { a as validateEvent, i as formatIssues, n as ValidatedEvent, r as defineAnalytics, t as AnalyticsPlan } from "./plan-CftGOQuT.js";
|
|
3
|
+
import { SyncOptions, syncTrackingPlan } from "./sync.js";
|
|
4
|
+
import { DEFAULT_ORIGIN } from "@stetcms/config";
|
|
5
|
+
|
|
6
|
+
//#region src/events.d.ts
|
|
7
|
+
/** The pageview every browser client sends. */
|
|
8
|
+
declare const PAGEVIEW = "$pageview";
|
|
9
|
+
/**
|
|
10
|
+
* Events the client sends for itself. `$`-prefixed so a tracking plan can
|
|
11
|
+
* never collide with one; their context (url, referrer) rides the envelope
|
|
12
|
+
* rather than the props, so they carry no schema of their own.
|
|
13
|
+
*/
|
|
14
|
+
declare const BUILT_IN_EVENTS: ReadonlySet<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Declares an event and the props it carries. Props are any Standard Schema
|
|
17
|
+
* validator, so the schema library is yours to pick (Zod, Valibot, ArkType).
|
|
18
|
+
* Events group by nesting, and nested events track under dot-joined names:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* events: {
|
|
22
|
+
* signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
23
|
+
* checkout: { started: event(), completed: event({ total: z.number() }) },
|
|
24
|
+
* }
|
|
25
|
+
* // → 'signup', 'checkout.started', 'checkout.completed'
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
declare function event<TProps extends PropsShape = Record<never, never>>(props?: TProps): EventDefinition<TProps>;
|
|
29
|
+
declare function isEventDefinition(value: unknown): value is EventDefinition;
|
|
30
|
+
/** Walks a dot-joined name (`checkout.completed`) to its definition. */
|
|
31
|
+
declare function resolveEvent(events: EventsShape, name: string): EventDefinition | undefined;
|
|
32
|
+
/** Flattens a nested plan to `[{ name: 'checkout.completed', props: ['total'] }]`. */
|
|
33
|
+
declare function flattenEvents(events: EventsShape, prefix?: string): {
|
|
34
|
+
name: string;
|
|
35
|
+
props: string[];
|
|
36
|
+
}[];
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/wire.d.ts
|
|
39
|
+
/** Events per batch. Anything larger is a client bug or an abusive caller. */
|
|
40
|
+
declare const MAX_BATCH_EVENTS = 100;
|
|
41
|
+
/** One event as it travels from the browser to the route you mounted. */
|
|
42
|
+
type WireEvent = {
|
|
43
|
+
name: string;
|
|
44
|
+
props: Record<string, unknown>; /** Epoch milliseconds, stamped in the browser when the event happened. */
|
|
45
|
+
timestamp: number;
|
|
46
|
+
url?: string;
|
|
47
|
+
referrer?: string;
|
|
48
|
+
};
|
|
49
|
+
/** The body the browser client POSTs to your route. */
|
|
50
|
+
type ClientBatch = {
|
|
51
|
+
context: Record<string, unknown>;
|
|
52
|
+
events: WireEvent[];
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* What the handler derives from the browser's request. Every field is
|
|
56
|
+
* optional: a proxy may strip the headers it comes from, and analytics never
|
|
57
|
+
* fails a request over missing context.
|
|
58
|
+
*/
|
|
59
|
+
type EventMetadata = {
|
|
60
|
+
/** Day-scoped visitor digest. Never a raw address, never stable past midnight. */visitor?: string;
|
|
61
|
+
country?: string;
|
|
62
|
+
region?: string;
|
|
63
|
+
city?: string;
|
|
64
|
+
browser?: string;
|
|
65
|
+
os?: string;
|
|
66
|
+
device?: 'desktop' | 'mobile' | 'tablet';
|
|
67
|
+
};
|
|
68
|
+
/** The body the handler forwards to Stet, once it has enriched the batch. */
|
|
69
|
+
type IngestBatch = {
|
|
70
|
+
context: Record<string, unknown>;
|
|
71
|
+
metadata: EventMetadata;
|
|
72
|
+
events: WireEvent[];
|
|
73
|
+
};
|
|
74
|
+
type ParseResult = {
|
|
75
|
+
ok: true;
|
|
76
|
+
value: ClientBatch;
|
|
77
|
+
} | {
|
|
78
|
+
ok: false;
|
|
79
|
+
error: string;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Validates a batch posted by a browser. The body is untrusted input from the
|
|
83
|
+
* open internet, so this checks shape by hand rather than trusting a cast;
|
|
84
|
+
* props are validated against the tracking plan separately.
|
|
85
|
+
*/
|
|
86
|
+
declare function parseClientBatch(body: unknown): ParseResult;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { type AnalyticsPlan, type AnalyticsTypes, BUILT_IN_EVENTS, type ClientBatch, DEFAULT_ORIGIN, type EventDefinition, type EventMetadata, type EventsRecord, type EventsShape, type InferProps, type IngestBatch, MAX_BATCH_EVENTS, PAGEVIEW, type PropsShape, type SyncOptions, type TrackArgs, type ValidatedEvent, type WireEvent, defineAnalytics, event, flattenEvents, formatIssues, isEventDefinition, parseClientBatch, resolveEvent, syncTrackingPlan, validateEvent };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { a as isEventDefinition, i as flattenEvents, n as PAGEVIEW, o as resolveEvent, r as event, t as BUILT_IN_EVENTS } from "./events-DnYdjoR6.js";
|
|
2
|
+
import { a as formatIssues, i as defineAnalytics, n as MAX_BATCH_EVENTS, o as validateEvent, r as parseClientBatch, t as DEFAULT_ORIGIN } from "./wire-J3xnrDYj.js";
|
|
3
|
+
import { syncTrackingPlan } from "./sync.js";
|
|
4
|
+
export { BUILT_IN_EVENTS, DEFAULT_ORIGIN, MAX_BATCH_EVENTS, PAGEVIEW, defineAnalytics, event, flattenEvents, formatIssues, isEventDefinition, parseClientBatch, resolveEvent, syncTrackingPlan, validateEvent };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { i as EventsShape, r as EventsRecord } from "./types-CVzbMMoR.js";
|
|
2
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
|
|
4
|
+
//#region src/plan.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A tracking plan: every event your product records, and the props each one
|
|
7
|
+
* carries. It is the single source of truth on both sides — the browser
|
|
8
|
+
* client types `track()` from it, the handler validates against it, and
|
|
9
|
+
* `@stetcms/vite` pushes it to Stet so your content team can build dashboards
|
|
10
|
+
* on the same events.
|
|
11
|
+
*/
|
|
12
|
+
type AnalyticsPlan<TEvents extends EventsShape = EventsShape> = {
|
|
13
|
+
events: TEvents; /** Stet deployment to send to. Defaults to `STET_ORIGIN`, then the cloud. */
|
|
14
|
+
origin?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Organization API key. Server-side only. Defaults to `STET_API_KEY`, which
|
|
17
|
+
* is how the plugin and the CLI read it too, so a plan file stays free of
|
|
18
|
+
* secrets and safe to commit.
|
|
19
|
+
*/
|
|
20
|
+
apiKey?: string; /** Phantom types read by `createAnalytics<typeof plan>`. No runtime value. */
|
|
21
|
+
$types: {
|
|
22
|
+
events: EventsRecord<TEvents>;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Declares the tracking plan, conventionally in `stet.config.ts`:
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* export default defineAnalytics({
|
|
30
|
+
* events: {
|
|
31
|
+
* signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
32
|
+
* checkout: { completed: event({ total: z.number() }) },
|
|
33
|
+
* },
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
declare function defineAnalytics<TEvents extends EventsShape>(config: {
|
|
38
|
+
events: TEvents;
|
|
39
|
+
origin?: string;
|
|
40
|
+
apiKey?: string;
|
|
41
|
+
}): AnalyticsPlan<TEvents>;
|
|
42
|
+
type ValidatedEvent = {
|
|
43
|
+
ok: true;
|
|
44
|
+
props: Record<string, unknown>;
|
|
45
|
+
} | {
|
|
46
|
+
ok: false;
|
|
47
|
+
issues: readonly StandardSchemaV1.Issue[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Validates an event's props against the plan. Declared props are checked
|
|
51
|
+
* with their own schema; undeclared props pass through, so adding a prop in
|
|
52
|
+
* the browser never blocks on a deploy of the plan.
|
|
53
|
+
*/
|
|
54
|
+
declare function validateEvent(events: EventsShape, name: string, props: Record<string, unknown> | undefined): Promise<ValidatedEvent>;
|
|
55
|
+
declare function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string;
|
|
56
|
+
//#endregion
|
|
57
|
+
export { validateEvent as a, formatIssues as i, ValidatedEvent as n, defineAnalytics as r, AnalyticsPlan as t };
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { i as EventsShape } from "./types-CVzbMMoR.js";
|
|
2
|
+
import { t as AnalyticsPlan } from "./plan-CftGOQuT.js";
|
|
3
|
+
|
|
4
|
+
//#region src/server.d.ts
|
|
5
|
+
type AnalyticsHandlerOptions = {
|
|
6
|
+
/**
|
|
7
|
+
* Props merged into every batch. Yours win over anything the browser sent,
|
|
8
|
+
* because the browser is the untrusted side. A function is called per
|
|
9
|
+
* request, which is how request-scoped values like a session reach here.
|
|
10
|
+
*/
|
|
11
|
+
context?: Record<string, unknown> | ((request: Request) => Record<string, unknown> | Promise<Record<string, unknown>>); /** Stet deployment. Defaults to the plan's, then `STET_ORIGIN`, then the cloud. */
|
|
12
|
+
origin?: string; /** Organization API key. Defaults to the plan's, then `STET_API_KEY`. */
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Salt mixed into the visitor digest. Defaults to the request's host, which
|
|
16
|
+
* keeps visitor ids from being comparable across sites you run.
|
|
17
|
+
*/
|
|
18
|
+
salt?: string; /** Called when a batch cannot be forwarded. Defaults to console.error. */
|
|
19
|
+
onError?: (error: unknown) => void; /** Override fetch, e.g. in tests. */
|
|
20
|
+
fetch?: typeof globalThis.fetch;
|
|
21
|
+
};
|
|
22
|
+
declare function createAnalyticsHandler<TEvents extends EventsShape>(plan: AnalyticsPlan<TEvents>, options?: AnalyticsHandlerOptions): (request: Request) => Promise<Response>;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { AnalyticsHandlerOptions, createAnalyticsHandler };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { a as formatIssues, o as validateEvent, r as parseClientBatch } from "./wire-J3xnrDYj.js";
|
|
2
|
+
import { resolveStetConfig } from "@stetcms/config";
|
|
3
|
+
//#region src/metadata.ts
|
|
4
|
+
const BOT_PATTERN = /bot|crawl|spider|slurp|headless|lighthouse|pingdom|preview|monitor|curl|wget|python-requests|axios|node-fetch/i;
|
|
5
|
+
/** Whether the caller announces itself as automation rather than a reader. */
|
|
6
|
+
function isBot(userAgent) {
|
|
7
|
+
return BOT_PATTERN.test(userAgent);
|
|
8
|
+
}
|
|
9
|
+
function browserOf(userAgent, brands) {
|
|
10
|
+
for (const brand of [
|
|
11
|
+
"Microsoft Edge",
|
|
12
|
+
"Opera",
|
|
13
|
+
"Samsung Internet",
|
|
14
|
+
"Google Chrome",
|
|
15
|
+
"Firefox"
|
|
16
|
+
]) if (brands.includes(brand)) return brand === "Google Chrome" ? "Chrome" : brand === "Microsoft Edge" ? "Edge" : brand;
|
|
17
|
+
if (/Edg[A-Z]?\//.test(userAgent)) return "Edge";
|
|
18
|
+
if (/OPR\/|Opera/.test(userAgent)) return "Opera";
|
|
19
|
+
if (/SamsungBrowser\//.test(userAgent)) return "Samsung Internet";
|
|
20
|
+
if (/Firefox\/|FxiOS\//.test(userAgent)) return "Firefox";
|
|
21
|
+
if (/Chrome\/|CriOS\//.test(userAgent)) return "Chrome";
|
|
22
|
+
if (/Safari\//.test(userAgent)) return "Safari";
|
|
23
|
+
}
|
|
24
|
+
function osOf(userAgent, platform) {
|
|
25
|
+
const hint = platform.replaceAll("\"", "").trim();
|
|
26
|
+
if (hint !== "") return hint === "macOS" || hint === "iOS" ? hint : hint === "Chrome OS" ? "ChromeOS" : hint;
|
|
27
|
+
if (/Android/.test(userAgent)) return "Android";
|
|
28
|
+
if (/iPhone|iPad|iPod/.test(userAgent)) return "iOS";
|
|
29
|
+
if (/Windows NT/.test(userAgent)) return "Windows";
|
|
30
|
+
if (/Mac OS X/.test(userAgent)) return "macOS";
|
|
31
|
+
if (/CrOS/.test(userAgent)) return "ChromeOS";
|
|
32
|
+
if (/Linux/.test(userAgent)) return "Linux";
|
|
33
|
+
}
|
|
34
|
+
function deviceOf(userAgent, mobileHint) {
|
|
35
|
+
if (/iPad|Tablet|PlayBook|Silk/.test(userAgent) || /Android(?!.*Mobile)/.test(userAgent)) return "tablet";
|
|
36
|
+
if (mobileHint.includes("?1") || /Mobi|iPhone|iPod|Android/.test(userAgent)) return "mobile";
|
|
37
|
+
if (userAgent === "") return;
|
|
38
|
+
return "desktop";
|
|
39
|
+
}
|
|
40
|
+
function clientAddress(headers) {
|
|
41
|
+
return headers.get("cf-connecting-ip") ?? headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
|
|
42
|
+
}
|
|
43
|
+
function decode(value) {
|
|
44
|
+
if (value === null || value === "") return;
|
|
45
|
+
try {
|
|
46
|
+
return decodeURIComponent(value);
|
|
47
|
+
} catch {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Geo as the edge already knows it. Nothing is looked up, so nothing is billed. */
|
|
52
|
+
function geoOf(request) {
|
|
53
|
+
const cf = request.cf;
|
|
54
|
+
const headers = request.headers;
|
|
55
|
+
return {
|
|
56
|
+
country: cf?.country ?? decode(headers.get("cf-ipcountry") ?? headers.get("x-vercel-ip-country")),
|
|
57
|
+
region: cf?.region ?? decode(headers.get("x-vercel-ip-country-region")),
|
|
58
|
+
city: cf?.city ?? decode(headers.get("x-vercel-ip-city"))
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A visitor id that cannot outlive the day it was made: the digest covers the
|
|
63
|
+
* date, so the same reader tomorrow is a different id, and the inputs (IP and
|
|
64
|
+
* user agent) are never stored or forwarded anywhere. No cookie, nothing to
|
|
65
|
+
* consent to, and nothing to join across days even by whoever holds the data.
|
|
66
|
+
*/
|
|
67
|
+
async function visitorDigest(request, salt) {
|
|
68
|
+
const address = clientAddress(request.headers);
|
|
69
|
+
const userAgent = request.headers.get("user-agent") ?? "";
|
|
70
|
+
if (address === "" && userAgent === "") return;
|
|
71
|
+
const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
72
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${day}:${salt}:${address}:${userAgent}`));
|
|
73
|
+
return [...new Uint8Array(digest)].slice(0, 16).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Everything Stet learns about a reader, derived here in your own backend so
|
|
77
|
+
* the raw address and user agent never leave it.
|
|
78
|
+
*/
|
|
79
|
+
async function deriveMetadata(request, salt) {
|
|
80
|
+
const headers = request.headers;
|
|
81
|
+
const userAgent = headers.get("user-agent") ?? "";
|
|
82
|
+
const metadata = {
|
|
83
|
+
visitor: await visitorDigest(request, salt),
|
|
84
|
+
...geoOf(request),
|
|
85
|
+
browser: browserOf(userAgent, headers.get("sec-ch-ua") ?? ""),
|
|
86
|
+
os: osOf(userAgent, headers.get("sec-ch-ua-platform") ?? ""),
|
|
87
|
+
device: deviceOf(userAgent, headers.get("sec-ch-ua-mobile") ?? "")
|
|
88
|
+
};
|
|
89
|
+
for (const key of Object.keys(metadata)) if (metadata[key] === void 0) delete metadata[key];
|
|
90
|
+
return metadata;
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/server.ts
|
|
94
|
+
/**
|
|
95
|
+
* The route you mount in your own app. Events go browser → your backend →
|
|
96
|
+
* Stet, so there is no third-party origin in the page, nothing for a blocker
|
|
97
|
+
* to match on, and every reader's address stays inside infrastructure you
|
|
98
|
+
* control.
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* import { createAnalyticsHandler } from '@stetcms/analytics/server';
|
|
102
|
+
* import plan from '../stet.config';
|
|
103
|
+
*
|
|
104
|
+
* const handler = createAnalyticsHandler(plan, {
|
|
105
|
+
* context: async (request) => ({ userId: (await session(request))?.userId }),
|
|
106
|
+
* });
|
|
107
|
+
* export const POST = handler;
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
function json(body, status = 200) {
|
|
111
|
+
return new Response(JSON.stringify(body), {
|
|
112
|
+
status,
|
|
113
|
+
headers: { "content-type": "application/json" }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function createAnalyticsHandler(plan, options = {}) {
|
|
117
|
+
const onError = options.onError ?? ((error) => console.error("[stet analytics]", error));
|
|
118
|
+
return async (request) => {
|
|
119
|
+
if (request.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
120
|
+
let body;
|
|
121
|
+
try {
|
|
122
|
+
body = await request.json();
|
|
123
|
+
} catch {
|
|
124
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseClientBatch(body);
|
|
127
|
+
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
128
|
+
if (isBot(request.headers.get("user-agent") ?? "")) return json({ accepted: 0 });
|
|
129
|
+
const events = [];
|
|
130
|
+
for (const event of parsed.value.events) {
|
|
131
|
+
const validated = await validateEvent(plan.events, event.name, event.props);
|
|
132
|
+
if (!validated.ok) return json({ error: `event "${event.name}": ${formatIssues(validated.issues)}` }, 400);
|
|
133
|
+
events.push({
|
|
134
|
+
...event,
|
|
135
|
+
props: validated.props
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const context = { ...parsed.value.context };
|
|
139
|
+
const server = typeof options.context === "function" ? await options.context(request) : options.context;
|
|
140
|
+
for (const [key, value] of Object.entries(server ?? {})) if (value !== void 0) context[key] = value;
|
|
141
|
+
const batch = {
|
|
142
|
+
context,
|
|
143
|
+
metadata: await deriveMetadata(request, options.salt ?? new URL(request.url).host),
|
|
144
|
+
events
|
|
145
|
+
};
|
|
146
|
+
const { origin, apiKey } = resolveStetConfig({
|
|
147
|
+
origin: plan.origin,
|
|
148
|
+
apiKey: plan.apiKey
|
|
149
|
+
}, {
|
|
150
|
+
origin: options.origin,
|
|
151
|
+
apiKey: options.apiKey
|
|
152
|
+
});
|
|
153
|
+
if (apiKey === void 0) {
|
|
154
|
+
onError(/* @__PURE__ */ new Error("No API key. Set STET_API_KEY or pass apiKey."));
|
|
155
|
+
return json({ error: "analytics is not configured" }, 500);
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const response = await (options.fetch ?? globalThis.fetch.bind(globalThis))(`${origin}/api/v1/events`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
"content-type": "application/json",
|
|
162
|
+
"x-api-key": apiKey
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify(batch)
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok) throw new Error(`Stet responded ${response.status}`);
|
|
167
|
+
return json({ accepted: events.length });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
onError(error);
|
|
170
|
+
return json({ error: "failed to forward events" }, 502);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
export { createAnalyticsHandler };
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { i as EventsShape } from "./types-CVzbMMoR.js";
|
|
2
|
+
|
|
3
|
+
//#region src/sync.d.ts
|
|
4
|
+
type SyncOptions = {
|
|
5
|
+
events: EventsShape; /** Organization API key. Server-side only. */
|
|
6
|
+
apiKey: string;
|
|
7
|
+
origin?: string;
|
|
8
|
+
fetch?: typeof globalThis.fetch;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Publishes the tracking plan, so the events your code declares can be picked
|
|
12
|
+
* from the dashboard before anyone has fired one. Run by `@stetcms/vite` on
|
|
13
|
+
* dev-server start and at the end of a build, and by `stet sync`.
|
|
14
|
+
*
|
|
15
|
+
* Replacement, not merge: an event deleted from your code disappears from the
|
|
16
|
+
* list on the next sync, while anything already recorded under that name
|
|
17
|
+
* keeps its history.
|
|
18
|
+
*/
|
|
19
|
+
declare function syncTrackingPlan(options: SyncOptions): Promise<{
|
|
20
|
+
synced: number;
|
|
21
|
+
}>;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { SyncOptions, syncTrackingPlan };
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { DEFAULT_ORIGIN } from "@stetcms/config";
|
|
2
|
+
//#region src/sync.ts
|
|
3
|
+
/**
|
|
4
|
+
* Publishing the tracking plan to Stet.
|
|
5
|
+
*
|
|
6
|
+
* Free of relative imports on purpose, like `@stetcms/client/codegen`: build
|
|
7
|
+
* tools load this entry under plain Node, which resolves package names but
|
|
8
|
+
* not this package's own extensionless paths. That is why the flatten walk is
|
|
9
|
+
* repeated here rather than imported from `./events`.
|
|
10
|
+
*/
|
|
11
|
+
function flatten(events, prefix = "") {
|
|
12
|
+
const flat = [];
|
|
13
|
+
for (const [key, value] of Object.entries(events)) {
|
|
14
|
+
const name = prefix === "" ? key : `${prefix}.${key}`;
|
|
15
|
+
if (value.$event === true) flat.push({
|
|
16
|
+
name,
|
|
17
|
+
props: Object.keys(value.props)
|
|
18
|
+
});
|
|
19
|
+
else flat.push(...flatten(value, name));
|
|
20
|
+
}
|
|
21
|
+
return flat;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Publishes the tracking plan, so the events your code declares can be picked
|
|
25
|
+
* from the dashboard before anyone has fired one. Run by `@stetcms/vite` on
|
|
26
|
+
* dev-server start and at the end of a build, and by `stet sync`.
|
|
27
|
+
*
|
|
28
|
+
* Replacement, not merge: an event deleted from your code disappears from the
|
|
29
|
+
* list on the next sync, while anything already recorded under that name
|
|
30
|
+
* keeps its history.
|
|
31
|
+
*/
|
|
32
|
+
async function syncTrackingPlan(options) {
|
|
33
|
+
const origin = options.origin ?? DEFAULT_ORIGIN;
|
|
34
|
+
const response = await (options.fetch ?? globalThis.fetch.bind(globalThis))(`${origin}/api/v1/events/schema`, {
|
|
35
|
+
method: "PUT",
|
|
36
|
+
headers: {
|
|
37
|
+
"content-type": "application/json",
|
|
38
|
+
"x-api-key": options.apiKey
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ events: flatten(options.events) })
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) throw new Error(`PUT ${origin}/api/v1/events/schema responded ${response.status}`);
|
|
43
|
+
return await response.json();
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { syncTrackingPlan };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
type PropsShape = Record<string, StandardSchemaV1>;
|
|
5
|
+
type EventDefinition<TProps extends PropsShape = PropsShape> = {
|
|
6
|
+
$event: true;
|
|
7
|
+
props: TProps;
|
|
8
|
+
};
|
|
9
|
+
/** Events nest into groups: `{ checkout: { started: event() } }`. */
|
|
10
|
+
type EventsShape = {
|
|
11
|
+
[name: string]: EventDefinition | EventsShape;
|
|
12
|
+
};
|
|
13
|
+
type Pretty<T> = { [K in keyof T]: T[K] } & {};
|
|
14
|
+
type RawProps<T extends PropsShape> = { [K in keyof T]: StandardSchemaV1.InferInput<T[K]> };
|
|
15
|
+
type UndefinedKeys<T> = { [K in keyof T]: undefined extends T[K] ? K : never }[keyof T];
|
|
16
|
+
/** A props shape as its call site writes it: schemas accepting `undefined` become optional. */
|
|
17
|
+
type InferProps<T extends PropsShape> = Pretty<Partial<Pick<RawProps<T>, UndefinedKeys<RawProps<T>>>> & Omit<RawProps<T>, UndefinedKeys<RawProps<T>>>>;
|
|
18
|
+
type Join<P extends string, K extends string> = P extends '' ? K : `${P}.${K}`;
|
|
19
|
+
type FlatEventProps<T extends EventsShape, P extends string = ''> = { [K in keyof T & string]: T[K] extends EventDefinition<infer TProps> ? Record<Join<P, K>, InferProps<TProps>> : T[K] extends EventsShape ? FlatEventProps<T[K], Join<P, K>> : never }[keyof T & string];
|
|
20
|
+
type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never;
|
|
21
|
+
/** A nested plan flattened to dot-joined names: `{ 'checkout.started': {...} }`. */
|
|
22
|
+
type EventsRecord<T extends EventsShape> = Pretty<UnionToIntersection<FlatEventProps<T>>>;
|
|
23
|
+
/**
|
|
24
|
+
* The argument tuple for `track(name, ...)`. Props stay optional while every
|
|
25
|
+
* one of them is, so an event carrying no props tracks with a bare name.
|
|
26
|
+
*/
|
|
27
|
+
type TrackArgs<TProps> = Partial<TProps> extends TProps ? [props?: TProps] : [props: TProps];
|
|
28
|
+
/**
|
|
29
|
+
* The structural contract between a tracking plan and the browser client:
|
|
30
|
+
* `createAnalytics<typeof plan>` reads only the phantom `$types`, so nothing
|
|
31
|
+
* in the plan's module graph (schemas, secrets) has to reach the browser.
|
|
32
|
+
*/
|
|
33
|
+
type AnalyticsTypes = {
|
|
34
|
+
$types: {
|
|
35
|
+
events: Record<string, Record<string, unknown>>;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { InferProps as a, EventsShape as i, EventDefinition as n, PropsShape as o, EventsRecord as r, TrackArgs as s, AnalyticsTypes as t };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { o as resolveEvent, t as BUILT_IN_EVENTS } from "./events-DnYdjoR6.js";
|
|
2
|
+
import { DEFAULT_ORIGIN as DEFAULT_ORIGIN$1 } from "@stetcms/config";
|
|
3
|
+
//#region src/plan.ts
|
|
4
|
+
/**
|
|
5
|
+
* Declares the tracking plan, conventionally in `stet.config.ts`:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* export default defineAnalytics({
|
|
9
|
+
* events: {
|
|
10
|
+
* signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
11
|
+
* checkout: { completed: event({ total: z.number() }) },
|
|
12
|
+
* },
|
|
13
|
+
* });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
function defineAnalytics(config) {
|
|
17
|
+
return {
|
|
18
|
+
...config,
|
|
19
|
+
$types: void 0
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Validates an event's props against the plan. Declared props are checked
|
|
24
|
+
* with their own schema; undeclared props pass through, so adding a prop in
|
|
25
|
+
* the browser never blocks on a deploy of the plan.
|
|
26
|
+
*/
|
|
27
|
+
async function validateEvent(events, name, props) {
|
|
28
|
+
if (BUILT_IN_EVENTS.has(name)) return {
|
|
29
|
+
ok: true,
|
|
30
|
+
props: props ?? {}
|
|
31
|
+
};
|
|
32
|
+
const definition = resolveEvent(events, name);
|
|
33
|
+
if (definition === void 0) return {
|
|
34
|
+
ok: false,
|
|
35
|
+
issues: [{ message: `unknown event "${name}"` }]
|
|
36
|
+
};
|
|
37
|
+
const input = props ?? {};
|
|
38
|
+
const output = { ...input };
|
|
39
|
+
const issues = [];
|
|
40
|
+
for (const [key, schema] of Object.entries(definition.props)) {
|
|
41
|
+
let result = schema["~standard"].validate(input[key]);
|
|
42
|
+
if (result instanceof Promise) result = await result;
|
|
43
|
+
if (result.issues) issues.push(...result.issues.map((issue) => ({
|
|
44
|
+
...issue,
|
|
45
|
+
message: `${key}: ${issue.message}`
|
|
46
|
+
})));
|
|
47
|
+
else output[key] = result.value;
|
|
48
|
+
}
|
|
49
|
+
if (issues.length > 0) return {
|
|
50
|
+
ok: false,
|
|
51
|
+
issues
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
props: output
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function formatIssues(issues) {
|
|
59
|
+
return issues.map((issue) => issue.message).join("; ");
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/wire.ts
|
|
63
|
+
/** Events per batch. Anything larger is a client bug or an abusive caller. */
|
|
64
|
+
const MAX_BATCH_EVENTS = 100;
|
|
65
|
+
const MAX_NAME_LENGTH = 120;
|
|
66
|
+
function isRecord(value) {
|
|
67
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
68
|
+
}
|
|
69
|
+
function parseEvent(value, index) {
|
|
70
|
+
if (!isRecord(value)) return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `events[${index}] is not an object`
|
|
73
|
+
};
|
|
74
|
+
const { name, props, timestamp, url, referrer } = value;
|
|
75
|
+
if (typeof name !== "string" || name === "" || name.length > MAX_NAME_LENGTH) return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: `events[${index}].name must be a name of 1 to ${MAX_NAME_LENGTH} characters`
|
|
78
|
+
};
|
|
79
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp < 0) return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `events[${index}].timestamp must be epoch milliseconds`
|
|
82
|
+
};
|
|
83
|
+
if (props !== void 0 && !isRecord(props)) return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: `events[${index}].props must be an object`
|
|
86
|
+
};
|
|
87
|
+
if (url !== void 0 && typeof url !== "string") return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: `events[${index}].url must be a string`
|
|
90
|
+
};
|
|
91
|
+
if (referrer !== void 0 && typeof referrer !== "string") return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: `events[${index}].referrer must be a string`
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
value: {
|
|
98
|
+
name,
|
|
99
|
+
props: isRecord(props) ? props : {},
|
|
100
|
+
timestamp,
|
|
101
|
+
...typeof url === "string" ? { url } : {},
|
|
102
|
+
...typeof referrer === "string" ? { referrer } : {}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Validates a batch posted by a browser. The body is untrusted input from the
|
|
108
|
+
* open internet, so this checks shape by hand rather than trusting a cast;
|
|
109
|
+
* props are validated against the tracking plan separately.
|
|
110
|
+
*/
|
|
111
|
+
function parseClientBatch(body) {
|
|
112
|
+
if (!isRecord(body)) return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: "body must be an object"
|
|
115
|
+
};
|
|
116
|
+
if (!Array.isArray(body.events) || body.events.length === 0) return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: "events must be a non-empty array"
|
|
119
|
+
};
|
|
120
|
+
if (body.events.length > 100) return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: `events must hold at most 100 entries`
|
|
123
|
+
};
|
|
124
|
+
if (body.context !== void 0 && !isRecord(body.context)) return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: "context must be an object"
|
|
127
|
+
};
|
|
128
|
+
const events = [];
|
|
129
|
+
for (const [index, raw] of body.events.entries()) {
|
|
130
|
+
const parsed = parseEvent(raw, index);
|
|
131
|
+
if (!parsed.ok) return parsed;
|
|
132
|
+
events.push(parsed.value);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
value: {
|
|
137
|
+
context: isRecord(body.context) ? body.context : {},
|
|
138
|
+
events
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
//#endregion
|
|
143
|
+
export { formatIssues as a, defineAnalytics as i, MAX_BATCH_EVENTS as n, validateEvent as o, parseClientBatch as r, DEFAULT_ORIGIN$1 as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stetcms/analytics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type-safe, cookieless product analytics for Stet, routed through your own backend.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"analytics",
|
|
7
|
+
"cookieless",
|
|
8
|
+
"pageviews",
|
|
9
|
+
"stet",
|
|
10
|
+
"tracking"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/jamiedavenport/stet/tree/main/published/analytics#readme",
|
|
13
|
+
"bugs": "https://github.com/jamiedavenport/stet/issues",
|
|
14
|
+
"license": "Apache-2.0",
|
|
15
|
+
"author": "Jamie Davenport (https://jxd.dev)",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/jamiedavenport/stet.git",
|
|
19
|
+
"directory": "published/analytics"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./client": {
|
|
31
|
+
"types": "./dist/client.d.ts",
|
|
32
|
+
"default": "./dist/client.js"
|
|
33
|
+
},
|
|
34
|
+
"./server": {
|
|
35
|
+
"types": "./dist/server.d.ts",
|
|
36
|
+
"default": "./dist/server.js"
|
|
37
|
+
},
|
|
38
|
+
"./sync": {
|
|
39
|
+
"types": "./dist/sync.d.ts",
|
|
40
|
+
"default": "./dist/sync.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@standard-schema/spec": "^1.1.0",
|
|
48
|
+
"@stetcms/config": "0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"publint": "^0.3.21",
|
|
52
|
+
"typescript": "^5.9.3",
|
|
53
|
+
"vite-plus": "0.2.2",
|
|
54
|
+
"vitest": "^4.1.9",
|
|
55
|
+
"zod": "^4.4.3"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "vp pack",
|
|
62
|
+
"tc": "tsc --noEmit",
|
|
63
|
+
"test": "vp test run"
|
|
64
|
+
}
|
|
65
|
+
}
|