highseat 0.2.0 → 0.3.1

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 CHANGED
@@ -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
- Set the site id from your site's Installation settings, and the proxy path for first-party delivery:
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,72 @@ 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
- 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.
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
+
97
+ ## Debugging
98
+
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.
100
+
101
+ Logging is on when `process.env.NODE_ENV` is `development`, so a production build logs nothing. Pass `debug={false}` to `<Highseat />` to turn it off, or `debug` to turn it on in any build. `inject({ site, debug })` takes the same option.
102
+
65
103
  ## Events
66
104
 
67
105
  ```ts
@@ -149,7 +187,11 @@ HIGHSEAT_SECRET=hs_secret_... # the site secret
149
187
 
150
188
  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.
151
189
 
152
- Then load the tracker from the path. In Next.js, `<Highseat />` reads `HIGHSEAT_PATH` itself. Elsewhere, pass `src="/3f9a0c1e2b"` to `<Highseat />` or `inject()`, or use the script tag from Installation settings.
190
+ 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:
191
+
192
+ ```html
193
+ <script defer src="/3f9a0c1e2b" data-site="site_…"></script>
194
+ ```
153
195
 
154
196
  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.
155
197
 
@@ -185,9 +227,27 @@ export default withHighseat({
185
227
  })
186
228
  ```
187
229
 
230
+ 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:
231
+
232
+ ```tsx
233
+ // app/layout.tsx
234
+ import { Highseat } from "highseat/next"
235
+
236
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
237
+ return (
238
+ <html lang="en">
239
+ <body>
240
+ {children}
241
+ <Highseat />
242
+ </body>
243
+ </html>
244
+ )
245
+ }
246
+ ```
247
+
188
248
  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.
189
249
 
190
- `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 well as the server: the rewrite is written at build time. 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.
250
+ `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.
191
251
 
192
252
  ### TanStack Start
193
253
 
