highseat 0.1.0-alpha.2 → 0.2.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 CHANGED
@@ -2,12 +2,10 @@
2
2
 
3
3
  Highseat analytics for JavaScript: typed tracking, a first-party proxy and framework components.
4
4
 
5
- > **Pre-release.** The `alpha` versions can change shape between releases.
6
-
7
5
  ## Install
8
6
 
9
7
  ```bash
10
- npm install highseat@alpha
8
+ npm install highseat
11
9
  ```
12
10
 
13
11
  ## Entry points
@@ -15,8 +13,8 @@ npm install highseat@alpha
15
13
  | Import | Contents | Needs |
16
14
  | --- | --- | --- |
17
15
  | `highseat` | `track()`, `identify()`, `getAttributionId()` and `inject()` | Nothing |
18
- | `highseat/react` | `<Highseat />` | React |
19
- | `highseat/next` | `<Highseat />`, `createProxy()` and `withHighseat()` for Next.js | Next.js |
16
+ | `highseat/react` | `<Highseat />` and `useIdentify()` | React |
17
+ | `highseat/next` | `<Highseat />`, `useIdentify()`, `createProxy()` and `withHighseat()` for Next.js | Next.js |
20
18
  | `highseat/proxy` | `createProxy()`, a first-party proxy for any runtime with `fetch` | Nothing |
21
19
 
