highseat 0.3.0 → 0.4.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/README.md +141 -18
- package/index.d.ts +40 -0
- package/index.js +15 -5
- package/index.js.map +1 -1
- package/next.d.ts +13 -6
- package/next.js +12 -6
- package/next.js.map +1 -1
- package/package.json +1 -1
- package/react.d.ts +19 -3
- package/react.js +29 -4
- package/react.js.map +1 -1
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ npm install highseat
|
|
|
12
12
|
|
|
13
13
|
| Import | Contents | Needs |
|
|
14
14
|
| --- | --- | --- |
|
|
15
|
-
| `highseat` | `track()`, `identify()`, `getAttributionId()` and `
|
|
16
|
-
| `highseat/react` | `<Highseat
|
|
17
|
-
| `highseat/next` | `<Highseat />`, `useIdentify()`, `createProxy()` and `withHighseat()` for Next.js | Next.js |
|
|
15
|
+
| `highseat` | `track()`, `identify()`, `getAttributionId()`, `inject()` and `setBeforeSend()` | Nothing |
|
|
16
|
+
| `highseat/react` | `<Highseat />`, `useIdentify()` and `useBeforeSend()` | React |
|
|
17
|
+
| `highseat/next` | `<Highseat />`, `useIdentify()`, `useBeforeSend()`, `createProxy()` and `withHighseat()` for Next.js | Next.js |
|
|
18
18
|
| `highseat/proxy` | `createProxy()`, a first-party proxy for any runtime with `fetch` | Nothing |
|
|
19
19
|
|
|
20
20
|
Each entry point loads on its own and imports no framework it does not name, so `highseat/proxy` runs in a Cloudflare Worker without React installed.
|
|
@@ -23,14 +23,7 @@ The package loads the tracker from Highseat, or from your site's proxy path, rat
|
|
|
23
23
|
|
|
24
24
|
## Next.js
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
NEXT_PUBLIC_HIGHSEAT_SITE=site_…
|
|
30
|
-
HIGHSEAT_PATH=/3f9a0c1e2b
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Then render `<Highseat />` once, in the root layout:
|
|
26
|
+
Render `<Highseat />` once, in the root layout, with the site id from your site's Installation settings:
|
|
34
27
|
|
|
35
28
|
```tsx
|
|
36
29
|
// app/layout.tsx
|
|
@@ -41,27 +34,66 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
41
34
|
<html lang="en">
|
|
42
35
|
<body>
|
|
43
36
|
{children}
|
|
44
|
-
<Highseat />
|
|
37
|
+
<Highseat site="site_…" />
|
|
45
38
|
</body>
|
|
46
39
|
</html>
|
|
47
40
|
)
|
|
48
41
|
}
|
|
49
42
|
```
|
|
50
43
|
|
|
51
|
-
|
|
44
|
+
To keep the site id out of the code, leave `site` out and set it for the build, with the proxy path for first-party delivery:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
NEXT_PUBLIC_HIGHSEAT_SITE=site_…
|
|
48
|
+
HIGHSEAT_PATH=/3f9a0c1e2b
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Without `HIGHSEAT_PATH`, `<Highseat />` loads the tracker from Highseat's host. The tracker counts each client-side navigation as one page view on its own. With the Pages Router, render it in `pages/_app.tsx` instead.
|
|
52
52
|
|
|
53
53
|
## React
|
|
54
54
|
|
|
55
|
+
Render `<Highseat />` once, beside your root component:
|
|
56
|
+
|
|
55
57
|
```tsx
|
|
56
58
|
import { Highseat } from "highseat/react"
|
|
57
59
|
|
|
58
60
|
<Highseat site="site_…" />
|
|
59
61
|
```
|
|
60
62
|
|
|
61
|
-
`src` takes the site's proxy path (`/3f9a0c1e2b`) for first-party delivery. `outbound={false}` and `downloads={false}` turn off the automatic link and download events, and `hashRouting` counts `#/route` changes as page views.
|
|
63
|
+
With Vite, `site={import.meta.env.VITE_HIGHSEAT_SITE}` reads the site id from the environment instead; with another bundler, read it from that bundler's public variable. `src` takes the site's proxy path (`/3f9a0c1e2b`) for first-party delivery. `outbound={false}` and `downloads={false}` turn off the automatic link and download events, and `hashRouting` counts `#/route` changes as page views.
|
|
62
64
|
|
|
63
65
|
Without React, `inject({ site })` from `highseat` adds the same script tag.
|
|
64
66
|
|
|
67
|
+
## TanStack Start
|
|
68
|
+
|
|
69
|
+
Render `<Highseat />` once, inside `<body>` in the root route's component:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
// src/routes/__root.tsx
|
|
73
|
+
import { Highseat } from "highseat/react"
|
|
74
|
+
|
|
75
|
+
<Highseat site="site_…" />
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
To serve the tracker from your own domain, see TanStack Start under [First-party proxy](#first-party-proxy).
|
|
79
|
+
|
|
80
|
+
## Astro
|
|
81
|
+
|
|
82
|
+
Add the tracker's tag to the `<head>` of your layout. `is:inline` keeps Astro from bundling it, so the tracker starts before any page script:
|
|
83
|
+
|
|
84
|
+
```astro
|
|
85
|
+
<!-- src/layouts/Layout.astro -->
|
|
86
|
+
<script is:inline defer src="https://cdn.highseat.com/script.js" data-site="site_…"></script>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Any other site
|
|
90
|
+
|
|
91
|
+
A site without a bundler, or a website builder such as Webflow or Shopify, needs no package. Add the tag to the `<head>` of every page, in the builder's head code field:
|
|
92
|
+
|
|
93
|
+
```html
|
|
94
|
+
<script defer src="https://cdn.highseat.com/script.js" data-site="site_…"></script>
|
|
95
|
+
```
|
|
96
|
+
|
|
65
97
|
## Debugging
|
|
66
98
|
|
|
67
99
|
In development, the package logs every `track()` and `identify()` call to the console, with its properties or id, and says whether the call went to the tracker or waits in the queue for it to load. It also warns once per page when nothing can reach Highseat: the page has no tracker script, or the script failed to load, as one an ad blocker stopped does.
|
|
@@ -133,6 +165,59 @@ The value has to tell loading apart from signed out. `useIdentify(session?.user.
|
|
|
133
165
|
|
|
134
166
|
Without React, call `identify()` from `highseat` after each sign-in and sign-out.
|
|
135
167
|
|
|
168
|
+
## Redact or drop events
|
|
169
|
+
|
|
170
|
+
A page URL can carry a secret: a password-reset token in the path, an email address in the query. A `beforeSend` hook runs in the browser on every event before the tracker sends it, page views from client-side navigation included. Return the event, changed or not, or `null` to drop it. The hook can change the URL, the referrer and the properties, and the page path follows the URL it returns. It cannot change the site or the user id.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import type { BeforeSendEvent } from "highseat"
|
|
174
|
+
|
|
175
|
+
// Strips `email` from the query and templates the reset token, in the URL and the referrer.
|
|
176
|
+
function redact(value: string) {
|
|
177
|
+
const url = new URL(value)
|
|
178
|
+
url.searchParams.delete("email")
|
|
179
|
+
url.pathname = url.pathname.replace(/^\/reset\/[^/]+/, "/reset/:token")
|
|
180
|
+
return url.href
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function beforeSend(event: BeforeSendEvent) {
|
|
184
|
+
// Drops every event on the admin pages, so an in-app navigation there sends nothing.
|
|
185
|
+
if (new URL(event.url).pathname.startsWith("/admin/")) return null
|
|
186
|
+
return { ...event, url: redact(event.url), referrer: event.referrer && redact(event.referrer) }
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
A hook that throws drops the event, since sending it unchanged would leak what the hook was there to remove. The error still reaches the console. Returning nothing drops the event too.
|
|
191
|
+
|
|
192
|
+
A function cannot pass from a server component to the browser, so in a Next.js App Router layout, set the hook with `useBeforeSend()` in a client component rendered beside `<Highseat />`:
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
"use client"
|
|
196
|
+
|
|
197
|
+
import { useBeforeSend } from "highseat/next"
|
|
198
|
+
import { beforeSend } from "~/lib/analytics"
|
|
199
|
+
|
|
200
|
+
export function Redact() {
|
|
201
|
+
useBeforeSend(beforeSend)
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`highseat/react` takes the hook as a prop, `<Highseat site="site_…" beforeSend={beforeSend} />`, and `inject({ site, beforeSend })` takes it as an option. `setBeforeSend()` from `highseat` sets or clears it at any time. Each of them works before the tracker has loaded, and the first page view already passes through the hook.
|
|
207
|
+
|
|
208
|
+
A site with only the script tag sets the hook on `window.highseat` in a script before the tracker's tag:
|
|
209
|
+
|
|
210
|
+
```html
|
|
211
|
+
<script>
|
|
212
|
+
window.highseat = {
|
|
213
|
+
beforeSend(event) {
|
|
214
|
+
return new URL(event.url).pathname.startsWith("/admin/") ? null : event
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
</script>
|
|
218
|
+
<script defer src="https://cdn.highseat.com/script.js" data-site="site_…"></script>
|
|
219
|
+
```
|
|
220
|
+
|
|
136
221
|
## Revenue attribution
|
|
137
222
|
|
|
138
223
|
`getAttributionId()` resolves to the id that joins a payment to the visit. Pass it to your checkout as `attribution_id` metadata. It resolves to `undefined` if the tracker has not loaded within three seconds, so a checkout that awaits it never hangs.
|
|
@@ -155,7 +240,11 @@ HIGHSEAT_SECRET=hs_secret_... # the site secret
|
|
|
155
240
|
|
|
156
241
|
A missing variable never stops the site. Without the secret, or without the path on a handler with a `fallback`, `createProxy()` warns once and turns the proxy off: with a `fallback`, every request goes to it, and without one the proxy path answers `503` with a JSON `error` naming the variable. A local server without the variables therefore still starts and serves the site.
|
|
157
242
|
|
|
158
|
-
Then load the tracker from the path. In Next.js, `<Highseat />` reads `HIGHSEAT_PATH` itself. Elsewhere, pass `src="/3f9a0c1e2b"` to `<Highseat />` or `inject()`, or
|
|
243
|
+
Then load the tracker from the path. In Next.js, `<Highseat />` reads `HIGHSEAT_PATH` itself. Elsewhere, pass `src="/3f9a0c1e2b"` to `<Highseat />` or `inject()`, or load it with a script tag in the `<head>` of every page:
|
|
244
|
+
|
|
245
|
+
```html
|
|
246
|
+
<script defer src="/3f9a0c1e2b" data-site="site_…"></script>
|
|
247
|
+
```
|
|
159
248
|
|
|
160
249
|
To rotate the path, rotate it in Installation settings, update `HIGHSEAT_PATH` and the snippet, and redeploy. On a host whose route is a file or a config entry (Netlify, Vercel without Next.js, Astro), move that route too.
|
|
161
250
|
|
|
@@ -191,9 +280,27 @@ export default withHighseat({
|
|
|
191
280
|
})
|
|
192
281
|
```
|
|
193
282
|
|
|
283
|
+
Then render `<Highseat />` in the root layout without `site` or `src`, in place of whatever loads the tracker now. It reads the site id from `NEXT_PUBLIC_HIGHSEAT_SITE` and the path from `HIGHSEAT_PATH`, and a `src` prop would outrank the path:
|
|
284
|
+
|
|
285
|
+
```tsx
|
|
286
|
+
// app/layout.tsx
|
|
287
|
+
import { Highseat } from "highseat/next"
|
|
288
|
+
|
|
289
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
290
|
+
return (
|
|
291
|
+
<html lang="en">
|
|
292
|
+
<body>
|
|
293
|
+
{children}
|
|
294
|
+
<Highseat />
|
|
295
|
+
</body>
|
|
296
|
+
</html>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
194
301
|
Next.js loads `next.config.ts` with `require`, and the package is ES modules only, so the config needs Node 20.19 or 22.12 and later, the versions that `require` an ES module without a flag.
|
|
195
302
|
|
|
196
|
-
`withHighseat()` rewrites `HIGHSEAT_PATH` to the route handler inside the app, so the visitor's IP survives and the route file never moves. Pass `{ route: "/stats" }` for a handler elsewhere. Without `HIGHSEAT_PATH`, `next dev` and `next build` warn once and leave the rewrite out, and `next typegen` runs as usual. Set the variable for the build as
|
|
303
|
+
`withHighseat()` rewrites `HIGHSEAT_PATH` to the route handler inside the app, so the visitor's IP survives and the route file never moves. Pass `{ route: "/stats" }` for a handler elsewhere. Without `HIGHSEAT_PATH`, `next dev` and `next build` warn once and leave the rewrite out, and `next typegen` runs as usual. Set the variable for the build, as a build argument in a Docker build. `withHighseat()` writes the build's value into the rewrite and into the bundled code of `<Highseat />` and `createProxy()`, so the running App Router server needs only `HIGHSEAT_SECRET`. Off Vercel, the outermost proxy that sets the header decides the preset: `"cloudflare"` behind a proxied Cloudflare record, even with nginx or Traefik in between, and `"nginx"` behind nginx alone.
|
|
197
304
|
|
|
198
305
|
### TanStack Start
|
|
199
306
|
|
|
@@ -268,6 +375,8 @@ export default {
|
|
|
268
375
|
|
|
269
376
|
Route the Worker on `yoursite.com/*`: `fallback: fetch` passes every other request on to your origin. Set the secret with `wrangler secret put HIGHSEAT_SECRET`.
|
|
270
377
|
|
|
378
|
+
A Worker that serves the site's static assets has no origin: pass `fallback: request => env.ASSETS.fetch(request)`, and leave `not_found_handling` out of your Wrangler config. With `not_found_handling` set, the assets answer every path that has no file, the proxy path included, so list the path in `run_worker_first`.
|
|
379
|
+
|
|
271
380
|
### Cloudflare Pages
|
|
272
381
|
|
|
273
382
|
```ts
|
|
@@ -290,7 +399,14 @@ export const onRequest: PagesFunction<Env> = ({ request, env, next }) => {
|
|
|
290
399
|
}
|
|
291
400
|
```
|
|
292
401
|
|
|
293
|
-
|
|
402
|
+
```json
|
|
403
|
+
// public/_routes.json
|
|
404
|
+
{ "version": 1, "include": ["/3f9a0c1e2b"], "exclude": [] }
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
`_routes.json` runs the function on the proxy path alone, so every page stays a static asset; without it the function runs on every one-segment path, and `next` serves your site for each one but the proxy's. Put it in the folder your build copies to its output as is, such as `public/`. Add both variables in the Pages project's settings.
|
|
408
|
+
|
|
409
|
+
Pages ignores `functions/` when the build ships a `_worker.js`, as the Astro, SvelteKit and Remix adapters do, and the proxy path then answers with your site's 404. Remove the adapter from a static site, or use the adapter's own endpoint instead.
|
|
294
410
|
|
|
295
411
|
### Netlify
|
|
296
412
|
|
|
@@ -336,7 +452,14 @@ export const ALL: APIRoute = ({ request, clientAddress }) =>
|
|
|
336
452
|
createProxy({ clientIp: () => clientAddress })(request)
|
|
337
453
|
```
|
|
338
454
|
|
|
339
|
-
|
|
455
|
+
Then load the tracker from the path in your layout:
|
|
456
|
+
|
|
457
|
+
```astro
|
|
458
|
+
<!-- src/layouts/Layout.astro -->
|
|
459
|
+
<script is:inline defer src="/3f9a0c1e2b" data-site={import.meta.env.PUBLIC_HIGHSEAT_SITE}></script>
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Needs a server adapter. The file name carries the path, so set only `HIGHSEAT_SECRET`, and `PUBLIC_HIGHSEAT_SITE` for the site id. On the Cloudflare adapter, pass `secret: locals.runtime.env.HIGHSEAT_SECRET`. `clientAddress` is the address that connected, so behind a proxied Cloudflare record, use `"cloudflare"` instead.
|
|
340
463
|
|
|
341
464
|
### Hono
|
|
342
465
|
|
package/index.d.ts
CHANGED
|
@@ -104,6 +104,36 @@ export type TrackArgs<Name extends string> = [Name] extends [EventName] ? {} ext
|
|
|
104
104
|
* own database id, never an email or other personal data.
|
|
105
105
|
*/
|
|
106
106
|
export type UserId = string | number | null | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* The part of an event {@link BeforeSend} sees and may change. The site, the visitor's user id
|
|
109
|
+
* and the attribution id are out of reach, so a hook cannot change who an event belongs to.
|
|
110
|
+
*/
|
|
111
|
+
export interface BeforeSendEvent {
|
|
112
|
+
/** The event's name: `pageview`, `engagement`, or a name passed to `track`. Read-only. */
|
|
113
|
+
readonly name: string;
|
|
114
|
+
/** The page's full URL. The page path is read off the URL you return. */
|
|
115
|
+
url: string;
|
|
116
|
+
/** The page the visitor came from, or the page left on a client-side navigation. */
|
|
117
|
+
referrer?: string;
|
|
118
|
+
/** The event's properties, each value a string. */
|
|
119
|
+
properties?: Record<string, string>;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Runs in the browser on every event before the tracker sends it, page views from client-side
|
|
123
|
+
* navigation included. Return the event, changed or not, or `null` to drop it. Any other falsy
|
|
124
|
+
* answer drops it too, and so does a hook that throws: a hook that keeps a secret out of the
|
|
125
|
+
* data must not leak it when it fails. The error still reaches the console.
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* function beforeSend(event: BeforeSendEvent) {
|
|
129
|
+
* const url = new URL(event.url)
|
|
130
|
+
* if (url.pathname.startsWith("/admin/")) return null
|
|
131
|
+
* url.searchParams.delete("email")
|
|
132
|
+
* return { ...event, url: url.href }
|
|
133
|
+
* }
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null;
|
|
107
137
|
/** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */
|
|
108
138
|
export interface HighseatGlobal {
|
|
109
139
|
/** Records an event. */
|
|
@@ -114,6 +144,8 @@ export interface HighseatGlobal {
|
|
|
114
144
|
getAttributionId?: () => string | undefined;
|
|
115
145
|
/** Calls made before the tracker loaded, drained in order when it starts. */
|
|
116
146
|
q?: [string, unknown[]][];
|
|
147
|
+
/** The site's {@link BeforeSend} hook, read on every event. */
|
|
148
|
+
beforeSend?: BeforeSend;
|
|
117
149
|
}
|
|
118
150
|
/** How long {@link getAttributionId} waits for the tracker. */
|
|
119
151
|
export interface AttributionOptions {
|
|
@@ -136,6 +168,8 @@ export interface InjectOptions {
|
|
|
136
168
|
downloads?: boolean;
|
|
137
169
|
/** Counts a change of `#/route` as a page view, for an app that routes by hash. */
|
|
138
170
|
hashRouting?: boolean;
|
|
171
|
+
/** Changes or drops each event before it leaves the browser. See {@link BeforeSend}. */
|
|
172
|
+
beforeSend?: BeforeSend;
|
|
139
173
|
/**
|
|
140
174
|
* Logs every `track()` and `identify()` call to the console, and warns once when no tracker
|
|
141
175
|
* script is on the page or the script failed to load. Defaults to `true` when
|
|
@@ -170,6 +204,12 @@ export declare function identify(id: UserId): void;
|
|
|
170
204
|
* when the script is still loading, and on a server or with tracking off.
|
|
171
205
|
*/
|
|
172
206
|
export declare function getAttributionId(options?: AttributionOptions): Promise<string | undefined>;
|
|
207
|
+
/**
|
|
208
|
+
* Sets the {@link BeforeSend} hook, or clears it with `undefined`. Works before the tracker has
|
|
209
|
+
* loaded, which picks the hook up when it starts, and applies from the next event once it runs.
|
|
210
|
+
* Does nothing on a server.
|
|
211
|
+
*/
|
|
212
|
+
export declare function setBeforeSend(beforeSend: BeforeSend | undefined): void;
|
|
173
213
|
/**
|
|
174
214
|
* Adds the tracker's script tag to the page, once. A second call for the same site, such as a
|
|
175
215
|
* component mounting again, finds the tag and adds nothing, but its `debug` option still applies.
|
package/index.js
CHANGED
|
@@ -41,7 +41,7 @@ function isTrackerScript(script) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
/** The page's tracker scripts, each one listened to from now on for a failed load. */
|
|
44
|
-
function
|
|
44
|
+
function getTrackerScripts() {
|
|
45
45
|
const scripts = Array.from(document.scripts).filter(isTrackerScript);
|
|
46
46
|
for (const script of scripts) script.addEventListener("error", markFailed);
|
|
47
47
|
return scripts;
|
|
@@ -62,7 +62,7 @@ function watchTracker() {
|
|
|
62
62
|
const deadline = Date.now() + TRACKER_TIMEOUT;
|
|
63
63
|
function check() {
|
|
64
64
|
if (window !== page || isLive(page.highseat)) return;
|
|
65
|
-
const scripts =
|
|
65
|
+
const scripts = getTrackerScripts();
|
|
66
66
|
if (scripts.length > 0 && scripts.every((script) => failedScripts.has(script))) {
|
|
67
67
|
console.warn(FAILED_SCRIPT_WARNING);
|
|
68
68
|
return;
|
|
@@ -135,7 +135,7 @@ async function getAttributionId(options = {}) {
|
|
|
135
135
|
let api = window.highseat;
|
|
136
136
|
while (!isLive(api)) {
|
|
137
137
|
if (Date.now() >= deadline) return;
|
|
138
|
-
if (
|
|
138
|
+
if (getTrackerScripts().every((script) => failedScripts.has(script))) return;
|
|
139
139
|
await new Promise((resolve) => setTimeout(resolve, TRACKER_POLL_INTERVAL));
|
|
140
140
|
api = window.highseat;
|
|
141
141
|
}
|
|
@@ -146,14 +146,24 @@ async function getAttributionId(options = {}) {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
/**
|
|
149
|
+
* Sets the {@link BeforeSend} hook, or clears it with `undefined`. Works before the tracker has
|
|
150
|
+
* loaded, which picks the hook up when it starts, and applies from the next event once it runs.
|
|
151
|
+
* Does nothing on a server.
|
|
152
|
+
*/
|
|
153
|
+
function setBeforeSend(beforeSend) {
|
|
154
|
+
const api = findGlobal();
|
|
155
|
+
if (api) api.beforeSend = beforeSend;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
149
158
|
* Adds the tracker's script tag to the page, once. A second call for the same site, such as a
|
|
150
159
|
* component mounting again, finds the tag and adds nothing, but its `debug` option still applies.
|
|
151
160
|
* Does nothing on a server.
|
|
152
161
|
*/
|
|
153
162
|
function inject(options) {
|
|
154
163
|
if (typeof document === "undefined") return;
|
|
155
|
-
const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, debug } = options;
|
|
164
|
+
const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, beforeSend, debug } = options;
|
|
156
165
|
debugOption = debug;
|
|
166
|
+
if (beforeSend) setBeforeSend(beforeSend);
|
|
157
167
|
if (isDebug()) watchTracker();
|
|
158
168
|
if (Array.from(document.scripts).some((script) => script.dataset.site === site)) return;
|
|
159
169
|
const script = document.createElement("script");
|
|
@@ -167,6 +177,6 @@ function inject(options) {
|
|
|
167
177
|
document.head.append(script);
|
|
168
178
|
}
|
|
169
179
|
//#endregion
|
|
170
|
-
export { SCRIPT_URL, getAttributionId, identify, inject, track };
|
|
180
|
+
export { SCRIPT_URL, getAttributionId, identify, inject, setBeforeSend, track };
|
|
171
181
|
|
|
172
182
|
//# sourceMappingURL=index.js.map
|
package/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { isDevelopment } from \"./env\"\n\n/**\n * The browser client. Every call goes to the hosted tracker on `window.highseat`; before the\n * tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:\n * on a server, with the tracker blocked, or with tracking off, a call does nothing.\n */\n\n/** A value an event property can hold. A `null` or `undefined` value is left out of the event. */\nexport type PropertyValue = string | number | boolean | null | undefined\n\n/**\n * Money attached to an event. `amount` is an integer in the currency's smallest unit, so\n * `{ amount: 4999, currency: \"USD\" }` is $49.99 and `{ amount: 5000, currency: \"JPY\" }` is ¥5000.\n */\nexport interface Revenue {\n /** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */\n amount: number\n /** An ISO 4217 code, such as `USD` or `EUR`. */\n currency: string\n}\n\n/** The properties of an event with no declared shape: any string keys, plus optional revenue. */\nexport interface Properties {\n /** Money the event brought in. */\n revenue?: Revenue\n [key: string]: PropertyValue | Revenue\n}\n\n/**\n * The events Highseat knows by name. A site extends it, or overrides an entry, through\n * {@link Register}. Type-only: it adds nothing to the bundle.\n */\nexport interface BuiltInEvents {\n /** A visitor created an account. */\n signup: Properties\n /** A visitor signed in. */\n login: Properties\n /**\n * A sale. With a payment provider connected, leave `revenue` out: the provider's webhook\n * records the money, and revenue sent here as well would count the sale twice. Send `revenue`\n * only for a checkout no provider reports, and then with an `order_id`, so a provider sale\n * added later for the same order is not counted again.\n */\n purchase: Properties\n /** A paid subscription began. */\n subscription_started: Properties\n /** A free trial began. */\n trial_started: Properties\n /** A file download. The tracker records links to files on its own. */\n file_download: { url: string }\n /** A click on a link to another site. The tracker records these on its own. */\n outbound_link_click: { url: string }\n /** A visit to a missing page. Call it from a single-page app's not-found route. */\n \"404\": Properties\n}\n\n/**\n * Declares a site's own events. Augment it from any file the compiler sees:\n *\n * ```ts\n * declare module \"highseat\" {\n * interface Register {\n * events: {\n * signup: { plan: \"free\" | \"pro\" }\n * }\n * strict: true\n * }\n * }\n * ```\n *\n * `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`\n * accept only built-in and registered names, so a misspelt name is a type error.\n */\nexport interface Register {}\n\n/**\n * A registered event's properties as `track` accepts them. A shape with no properties takes none,\n * and a property whose type an event cannot carry becomes `never`, so a call passing it fails\n * instead of sending `\"[object Object]\"`. The shape is not rejected at registration, where a\n * failed check would silently drop every registered event at once.\n */\ntype CheckedProperties<Shape> = [keyof Shape] extends [never]\n ? Record<string, never>\n : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never }\n\ntype RegisteredEvents = Register extends { events: infer Registered }\n ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]> }\n : {}\n\ntype KnownEvents = Omit<BuiltInEvents, keyof RegisteredEvents> & RegisteredEvents\n\n/** Every event name `track` knows, built-in or registered. */\nexport type EventName = Extract<keyof KnownEvents, string>\n\n/**\n * The names `track` accepts: known names, and any other string unless the site is strict.\n * `string & {}` keeps the known names in the editor's suggestions, which a plain `string` in the\n * union would absorb.\n */\nexport type TrackName = Register extends { strict: true } ? EventName : EventName | (string & {})\n\n/**\n * The arguments after an event's name. A known name takes its declared properties, required\n * when any of them is; any other name takes {@link Properties}. Wrapped in tuples so a union of\n * names is checked as a whole rather than name by name.\n */\nexport type TrackArgs<Name extends string> = [Name] extends [EventName]\n ? {} extends KnownEvents[Name]\n ? [properties?: KnownEvents[Name]]\n : [properties: KnownEvents[Name]]\n : [properties?: Properties]\n\n/**\n * A user id from your own system, as {@link identify} takes it. A string or a number identifies\n * the visitor, `null` clears the id, and `undefined` or an empty string changes nothing. Pass your\n * own database id, never an email or other personal data.\n */\nexport type UserId = string | number | null | undefined\n\n/** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */\nexport interface HighseatGlobal {\n /** Records an event. */\n track?: (name: string, properties?: Properties) => void\n /** Sets or clears the visitor's user id. */\n identify?: (id: UserId) => void\n /** The id that joins a payment to this tab's visit. */\n getAttributionId?: () => string | undefined\n /** Calls made before the tracker loaded, drained in order when it starts. */\n q?: [string, unknown[]][]\n}\n\n/** How long {@link getAttributionId} waits for the tracker. */\nexport interface AttributionOptions {\n /** Milliseconds to wait for the tracker to load. Defaults to 3000. */\n timeout?: number\n}\n\n/** How the tracker is loaded. Each option maps to an attribute on its script tag. */\nexport interface InjectOptions {\n /** The site id, `site_…`, from the site's Installation settings. */\n site: string\n /**\n * Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party\n * delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back\n * to the same place.\n */\n src?: string\n /** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */\n outbound?: boolean\n /** Records clicks on links to files as `file_download`. Defaults to `true`. */\n downloads?: boolean\n /** Counts a change of `#/route` as a page view, for an app that routes by hash. */\n hashRouting?: boolean\n /**\n * Logs every `track()` and `identify()` call to the console, and warns once when no tracker\n * script is on the page or the script failed to load. Defaults to `true` when\n * `process.env.NODE_ENV` is `development`, so a production build logs nothing.\n */\n debug?: boolean\n}\n\ndeclare global {\n interface Window {\n highseat?: HighseatGlobal\n }\n}\n\n/** Where the tracker loads from when the site has no proxy path. */\nexport const SCRIPT_URL = \"https://cdn.highseat.com/script.js\"\n\n// The tracker marks its own API with this symbol. A site's own stub can define methods too, so\n// only the symbol proves the tracker is running.\nconst LIVE = Symbol.for(\"highseat\")\n\nconst SCRIPT_HOST = new URL(SCRIPT_URL).host\n// A site's proxy path: `/` and ten hex characters.\nconst PROXY_PATH = /^\\/[0-9a-f]{10}$/\n\nconst TRACKER_TIMEOUT = 3000\nconst TRACKER_POLL_INTERVAL = 50\n\nconst MISSING_SCRIPT_WARNING = [\n \"highseat: no tracker script on this page, so no event reaches Highseat.\",\n \"Render <Highseat />, call inject({ site }), or add the script tag from the site's Installation settings.\",\n].join(\" \")\nconst FAILED_SCRIPT_WARNING = [\n \"highseat: the tracker script failed to load, so no event reaches Highseat.\",\n \"An ad blocker may have stopped it: load the tracker from the site's proxy path instead.\",\n].join(\" \")\nconst SLOW_SCRIPT_WARNING = [\n `highseat: the tracker script has not loaded after ${TRACKER_TIMEOUT / 1000} seconds,`,\n \"so every call waits in the queue. Check the network panel for a blocked or failed request.\",\n].join(\" \")\n\n// The script tags `inject` added, and every tracker script whose request failed, such as one an\n// ad blocker stopped. A browser keeps no record of a failed script, so a failure is only known\n// to a listener attached before it happened.\nconst injectedScripts = new WeakSet<HTMLScriptElement>()\nconst failedScripts = new WeakSet<HTMLScriptElement>()\n\n// The `debug` option of the last `inject` call. Unset falls back to the build's environment.\nlet debugOption: boolean | undefined\n// The windows whose tracker is being watched, so a page warns about its script once.\nconst watchedWindows = new WeakSet<Window>()\n\nfunction findGlobal(): HighseatGlobal | undefined {\n if (typeof window === \"undefined\") return\n return (window.highseat ??= {})\n}\n\nfunction isLive(api: HighseatGlobal | undefined): api is HighseatGlobal {\n return !!api && (api as Record<symbol, unknown>)[LIVE] === true\n}\n\n// One function for every script, so attaching it on each poll adds no second listener.\nfunction markFailed(event: Event) {\n failedScripts.add(event.currentTarget as HTMLScriptElement)\n}\n\n/**\n * Whether a script tag loads the tracker, judged as the tracker judges itself. Fathom's snippet\n * carries a `data-site` too, so a tag is matched by a `site_` id, or by a `src` on a proxy path\n * or on Highseat's host: an older site's id is a UUID, which any tool could use.\n */\nfunction isTrackerScript(script: HTMLScriptElement) {\n const { site } = script.dataset\n if (injectedScripts.has(script) || site?.startsWith(\"site_\")) return true\n if (site === undefined) return false\n\n try {\n // A browser hands back `src` resolved; the base only matters to a relative one.\n const url = new URL(script.src, \"http://localhost\")\n return url.host === SCRIPT_HOST || PROXY_PATH.test(url.pathname)\n } catch {\n return false\n }\n}\n\n/** The page's tracker scripts, each one listened to from now on for a failed load. */\nfunction findTrackerScripts() {\n const scripts = Array.from(document.scripts).filter(isTrackerScript)\n for (const script of scripts) script.addEventListener(\"error\", markFailed)\n return scripts\n}\n\nfunction isDebug() {\n return debugOption ?? isDevelopment()\n}\n\n/**\n * Warns once per page when the tracker cannot arrive: no tracker script, or only failed ones. A\n * missing script is judged at the timeout, because `<Highseat />` adds its tag in an effect that\n * runs after the effects of the components inside it.\n */\nfunction watchTracker() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n if (watchedWindows.has(window)) return\n watchedWindows.add(window)\n\n const page = window\n const deadline = Date.now() + TRACKER_TIMEOUT\n\n function check() {\n // A replaced global window, as a test sets up for each case, is another page.\n if (window !== page || isLive(page.highseat)) return\n\n const scripts = findTrackerScripts()\n\n if (scripts.length > 0 && scripts.every(script => failedScripts.has(script))) {\n console.warn(FAILED_SCRIPT_WARNING)\n return\n }\n if (Date.now() < deadline) {\n setTimeout(check, TRACKER_POLL_INTERVAL)\n return\n }\n console.warn(scripts.length === 0 ? MISSING_SCRIPT_WARNING : SLOW_SCRIPT_WARNING)\n }\n\n setTimeout(check, TRACKER_POLL_INTERVAL)\n}\n\n/**\n * Logs a call when debugging is on. The check waits a microtask, so a `debug` option that\n * `<Highseat />` passes in its effect applies to the calls its children make in theirs.\n */\nfunction logCall(method: \"track\" | \"identify\", args: unknown[], isQueued: boolean) {\n queueMicrotask(() => {\n if (!isDebug()) return\n\n const status = isQueued ? \"queued until the tracker loads\" : \"sent to the tracker\"\n const [value, properties] = args\n // `JSON.stringify` quotes a name or a string id, and returns `undefined` for an unset id.\n const message = `highseat: ${method} ${String(JSON.stringify(value))}, ${status}`\n\n if (properties === undefined) console.info(message)\n else console.info(message, properties)\n watchTracker()\n })\n}\n\nfunction call(method: \"track\" | \"identify\", args: unknown[]) {\n const api = findGlobal()\n if (!api) return\n\n const handler = api[method] as ((...values: unknown[]) => void) | undefined\n if (typeof handler !== \"function\") {\n api.q ??= []\n api.q.push([method, args])\n logCall(method, args, true)\n return\n }\n\n logCall(method, args, false)\n try {\n handler(...args)\n } catch {}\n}\n\n/**\n * Records an event. A known name autocompletes and checks its properties; any other name is a\n * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so\n * `Purchase` and `purchase` are two events.\n */\nexport function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void {\n const [properties] = args\n call(\"track\", properties === undefined ? [name] : [name, properties])\n}\n\n/**\n * Links the visitor to a user id from your own system, on this event and every later one, across\n * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so\n * `identify(user?.id)` before a session loads does not sign the visitor out.\n */\nexport function identify(id: UserId): void {\n call(\"identify\", [id])\n}\n\n/**\n * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata\n * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.\n * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has\n * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout\n * when the script is still loading, and on a server or with tracking off.\n */\nexport async function getAttributionId(\n options: AttributionOptions = {},\n): Promise<string | undefined> {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n\n const deadline = Date.now() + (options.timeout ?? TRACKER_TIMEOUT)\n let api = window.highseat\n\n while (!isLive(api)) {\n if (Date.now() >= deadline) return\n\n const scripts = findTrackerScripts()\n // No tracker script at all, or only failed ones: the tracker cannot arrive.\n if (scripts.every(script => failedScripts.has(script))) return\n\n await new Promise(resolve => setTimeout(resolve, TRACKER_POLL_INTERVAL))\n api = window.highseat\n }\n\n try {\n return api.getAttributionId?.()\n } catch {\n return\n }\n}\n\n/**\n * Adds the tracker's script tag to the page, once. A second call for the same site, such as a\n * component mounting again, finds the tag and adds nothing, but its `debug` option still applies.\n * Does nothing on a server.\n */\nexport function inject(options: InjectOptions): void {\n if (typeof document === \"undefined\") return\n\n const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, debug } = options\n debugOption = debug\n if (isDebug()) watchTracker()\n if (Array.from(document.scripts).some(script => script.dataset.site === site)) return\n\n const script = document.createElement(\"script\")\n script.addEventListener(\"error\", markFailed)\n injectedScripts.add(script)\n script.src = src\n script.dataset.site = site\n if (outbound === false) script.dataset.outbound = \"false\"\n if (downloads === false) script.dataset.downloads = \"false\"\n if (hashRouting) script.dataset.hashRouting = \"\"\n document.head.append(script)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { isDevelopment } from \"./env\"\n\n/**\n * The browser client. Every call goes to the hosted tracker on `window.highseat`; before the\n * tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:\n * on a server, with the tracker blocked, or with tracking off, a call does nothing.\n */\n\n/** A value an event property can hold. A `null` or `undefined` value is left out of the event. */\nexport type PropertyValue = string | number | boolean | null | undefined\n\n/**\n * Money attached to an event. `amount` is an integer in the currency's smallest unit, so\n * `{ amount: 4999, currency: \"USD\" }` is $49.99 and `{ amount: 5000, currency: \"JPY\" }` is ¥5000.\n */\nexport interface Revenue {\n /** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */\n amount: number\n /** An ISO 4217 code, such as `USD` or `EUR`. */\n currency: string\n}\n\n/** The properties of an event with no declared shape: any string keys, plus optional revenue. */\nexport interface Properties {\n /** Money the event brought in. */\n revenue?: Revenue\n [key: string]: PropertyValue | Revenue\n}\n\n/**\n * The events Highseat knows by name. A site extends it, or overrides an entry, through\n * {@link Register}. Type-only: it adds nothing to the bundle.\n */\nexport interface BuiltInEvents {\n /** A visitor created an account. */\n signup: Properties\n /** A visitor signed in. */\n login: Properties\n /**\n * A sale. With a payment provider connected, leave `revenue` out: the provider's webhook\n * records the money, and revenue sent here as well would count the sale twice. Send `revenue`\n * only for a checkout no provider reports, and then with an `order_id`, so a provider sale\n * added later for the same order is not counted again.\n */\n purchase: Properties\n /** A paid subscription began. */\n subscription_started: Properties\n /** A free trial began. */\n trial_started: Properties\n /** A file download. The tracker records links to files on its own. */\n file_download: { url: string }\n /** A click on a link to another site. The tracker records these on its own. */\n outbound_link_click: { url: string }\n /** A visit to a missing page. Call it from a single-page app's not-found route. */\n \"404\": Properties\n}\n\n/**\n * Declares a site's own events. Augment it from any file the compiler sees:\n *\n * ```ts\n * declare module \"highseat\" {\n * interface Register {\n * events: {\n * signup: { plan: \"free\" | \"pro\" }\n * }\n * strict: true\n * }\n * }\n * ```\n *\n * `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`\n * accept only built-in and registered names, so a misspelt name is a type error.\n */\nexport interface Register {}\n\n/**\n * A registered event's properties as `track` accepts them. A shape with no properties takes none,\n * and a property whose type an event cannot carry becomes `never`, so a call passing it fails\n * instead of sending `\"[object Object]\"`. The shape is not rejected at registration, where a\n * failed check would silently drop every registered event at once.\n */\ntype CheckedProperties<Shape> = [keyof Shape] extends [never]\n ? Record<string, never>\n : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never }\n\ntype RegisteredEvents = Register extends { events: infer Registered }\n ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]> }\n : {}\n\ntype KnownEvents = Omit<BuiltInEvents, keyof RegisteredEvents> & RegisteredEvents\n\n/** Every event name `track` knows, built-in or registered. */\nexport type EventName = Extract<keyof KnownEvents, string>\n\n/**\n * The names `track` accepts: known names, and any other string unless the site is strict.\n * `string & {}` keeps the known names in the editor's suggestions, which a plain `string` in the\n * union would absorb.\n */\nexport type TrackName = Register extends { strict: true } ? EventName : EventName | (string & {})\n\n/**\n * The arguments after an event's name. A known name takes its declared properties, required\n * when any of them is; any other name takes {@link Properties}. Wrapped in tuples so a union of\n * names is checked as a whole rather than name by name.\n */\nexport type TrackArgs<Name extends string> = [Name] extends [EventName]\n ? {} extends KnownEvents[Name]\n ? [properties?: KnownEvents[Name]]\n : [properties: KnownEvents[Name]]\n : [properties?: Properties]\n\n/**\n * A user id from your own system, as {@link identify} takes it. A string or a number identifies\n * the visitor, `null` clears the id, and `undefined` or an empty string changes nothing. Pass your\n * own database id, never an email or other personal data.\n */\nexport type UserId = string | number | null | undefined\n\n/**\n * The part of an event {@link BeforeSend} sees and may change. The site, the visitor's user id\n * and the attribution id are out of reach, so a hook cannot change who an event belongs to.\n */\nexport interface BeforeSendEvent {\n /** The event's name: `pageview`, `engagement`, or a name passed to `track`. Read-only. */\n readonly name: string\n /** The page's full URL. The page path is read off the URL you return. */\n url: string\n /** The page the visitor came from, or the page left on a client-side navigation. */\n referrer?: string\n /** The event's properties, each value a string. */\n properties?: Record<string, string>\n}\n\n/**\n * Runs in the browser on every event before the tracker sends it, page views from client-side\n * navigation included. Return the event, changed or not, or `null` to drop it. Any other falsy\n * answer drops it too, and so does a hook that throws: a hook that keeps a secret out of the\n * data must not leak it when it fails. The error still reaches the console.\n *\n * ```ts\n * function beforeSend(event: BeforeSendEvent) {\n * const url = new URL(event.url)\n * if (url.pathname.startsWith(\"/admin/\")) return null\n * url.searchParams.delete(\"email\")\n * return { ...event, url: url.href }\n * }\n * ```\n */\nexport type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null\n\n/** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */\nexport interface HighseatGlobal {\n /** Records an event. */\n track?: (name: string, properties?: Properties) => void\n /** Sets or clears the visitor's user id. */\n identify?: (id: UserId) => void\n /** The id that joins a payment to this tab's visit. */\n getAttributionId?: () => string | undefined\n /** Calls made before the tracker loaded, drained in order when it starts. */\n q?: [string, unknown[]][]\n /** The site's {@link BeforeSend} hook, read on every event. */\n beforeSend?: BeforeSend\n}\n\n/** How long {@link getAttributionId} waits for the tracker. */\nexport interface AttributionOptions {\n /** Milliseconds to wait for the tracker to load. Defaults to 3000. */\n timeout?: number\n}\n\n/** How the tracker is loaded. Each option maps to an attribute on its script tag. */\nexport interface InjectOptions {\n /** The site id, `site_…`, from the site's Installation settings. */\n site: string\n /**\n * Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party\n * delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back\n * to the same place.\n */\n src?: string\n /** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */\n outbound?: boolean\n /** Records clicks on links to files as `file_download`. Defaults to `true`. */\n downloads?: boolean\n /** Counts a change of `#/route` as a page view, for an app that routes by hash. */\n hashRouting?: boolean\n /** Changes or drops each event before it leaves the browser. See {@link BeforeSend}. */\n beforeSend?: BeforeSend\n /**\n * Logs every `track()` and `identify()` call to the console, and warns once when no tracker\n * script is on the page or the script failed to load. Defaults to `true` when\n * `process.env.NODE_ENV` is `development`, so a production build logs nothing.\n */\n debug?: boolean\n}\n\ndeclare global {\n interface Window {\n highseat?: HighseatGlobal\n }\n}\n\n/** Where the tracker loads from when the site has no proxy path. */\nexport const SCRIPT_URL = \"https://cdn.highseat.com/script.js\"\n\n// The tracker marks its own API with this symbol. A site's own stub can define methods too, so\n// only the symbol proves the tracker is running.\nconst LIVE = Symbol.for(\"highseat\")\n\nconst SCRIPT_HOST = new URL(SCRIPT_URL).host\n// A site's proxy path: `/` and ten hex characters.\nconst PROXY_PATH = /^\\/[0-9a-f]{10}$/\n\nconst TRACKER_TIMEOUT = 3000\nconst TRACKER_POLL_INTERVAL = 50\n\nconst MISSING_SCRIPT_WARNING = [\n \"highseat: no tracker script on this page, so no event reaches Highseat.\",\n \"Render <Highseat />, call inject({ site }), or add the script tag from the site's Installation settings.\",\n].join(\" \")\nconst FAILED_SCRIPT_WARNING = [\n \"highseat: the tracker script failed to load, so no event reaches Highseat.\",\n \"An ad blocker may have stopped it: load the tracker from the site's proxy path instead.\",\n].join(\" \")\nconst SLOW_SCRIPT_WARNING = [\n `highseat: the tracker script has not loaded after ${TRACKER_TIMEOUT / 1000} seconds,`,\n \"so every call waits in the queue. Check the network panel for a blocked or failed request.\",\n].join(\" \")\n\n// The script tags `inject` added, and every tracker script whose request failed, such as one an\n// ad blocker stopped. A browser keeps no record of a failed script, so a failure is only known\n// to a listener attached before it happened.\nconst injectedScripts = new WeakSet<HTMLScriptElement>()\nconst failedScripts = new WeakSet<HTMLScriptElement>()\n\n// The `debug` option of the last `inject` call. Unset falls back to the build's environment.\nlet debugOption: boolean | undefined\n// The windows whose tracker is being watched, so a page warns about its script once.\nconst watchedWindows = new WeakSet<Window>()\n\nfunction findGlobal(): HighseatGlobal | undefined {\n if (typeof window === \"undefined\") return\n return (window.highseat ??= {})\n}\n\nfunction isLive(api: HighseatGlobal | undefined): api is HighseatGlobal {\n return !!api && (api as Record<symbol, unknown>)[LIVE] === true\n}\n\n// One function for every script, so attaching it on each poll adds no second listener.\nfunction markFailed(event: Event) {\n failedScripts.add(event.currentTarget as HTMLScriptElement)\n}\n\n/**\n * Whether a script tag loads the tracker, judged as the tracker judges itself. Fathom's snippet\n * carries a `data-site` too, so a tag is matched by a `site_` id, or by a `src` on a proxy path\n * or on Highseat's host: an older site's id is a UUID, which any tool could use.\n */\nfunction isTrackerScript(script: HTMLScriptElement) {\n const { site } = script.dataset\n if (injectedScripts.has(script) || site?.startsWith(\"site_\")) return true\n if (site === undefined) return false\n\n try {\n // A browser hands back `src` resolved; the base only matters to a relative one.\n const url = new URL(script.src, \"http://localhost\")\n return url.host === SCRIPT_HOST || PROXY_PATH.test(url.pathname)\n } catch {\n return false\n }\n}\n\n/** The page's tracker scripts, each one listened to from now on for a failed load. */\nfunction getTrackerScripts() {\n const scripts = Array.from(document.scripts).filter(isTrackerScript)\n for (const script of scripts) script.addEventListener(\"error\", markFailed)\n return scripts\n}\n\nfunction isDebug() {\n return debugOption ?? isDevelopment()\n}\n\n/**\n * Warns once per page when the tracker cannot arrive: no tracker script, or only failed ones. A\n * missing script is judged at the timeout, because `<Highseat />` adds its tag in an effect that\n * runs after the effects of the components inside it.\n */\nfunction watchTracker() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n if (watchedWindows.has(window)) return\n watchedWindows.add(window)\n\n const page = window\n const deadline = Date.now() + TRACKER_TIMEOUT\n\n function check() {\n // A replaced global window, as a test sets up for each case, is another page.\n if (window !== page || isLive(page.highseat)) return\n\n const scripts = getTrackerScripts()\n\n if (scripts.length > 0 && scripts.every(script => failedScripts.has(script))) {\n console.warn(FAILED_SCRIPT_WARNING)\n return\n }\n if (Date.now() < deadline) {\n setTimeout(check, TRACKER_POLL_INTERVAL)\n return\n }\n console.warn(scripts.length === 0 ? MISSING_SCRIPT_WARNING : SLOW_SCRIPT_WARNING)\n }\n\n setTimeout(check, TRACKER_POLL_INTERVAL)\n}\n\n/**\n * Logs a call when debugging is on. The check waits a microtask, so a `debug` option that\n * `<Highseat />` passes in its effect applies to the calls its children make in theirs.\n */\nfunction logCall(method: \"track\" | \"identify\", args: unknown[], isQueued: boolean) {\n queueMicrotask(() => {\n if (!isDebug()) return\n\n const status = isQueued ? \"queued until the tracker loads\" : \"sent to the tracker\"\n const [value, properties] = args\n // `JSON.stringify` quotes a name or a string id, and returns `undefined` for an unset id.\n const message = `highseat: ${method} ${String(JSON.stringify(value))}, ${status}`\n\n if (properties === undefined) console.info(message)\n else console.info(message, properties)\n watchTracker()\n })\n}\n\nfunction call(method: \"track\" | \"identify\", args: unknown[]) {\n const api = findGlobal()\n if (!api) return\n\n const handler = api[method] as ((...values: unknown[]) => void) | undefined\n if (typeof handler !== \"function\") {\n api.q ??= []\n api.q.push([method, args])\n logCall(method, args, true)\n return\n }\n\n logCall(method, args, false)\n try {\n handler(...args)\n } catch {}\n}\n\n/**\n * Records an event. A known name autocompletes and checks its properties; any other name is a\n * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so\n * `Purchase` and `purchase` are two events.\n */\nexport function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void {\n const [properties] = args\n call(\"track\", properties === undefined ? [name] : [name, properties])\n}\n\n/**\n * Links the visitor to a user id from your own system, on this event and every later one, across\n * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so\n * `identify(user?.id)` before a session loads does not sign the visitor out.\n */\nexport function identify(id: UserId): void {\n call(\"identify\", [id])\n}\n\n/**\n * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata\n * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.\n * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has\n * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout\n * when the script is still loading, and on a server or with tracking off.\n */\nexport async function getAttributionId(\n options: AttributionOptions = {},\n): Promise<string | undefined> {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n\n const deadline = Date.now() + (options.timeout ?? TRACKER_TIMEOUT)\n let api = window.highseat\n\n while (!isLive(api)) {\n if (Date.now() >= deadline) return\n\n const scripts = getTrackerScripts()\n // No tracker script at all, or only failed ones: the tracker cannot arrive.\n if (scripts.every(script => failedScripts.has(script))) return\n\n await new Promise(resolve => setTimeout(resolve, TRACKER_POLL_INTERVAL))\n api = window.highseat\n }\n\n try {\n return api.getAttributionId?.()\n } catch {\n return\n }\n}\n\n/**\n * Sets the {@link BeforeSend} hook, or clears it with `undefined`. Works before the tracker has\n * loaded, which picks the hook up when it starts, and applies from the next event once it runs.\n * Does nothing on a server.\n */\nexport function setBeforeSend(beforeSend: BeforeSend | undefined): void {\n const api = findGlobal()\n if (api) api.beforeSend = beforeSend\n}\n\n/**\n * Adds the tracker's script tag to the page, once. A second call for the same site, such as a\n * component mounting again, finds the tag and adds nothing, but its `debug` option still applies.\n * Does nothing on a server.\n */\nexport function inject(options: InjectOptions): void {\n if (typeof document === \"undefined\") return\n\n const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, beforeSend, debug } = options\n debugOption = debug\n // Set before the script tag is added, so the first page view already passes through it.\n if (beforeSend) setBeforeSend(beforeSend)\n if (isDebug()) watchTracker()\n if (Array.from(document.scripts).some(script => script.dataset.site === site)) return\n\n const script = document.createElement(\"script\")\n script.addEventListener(\"error\", markFailed)\n injectedScripts.add(script)\n script.src = src\n script.dataset.site = site\n if (outbound === false) script.dataset.outbound = \"false\"\n if (downloads === false) script.dataset.downloads = \"false\"\n if (hashRouting) script.dataset.hashRouting = \"\"\n document.head.append(script)\n}\n"],"mappings":"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"}
|
package/next.d.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import { HighseatProps as HighseatProps$1, useIdentify } from "./react.js";
|
|
1
|
+
import { HighseatProps as HighseatProps$1, useBeforeSend, useIdentify } from "./react.js";
|
|
2
2
|
import { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions, createProxy } from "./proxy.js";
|
|
3
3
|
import { ReactElement } from "react";
|
|
4
4
|
//#region src/next.d.ts
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The props of `<Highseat />` in a server layout. `beforeSend` is left out: a server component
|
|
7
|
+
* cannot pass a function to the browser, so the hook is set with {@link useBeforeSend} in a client
|
|
8
|
+
* component instead.
|
|
9
|
+
*/
|
|
10
|
+
export interface HighseatProps extends Partial<Omit<HighseatProps$1, "beforeSend">> {}
|
|
6
11
|
/**
|
|
7
12
|
* Loads the Highseat tracker in a Next.js app. Render it once, in the root layout. The site id
|
|
8
13
|
* comes from `NEXT_PUBLIC_HIGHSEAT_SITE`, and the script loads from the site's proxy path in
|
|
9
14
|
* `HIGHSEAT_PATH` when one is set. `HIGHSEAT_PATH` is a server variable, so in the Pages Router,
|
|
10
15
|
* where the layout also renders in the browser, pass `src` instead. A prop wins over its variable.
|
|
11
16
|
* `userId` takes an id the server already knows. For a session read in the browser, call
|
|
12
|
-
* `useIdentify` in a client component instead
|
|
17
|
+
* `useIdentify` in a client component instead, and set a `beforeSend` hook with `useBeforeSend`.
|
|
13
18
|
*/
|
|
14
19
|
export declare function Highseat(props: HighseatProps): ReactElement | null;
|
|
15
20
|
/** A Next.js rewrite: the subset of its fields this package writes and reads. */
|
|
@@ -47,8 +52,10 @@ export interface WithHighseatOptions {
|
|
|
47
52
|
*
|
|
48
53
|
* The proxy is optional. Without a path, `next dev` and `next build` warn once and leave the
|
|
49
54
|
* rewrite out, and `<Highseat />` loads the tracker from Highseat's host. Nothing here throws,
|
|
50
|
-
* so `next typegen` runs without the variable too.
|
|
51
|
-
*
|
|
55
|
+
* so `next typegen` runs without the variable too. With a path, it also sets `HIGHSEAT_PATH` in
|
|
56
|
+
* the config's `env`, so Next.js writes the build's value into the bundled code of `<Highseat />`
|
|
57
|
+
* and `createProxy()`. In the App Router, the script and the rewrite then agree, whatever the
|
|
58
|
+
* server's environment.
|
|
52
59
|
*
|
|
53
60
|
* ```ts
|
|
54
61
|
* // next.config.ts
|
|
@@ -58,5 +65,5 @@ export interface WithHighseatOptions {
|
|
|
58
65
|
*/
|
|
59
66
|
export declare function withHighseat<Config extends object>(config: Config, options?: WithHighseatOptions): WithHighseatConfig<Config>;
|
|
60
67
|
//#endregion
|
|
61
|
-
export { type ClientIp, type ClientIpPreset, type ProxyHandler, type ProxyOptions, createProxy, useIdentify };
|
|
68
|
+
export { type ClientIp, type ClientIpPreset, type ProxyHandler, type ProxyOptions, createProxy, useBeforeSend, useIdentify };
|
|
62
69
|
//# sourceMappingURL=next.d.ts.map
|
package/next.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findNextSite, findProxyPath } from "./env.js";
|
|
2
|
-
import { Highseat as Highseat$1, useIdentify } from "./react.js";
|
|
2
|
+
import { Highseat as Highseat$1, useBeforeSend, useIdentify } from "./react.js";
|
|
3
3
|
import { createProxy } from "./proxy.js";
|
|
4
4
|
import { createElement } from "react";
|
|
5
5
|
//#region src/next.ts
|
|
@@ -9,7 +9,7 @@ import { createElement } from "react";
|
|
|
9
9
|
* `HIGHSEAT_PATH` when one is set. `HIGHSEAT_PATH` is a server variable, so in the Pages Router,
|
|
10
10
|
* where the layout also renders in the browser, pass `src` instead. A prop wins over its variable.
|
|
11
11
|
* `userId` takes an id the server already knows. For a session read in the browser, call
|
|
12
|
-
* `useIdentify` in a client component instead
|
|
12
|
+
* `useIdentify` in a client component instead, and set a `beforeSend` hook with `useBeforeSend`.
|
|
13
13
|
*/
|
|
14
14
|
function Highseat(props) {
|
|
15
15
|
const site = props.site ?? findNextSite();
|
|
@@ -33,8 +33,10 @@ let hasWarnedNoPath = false;
|
|
|
33
33
|
*
|
|
34
34
|
* The proxy is optional. Without a path, `next dev` and `next build` warn once and leave the
|
|
35
35
|
* rewrite out, and `<Highseat />` loads the tracker from Highseat's host. Nothing here throws,
|
|
36
|
-
* so `next typegen` runs without the variable too.
|
|
37
|
-
*
|
|
36
|
+
* so `next typegen` runs without the variable too. With a path, it also sets `HIGHSEAT_PATH` in
|
|
37
|
+
* the config's `env`, so Next.js writes the build's value into the bundled code of `<Highseat />`
|
|
38
|
+
* and `createProxy()`. In the App Router, the script and the rewrite then agree, whatever the
|
|
39
|
+
* server's environment.
|
|
38
40
|
*
|
|
39
41
|
* ```ts
|
|
40
42
|
* // next.config.ts
|
|
@@ -44,7 +46,7 @@ let hasWarnedNoPath = false;
|
|
|
44
46
|
*/
|
|
45
47
|
function withHighseat(config, options = {}) {
|
|
46
48
|
const path = findProxyPath(options.path);
|
|
47
|
-
const { rewrites: ownRewrites } = config;
|
|
49
|
+
const { rewrites: ownRewrites, env } = config;
|
|
48
50
|
async function passRewritesThrough() {
|
|
49
51
|
if (!hasWarnedNoPath) {
|
|
50
52
|
hasWarnedNoPath = true;
|
|
@@ -73,10 +75,14 @@ function withHighseat(config, options = {}) {
|
|
|
73
75
|
}
|
|
74
76
|
return {
|
|
75
77
|
...config,
|
|
78
|
+
env: {
|
|
79
|
+
...env,
|
|
80
|
+
HIGHSEAT_PATH: path
|
|
81
|
+
},
|
|
76
82
|
rewrites
|
|
77
83
|
};
|
|
78
84
|
}
|
|
79
85
|
//#endregion
|
|
80
|
-
export { Highseat, createProxy, useIdentify, withHighseat };
|
|
86
|
+
export { Highseat, createProxy, useBeforeSend, useIdentify, withHighseat };
|
|
81
87
|
|
|
82
88
|
//# sourceMappingURL=next.js.map
|
package/next.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next.js","names":["ReactHighseat"],"sources":["../src/next.ts"],"sourcesContent":["import { createElement, type ReactElement } from \"react\"\nimport { findNextSite, findProxyPath } from \"./env\"\nimport { Highseat as ReactHighseat, type HighseatProps as ReactHighseatProps } from \"./react\"\n\nexport { createProxy } from \"./proxy\"\nexport { useIdentify } from \"./react\"\nexport type { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions } from \"./proxy\"\n\nexport interface HighseatProps extends Partial<ReactHighseatProps
|
|
1
|
+
{"version":3,"file":"next.js","names":["ReactHighseat"],"sources":["../src/next.ts"],"sourcesContent":["import { createElement, type ReactElement } from \"react\"\nimport { findNextSite, findProxyPath } from \"./env\"\nimport { Highseat as ReactHighseat, type HighseatProps as ReactHighseatProps } from \"./react\"\n\nexport { createProxy } from \"./proxy\"\nexport { useBeforeSend, useIdentify } from \"./react\"\nexport type { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions } from \"./proxy\"\n\n/**\n * The props of `<Highseat />` in a server layout. `beforeSend` is left out: a server component\n * cannot pass a function to the browser, so the hook is set with {@link useBeforeSend} in a client\n * component instead.\n */\nexport interface HighseatProps extends Partial<Omit<ReactHighseatProps, \"beforeSend\">> {}\n\n/**\n * Loads the Highseat tracker in a Next.js app. Render it once, in the root layout. The site id\n * comes from `NEXT_PUBLIC_HIGHSEAT_SITE`, and the script loads from the site's proxy path in\n * `HIGHSEAT_PATH` when one is set. `HIGHSEAT_PATH` is a server variable, so in the Pages Router,\n * where the layout also renders in the browser, pass `src` instead. A prop wins over its variable.\n * `userId` takes an id the server already knows. For a session read in the browser, call\n * `useIdentify` in a client component instead, and set a `beforeSend` hook with `useBeforeSend`.\n */\nexport function Highseat(props: HighseatProps): ReactElement | null {\n const site = props.site ?? findNextSite()\n const src = props.src ?? findProxyPath()\n\n if (!site) {\n console.warn(\"highseat: set NEXT_PUBLIC_HIGHSEAT_SITE, or pass `site` to <Highseat />\")\n return null\n }\n\n return createElement(ReactHighseat, { ...props, site, src })\n}\n\n/** A Next.js rewrite: the subset of its fields this package writes and reads. */\ninterface Rewrite {\n source: string\n destination: string\n}\n\n/** The phases Next.js applies rewrites in, when `rewrites()` returns an object. */\ninterface RewritePhases {\n beforeFiles?: Rewrite[]\n afterFiles?: Rewrite[]\n fallback?: Rewrite[]\n}\n\n/** The part of a Next.js config `withHighseat` reads. */\ninterface NextConfigWithRewrites {\n rewrites?: () => Promise<Rewrite[] | RewritePhases>\n env?: Record<string, string | undefined>\n}\n\n/** A Next.js config with the proxy's rewrite added, or its own rewrites when there is no path. */\nexport type WithHighseatConfig<Config> = Config & {\n rewrites: () => Promise<Rewrite[] | RewritePhases>\n}\n\nexport interface WithHighseatOptions {\n /**\n * The site's proxy path from Installation settings. Defaults to the `HIGHSEAT_PATH`\n * environment variable, the one `createProxy()` reads, so rotating the path is an\n * environment change and a redeploy.\n */\n path?: string\n /**\n * Where the app's proxy route handler lives. Defaults to `/api/highseat`, the route of\n * `app/api/highseat/route.ts`.\n */\n route?: string\n}\n\nconst DEFAULT_ROUTE = \"/api/highseat\"\n\n// A dev session can ask for the rewrites more than once, so the warning is printed once.\nlet hasWarnedNoPath = false\n\n/**\n * Adds a rewrite from the site's proxy path to the app's proxy route handler, ahead of every\n * other route. The rewrite stays inside the app, so the handler still sees the visitor's IP, and\n * the route file never moves when the path rotates.\n *\n * The proxy is optional. Without a path, `next dev` and `next build` warn once and leave the\n * rewrite out, and `<Highseat />` loads the tracker from Highseat's host. Nothing here throws,\n * so `next typegen` runs without the variable too. With a path, it also sets `HIGHSEAT_PATH` in\n * the config's `env`, so Next.js writes the build's value into the bundled code of `<Highseat />`\n * and `createProxy()`. In the App Router, the script and the rewrite then agree, whatever the\n * server's environment.\n *\n * ```ts\n * // next.config.ts\n * import { withHighseat } from \"highseat/next\"\n * export default withHighseat({ reactStrictMode: true })\n * ```\n */\nexport function withHighseat<Config extends object>(\n config: Config,\n options: WithHighseatOptions = {},\n): WithHighseatConfig<Config> {\n const path = findProxyPath(options.path)\n\n // Typed loosely so any Next.js version's config fits; only `rewrites` and `env` are read.\n const { rewrites: ownRewrites, env } = config as NextConfigWithRewrites\n\n async function passRewritesThrough(): Promise<Rewrite[] | RewritePhases> {\n if (!hasWarnedNoPath) {\n hasWarnedNoPath = true\n console.warn(\n \"highseat/next: no HIGHSEAT_PATH, so this build has no proxy rewrite and the tracker loads from Highseat's host. For first-party delivery, set it to the path from the site's Installation settings, or pass `path`, for the build as well as at runtime: a path set only at runtime points the tracker at a route this build does not have.\",\n )\n }\n return (await ownRewrites?.()) ?? []\n }\n\n if (!path) return { ...config, rewrites: passRewritesThrough }\n\n const rewrite = { source: path, destination: options.route ?? DEFAULT_ROUTE }\n\n async function rewrites(): Promise<RewritePhases> {\n const existing = (await ownRewrites?.()) ?? []\n\n // A plain array is Next.js shorthand for the `afterFiles` phase.\n if (Array.isArray(existing)) return { beforeFiles: [rewrite], afterFiles: existing }\n return { ...existing, beforeFiles: [rewrite, ...(existing.beforeFiles ?? [])] }\n }\n\n return { ...config, env: { ...env, HIGHSEAT_PATH: path }, rewrites }\n}\n"],"mappings":";;;;;;;;;;;;;AAuBA,SAAgB,SAAS,OAA2C;CAClE,MAAM,OAAO,MAAM,QAAQ,aAAa;CACxC,MAAM,MAAM,MAAM,OAAO,cAAc;CAEvC,IAAI,CAAC,MAAM;EACT,QAAQ,KAAK,yEAAyE;EACtF,OAAO;CACT;CAEA,OAAO,cAAcA,YAAe;EAAE,GAAG;EAAO;EAAM;CAAI,CAAC;AAC7D;AAwCA,MAAM,gBAAgB;AAGtB,IAAI,kBAAkB;;;;;;;;;;;;;;;;;;;AAoBtB,SAAgB,aACd,QACA,UAA+B,CAAC,GACJ;CAC5B,MAAM,OAAO,cAAc,QAAQ,IAAI;CAGvC,MAAM,EAAE,UAAU,aAAa,QAAQ;CAEvC,eAAe,sBAA0D;EACvE,IAAI,CAAC,iBAAiB;GACpB,kBAAkB;GAClB,QAAQ,KACN,6UACF;EACF;EACA,OAAQ,MAAM,cAAc,KAAM,CAAC;CACrC;CAEA,IAAI,CAAC,MAAM,OAAO;EAAE,GAAG;EAAQ,UAAU;CAAoB;CAE7D,MAAM,UAAU;EAAE,QAAQ;EAAM,aAAa,QAAQ,SAAS;CAAc;CAE5E,eAAe,WAAmC;EAChD,MAAM,WAAY,MAAM,cAAc,KAAM,CAAC;EAG7C,IAAI,MAAM,QAAQ,QAAQ,GAAG,OAAO;GAAE,aAAa,CAAC,OAAO;GAAG,YAAY;EAAS;EACnF,OAAO;GAAE,GAAG;GAAU,aAAa,CAAC,SAAS,GAAI,SAAS,eAAe,CAAC,CAAE;EAAE;CAChF;CAEA,OAAO;EAAE,GAAG;EAAQ,KAAK;GAAE,GAAG;GAAK,eAAe;EAAK;EAAG;CAAS;AACrE"}
|
package/package.json
CHANGED
package/react.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InjectOptions, UserId } from "./index.js";
|
|
1
|
+
import { BeforeSend, InjectOptions, UserId } from "./index.js";
|
|
2
2
|
//#region src/react.d.ts
|
|
3
3
|
export interface HighseatProps extends InjectOptions {
|
|
4
4
|
/**
|
|
@@ -23,12 +23,28 @@ export interface HighseatProps extends InjectOptions {
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
export declare function useIdentify(userId: UserId): void;
|
|
26
|
+
/**
|
|
27
|
+
* Sets the {@link BeforeSend} hook while the component is mounted, and clears it on unmount.
|
|
28
|
+
* `undefined` leaves the current hook alone. Use it in a client component when the tracker loads
|
|
29
|
+
* from a server component, such as `<Highseat />` in a Next.js App Router layout, where a
|
|
30
|
+
* function cannot be passed as a prop. Effects run before the tracker's script loads, so the
|
|
31
|
+
* first page view already passes through the hook.
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* "use client"
|
|
35
|
+
* export function Redact() {
|
|
36
|
+
* useBeforeSend(event => (event.url.includes("/admin/") ? null : event))
|
|
37
|
+
* return null
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function useBeforeSend(beforeSend: BeforeSend | undefined): void;
|
|
26
42
|
/**
|
|
27
43
|
* Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
|
|
28
44
|
* the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
|
|
29
45
|
* counts client-side navigation on its own. With `userId`, it also identifies the visitor, as
|
|
30
|
-
* {@link useIdentify} does.
|
|
46
|
+
* {@link useIdentify} does, and with `beforeSend` it sets the hook, as {@link useBeforeSend} does.
|
|
31
47
|
*/
|
|
32
|
-
export declare function Highseat({ site, src, outbound, downloads, hashRouting, debug, userId }: HighseatProps): null;
|
|
48
|
+
export declare function Highseat({ site, src, outbound, downloads, hashRouting, beforeSend, debug, userId }: HighseatProps): null;
|
|
33
49
|
//#endregion
|
|
34
50
|
//# sourceMappingURL=react.d.ts.map
|
package/react.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { inject } from "./index.js";
|
|
2
|
+
import { inject, setBeforeSend } from "./index.js";
|
|
3
3
|
import { syncIdentity } from "./identity.js";
|
|
4
4
|
import { useEffect, useRef } from "react";
|
|
5
5
|
//#region src/react.ts
|
|
@@ -24,12 +24,37 @@ function useIdentify(userId) {
|
|
|
24
24
|
}, [userId]);
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
+
* Sets the {@link BeforeSend} hook while the component is mounted, and clears it on unmount.
|
|
28
|
+
* `undefined` leaves the current hook alone. Use it in a client component when the tracker loads
|
|
29
|
+
* from a server component, such as `<Highseat />` in a Next.js App Router layout, where a
|
|
30
|
+
* function cannot be passed as a prop. Effects run before the tracker's script loads, so the
|
|
31
|
+
* first page view already passes through the hook.
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* "use client"
|
|
35
|
+
* export function Redact() {
|
|
36
|
+
* useBeforeSend(event => (event.url.includes("/admin/") ? null : event))
|
|
37
|
+
* return null
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
function useBeforeSend(beforeSend) {
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!beforeSend) return;
|
|
44
|
+
setBeforeSend(beforeSend);
|
|
45
|
+
return () => {
|
|
46
|
+
if (window.highseat?.beforeSend === beforeSend) setBeforeSend(void 0);
|
|
47
|
+
};
|
|
48
|
+
}, [beforeSend]);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
27
51
|
* Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
|
|
28
52
|
* the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
|
|
29
53
|
* counts client-side navigation on its own. With `userId`, it also identifies the visitor, as
|
|
30
|
-
* {@link useIdentify} does.
|
|
54
|
+
* {@link useIdentify} does, and with `beforeSend` it sets the hook, as {@link useBeforeSend} does.
|
|
31
55
|
*/
|
|
32
|
-
function Highseat({ site, src, outbound, downloads, hashRouting, debug, userId }) {
|
|
56
|
+
function Highseat({ site, src, outbound, downloads, hashRouting, beforeSend, debug, userId }) {
|
|
57
|
+
useBeforeSend(beforeSend);
|
|
33
58
|
useEffect(() => {
|
|
34
59
|
inject({
|
|
35
60
|
site,
|
|
@@ -51,6 +76,6 @@ function Highseat({ site, src, outbound, downloads, hashRouting, debug, userId }
|
|
|
51
76
|
return null;
|
|
52
77
|
}
|
|
53
78
|
//#endregion
|
|
54
|
-
export { Highseat, useIdentify };
|
|
79
|
+
export { Highseat, useBeforeSend, useIdentify };
|
|
55
80
|
|
|
56
81
|
//# sourceMappingURL=react.js.map
|
package/react.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { syncIdentity } from \"./identity\"\nimport { inject, type InjectOptions, type UserId } from \"./index\"\n\nexport interface HighseatProps extends InjectOptions {\n /**\n * The signed-in user's id from your own system, never an email or other personal data. Pass\n * `null` once the visitor is signed out, and `undefined` while the session is still loading.\n * See {@link useIdentify}.\n */\n userId?: UserId\n}\n\n/**\n * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`\n * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and\n * `undefined`, a session still loading, changes nothing. Works in any client component, with or\n * without `<Highseat />` on the page, and before the tracker has loaded.\n *\n * Tell loading apart from signed out: `useIdentify(session?.user.id)` passes `undefined` after\n * sign-out too, so it never clears the id.\n *\n * ```tsx\n * const { data, isPending } = useSession()\n * useIdentify(isPending ? undefined : (data?.user.id ?? null))\n * ```\n */\nexport function useIdentify(userId: UserId): void {\n // The id this component identified last. A ref survives Strict Mode's second effect run, so\n // the same id is never sent twice.\n const identified = useRef<UserId>(undefined)\n\n useEffect(() => {\n identified.current = syncIdentity(identified.current, userId)\n }, [userId])\n}\n\n/**\n * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after\n * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker\n * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as\n * {@link useIdentify} does.\n */\nexport function Highseat({\n site,\n src,\n outbound,\n downloads,\n hashRouting,\n debug,\n userId,\n}: HighseatProps): null {\n useEffect(() => {\n inject({ site, src, outbound, downloads, hashRouting, debug })\n }, [site, src, outbound, downloads, hashRouting, debug])\n\n useIdentify(userId)\n\n return null\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,YAAY,QAAsB;CAGhD,MAAM,aAAa,OAAe,KAAA,CAAS;CAE3C,gBAAgB;EACd,WAAW,UAAU,aAAa,WAAW,SAAS,MAAM;CAC9D,GAAG,CAAC,MAAM,CAAC;AACb;;;;;;;AAQA,SAAgB,SAAS,EACvB,MACA,KACA,UACA,WACA,aACA,OACA,UACsB;
|
|
1
|
+
{"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { syncIdentity } from \"./identity\"\nimport { inject, setBeforeSend, type BeforeSend, type InjectOptions, type UserId } from \"./index\"\n\nexport interface HighseatProps extends InjectOptions {\n /**\n * The signed-in user's id from your own system, never an email or other personal data. Pass\n * `null` once the visitor is signed out, and `undefined` while the session is still loading.\n * See {@link useIdentify}.\n */\n userId?: UserId\n}\n\n/**\n * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`\n * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and\n * `undefined`, a session still loading, changes nothing. Works in any client component, with or\n * without `<Highseat />` on the page, and before the tracker has loaded.\n *\n * Tell loading apart from signed out: `useIdentify(session?.user.id)` passes `undefined` after\n * sign-out too, so it never clears the id.\n *\n * ```tsx\n * const { data, isPending } = useSession()\n * useIdentify(isPending ? undefined : (data?.user.id ?? null))\n * ```\n */\nexport function useIdentify(userId: UserId): void {\n // The id this component identified last. A ref survives Strict Mode's second effect run, so\n // the same id is never sent twice.\n const identified = useRef<UserId>(undefined)\n\n useEffect(() => {\n identified.current = syncIdentity(identified.current, userId)\n }, [userId])\n}\n\n/**\n * Sets the {@link BeforeSend} hook while the component is mounted, and clears it on unmount.\n * `undefined` leaves the current hook alone. Use it in a client component when the tracker loads\n * from a server component, such as `<Highseat />` in a Next.js App Router layout, where a\n * function cannot be passed as a prop. Effects run before the tracker's script loads, so the\n * first page view already passes through the hook.\n *\n * ```tsx\n * \"use client\"\n * export function Redact() {\n * useBeforeSend(event => (event.url.includes(\"/admin/\") ? null : event))\n * return null\n * }\n * ```\n */\nexport function useBeforeSend(beforeSend: BeforeSend | undefined): void {\n useEffect(() => {\n if (!beforeSend) return\n setBeforeSend(beforeSend)\n return () => {\n // Another component may have set its own hook since.\n if (window.highseat?.beforeSend === beforeSend) setBeforeSend(undefined)\n }\n }, [beforeSend])\n}\n\n/**\n * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after\n * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker\n * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as\n * {@link useIdentify} does, and with `beforeSend` it sets the hook, as {@link useBeforeSend} does.\n */\nexport function Highseat({\n site,\n src,\n outbound,\n downloads,\n hashRouting,\n beforeSend,\n debug,\n userId,\n}: HighseatProps): null {\n // Declared first, so its effect sets the hook before the next one adds the script tag.\n useBeforeSend(beforeSend)\n\n useEffect(() => {\n inject({ site, src, outbound, downloads, hashRouting, debug })\n }, [site, src, outbound, downloads, hashRouting, debug])\n\n useIdentify(userId)\n\n return null\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,YAAY,QAAsB;CAGhD,MAAM,aAAa,OAAe,KAAA,CAAS;CAE3C,gBAAgB;EACd,WAAW,UAAU,aAAa,WAAW,SAAS,MAAM;CAC9D,GAAG,CAAC,MAAM,CAAC;AACb;;;;;;;;;;;;;;;;AAiBA,SAAgB,cAAc,YAA0C;CACtE,gBAAgB;EACd,IAAI,CAAC,YAAY;EACjB,cAAc,UAAU;EACxB,aAAa;GAEX,IAAI,OAAO,UAAU,eAAe,YAAY,cAAc,KAAA,CAAS;EACzE;CACF,GAAG,CAAC,UAAU,CAAC;AACjB;;;;;;;AAQA,SAAgB,SAAS,EACvB,MACA,KACA,UACA,WACA,aACA,YACA,OACA,UACsB;CAEtB,cAAc,UAAU;CAExB,gBAAgB;EACd,OAAO;GAAE;GAAM;GAAK;GAAU;GAAW;GAAa;EAAM,CAAC;CAC/D,GAAG;EAAC;EAAM;EAAK;EAAU;EAAW;EAAa;CAAK,CAAC;CAEvD,YAAY,MAAM;CAElB,OAAO;AACT"}
|