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/next.d.ts CHANGED
@@ -47,8 +47,10 @@ export interface WithHighseatOptions {
47
47
  *
48
48
  * The proxy is optional. Without a path, `next dev` and `next build` warn once and leave the
49
49
  * rewrite out, and `<Highseat />` loads the tracker from Highseat's host. Nothing here throws,
50
- * so `next typegen` runs without the variable too. The build writes the rewrite, so a path set
51
- * only at runtime points a dynamically rendered `<Highseat />` at a route that is not there.
50
+ * so `next typegen` runs without the variable too. With a path, it also sets `HIGHSEAT_PATH` in
51
+ * the config's `env`, so Next.js writes the build's value into the bundled code of `<Highseat />`
52
+ * and `createProxy()`. In the App Router, the script and the rewrite then agree, whatever the
53
+ * server's environment.
52
54
  *
53
55
  * ```ts
54
56
  * // next.config.ts
package/next.js CHANGED
@@ -1,5 +1,6 @@
1
- import { n as useIdentify, t as Highseat$1 } from "./react2.js";
2
- import { n as findNextSite, r as findProxyPath, t as createProxy } from "./proxy2.js";
1
+ import { findNextSite, findProxyPath } from "./env.js";
2
+ import { Highseat as Highseat$1, useIdentify } from "./react.js";
3
+ import { createProxy } from "./proxy.js";
3
4
  import { createElement } from "react";
4
5
  //#region src/next.ts
5
6
  /**
@@ -32,8 +33,10 @@ let hasWarnedNoPath = false;
32
33
  *
33
34
  * The proxy is optional. Without a path, `next dev` and `next build` warn once and leave the
34
35
  * rewrite out, and `<Highseat />` loads the tracker from Highseat's host. Nothing here throws,
35
- * so `next typegen` runs without the variable too. The build writes the rewrite, so a path set
36
- * only at runtime points a dynamically rendered `<Highseat />` at a route that is not there.
36
+ * so `next typegen` runs without the variable too. With a path, it also sets `HIGHSEAT_PATH` in
37
+ * the config's `env`, so Next.js writes the build's value into the bundled code of `<Highseat />`
38
+ * and `createProxy()`. In the App Router, the script and the rewrite then agree, whatever the
39
+ * server's environment.
37
40
  *
38
41
  * ```ts
39
42
  * // next.config.ts
@@ -43,7 +46,7 @@ let hasWarnedNoPath = false;
43
46
  */
44
47
  function withHighseat(config, options = {}) {
45
48
  const path = findProxyPath(options.path);
46
- const { rewrites: ownRewrites } = config;
49
+ const { rewrites: ownRewrites, env } = config;
47
50
  async function passRewritesThrough() {
48
51
  if (!hasWarnedNoPath) {
49
52
  hasWarnedNoPath = true;
@@ -72,6 +75,10 @@ function withHighseat(config, options = {}) {
72
75
  }
73
76
  return {
74
77
  ...config,
78
+ env: {
79
+ ...env,
80
+ HIGHSEAT_PATH: path
81
+ },
75
82
  rewrites
76
83
  };
77
84
  }
package/next.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"next.js","names":["ReactHighseat"],"sources":["../src/next.ts"],"sourcesContent":["import { createElement, type ReactElement } from \"react\"\nimport { findNextSite, findProxyPath } from \"./env\"\nimport { Highseat as ReactHighseat, type HighseatProps as ReactHighseatProps } from \"./react\"\n\nexport { createProxy } from \"./proxy\"\nexport { useIdentify } from \"./react\"\nexport type { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions } from \"./proxy\"\n\nexport interface HighseatProps extends Partial<ReactHighseatProps> {}\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"}
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 env?: Record<string, string | undefined>\n}\n\n/** A Next.js config with the proxy's rewrite added, or its own rewrites when there is no path. */\nexport type WithHighseatConfig<Config> = Config & {\n rewrites: () => Promise<Rewrite[] | RewritePhases>\n}\n\nexport interface WithHighseatOptions {\n /**\n * The site's proxy path from Installation settings. Defaults to the `HIGHSEAT_PATH`\n * environment variable, the one `createProxy()` reads, so rotating the path is an\n * environment change and a redeploy.\n */\n path?: string\n /**\n * Where the app's proxy route handler lives. Defaults to `/api/highseat`, the route of\n * `app/api/highseat/route.ts`.\n */\n route?: string\n}\n\nconst DEFAULT_ROUTE = \"/api/highseat\"\n\n// A dev session can ask for the rewrites more than once, so the warning is printed once.\nlet hasWarnedNoPath = false\n\n/**\n * Adds a rewrite from the site's proxy path to the app's proxy route handler, ahead of every\n * other route. The rewrite stays inside the app, so the handler still sees the visitor's IP, and\n * the route file never moves when the path rotates.\n *\n * The proxy is optional. Without a path, `next dev` and `next build` warn once and leave the\n * rewrite out, and `<Highseat />` loads the tracker from Highseat's host. Nothing here throws,\n * so `next typegen` runs without the variable too. With a path, it also sets `HIGHSEAT_PATH` in\n * the config's `env`, so Next.js writes the build's value into the bundled code of `<Highseat />`\n * and `createProxy()`. In the App Router, the script and the rewrite then agree, whatever the\n * server's environment.\n *\n * ```ts\n * // next.config.ts\n * import { withHighseat } from \"highseat/next\"\n * export default withHighseat({ reactStrictMode: true })\n * ```\n */\nexport function withHighseat<Config extends object>(\n config: Config,\n options: WithHighseatOptions = {},\n): WithHighseatConfig<Config> {\n const path = findProxyPath(options.path)\n\n // Typed loosely so any Next.js version's config fits; only `rewrites` and `env` are read.\n const { rewrites: ownRewrites, env } = config as NextConfigWithRewrites\n\n async function passRewritesThrough(): Promise<Rewrite[] | RewritePhases> {\n if (!hasWarnedNoPath) {\n hasWarnedNoPath = true\n console.warn(\n \"highseat/next: no HIGHSEAT_PATH, so this build has no proxy rewrite and the tracker loads from Highseat's host. For first-party delivery, set it to the path from the site's Installation settings, or pass `path`, for the build as well as at runtime: a path set only at runtime points the tracker at a route this build does not have.\",\n )\n }\n return (await ownRewrites?.()) ?? []\n }\n\n if (!path) return { ...config, rewrites: passRewritesThrough }\n\n const rewrite = { source: path, destination: options.route ?? DEFAULT_ROUTE }\n\n async function rewrites(): Promise<RewritePhases> {\n const existing = (await ownRewrites?.()) ?? []\n\n // A plain array is Next.js shorthand for the `afterFiles` phase.\n if (Array.isArray(existing)) return { beforeFiles: [rewrite], afterFiles: existing }\n return { ...existing, beforeFiles: [rewrite, ...(existing.beforeFiles ?? [])] }\n }\n\n return { ...config, env: { ...env, HIGHSEAT_PATH: path }, rewrites }\n}\n"],"mappings":";;;;;;;;;;;;;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;AAwCA,MAAM,gBAAgB;AAGtB,IAAI,kBAAkB;;;;;;;;;;;;;;;;;;;AAoBtB,SAAgB,aACd,QACA,UAA+B,CAAC,GACJ;CAC5B,MAAM,OAAO,cAAc,QAAQ,IAAI;CAGvC,MAAM,EAAE,UAAU,aAAa,QAAQ;CAEvC,eAAe,sBAA0D;EACvE,IAAI,CAAC,iBAAiB;GACpB,kBAAkB;GAClB,QAAQ,KACN,6UACF;EACF;EACA,OAAQ,MAAM,cAAc,KAAM,CAAC;CACrC;CAEA,IAAI,CAAC,MAAM,OAAO;EAAE,GAAG;EAAQ,UAAU;CAAoB;CAE7D,MAAM,UAAU;EAAE,QAAQ;EAAM,aAAa,QAAQ,SAAS;CAAc;CAE5E,eAAe,WAAmC;EAChD,MAAM,WAAY,MAAM,cAAc,KAAM,CAAC;EAG7C,IAAI,MAAM,QAAQ,QAAQ,GAAG,OAAO;GAAE,aAAa,CAAC,OAAO;GAAG,YAAY;EAAS;EACnF,OAAO;GAAE,GAAG;GAAU,aAAa,CAAC,SAAS,GAAI,SAAS,eAAe,CAAC,CAAE;EAAE;CAChF;CAEA,OAAO;EAAE,GAAG;EAAQ,KAAK;GAAE,GAAG;GAAK,eAAe;EAAK;EAAG;CAAS;AACrE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "highseat",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Highseat analytics for JavaScript: typed tracking, a first-party proxy and framework components.",
5
5
  "keywords": [
6
6
  "analytics",
package/proxy.js CHANGED
@@ -1 +1,199 @@
1
- export { t as createProxy } from "./proxy2.js";
1
+ import { findProxyPath, findSecret, isNextBuild } from "./env.js";
2
+ //#region src/proxy.ts
3
+ const DEFAULT_UPSTREAM = "https://cdn.highseat.com";
4
+ const SECRET_PREFIXES = [
5
+ "hs_secret_",
6
+ "hs_proxy_",
7
+ "ana_proxy_"
8
+ ];
9
+ const CLIENT_IP_HEADERS = {
10
+ vercel: "x-real-ip",
11
+ cloudflare: "cf-connecting-ip",
12
+ netlify: "x-nf-client-connection-ip",
13
+ nginx: "x-real-ip"
14
+ };
15
+ const SCRIPT_REQUEST_HEADERS = ["user-agent", "if-none-match"];
16
+ const EVENT_REQUEST_HEADERS = [
17
+ "content-type",
18
+ "user-agent",
19
+ "origin"
20
+ ];
21
+ const RESPONSE_HEADERS = [
22
+ "content-type",
23
+ "etag",
24
+ "cache-control",
25
+ "vary",
26
+ "retry-after",
27
+ "x-hs-ip",
28
+ "x-hs-ip-source"
29
+ ];
30
+ const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
31
+ 101,
32
+ 204,
33
+ 205,
34
+ 304
35
+ ]);
36
+ const UPSTREAM_TIMEOUT_MS = 1e4;
37
+ const MAX_EVENT_BYTES = 524288;
38
+ function getClientIpReader(clientIp) {
39
+ if (typeof clientIp === "function") return {
40
+ read: (request) => clientIp(request)?.trim() ?? "",
41
+ source: "the clientIp function"
42
+ };
43
+ const header = CLIENT_IP_HEADERS[clientIp];
44
+ if (!header) throw new Error(`highseat/proxy: unknown clientIp preset "${String(clientIp)}".`);
45
+ return {
46
+ read: (request) => request.headers.get(header)?.trim() ?? "",
47
+ source: `the "${clientIp}" preset's ${header} header`
48
+ };
49
+ }
50
+ function copyHeaders(from, to, names) {
51
+ for (const name of names) {
52
+ const value = from.get(name);
53
+ if (value !== null) to.set(name, value);
54
+ }
55
+ }
56
+ function answerError(status, error) {
57
+ return Response.json({ error }, {
58
+ status,
59
+ headers: { "cache-control": "no-store" }
60
+ });
61
+ }
62
+ function answerOtherPath() {
63
+ return answerError(404, "Not the proxy path. The Highseat proxy answers on its own path only.");
64
+ }
65
+ /** The site secret, or `undefined` when none is set. Throws on a value that is not one. */
66
+ function findValidSecret(option) {
67
+ const secret = option ?? findSecret();
68
+ if (!secret) return;
69
+ if (!SECRET_PREFIXES.some((prefix) => secret.startsWith(prefix))) throw new Error(`highseat/proxy: not a site secret. Copy it from the site's Installation settings; it starts with "${SECRET_PREFIXES[0]}".`);
70
+ return secret;
71
+ }
72
+ let hasWarnedMissing = false;
73
+ /**
74
+ * The handler for a proxy missing a variable: every request goes to `fallback`, or the proxy
75
+ * path answers 503 naming what is missing.
76
+ */
77
+ function createDisabledProxy(missing, path, fallback) {
78
+ const names = missing.join(" and ");
79
+ if (!hasWarnedMissing && !isNextBuild()) {
80
+ hasWarnedMissing = true;
81
+ console.warn(`highseat/proxy: ${names} not set, so the proxy is off and ${fallback ? "every request goes to `fallback`" : "the proxy path answers 503"}.`);
82
+ }
83
+ return async function proxy(request) {
84
+ if (fallback) return fallback(request);
85
+ if (path !== void 0 && new URL(request.url).pathname !== path) return answerOtherPath();
86
+ return answerError(503, `The Highseat proxy is off: set ${names}.`);
87
+ };
88
+ }
89
+ /** The request body, or `undefined` once it passes `MAX_EVENT_BYTES`. */
90
+ async function readEventBody(request) {
91
+ if (Number(request.headers.get("content-length")) > MAX_EVENT_BYTES) return;
92
+ if (!request.body) return /* @__PURE__ */ new Uint8Array();
93
+ const reader = request.body.getReader();
94
+ const chunks = [];
95
+ let size = 0;
96
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
97
+ size += chunk.value.byteLength;
98
+ if (size > MAX_EVENT_BYTES) {
99
+ reader.cancel();
100
+ return;
101
+ }
102
+ chunks.push(chunk.value);
103
+ }
104
+ const body = new Uint8Array(size);
105
+ let offset = 0;
106
+ for (const chunk of chunks) {
107
+ body.set(chunk, offset);
108
+ offset += chunk.byteLength;
109
+ }
110
+ return body;
111
+ }
112
+ /**
113
+ * Creates the first-party proxy handler for a site. A GET serves the tracker script and a POST
114
+ * forwards its events, both to the ingest host, with the site secret and the visitor's IP. The
115
+ * returned function takes a `Request` and returns a `Response`, so it mounts as a Next.js route
116
+ * handler, a Cloudflare Worker, a Pages Function, a Netlify or Vercel function, an Astro
117
+ * endpoint or a Hono route.
118
+ *
119
+ * A missing path or secret never throws: the handler passes every request to `fallback`, or
120
+ * answers the proxy path with a 503 naming the variable, and warns once. A value that is not a
121
+ * site secret throws, since that is a copying mistake rather than a variable left unset.
122
+ */
123
+ function createProxy(options) {
124
+ const path = findProxyPath(options.path);
125
+ const { fallback } = options;
126
+ let foundSecret;
127
+ try {
128
+ foundSecret = findValidSecret(options.secret);
129
+ } catch (error) {
130
+ if (!isNextBuild()) throw error;
131
+ return () => Promise.reject(error);
132
+ }
133
+ const upstream = new URL(options.upstream ?? DEFAULT_UPSTREAM).origin;
134
+ const missing = [...fallback && path === void 0 ? ["HIGHSEAT_PATH"] : [], ...foundSecret ? [] : ["HIGHSEAT_SECRET"]];
135
+ if (!foundSecret || missing.length) return createDisabledProxy(missing, path, fallback);
136
+ const secret = foundSecret;
137
+ const clientIpReader = getClientIpReader(options.clientIp);
138
+ let hasWarnedNoIp = false;
139
+ function readClientIp(request) {
140
+ const ip = clientIpReader.read(request);
141
+ if (!ip && !hasWarnedNoIp) {
142
+ hasWarnedNoIp = true;
143
+ console.warn(`highseat/proxy: no visitor IP in ${clientIpReader.source}, so Highseat refuses every event. Set clientIp to the outermost proxy in front of this server.`);
144
+ }
145
+ return ip;
146
+ }
147
+ function buildUpstreamHeaders(request, names) {
148
+ const headers = new Headers({
149
+ "accept-encoding": "identity",
150
+ "x-hs-secret": secret,
151
+ "x-hs-client-ip": readClientIp(request)
152
+ });
153
+ copyHeaders(request.headers, headers, names);
154
+ return headers;
155
+ }
156
+ async function forward(url, init) {
157
+ let response;
158
+ try {
159
+ response = await fetch(url, {
160
+ ...init,
161
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
162
+ });
163
+ } catch {
164
+ return answerError(502, "Highseat is unreachable from this proxy.");
165
+ }
166
+ const headers = new Headers();
167
+ copyHeaders(response.headers, headers, RESPONSE_HEADERS);
168
+ if (init.method === "POST") headers.set("cache-control", "no-store");
169
+ const body = NULL_BODY_STATUSES.has(response.status) ? null : response.body;
170
+ return new Response(body, {
171
+ status: response.status,
172
+ headers
173
+ });
174
+ }
175
+ return async function proxy(request) {
176
+ if (path !== void 0 && new URL(request.url).pathname !== path) return fallback ? fallback(request) : answerOtherPath();
177
+ if (request.method === "GET" || request.method === "HEAD") return forward(`${upstream}/script.js`, {
178
+ method: request.method,
179
+ headers: buildUpstreamHeaders(request, SCRIPT_REQUEST_HEADERS)
180
+ });
181
+ if (request.method === "POST") {
182
+ const body = await readEventBody(request);
183
+ if (!body) return answerError(413, "The event batch is too large.");
184
+ return forward(`${upstream}/api/event`, {
185
+ method: "POST",
186
+ headers: buildUpstreamHeaders(request, EVENT_REQUEST_HEADERS),
187
+ body
188
+ });
189
+ }
190
+ return new Response(null, {
191
+ status: 405,
192
+ headers: { allow: "GET, HEAD, POST" }
193
+ });
194
+ };
195
+ }
196
+ //#endregion
197
+ export { createProxy };
198
+
199
+ //# sourceMappingURL=proxy.js.map
package/proxy.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.js","names":[],"sources":["../src/proxy.ts"],"sourcesContent":["import { findProxyPath, findSecret, isNextBuild } from \"./env\"\n\n/** A host whose own header carries the visitor's IP. */\nexport type ClientIpPreset = \"vercel\" | \"cloudflare\" | \"netlify\" | \"nginx\"\n\n/**\n * Where the proxy reads the visitor's IP: a host preset, or a function for any other setup. A\n * function returns the IP, or nothing when the request carries none.\n */\nexport type ClientIp = ClientIpPreset | ((request: Request) => string | null | undefined)\n\nexport interface ProxyOptions {\n /**\n * The site's proxy path from Installation settings, such as `/3f9a0c1e2b`, with or without its\n * leading slash. A request on any\n * other path is answered 404, or handed to `fallback`. Defaults to the `HIGHSEAT_PATH`\n * environment variable. Leave both unset on a host whose router already sends only the proxy\n * path here, such as a file named after the path.\n */\n path?: string\n /**\n * The site secret, `hs_secret_…`, or an older one that starts `hs_proxy_` or `ana_proxy_`.\n * Defaults to the `HIGHSEAT_SECRET` environment variable. Without one the proxy is off: the\n * proxy path answers 503, or every request goes to `fallback`.\n */\n secret?: string\n /**\n * Where the visitor's IP comes from: `vercel`, `cloudflare`, `netlify`, `nginx` (the\n * `X-Real-IP` header your nginx sets), or a function. A visitor can write any header the host\n * does not overwrite, so there is no default.\n *\n * The outermost proxy that sets the header decides the preset. Behind a proxied Cloudflare\n * record, use `cloudflare` on any origin, not only a Worker or a Pages Function: a Next.js,\n * TanStack Start or Node server behind Cloudflare, with or without Traefik or nginx in between,\n * sees a Cloudflare edge address in `X-Real-IP`, and the visitor's IP in `cf-connecting-ip`.\n */\n clientIp: ClientIp\n /** The ingest origin the proxy forwards to. Defaults to `https://cdn.highseat.com`. */\n upstream?: string\n /**\n * Answers a request on any path other than `path`, for a handler that sees every request on\n * the site: `fetch` in a Cloudflare Worker routed on the whole site, `next` in a Pages\n * Function. Needs `path`: without it, or without the secret, the proxy is off and every\n * request goes here, so the site still serves in a development setup without the variables.\n */\n fallback?: (request: Request) => Response | Promise<Response>\n}\n\n/** A fetch handler, the shape every supported runtime and framework route accepts. */\nexport type ProxyHandler = (request: Request) => Promise<Response>\n\nconst DEFAULT_UPSTREAM = \"https://cdn.highseat.com\"\n// The current prefix first. Ingest still accepts the secrets minted under the older two, so a\n// site keeps its secret when it moves to this handler.\nconst SECRET_PREFIXES = [\"hs_secret_\", \"hs_proxy_\", \"ana_proxy_\"]\n\nconst CLIENT_IP_HEADERS: Record<ClientIpPreset, string> = {\n vercel: \"x-real-ip\",\n // Read from the inbound request: Cloudflare overwrites it on the Worker's own subrequest.\n cloudflare: \"cf-connecting-ip\",\n netlify: \"x-nf-client-connection-ip\",\n nginx: \"x-real-ip\",\n}\n\n// The visitor headers each direction forwards. Copied by name, so a cookie, an `Authorization`\n// header or a visitor's own `x-hs-*` header never reaches ingest.\nconst SCRIPT_REQUEST_HEADERS = [\"user-agent\", \"if-none-match\"]\nconst EVENT_REQUEST_HEADERS = [\"content-type\", \"user-agent\", \"origin\"]\n\n// The upstream headers the response keeps. Rebuilt by name because Node, Bun and Deno hand back\n// a decompressed body under the original `content-encoding` and `content-length`, which would\n// describe bytes this handler no longer has. `x-hs-ip` and `x-hs-ip-source` show which IP\n// ingest resolved, for `curl -i` against the proxy path.\nconst RESPONSE_HEADERS = [\n \"content-type\",\n \"etag\",\n \"cache-control\",\n \"vary\",\n \"retry-after\",\n \"x-hs-ip\",\n \"x-hs-ip-source\",\n]\n\n// Statuses whose response must not carry a body, which the Response constructor enforces.\nconst NULL_BODY_STATUSES = new Set([101, 204, 205, 304])\n\nconst UPSTREAM_TIMEOUT_MS = 10_000\n\n// Ingest refuses a larger batch, so a bigger body is answered here without buffering the rest.\nconst MAX_EVENT_BYTES = 512 * 1024\n\ninterface ClientIpReader {\n read: (request: Request) => string\n /** Where `read` looks, for the warning about an empty IP. */\n source: string\n}\n\nfunction getClientIpReader(clientIp: ClientIp): ClientIpReader {\n if (typeof clientIp === \"function\") {\n return { read: request => clientIp(request)?.trim() ?? \"\", source: \"the clientIp function\" }\n }\n\n const header = CLIENT_IP_HEADERS[clientIp]\n if (!header) throw new Error(`highseat/proxy: unknown clientIp preset \"${String(clientIp)}\".`)\n return {\n read: request => request.headers.get(header)?.trim() ?? \"\",\n source: `the \"${clientIp}\" preset's ${header} header`,\n }\n}\n\nfunction copyHeaders(from: Headers, to: Headers, names: string[]) {\n for (const name of names) {\n const value = from.get(name)\n if (value !== null) to.set(name, value)\n }\n}\n\nfunction answerError(status: number, error: string) {\n return Response.json({ error }, { status, headers: { \"cache-control\": \"no-store\" } })\n}\n\n// A JSON body rather than an empty 404, so `curl -i` on the route handler's own URL tells this\n// answer apart from ingest's.\nfunction answerOtherPath() {\n return answerError(404, \"Not the proxy path. The Highseat proxy answers on its own path only.\")\n}\n\n/** The site secret, or `undefined` when none is set. Throws on a value that is not one. */\nfunction findValidSecret(option: string | undefined): string | undefined {\n const secret = option ?? findSecret()\n if (!secret) return\n\n if (!SECRET_PREFIXES.some(prefix => secret.startsWith(prefix))) {\n throw new Error(\n `highseat/proxy: not a site secret. Copy it from the site's Installation settings; it starts with \"${SECRET_PREFIXES[0]}\".`,\n )\n }\n return secret\n}\n\n// The handler can be created per request (a Worker that reads its options from `env`), so a\n// missing variable is reported once per process rather than once per handler.\nlet hasWarnedMissing = false\n\n/**\n * The handler for a proxy missing a variable: every request goes to `fallback`, or the proxy\n * path answers 503 naming what is missing.\n */\nfunction createDisabledProxy(\n missing: string[],\n path: string | undefined,\n fallback: ProxyOptions[\"fallback\"],\n): ProxyHandler {\n const names = missing.join(\" and \")\n\n // A Next.js build loads every route without serving one, often without the runtime variables.\n if (!hasWarnedMissing && !isNextBuild()) {\n hasWarnedMissing = true\n const outcome = fallback ? \"every request goes to `fallback`\" : \"the proxy path answers 503\"\n console.warn(`highseat/proxy: ${names} not set, so the proxy is off and ${outcome}.`)\n }\n\n return async function proxy(request) {\n if (fallback) return fallback(request)\n if (path !== undefined && new URL(request.url).pathname !== path) return answerOtherPath()\n return answerError(503, `The Highseat proxy is off: set ${names}.`)\n }\n}\n\n/** The request body, or `undefined` once it passes `MAX_EVENT_BYTES`. */\nasync function readEventBody(request: Request): Promise<Uint8Array<ArrayBuffer> | undefined> {\n if (Number(request.headers.get(\"content-length\")) > MAX_EVENT_BYTES) return\n if (!request.body) return new Uint8Array()\n\n const reader = request.body.getReader()\n const chunks: Uint8Array[] = []\n let size = 0\n\n for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {\n size += chunk.value.byteLength\n if (size > MAX_EVENT_BYTES) {\n void reader.cancel()\n return\n }\n chunks.push(chunk.value)\n }\n\n const body = new Uint8Array(size)\n let offset = 0\n for (const chunk of chunks) {\n body.set(chunk, offset)\n offset += chunk.byteLength\n }\n return body\n}\n\n/**\n * Creates the first-party proxy handler for a site. A GET serves the tracker script and a POST\n * forwards its events, both to the ingest host, with the site secret and the visitor's IP. The\n * returned function takes a `Request` and returns a `Response`, so it mounts as a Next.js route\n * handler, a Cloudflare Worker, a Pages Function, a Netlify or Vercel function, an Astro\n * endpoint or a Hono route.\n *\n * A missing path or secret never throws: the handler passes every request to `fallback`, or\n * answers the proxy path with a 503 naming the variable, and warns once. A value that is not a\n * site secret throws, since that is a copying mistake rather than a variable left unset.\n */\nexport function createProxy(options: ProxyOptions): ProxyHandler {\n const path = findProxyPath(options.path)\n const { fallback } = options\n let foundSecret: string | undefined\n\n try {\n foundSecret = findValidSecret(options.secret)\n } catch (error) {\n // Next.js loads every route while it builds, so there the error waits for the first request\n // rather than failing a build that never serves one.\n if (!isNextBuild()) throw error\n return () => Promise.reject(error)\n }\n\n const upstream = new URL(options.upstream ?? DEFAULT_UPSTREAM).origin\n\n // Without a secret, ingest would read every visitor as this server and drop it as datacenter\n // traffic. Without a path, a handler that sees every request cannot tell the proxy's apart.\n const missing = [\n ...(fallback && path === undefined ? [\"HIGHSEAT_PATH\"] : []),\n ...(foundSecret ? [] : [\"HIGHSEAT_SECRET\"]),\n ]\n if (!foundSecret || missing.length) return createDisabledProxy(missing, path, fallback)\n\n const secret = foundSecret\n\n const clientIpReader = getClientIpReader(options.clientIp)\n let hasWarnedNoIp = false\n\n // Ingest answers 400 to every event without a visitor IP and says nothing about the preset,\n // so the first request without one names the source it read.\n function readClientIp(request: Request) {\n const ip = clientIpReader.read(request)\n\n if (!ip && !hasWarnedNoIp) {\n hasWarnedNoIp = true\n console.warn(\n `highseat/proxy: no visitor IP in ${clientIpReader.source}, so Highseat refuses every event. Set clientIp to the outermost proxy in front of this server.`,\n )\n }\n return ip\n }\n\n function buildUpstreamHeaders(request: Request, names: string[]) {\n const headers = new Headers({\n // The response never copies `content-encoding`, so ingest is asked for a plain body.\n \"accept-encoding\": \"identity\",\n \"x-hs-secret\": secret,\n \"x-hs-client-ip\": readClientIp(request),\n })\n copyHeaders(request.headers, headers, names)\n return headers\n }\n\n async function forward(url: string, init: RequestInit) {\n let response: Response\n\n try {\n response = await fetch(url, { ...init, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) })\n } catch {\n return answerError(502, \"Highseat is unreachable from this proxy.\")\n }\n\n const headers = new Headers()\n copyHeaders(response.headers, headers, RESPONSE_HEADERS)\n if (init.method === \"POST\") headers.set(\"cache-control\", \"no-store\")\n\n const body = NULL_BODY_STATUSES.has(response.status) ? null : response.body\n return new Response(body, { status: response.status, headers })\n }\n\n return async function proxy(request) {\n if (path !== undefined && new URL(request.url).pathname !== path) {\n return fallback ? fallback(request) : answerOtherPath()\n }\n\n if (request.method === \"GET\" || request.method === \"HEAD\") {\n return forward(`${upstream}/script.js`, {\n method: request.method,\n headers: buildUpstreamHeaders(request, SCRIPT_REQUEST_HEADERS),\n })\n }\n\n if (request.method === \"POST\") {\n const body = await readEventBody(request)\n if (!body) return answerError(413, \"The event batch is too large.\")\n\n return forward(`${upstream}/api/event`, {\n method: \"POST\",\n headers: buildUpstreamHeaders(request, EVENT_REQUEST_HEADERS),\n body,\n })\n }\n\n return new Response(null, { status: 405, headers: { allow: \"GET, HEAD, POST\" } })\n }\n}\n"],"mappings":"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"}
package/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as InjectOptions, p as UserId } from "./index2.js";
1
+ import { InjectOptions, UserId } from "./index.js";
2
2
  //#region src/react.d.ts