@@ -262,6 +322,8 @@ export default {
262
322
 
263
323
  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`.
264
324
 
325
+ 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`.
326
+
265
327
  ### Cloudflare Pages
266
328
 
267
329
  ```ts
@@ -284,7 +346,14 @@ export const onRequest: PagesFunction<Env> = ({ request, env, next }) => {
284
346
  }
285
347
  ```
286
348
 
287
- The function sees each one-segment path, and `next` serves your site for every path but the proxy's. Add both variables in the Pages project's settings.
349
+ ```json
350
+ // public/_routes.json
351
+ { "version": 1, "include": ["/3f9a0c1e2b"], "exclude": [] }
352
+ ```
353
+
354
+ `_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.
355
+
356
+ 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.
288
357
 
289
358
  ### Netlify
290
359
 
@@ -330,7 +399,14 @@ export const ALL: APIRoute = ({ request, clientAddress }) =>
330
399
  createProxy({ clientIp: () => clientAddress })(request)
331
400
  ```
332
401
 
333
- Needs a server adapter. The file name carries the path, so set only `HIGHSEAT_SECRET`. On the Cloudflare adapter, pass `secret: locals.runtime.env.HIGHSEAT_SECRET`.
402
+ Then load the tracker from the path in your layout:
403
+
404
+ ```astro
405
+ <!-- src/layouts/Layout.astro -->
406
+ <script is:inline defer src="/3f9a0c1e2b" data-site={import.meta.env.PUBLIC_HIGHSEAT_SITE}></script>
407
+ ```
408
+
409
+ 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.
334
410
 
335
411
  ### Hono
336
412
 
package/env.js ADDED
@@ -0,0 +1,51 @@
1
+ //#region src/env.ts
2
+ /**
3
+ * The variables the package reads from a customer's environment. Each is named in full, never
4
+ * looked up by a computed key: Next.js inlines a `NEXT_PUBLIC_` variable into browser code only
5
+ * where the source spells out `process.env.NEXT_PUBLIC_…`. The server variables return
6
+ * `undefined` where the runtime has no `process`: a Cloudflare Worker without `nodejs_compat`,
7
+ * whose options carry the values instead.
8
+ */
9
+ /** The site id for `<Highseat />` in Next.js. Public, so it reaches the browser. */
10
+ function findNextSite() {
11
+ return process.env.NEXT_PUBLIC_HIGHSEAT_SITE;
12
+ }
13
+ /**
14
+ * The site's proxy path: `option` when given, else `HIGHSEAT_PATH`, a server variable shared by
15
+ * `<Highseat />`, `withHighseat()` and the proxy handler. A value written without its leading
16
+ * slash gets one, as Installation settings shows it (`/3f9a0c1e2b`). An empty value counts as
17
+ * unset.
18
+ */
19
+ function findProxyPath(option) {
20
+ const path = option ?? (typeof process === "undefined" ? void 0 : process.env.HIGHSEAT_PATH);
21
+ if (!path) return;
22
+ return path.startsWith("/") ? path : `/${path}`;
23
+ }
24
+ /** The site secret for the proxy handler. */
25
+ function findSecret() {
26
+ if (typeof process === "undefined") return;
27
+ return process.env.HIGHSEAT_SECRET;
28
+ }
29
+ /**
30
+ * Whether this runs inside `next build`, which loads every route without serving one. Set in the
31
+ * build's route workers only: `next.config.ts` receives the phase as an argument instead.
32
+ */
33
+ function isNextBuild() {
34
+ if (typeof process === "undefined") return false;
35
+ return process.env.NEXT_PHASE === "phase-production-build";
36
+ }
37
+ /**
38
+ * Whether the build runs in development. A bundler replaces `process.env.NODE_ENV` with a
39
+ * literal. A browser without that replacement has no `process`, so the read throws.
40
+ */
41
+ function isDevelopment() {
42
+ try {
43
+ return process.env.NODE_ENV === "development";
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+ //#endregion
49
+ export { findNextSite, findProxyPath, findSecret, isDevelopment, isNextBuild };
50
+
51
+ //# sourceMappingURL=env.js.map
package/env.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.js","names":[],"sources":["../src/env.ts"],"sourcesContent":["/**\n * The variables the package reads from a customer's environment. Each is named in full, never\n * looked up by a computed key: Next.js inlines a `NEXT_PUBLIC_` variable into browser code only\n * where the source spells out `process.env.NEXT_PUBLIC_…`. The server variables return\n * `undefined` where the runtime has no `process`: a Cloudflare Worker without `nodejs_compat`,\n * whose options carry the values instead.\n */\n\n/** The site id for `<Highseat />` in Next.js. Public, so it reaches the browser. */\nexport function findNextSite(): string | undefined {\n return process.env.NEXT_PUBLIC_HIGHSEAT_SITE\n}\n\n/**\n * The site's proxy path: `option` when given, else `HIGHSEAT_PATH`, a server variable shared by\n * `<Highseat />`, `withHighseat()` and the proxy handler. A value written without its leading\n * slash gets one, as Installation settings shows it (`/3f9a0c1e2b`). An empty value counts as\n * unset.\n */\nexport function findProxyPath(option?: string): string | undefined {\n const path = option ?? (typeof process === \"undefined\" ? undefined : process.env.HIGHSEAT_PATH)\n if (!path) return\n return path.startsWith(\"/\") ? path : `/${path}`\n}\n\n/** The site secret for the proxy handler. */\nexport function findSecret(): string | undefined {\n if (typeof process === \"undefined\") return\n return process.env.HIGHSEAT_SECRET\n}\n\n/**\n * Whether this runs inside `next build`, which loads every route without serving one. Set in the\n * build's route workers only: `next.config.ts` receives the phase as an argument instead.\n */\nexport function isNextBuild(): boolean {\n if (typeof process === \"undefined\") return false\n return process.env.NEXT_PHASE === \"phase-production-build\"\n}\n\n/**\n * Whether the build runs in development. A bundler replaces `process.env.NODE_ENV` with a\n * literal. A browser without that replacement has no `process`, so the read throws.\n */\nexport function isDevelopment(): boolean {\n try {\n return process.env.NODE_ENV === \"development\"\n } catch {\n return false\n }\n}\n"],"mappings":";;;;;;;;;AASA,SAAgB,eAAmC;CACjD,OAAO,QAAQ,IAAI;AACrB;;;;;;;AAQA,SAAgB,cAAc,QAAqC;CACjE,MAAM,OAAO,WAAW,OAAO,YAAY,cAAc,KAAA,IAAY,QAAQ,IAAI;CACjF,IAAI,CAAC,MAAM;CACX,OAAO,KAAK,WAAW,GAAG,IAAI,OAAO,IAAI;AAC3C;;AAGA,SAAgB,aAAiC;CAC/C,IAAI,OAAO,YAAY,aAAa;CACpC,OAAO,QAAQ,IAAI;AACrB;;;;;AAMA,SAAgB,cAAuB;CACrC,IAAI,OAAO,YAAY,aAAa,OAAO;CAC3C,OAAO,QAAQ,IAAI,eAAe;AACpC;;;;;AAMA,SAAgB,gBAAyB;CACvC,IAAI;EACF,OAAO,QAAQ,IAAI,aAAa;CAClC,QAAQ;EACN,OAAO;CACT;AACF"}
package/identity.js ADDED
@@ -0,0 +1,16 @@
1
+ import { identify } from "./index.js";
2
+ //#region src/identity.ts
3
+ /**
4
+ * Identifies `userId` when it differs from `previous`, the id identified last, and returns the id
5
+ * identified now. `undefined` means the session has not loaded, so it changes nothing and keeps
6
+ * `previous`: a session that reloads with the same user calls `identify` once, not twice.
7
+ */
8
+ function syncIdentity(previous, userId) {
9
+ if (userId === void 0 || userId === previous) return previous;
10
+ identify(userId);
11
+ return userId;
12
+ }
13
+ //#endregion
14
+ export { syncIdentity };
15
+
16
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.js","names":[],"sources":["../src/identity.ts"],"sourcesContent":["import { identify, type UserId } from \"./index\"\n\n/**\n * Identifies `userId` when it differs from `previous`, the id identified last, and returns the id\n * identified now. `undefined` means the session has not loaded, so it changes nothing and keeps\n * `previous`: a session that reloads with the same user calls `identify` once, not twice.\n */\nexport function syncIdentity(previous: UserId, userId: UserId): UserId {\n if (userId === undefined || userId === previous) return previous\n identify(userId)\n return userId\n}\n"],"mappings":";;;;;;;AAOA,SAAgB,aAAa,UAAkB,QAAwB;CACrE,IAAI,WAAW,KAAA,KAAa,WAAW,UAAU,OAAO;CACxD,SAAS,MAAM;CACf,OAAO;AACT"}
package/index.d.ts CHANGED
@@ -1,2 +1,181 @@
1
- import { _ as track, a as InjectOptions, c as Register, d as TrackArgs, f as TrackName, g as inject, h as identify, i as HighseatGlobal, l as Revenue, m as getAttributionId, n as BuiltInEvents, o as Properties, p as UserId, r as EventName, s as PropertyValue, t as AttributionOptions, u as SCRIPT_URL } from "./index2.js";
2
- export { AttributionOptions, BuiltInEvents, EventName, HighseatGlobal, InjectOptions, Properties, PropertyValue, Register, Revenue, SCRIPT_URL, TrackArgs, TrackName, UserId, getAttributionId, identify, inject, track };
1
+ //#region src/index.d.ts
2
+ /**
3
+ * The browser client. Every call goes to the hosted tracker on `window.highseat`; before the
4
+ * tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:
5
+ * on a server, with the tracker blocked, or with tracking off, a call does nothing.
6
+ */
7
+ /** A value an event property can hold. A `null` or `undefined` value is left out of the event. */
8
+ export type PropertyValue = string | number | boolean | null | undefined;
9
+ /**
10
+ * Money attached to an event. `amount` is an integer in the currency's smallest unit, so
11
+ * `{ amount: 4999, currency: "USD" }` is $49.99 and `{ amount: 5000, currency: "JPY" }` is ¥5000.
12
+ */
13
+ export interface Revenue {
14
+ /** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */
15
+ amount: number;
16
+ /** An ISO 4217 code, such as `USD` or `EUR`. */
17
+ currency: string;
18
+ }
19
+ /** The properties of an event with no declared shape: any string keys, plus optional revenue. */
20
+ export interface Properties {
21
+ /** Money the event brought in. */
22
+ revenue?: Revenue;
23
+ [key: string]: PropertyValue | Revenue;
24
+ }
25
+ /**
26
+ * The events Highseat knows by name. A site extends it, or overrides an entry, through
27
+ * {@link Register}. Type-only: it adds nothing to the bundle.
28
+ */
29
+ export interface BuiltInEvents {
30
+ /** A visitor created an account. */
31
+ signup: Properties;
32
+ /** A visitor signed in. */
33
+ login: Properties;
34
+ /**
35
+ * A sale. With a payment provider connected, leave `revenue` out: the provider's webhook
36
+ * records the money, and revenue sent here as well would count the sale twice. Send `revenue`
37
+ * only for a checkout no provider reports, and then with an `order_id`, so a provider sale
38
+ * added later for the same order is not counted again.
39
+ */
40
+ purchase: Properties;
41
+ /** A paid subscription began. */
42
+ subscription_started: Properties;
43
+ /** A free trial began. */
44
+ trial_started: Properties;
45
+ /** A file download. The tracker records links to files on its own. */
46
+ file_download: {
47
+ url: string;
48
+ };
49
+ /** A click on a link to another site. The tracker records these on its own. */
50
+ outbound_link_click: {
51
+ url: string;
52
+ };
53
+ /** A visit to a missing page. Call it from a single-page app's not-found route. */
54
+ "404": Properties;
55
+ }
56
+ /**
57
+ * Declares a site's own events. Augment it from any file the compiler sees:
58
+ *
59
+ * ```ts
60
+ * declare module "highseat" {
61
+ * interface Register {
62
+ * events: {
63
+ * signup: { plan: "free" | "pro" }
64
+ * }
65
+ * strict: true
66
+ * }
67
+ * }
68
+ * ```
69
+ *
70
+ * `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`
71
+ * accept only built-in and registered names, so a misspelt name is a type error.
72
+ */
73
+ export interface Register {}
74
+ /**
75
+ * A registered event's properties as `track` accepts them. A shape with no properties takes none,
76
+ * and a property whose type an event cannot carry becomes `never`, so a call passing it fails
77
+ * instead of sending `"[object Object]"`. The shape is not rejected at registration, where a
78
+ * failed check would silently drop every registered event at once.
79
+ */
80
+ type CheckedProperties<Shape> = [keyof Shape] extends [never] ? Record<string, never> : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never; };
81
+ type RegisteredEvents = Register extends {
82
+ events: infer Registered;
83
+ } ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]>; } : {};
84
+ type KnownEvents = Omit<BuiltInEvents, keyof RegisteredEvents> & RegisteredEvents;
85
+ /** Every event name `track` knows, built-in or registered. */
86
+ export type EventName = Extract<keyof KnownEvents, string>;
87
+ /**
88
+ * The names `track` accepts: known names, and any other string unless the site is strict.
89
+ * `string & {}` keeps the known names in the editor's suggestions, which a plain `string` in the
90
+ * union would absorb.
91
+ */
92
+ export type TrackName = Register extends {
93
+ strict: true;
94
+ } ? EventName : EventName | (string & {});
95
+ /**
96
+ * The arguments after an event's name. A known name takes its declared properties, required
97
+ * when any of them is; any other name takes {@link Properties}. Wrapped in tuples so a union of
98
+ * names is checked as a whole rather than name by name.
99
+ */
100
+ export type TrackArgs<Name extends string> = [Name] extends [EventName] ? {} extends KnownEvents[Name] ? [properties?: KnownEvents[Name]] : [properties: KnownEvents[Name]] : [properties?: Properties];
101
+ /**
102
+ * A user id from your own system, as {@link identify} takes it. A string or a number identifies
103
+ * the visitor, `null` clears the id, and `undefined` or an empty string changes nothing. Pass your
104
+ * own database id, never an email or other personal data.
105
+ */
106
+ export type UserId = string | number | null | undefined;
107
+ /** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */
108
+ export interface HighseatGlobal {
109
+ /** Records an event. */
110
+ track?: (name: string, properties?: Properties) => void;
111
+ /** Sets or clears the visitor's user id. */
112
+ identify?: (id: UserId) => void;
113
+ /** The id that joins a payment to this tab's visit. */
114
+ getAttributionId?: () => string | undefined;
115
+ /** Calls made before the tracker loaded, drained in order when it starts. */
116
+ q?: [string, unknown[]][];
117
+ }
118
+ /** How long {@link getAttributionId} waits for the tracker. */
119
+ export interface AttributionOptions {
120
+ /** Milliseconds to wait for the tracker to load. Defaults to 3000. */
121
+ timeout?: number;
122
+ }
123
+ /** How the tracker is loaded. Each option maps to an attribute on its script tag. */
124
+ export interface InjectOptions {
125
+ /** The site id, `site_…`, from the site's Installation settings. */
126
+ site: string;
127
+ /**
128
+ * Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party
129
+ * delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back
130
+ * to the same place.
131
+ */
132
+ src?: string;
133
+ /** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */
134
+ outbound?: boolean;
135
+ /** Records clicks on links to files as `file_download`. Defaults to `true`. */
136
+ downloads?: boolean;
137
+ /** Counts a change of `#/route` as a page view, for an app that routes by hash. */
138
+ hashRouting?: boolean;
139
+ /**
140
+ * Logs every `track()` and `identify()` call to the console, and warns once when no tracker
141
+ * script is on the page or the script failed to load. Defaults to `true` when
142
+ * `process.env.NODE_ENV` is `development`, so a production build logs nothing.
143
+ */
144
+ debug?: boolean;
145
+ }
146
+ declare global {
147
+ interface Window {
148
+ highseat?: HighseatGlobal;
149
+ }
150
+ }
151
+ /** Where the tracker loads from when the site has no proxy path. */
152
+ export declare const SCRIPT_URL = "https://cdn.highseat.com/script.js";
153
+ /**
154
+ * Records an event. A known name autocompletes and checks its properties; any other name is a
155
+ * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so
156
+ * `Purchase` and `purchase` are two events.
157
+ */
158
+ export declare function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void;
159
+ /**
160
+ * Links the visitor to a user id from your own system, on this event and every later one, across
161
+ * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so
162
+ * `identify(user?.id)` before a session loads does not sign the visitor out.
163
+ */
164
+ export declare function identify(id: UserId): void;
165
+ /**
166
+ * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata
167
+ * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.
168
+ * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has
169
+ * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout
170
+ * when the script is still loading, and on a server or with tracking off.
171
+ */
172
+ export declare function getAttributionId(options?: AttributionOptions): Promise<string | undefined>;
173
+ /**
174
+ * Adds the tracker's script tag to the page, once. A second call for the same site, such as a
175
+ * component mounting again, finds the tag and adds nothing, but its `debug` option still applies.
176
+ * Does nothing on a server.
177
+ */
178
+ export declare function inject(options: InjectOptions): void;
179
+ //#endregion
180
+ export {};
181
+ //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,13 +1,19 @@
1
+ import { isDevelopment } from "./env.js";
1
2
  //#region src/index.ts
