@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 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.
@@ -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 };
@@ -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 };
@@ -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
+ }