22
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.
@@ -92,6 +90,43 @@ declare module "highseat" {
92
90
 
93
91
  Without `strict: true`, any other name still compiles as a custom event. The declarations are types only and add nothing to your bundle.
94
92
 
93
+ ### Identify users
94
+
95
+ `identify()` links the visitor to a user id from your own system, on every later event and across visits. Pass your own database id, never an email or other personal data. `identify(null)` clears the id, and an empty or `undefined` id changes nothing.
96
+
97
+ Most sites never call `identify()` directly. They pass the signed-in user's id instead, and the package calls `identify()` each time the value changes. A string or a number identifies the visitor. `null` means signed out, so a sign-out or an expired session clears the id. A sign-out in another tab clears it too once your auth library updates the session in this tab. `undefined` means the session is still loading, and changes nothing. The same id on every render calls `identify()` once.
98
+
99
+ When the server already knows the user, pass the id to `<Highseat />`:
100
+
101
+ ```tsx
102
+ <Highseat site="site_…" userId={user?.id ?? null} />
103
+ ```
104
+
105
+ When the session comes from a client hook, call `useIdentify()` in any client component. It needs no `<Highseat />` nearby, so a Next.js App Router site keeps `<Highseat />` in its server layout and every page static. `highseat/next` and `highseat/react` both export it:
106
+
107
+ ```tsx
108
+ "use client"
109
+
110
+ import { useIdentify } from "highseat/next"
111
+ import { useSession } from "~/lib/auth-client"
112
+
113
+ export function IdentifyUser() {
114
+ const { data, isPending } = useSession()
115
+ useIdentify(isPending ? undefined : (data?.user.id ?? null))
116
+ return null
117
+ }
118
+ ```
119
+
120
+ The value has to tell loading apart from signed out. `useIdentify(session?.user.id)` passes `undefined` after sign-out too, so it never clears the id. `useIdentify(session?.user.id ?? null)` clears the id while the session loads, so the first page view has no user. Each auth library marks loading its own way:
121
+
122
+ | Library | Hook | Value |
123
+ | --- | --- | --- |
124
+ | Better Auth | `const { data, isPending } = useSession()` | `isPending ? undefined : (data?.user.id ?? null)` |
125
+ | Auth.js (NextAuth) | `const { data, status } = useSession()` | `status === "loading" ? undefined : (data?.user?.id ?? null)` |
126
+ | Clerk | `const { user, isLoaded } = useUser()` | `isLoaded ? (user?.id ?? null) : undefined` |
127
+
128
+ Without React, call `identify()` from `highseat` after each sign-in and sign-out.
129
+
95
130
  ## Revenue attribution
96
131
 
97
132
  `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.
@@ -154,6 +189,54 @@ Next.js loads `next.config.ts` with `require`, and the package is ES modules onl
154
189
 
155
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.
156
191
 
192
+ ### TanStack Start
193
+
194
+ ```ts
195
+ // src/server.ts
196
+ import handler, { createServerEntry } from "@tanstack/react-start/server-entry"
197
+ import { createProxy } from "highseat/proxy"
198
+
199
+ const proxy = createProxy({
200
+ clientIp: "cloudflare",
201
+ fallback: request => handler.fetch(request),
202
+ })
203
+
204
+ export default createServerEntry({ fetch: request => proxy(request) })
205
+ ```
206
+
207
+ ```ts
208
+ // vite.config.ts
209
+ import { defineConfig, loadEnv } from "vite"
210
+
211
+ export default defineConfig(({ mode }) => ({
212
+ // Loads HIGHSEAT_PATH alone, so the site secret beside it never reaches the bundle.
213
+ define: {
214
+ "import.meta.env.HIGHSEAT_PATH": JSON.stringify(
215
+ loadEnv(mode, process.cwd(), "HIGHSEAT_PATH").HIGHSEAT_PATH,
216
+ ),
217
+ },
218
+ // Your own config.
219
+ }))
220
+ ```
221
+
222
+ ```tsx
223
+ // src/routes/__root.tsx
224
+ <Highseat site={import.meta.env.VITE_HIGHSEAT_SITE} src={import.meta.env.HIGHSEAT_PATH} />
225
+ ```
226
+
227
+ The server entry answers `HIGHSEAT_PATH` with the proxy and hands every other request to the app. A file route named after the path does not work: a catch-all route such as `$id` matches it first. The `define` gives the browser the same path, so set `HIGHSEAT_PATH` for the build as well as the server, as a build argument in a Docker build. Pick the preset for the outermost proxy that sets the header, as for Next.js.
228
+
229
+ `fallback` receives the request alone, so the app loses the options the server entry was called with. An app that reads them routes the path itself, with a proxy created without `fallback`:
230
+
231
+ ```ts
232
+ export default createServerEntry({
233
+ fetch: (request, options) =>
234
+ new URL(request.url).pathname === process.env.HIGHSEAT_PATH
235
+ ? proxy(request)
236
+ : handler.fetch(request, options),
237
+ })
238
+ ```
239
+
157
240
  ### Cloudflare Worker
158
241
 
159
242
  ```ts
package/index.d.ts CHANGED
@@ -1,168 +1,2 @@
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
- /** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */
102
- export interface HighseatGlobal {
103
- /** Records an event. */
104
- track?: (name: string, properties?: Properties) => void;
105
- /** Sets or clears the visitor's user id. */
106
- identify?: (id: string | number | null | undefined) => void;
107
- /** The id that joins a payment to this tab's visit. */
108
- getAttributionId?: () => string | undefined;
109
- /** Calls made before the tracker loaded, drained in order when it starts. */
110
- q?: [string, unknown[]][];
111
- }
112
- /** How long {@link getAttributionId} waits for the tracker. */
113
- export interface AttributionOptions {
114
- /** Milliseconds to wait for the tracker to load. Defaults to 3000. */
115
- timeout?: number;
116
- }
117
- /** How the tracker is loaded. Each option maps to an attribute on its script tag. */
118
- export interface InjectOptions {
119
- /** The site id, `site_…`, from the site's Installation settings. */
120
- site: string;
121
- /**
122
- * Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party
123
- * delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back
124
- * to the same place.
125
- */
126
- src?: string;
127
- /** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */
128
- outbound?: boolean;
129
- /** Records clicks on links to files as `file_download`. Defaults to `true`. */
130
- downloads?: boolean;
131
- /** Counts a change of `#/route` as a page view, for an app that routes by hash. */
132
- hashRouting?: boolean;
133
- }
134
- declare global {
135
- interface Window {
136
- highseat?: HighseatGlobal;
137
- }
138
- }
139
- /** Where the tracker loads from when the site has no proxy path. */
140
- export declare const SCRIPT_URL = "https://cdn.highseat.com/script.js";
141
- /**
142
- * Records an event. A known name autocompletes and checks its properties; any other name is a
143
- * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so
144
- * `Purchase` and `purchase` are two events.
145
- */
146
- export declare function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void;
147
- /**
148
- * Links the visitor to a user id from your own system, on this event and every later one, across
149
- * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so
150
- * `identify(user?.id)` before a session loads does not sign the visitor out.
151
- */
152
- export declare function identify(id: string | number | null | undefined): void;
153
- /**
154
- * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata
155
- * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.
156
- * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has
157
- * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout
158
- * when the script is still loading, and on a server or with tracking off.
159
- */
160
- export declare function getAttributionId(options?: AttributionOptions): Promise<string | undefined>;
161
- /**
162
- * Adds the tracker's script tag to the page, once. A second call for the same site, such as a
163
- * component mounting again, finds the tag and does nothing. Does nothing on a server.
164
- */
165
- export declare function inject(options: InjectOptions): void;
166
- //#endregion
167
- export {};
168
- //# sourceMappingURL=index.d.ts.map
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 };
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/** 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: string | number | null | undefined) => 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: string | number | null | undefined): 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":["/**\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":";;AAiKA,MAAa,aAAa;AAI1B,MAAM,OAAO,OAAO,IAAI,UAAU;AAElC,MAAM,cAAc,IAAI,IAAI,UAAU,CAAC,CAAC;AAExC,MAAM,aAAa;AAEnB,MAAM,sBAAsB;AAC5B,MAAM,4BAA4B;AAKlC,MAAM,kCAAkB,IAAI,QAA2B;AACvD,MAAM,gCAAgB,IAAI,QAA2B;AAErD,SAAS,aAAyC;CAChD,IAAI,OAAO,WAAW,aAAa;CACnC,OAAQ,OAAO,aAAa,CAAC;AAC/B;AAEA,SAAS,OAAO,KAAwD;CACtE,OAAO,CAAC,CAAC,OAAQ,IAAgC,UAAU;AAC7D;AAGA,SAAS,WAAW,OAAc;CAChC,cAAc,IAAI,MAAM,aAAkC;AAC5D;;;;;;AAOA,SAAS,gBAAgB,QAA2B;CAClD,MAAM,EAAE,SAAS,OAAO;CACxB,IAAI,gBAAgB,IAAI,MAAM,KAAK,MAAM,WAAW,OAAO,GAAG,OAAO;CACrE,IAAI,SAAS,KAAA,GAAW,OAAO;CAE/B,IAAI;EAEF,MAAM,MAAM,IAAI,IAAI,OAAO,KAAK,kBAAkB;EAClD,OAAO,IAAI,SAAS,eAAe,WAAW,KAAK,IAAI,QAAQ;CACjE,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,qBAAqB;CAC5B,OAAO,MAAM,KAAK,SAAS,OAAO,CAAC,CAAC,OAAO,eAAe;AAC5D;AAEA,SAAS,KAAK,QAA8B,MAAiB;CAC3D,MAAM,MAAM,WAAW;CACvB,IAAI,CAAC,KAAK;CAEV,MAAM,UAAU,IAAI;CACpB,IAAI,OAAO,YAAY,YAAY;EACjC,IAAI,MAAM,CAAC;EACX,IAAI,EAAE,KAAK,CAAC,QAAQ,IAAI,CAAC;EACzB;CACF;CAEA,IAAI;EACF,QAAQ,GAAG,IAAI;CACjB,QAAQ,CAAC;AACX;;;;;;AAOA,SAAgB,MAA8B,MAAY,GAAG,MAA6B;CACxF,MAAM,CAAC,cAAc;CACrB,KAAK,SAAS,eAAe,KAAA,IAAY,CAAC,IAAI,IAAI,CAAC,MAAM,UAAU,CAAC;AACtE;;;;;;AAOA,SAAgB,SAAS,IAAkB;CACzC,KAAK,YAAY,CAAC,EAAE,CAAC;AACvB;;;;;;;;AASA,eAAsB,iBACpB,UAA8B,CAAC,GACF;CAC7B,IAAI,OAAO,WAAW,eAAe,OAAO,aAAa,aAAa;CAEtE,MAAM,WAAW,KAAK,IAAI,KAAK,QAAQ,WAAW;CAClD,IAAI,MAAM,OAAO;CAEjB,OAAO,CAAC,OAAO,GAAG,GAAG;EACnB,IAAI,KAAK,IAAI,KAAK,UAAU;EAE5B,MAAM,UAAU,mBAAmB;EACnC,KAAK,MAAM,UAAU,SAAS,OAAO,iBAAiB,SAAS,UAAU;EAEzE,IAAI,QAAQ,OAAM,WAAU,cAAc,IAAI,MAAM,CAAC,GAAG;EAExD,MAAM,IAAI,SAAQ,YAAW,WAAW,SAAS,yBAAyB,CAAC;EAC3E,MAAM,OAAO;CACf;CAEA,IAAI;EACF,OAAO,IAAI,mBAAmB;CAChC,QAAQ;EACN;CACF;AACF;;;;;AAMA,SAAgB,OAAO,SAA8B;CACnD,IAAI,OAAO,aAAa,aAAa;CAErC,MAAM,EAAE,MAAM,MAAM,YAAY,UAAU,WAAW,gBAAgB;CACrE,IAAI,MAAM,KAAK,SAAS,OAAO,CAAC,CAAC,MAAK,WAAU,OAAO,QAAQ,SAAS,IAAI,GAAG;CAE/E,MAAM,SAAS,SAAS,cAAc,QAAQ;CAC9C,OAAO,iBAAiB,SAAS,UAAU;CAC3C,gBAAgB,IAAI,MAAM;CAC1B,OAAO,MAAM;CACb,OAAO,QAAQ,OAAO;CACtB,IAAI,aAAa,OAAO,OAAO,QAAQ,WAAW;CAClD,IAAI,cAAc,OAAO,OAAO,QAAQ,YAAY;CACpD,IAAI,aAAa,OAAO,QAAQ,cAAc;CAC9C,SAAS,KAAK,OAAO,MAAM;AAC7B"}
package/index2.d.ts ADDED
@@ -0,0 +1,174 @@
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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ declare global {
141
+ interface Window {
142
+ highseat?: HighseatGlobal;
143
+ }
144
+ }
145
+ /** Where the tracker loads from when the site has no proxy path. */
146
+ declare const SCRIPT_URL = "https://cdn.highseat.com/script.js";
147
+ /**
148
+ * Records an event. A known name autocompletes and checks its properties; any other name is a
149
+ * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so
150
+ * `Purchase` and `purchase` are two events.
151
+ */
152
+ declare function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void;
153
+ /**
154
+ * Links the visitor to a user id from your own system, on this event and every later one, across
155
+ * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so
156
+ * `identify(user?.id)` before a session loads does not sign the visitor out.
157
+ */
158
+ declare function identify(id: UserId): void;
159
+ /**
160
+ * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata
161
+ * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.
162
+ * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has
163
+ * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout
164
+ * when the script is still loading, and on a server or with tracking off.
165
+ */
166
+ declare function getAttributionId(options?: AttributionOptions): Promise<string | undefined>;
167
+ /**
168
+ * Adds the tracker's script tag to the page, once. A second call for the same site, such as a
169
+ * component mounting again, finds the tag and does nothing. Does nothing on a server.
170
+ */
171
+ declare function inject(options: InjectOptions): void;
172
+ //#endregion
173
+ export { track as _, InjectOptions as a, Register as c, TrackArgs as d, TrackName as f, inject as g, identify as h, HighseatGlobal as i, Revenue as l, getAttributionId as m, BuiltInEvents as n, Properties as o, UserId as p, EventName as r, PropertyValue as s, AttributionOptions as t, SCRIPT_URL as u };
174
+ //# sourceMappingURL=index2.d.ts.map
package/next.d.ts CHANGED
@@ -1,13 +1,15 @@
1
- import { InjectOptions } from "./index.js";
1
+ import { HighseatProps as HighseatProps$1, 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
- export interface HighseatProps extends Partial<InjectOptions> {}
5
+ export interface HighseatProps extends Partial<HighseatProps$1> {}
6
6
  /**
7
7
  * Loads the Highseat tracker in a Next.js app. Render it once, in the root layout. The site id
8
8
  * comes from `NEXT_PUBLIC_HIGHSEAT_SITE`, and the script loads from the site's proxy path in
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
+ * `userId` takes an id the server already knows. For a session read in the browser, call
12
+ * `useIdentify` in a client component instead.
11
13
  */
12
14
  export declare function Highseat(props: HighseatProps): ReactElement | null;
13
15
  /** A Next.js rewrite: the subset of its fields this package writes and reads. */
@@ -56,5 +58,5 @@ export interface WithHighseatOptions {
56
58
  */
57
59
  export declare function withHighseat<Config extends object>(config: Config, options?: WithHighseatOptions): WithHighseatConfig<Config>;
58
60
  //#endregion
59
- export { type ClientIp, type ClientIpPreset, type ProxyHandler, type ProxyOptions, createProxy };
61
+ export { type ClientIp, type ClientIpPreset, type ProxyHandler, type ProxyOptions, createProxy, useIdentify };
60
62
  //# sourceMappingURL=next.d.ts.map
package/next.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Highseat as Highseat$1 } from "./react.js";
1
+ import { n as useIdentify, t as Highseat$1 } from "./react2.js";
2
2
  import { n as findNextSite, r as findProxyPath, t as createProxy } from "./proxy2.js";
3
3
  import { createElement } from "react";
4
4
  //#region src/next.ts
@@ -7,6 +7,8 @@ import { createElement } from "react";
7
7
  * comes from `NEXT_PUBLIC_HIGHSEAT_SITE`, and the script loads from the site's proxy path in
8
8
  * `HIGHSEAT_PATH` when one is set. `HIGHSEAT_PATH` is a server variable, so in the Pages Router,
9
9
  * where the layout also renders in the browser, pass `src` instead. A prop wins over its variable.
10
+ * `userId` takes an id the server already knows. For a session read in the browser, call
11
+ * `useIdentify` in a client component instead.
10
12
  */
11
13
  function Highseat(props) {
12
14
  const site = props.site ?? findNextSite();
@@ -74,6 +76,6 @@ function withHighseat(config, options = {}) {
74
76
  };
75
77
  }
76
78
  //#endregion
77
- export { Highseat, createProxy, withHighseat };
79
+ export { Highseat, createProxy, useIdentify, withHighseat };
78
80
 
79
81
  //# 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 type { InjectOptions } from \"./index\"\nimport { Highseat as ReactHighseat } from \"./react\"\n\nexport { createProxy } from \"./proxy\"\nexport type { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions } from \"./proxy\"\n\nexport interface HighseatProps extends Partial<InjectOptions> {}\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 */\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}\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. The build writes the rewrite, so a path set\n * only at runtime points a dynamically rendered `<Highseat />` at a route that is not there.\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` is read.\n const { rewrites: ownRewrites } = 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, rewrites }\n}\n"],"mappings":";;;;;;;;;;AAgBA,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;AAuCA,MAAM,gBAAgB;AAGtB,IAAI,kBAAkB;;;;;;;;;;;;;;;;;AAkBtB,SAAgB,aACd,QACA,UAA+B,CAAC,GACJ;CAC5B,MAAM,OAAO,cAAc,QAAQ,IAAI;CAGvC,MAAM,EAAE,UAAU,gBAAgB;CAElC,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;CAAS;AAC/B"}
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> {}\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.\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}\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. The build writes the rewrite, so a path set\n * only at runtime points a dynamically rendered `<Highseat />` at a route that is not there.\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` is read.\n const { rewrites: ownRewrites } = 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, rewrites }\n}\n"],"mappings":";;;;;;;;;;;;AAkBA,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;AAuCA,MAAM,gBAAgB;AAGtB,IAAI,kBAAkB;;;;;;;;;;;;;;;;;AAkBtB,SAAgB,aACd,QACA,UAA+B,CAAC,GACJ;CAC5B,MAAM,OAAO,cAAc,QAAQ,IAAI;CAGvC,MAAM,EAAE,UAAU,gBAAgB;CAElC,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;CAAS;AAC/B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "highseat",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.2.0",
4
4
  "description": "Highseat analytics for JavaScript: typed tracking, a first-party proxy and framework components.",
5
5
  "keywords": [
6
6
  "analytics",
package/react.d.ts CHANGED
@@ -1,11 +1,34 @@
1
- import { InjectOptions } from "./index.js";
1
+ import { a as InjectOptions, p as UserId } from "./index2.js";
2
2
  //#region src/react.d.ts
3
- export interface HighseatProps extends InjectOptions {}
3
+ export interface HighseatProps extends InjectOptions {
4
+ /**
5
+ * The signed-in user's id from your own system, never an email or other personal data. Pass
6
+ * `null` once the visitor is signed out, and `undefined` while the session is still loading.
7
+ * See {@link useIdentify}.
8
+ */
9
+ userId?: UserId;
10
+ }
11
+ /**
12
+ * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`
13
+ * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and
14
+ * `undefined`, a session still loading, changes nothing. Works in any client component, with or
15
+ * without `<Highseat />` on the page, and before the tracker has loaded.
16
+ *
17
+ * Tell loading apart from signed out: `useIdentify(session?.user.id)` passes `undefined` after
18
+ * sign-out too, so it never clears the id.
19
+ *
20
+ * ```tsx
21
+ * const { data, isPending } = useSession()
22
+ * useIdentify(isPending ? undefined : (data?.user.id ?? null))
23
+ * ```
24
+ */
25
+ export declare function useIdentify(userId: UserId): void;
4
26
  /**
5
27
  * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
6
28
  * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
7
- * counts client-side navigation on its own.
29
+ * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as
30
+ * {@link useIdentify} does.
8
31
  */