2
3
  /** Where the tracker loads from when the site has no proxy path. */
3
4
  const SCRIPT_URL = "https://cdn.highseat.com/script.js";
4
5
  const LIVE = Symbol.for("highseat");
5
6
  const SCRIPT_HOST = new URL(SCRIPT_URL).host;
6
7
  const PROXY_PATH = /^\/[0-9a-f]{10}$/;
7
- const ATTRIBUTION_TIMEOUT = 3e3;
8
- const ATTRIBUTION_POLL_INTERVAL = 50;
8
+ const TRACKER_TIMEOUT = 3e3;
9
+ const TRACKER_POLL_INTERVAL = 50;
10
+ const MISSING_SCRIPT_WARNING = ["highseat: no tracker script on this page, so no event reaches Highseat.", "Render <Highseat />, call inject({ site }), or add the script tag from the site's Installation settings."].join(" ");
11
+ const FAILED_SCRIPT_WARNING = ["highseat: the tracker script failed to load, so no event reaches Highseat.", "An ad blocker may have stopped it: load the tracker from the site's proxy path instead."].join(" ");
12
+ const SLOW_SCRIPT_WARNING = [`highseat: the tracker script has not loaded after ${TRACKER_TIMEOUT / 1e3} seconds,`, "so every call waits in the queue. Check the network panel for a blocked or failed request."].join(" ");
9
13
  const injectedScripts = /* @__PURE__ */ new WeakSet();