3
3
  export interface HighseatProps extends InjectOptions {
4
4
  /**
@@ -29,6 +29,6 @@ export declare function useIdentify(userId: UserId): void;
29
29
  * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as
30
30
  * {@link useIdentify} does.
31
31
  */
32
- export declare function Highseat({ site, src, outbound, downloads, hashRouting, userId }: HighseatProps): null;
32
+ export declare function Highseat({ site, src, outbound, downloads, hashRouting, debug, userId }: HighseatProps): null;
33
33
  //#endregion
34
34
  //# sourceMappingURL=react.d.ts.map
package/react.js CHANGED
@@ -1,2 +1,56 @@
1
1
  "use client";
2
- export { t as Highseat, n as useIdentify } from "./react2.js";
2
+ import { inject } from "./index.js";
3
+ import { syncIdentity } from "./identity.js";
4
+ import { useEffect, useRef } from "react";
5
+ //#region src/react.ts
6
+ /**
7
+ * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`
8
+ * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and
9
+ * `undefined`, a session still loading, changes nothing. Works in any client component, with or
10
+ * without `<Highseat />` on the page, and before the tracker has loaded.
11
+ *
12
+ * Tell loading apart from signed out: `useIdentify(session?.user.id)` passes `undefined` after
13
+ * sign-out too, so it never clears the id.
14
+ *
15
+ * ```tsx
16
+ * const { data, isPending } = useSession()
17
+ * useIdentify(isPending ? undefined : (data?.user.id ?? null))
18
+ * ```
19
+ */
20
+ function useIdentify(userId) {
21
+ const identified = useRef(void 0);
22
+ useEffect(() => {
23
+ identified.current = syncIdentity(identified.current, userId);
24
+ }, [userId]);
25
+ }
26
+ /**
27
+ * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
28
+ * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
29
+ * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as
30
+ * {@link useIdentify} does.
31
+ */
32
+ function Highseat({ site, src, outbound, downloads, hashRouting, debug, userId }) {
33
+ useEffect(() => {
34
+ inject({
35
+ site,
36
+ src,
37
+ outbound,
38
+ downloads,
39
+ hashRouting,
40
+ debug
41
+ });
42
+ }, [
43
+ site,
44
+ src,
45
+ outbound,
46
+ downloads,
47
+ hashRouting,
48
+ debug
49
+ ]);
50
+ useIdentify(userId);
51
+ return null;
52
+ }
53
+ //#endregion
54
+ export { Highseat, useIdentify };
55
+
56
+ //# sourceMappingURL=react.js.map
package/react.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { syncIdentity } from \"./identity\"\nimport { inject, type InjectOptions, type UserId } from \"./index\"\n\nexport interface HighseatProps extends InjectOptions {\n /**\n * The signed-in user's id from your own system, never an email or other personal data. Pass\n * `null` once the visitor is signed out, and `undefined` while the session is still loading.\n * See {@link useIdentify}.\n */\n userId?: UserId\n}\n\n/**\n * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`\n * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and\n * `undefined`, a session still loading, changes nothing. Works in any client component, with or\n * without `<Highseat />` on the page, and before the tracker has loaded.\n *\n * Tell loading apart from signed out: `useIdentify(session?.user.id)` passes `undefined` after\n * sign-out too, so it never clears the id.\n *\n * ```tsx\n * const { data, isPending } = useSession()\n * useIdentify(isPending ? undefined : (data?.user.id ?? null))\n * ```\n */\nexport function useIdentify(userId: UserId): void {\n // The id this component identified last. A ref survives Strict Mode's second effect run, so\n // the same id is never sent twice.\n const identified = useRef<UserId>(undefined)\n\n useEffect(() => {\n identified.current = syncIdentity(identified.current, userId)\n }, [userId])\n}\n\n/**\n * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after\n * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker\n * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as\n * {@link useIdentify} does.\n */\nexport function Highseat({\n site,\n src,\n outbound,\n downloads,\n hashRouting,\n debug,\n userId,\n}: HighseatProps): null {\n useEffect(() => {\n inject({ site, src, outbound, downloads, hashRouting, debug })\n }, [site, src, outbound, downloads, hashRouting, debug])\n\n useIdentify(userId)\n\n return null\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,YAAY,QAAsB;CAGhD,MAAM,aAAa,OAAe,KAAA,CAAS;CAE3C,gBAAgB;EACd,WAAW,UAAU,aAAa,WAAW,SAAS,MAAM;CAC9D,GAAG,CAAC,MAAM,CAAC;AACb;;;;;;;AAQA,SAAgB,SAAS,EACvB,MACA,KACA,UACA,WACA,aACA,OACA,UACsB;CACtB,gBAAgB;EACd,OAAO;GAAE;GAAM;GAAK;GAAU;GAAW;GAAa;EAAM,CAAC;CAC/D,GAAG;EAAC;EAAM;EAAK;EAAU;EAAW;EAAa;CAAK,CAAC;CAEvD,YAAY,MAAM;CAElB,OAAO;AACT"}
package/index2.d.ts DELETED
@@ -1,174 +0,0 @@
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