9
- export declare function Highseat({ site, src, outbound, downloads, hashRouting }: HighseatProps): null;
32
+ export declare function Highseat({ site, src, outbound, downloads, hashRouting, userId }: HighseatProps): null;
10
33
  //#endregion
11
34
  //# sourceMappingURL=react.d.ts.map
package/react.js CHANGED
@@ -1,31 +1,2 @@
1
1
  "use client";
2
- import { inject } from "./index.js";
3
- import { useEffect } from "react";
4
- //#region src/react.ts
5
- /**
6
- * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
7
- * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
8
- * counts client-side navigation on its own.
9
- */
10
- function Highseat({ site, src, outbound, downloads, hashRouting }) {
11
- useEffect(() => {
12
- inject({
13
- site,
14
- src,
15
- outbound,
16
- downloads,
17
- hashRouting
18
- });
19
- }, [
20
- site,
21
- src,
22
- outbound,
23
- downloads,
24
- hashRouting
25
- ]);
26
- return null;
27
- }
28
- //#endregion
29
- export { Highseat };
30
-
31
- //# sourceMappingURL=react.js.map
2
+ export { t as Highseat, n as useIdentify } from "./react2.js";
package/react2.js ADDED
@@ -0,0 +1,64 @@
1
+ import { identify, inject } from "./index.js";
2
+ import { useEffect, useRef } from "react";
3
+ //#region src/identity.ts
4
+ /**
5
+ * Identifies `userId` when it differs from `previous`, the id identified last, and returns the id
6
+ * identified now. `undefined` means the session has not loaded, so it changes nothing and keeps
7
+ * `previous`: a session that reloads with the same user calls `identify` once, not twice.
8
+ */
9
+ function syncIdentity(previous, userId) {
10
+ if (userId === void 0 || userId === previous) return previous;
11
+ identify(userId);
12
+ return userId;
13
+ }
14
+ //#endregion
15
+ //#region src/react.ts
16
+ /**
17
+ * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`
18
+ * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and
19
+ * `undefined`, a session still loading, changes nothing. Works in any client component, with or
20
+ * without `<Highseat />` on the page, and before the tracker has loaded.
21
+ *
22
+ * Tell loading apart from signed out: `useIdentify(session?.user.id)` passes `undefined` after
23
+ * sign-out too, so it never clears the id.
24
+ *
25
+ * ```tsx
26
+ * const { data, isPending } = useSession()
27
+ * useIdentify(isPending ? undefined : (data?.user.id ?? null))
28
+ * ```
29
+ */
30
+ function useIdentify(userId) {
31
+ const identified = useRef(void 0);
32
+ useEffect(() => {
33
+ identified.current = syncIdentity(identified.current, userId);
34
+ }, [userId]);
35
+ }
36
+ /**
37
+ * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
38
+ * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
39
+ * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as
40
+ * {@link useIdentify} does.
41
+ */
42
+ function Highseat({ site, src, outbound, downloads, hashRouting, userId }) {
43
+ useEffect(() => {
44
+ inject({
45
+ site,
46
+ src,
47
+ outbound,
48
+ downloads,
49
+ hashRouting
50
+ });
51
+ }, [
52
+ site,
53
+ src,
54
+ outbound,
55
+ downloads,
56
+ hashRouting
57
+ ]);
58
+ useIdentify(userId);
59
+ return null;
60
+ }
61
+ //#endregion
62
+ export { useIdentify as n, Highseat as t };
63
+
64
+ //# sourceMappingURL=react2.js.map
package/react2.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react2.js","names":[],"sources":["../src/identity.ts","../src/react.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","\"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 userId,\n}: HighseatProps): null {\n useEffect(() => {\n inject({ site, src, outbound, downloads, hashRouting })\n }, [site, src, outbound, downloads, hashRouting])\n\n useIdentify(userId)\n\n return null\n}\n"],"mappings":";;;;;;;;AAOA,SAAgB,aAAa,UAAkB,QAAwB;CACrE,IAAI,WAAW,KAAA,KAAa,WAAW,UAAU,OAAO;CACxD,SAAS,MAAM;CACf,OAAO;AACT;;;;;;;;;;;;;;;;;ACkBA,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,UACsB;CACtB,gBAAgB;EACd,OAAO;GAAE;GAAM;GAAK;GAAU;GAAW;EAAY,CAAC;CACxD,GAAG;EAAC;EAAM;EAAK;EAAU;EAAW;CAAW,CAAC;CAEhD,YAAY,MAAM;CAElB,OAAO;AACT"}
package/react.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["\"use client\"\n\nimport { useEffect } from \"react\"\nimport { inject, type InjectOptions } from \"./index\"\n\nexport interface HighseatProps extends InjectOptions {}\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.\n */\nexport function Highseat({ site, src, outbound, downloads, hashRouting }: HighseatProps): null {\n useEffect(() => {\n inject({ site, src, outbound, downloads, hashRouting })\n }, [site, src, outbound, downloads, hashRouting])\n\n return null\n}\n"],"mappings":";;;;;;;;;AAYA,SAAgB,SAAS,EAAE,MAAM,KAAK,UAAU,WAAW,eAAoC;CAC7F,gBAAgB;EACd,OAAO;GAAE;GAAM;GAAK;GAAU;GAAW;EAAY,CAAC;CACxD,GAAG;EAAC;EAAM;EAAK;EAAU;EAAW;CAAW,CAAC;CAEhD,OAAO;AACT"}