10
14
  const failedScripts = /* @__PURE__ */ new WeakSet();
15
+ let debugOption;
16
+ const watchedWindows = /* @__PURE__ */ new WeakSet();
11
17
  function findGlobal() {
12
18
  if (typeof window === "undefined") return;
13
19
  return window.highseat ??= {};
@@ -34,8 +40,55 @@ function isTrackerScript(script) {
34
40
  return false;
35
41
  }
36
42
  }
43
+ /** The page's tracker scripts, each one listened to from now on for a failed load. */
37
44
  function findTrackerScripts() {
38
- return Array.from(document.scripts).filter(isTrackerScript);
45
+ const scripts = Array.from(document.scripts).filter(isTrackerScript);
46
+ for (const script of scripts) script.addEventListener("error", markFailed);
47
+ return scripts;
48
+ }
49
+ function isDebug() {
50
+ return debugOption ?? isDevelopment();
51
+ }
52
+ /**
53
+ * Warns once per page when the tracker cannot arrive: no tracker script, or only failed ones. A
54
+ * missing script is judged at the timeout, because `<Highseat />` adds its tag in an effect that
55
+ * runs after the effects of the components inside it.
56
+ */
57
+ function watchTracker() {
58
+ if (typeof window === "undefined" || typeof document === "undefined") return;
59
+ if (watchedWindows.has(window)) return;
60
+ watchedWindows.add(window);
61
+ const page = window;
62
+ const deadline = Date.now() + TRACKER_TIMEOUT;
63
+ function check() {
64
+ if (window !== page || isLive(page.highseat)) return;
65
+ const scripts = findTrackerScripts();
66
+ if (scripts.length > 0 && scripts.every((script) => failedScripts.has(script))) {
67
+ console.warn(FAILED_SCRIPT_WARNING);
68
+ return;
69
+ }
70
+ if (Date.now() < deadline) {
71
+ setTimeout(check, TRACKER_POLL_INTERVAL);
72
+ return;
73
+ }
74
+ console.warn(scripts.length === 0 ? MISSING_SCRIPT_WARNING : SLOW_SCRIPT_WARNING);
75
+ }
76
+ setTimeout(check, TRACKER_POLL_INTERVAL);
77
+ }
78
+ /**
79
+ * Logs a call when debugging is on. The check waits a microtask, so a `debug` option that
80
+ * `<Highseat />` passes in its effect applies to the calls its children make in theirs.
81
+ */
82
+ function logCall(method, args, isQueued) {
83
+ queueMicrotask(() => {
84
+ if (!isDebug()) return;
85
+ const status = isQueued ? "queued until the tracker loads" : "sent to the tracker";
86
+ const [value, properties] = args;
87
+ const message = `highseat: ${method} ${String(JSON.stringify(value))}, ${status}`;
88
+ if (properties === void 0) console.info(message);
89
+ else console.info(message, properties);
90
+ watchTracker();
91
+ });
39
92
  }
40
93
  function call(method, args) {
41
94
  const api = findGlobal();
@@ -44,8 +97,10 @@ function call(method, args) {
44
97
  if (typeof handler !== "function") {
45
98
  api.q ??= [];
46
99
  api.q.push([method, args]);
100
+ logCall(method, args, true);
47
101
  return;
48
102
  }
103
+ logCall(method, args, false);
49
104
  try {
50
105
  handler(...args);
51
106
  } catch {}
@@ -76,14 +131,12 @@ function identify(id) {
76
131
  */
77
132
  async function getAttributionId(options = {}) {
78
133
  if (typeof window === "undefined" || typeof document === "undefined") return;
79
- const deadline = Date.now() + (options.timeout ?? ATTRIBUTION_TIMEOUT);
134
+ const deadline = Date.now() + (options.timeout ?? TRACKER_TIMEOUT);
80
135
  let api = window.highseat;
81
136
  while (!isLive(api)) {
82
137
  if (Date.now() >= deadline) return;
83
- const scripts = findTrackerScripts();
84
- for (const script of scripts) script.addEventListener("error", markFailed);
85
- if (scripts.every((script) => failedScripts.has(script))) return;
86
- await new Promise((resolve) => setTimeout(resolve, ATTRIBUTION_POLL_INTERVAL));
138
+ if (findTrackerScripts().every((script) => failedScripts.has(script))) return;
139
+ await new Promise((resolve) => setTimeout(resolve, TRACKER_POLL_INTERVAL));
87
140
  api = window.highseat;
88
141
  }
89
142
  try {
@@ -94,11 +147,14 @@ async function getAttributionId(options = {}) {
94
147
  }
95
148
  /**
96
149
  * Adds the tracker's script tag to the page, once. A second call for the same site, such as a
97
- * component mounting again, finds the tag and does nothing. Does nothing on a server.
150
+ * component mounting again, finds the tag and adds nothing, but its `debug` option still applies.
151
+ * Does nothing on a server.
98
152
  */
99
153
  function inject(options) {
100
154
  if (typeof document === "undefined") return;
101
- const { site, src = SCRIPT_URL, outbound, downloads, hashRouting } = options;
155
+ const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, debug } = options;
156
+ debugOption = debug;
157
+ if (isDebug()) watchTracker();
102
158
  if (Array.from(document.scripts).some((script) => script.dataset.site === site)) return;
103
159
  const script = document.createElement("script");
104
160
  script.addEventListener("error", markFailed);
package/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\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\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 ATTRIBUTION_TIMEOUT = 3000\nconst ATTRIBUTION_POLL_INTERVAL = 50\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\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\nfunction findTrackerScripts() {\n return Array.from(document.scripts).filter(isTrackerScript)\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 return\n }\n\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 ?? ATTRIBUTION_TIMEOUT)\n let api = window.highseat\n\n while (!isLive(api)) {\n if (Date.now() >= deadline) return\n\n const scripts = findTrackerScripts()\n for (const script of scripts) script.addEventListener(\"error\", markFailed)\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, ATTRIBUTION_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 does nothing. 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 } = options\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/** 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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"}