highseat 0.4.0 → 0.6.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/mcp.js ADDED
@@ -0,0 +1,160 @@
1
+ import { track } from "./server.js";
2
+ //#region src/mcp.ts
3
+ const CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo";
4
+ const ERROR_CODE = /^[A-Z][A-Z0-9_]{1,63}$/;
5
+ const ERROR_CODE_PREFIX = /^([A-Z][A-Z0-9_]{1,63}):/;
6
+ const MAX_PARSED_LENGTH = 262144;
7
+ const wrapped = /* @__PURE__ */ new WeakSet();
8
+ const callClients = /* @__PURE__ */ new WeakMap();
9
+ function isRecord(value) {
10
+ return !!value && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+ function toClient(info) {
13
+ if (!info?.name) return;
14
+ return {
15
+ client: info.name,
16
+ ...info.version ? { client_version: info.version } : {},
17
+ client_source: "client_info"
18
+ };
19
+ }
20
+ /**
21
+ * The client behind a call. A client of the 2026-07-28 protocol names itself on every request. A
22
+ * 2025-era client names itself only in its `initialize` handshake, which a stateful server
23
+ * remembers and a stateless one never sees again, so its User-Agent's product name stands in.
24
+ */
25
+ function readClient(context, server) {
26
+ const envelope = context.mcpReq.envelope;
27
+ const declared = isRecord(envelope) ? envelope[CLIENT_INFO_KEY] : void 0;
28
+ const client = toClient(isRecord(declared) ? declared : void 0) ?? toClient(server?.server.getClientVersion());
29
+ if (client) return client;
30
+ const product = context.http?.req?.headers.get("user-agent")?.split(/[\s/]/)[0];
31
+ return product ? {
32
+ client: product,
33
+ client_source: "user_agent"
34
+ } : {
35
+ client: "unknown",
36
+ client_source: "none"
37
+ };
38
+ }
39
+ /**
40
+ * How many items a result holds: the length of a list, or of the one list a result object holds,
41
+ * such as `data` in a page of rows. A result with no list, or with several, has no count.
42
+ */
43
+ function countItems(data) {
44
+ if (Array.isArray(data)) return data.length;
45
+ if (!isRecord(data)) return;
46
+ const lists = Object.values(data).filter(Array.isArray);
47
+ return lists.length === 1 ? lists[0]?.length : void 0;
48
+ }
49
+ /** The structured data a result carries, or the JSON of its one text block. */
50
+ function readData(result) {
51
+ if (result.structuredContent !== void 0) return result.structuredContent;
52
+ const [block, ...rest] = result.content;
53
+ if (rest.length > 0 || block?.type !== "text" || block.text.length > MAX_PARSED_LENGTH) return;
54
+ try {
55
+ return JSON.parse(block.text);
56
+ } catch {
57
+ return;
58
+ }
59
+ }
60
+ /** The error code a failed result starts its text with, such as `NOT_FOUND: …`. */
61
+ function readErrorCode(result) {
62
+ const block = result.content.find((content) => content.type === "text");
63
+ return block?.type === "text" ? block.text.match(ERROR_CODE_PREFIX)?.[1] : void 0;
64
+ }
65
+ function readOutcome(result, isReadOnly) {
66
+ if (result.isError) return { status: readErrorCode(result) ?? "TOOL_ERROR" };
67
+ const results = isReadOnly ? countItems(readData(result)) : void 0;
68
+ return {
69
+ status: "ok",
70
+ ...results === void 0 ? {} : { results }
71
+ };
72
+ }
73
+ /** The code a thrown error carries, such as an `ORPCError`'s, or a generic one. */
74
+ function readThrownCode(error) {
75
+ const code = isRecord(error) ? error.code : void 0;
76
+ return typeof code === "string" && ERROR_CODE.test(code) ? code : "INTERNAL_SERVER_ERROR";
77
+ }
78
+ function isCallToolResult(value) {
79
+ return isRecord(value) && Array.isArray(value.content);
80
+ }
81
+ /**
82
+ * Records every tool call on an MCP server built with the MCP TypeScript SDK v2. Call it once,
83
+ * right after creating the server and before registering any tool: it wraps `registerTool`, so a
84
+ * tool registered earlier would go unrecorded, and it throws when the server already has tools.
85
+ *
86
+ * ```ts
87
+ * const server = new McpServer({ name: "Acme", version: "1.0.0" })
88
+ * trackToolCalls(server, { server: "public" })
89
+ * server.registerTool("search", config, handler)
90
+ * ```
91
+ *
92
+ * Each call sends `mcp_tool_call` with the tool, the client, the status, the time it took and,
93
+ * for a read-only tool whose result is a list, how many items it returned. The tool's arguments,
94
+ * the caller's IP and its user agent are never sent. A call still waiting for input from the
95
+ * client is recorded once it ends. Failures to send never reach the tool: they go to the
96
+ * tracker's `onError`.
97
+ */
98
+ function trackToolCalls(server, options = {}) {
99
+ if (wrapped.has(server)) return;
100
+ const registered = server._registeredTools;
101
+ if (registered && Object.keys(registered).length > 0) throw new Error("highseat/mcp: call trackToolCalls() before registering any tool, or those tools go unrecorded.");
102
+ wrapped.add(server);
103
+ const { tracker = { track }, server: label, hostname, waitUntil } = options;
104
+ const register = server.registerTool.bind(server);
105
+ function send(tool, client, outcome, startedAt) {
106
+ tracker.track("mcp_tool_call", {
107
+ tool,
108
+ ...label ? { server: label } : {},
109
+ ...client,
110
+ ...outcome,
111
+ duration_ms: Math.round(performance.now() - startedAt)
112
+ }, {
113
+ ...hostname ? { hostname } : {},
114
+ ...waitUntil ? { waitUntil } : {}
115
+ });
116
+ }
117
+ function registerTracked(name, config, handler) {
118
+ const isReadOnly = config.annotations?.readOnlyHint === true;
119
+ return register(name, config, async (...args) => {
120
+ const context = args.at(-1);
121
+ const client = readClient(context, server);
122
+ callClients.set(context, client);
123
+ const startedAt = performance.now();
124
+ let result;
125
+ try {
126
+ result = await handler(...args);
127
+ } catch (error) {
128
+ send(name, client, { status: readThrownCode(error) }, startedAt);
129
+ throw error;
130
+ }
131
+ if (isCallToolResult(result)) send(name, client, readOutcome(result, isReadOnly), startedAt);
132
+ return result;
133
+ });
134
+ }
135
+ server.registerTool = registerTracked;
136
+ }
137
+ /**
138
+ * Tags a link a tool returns, so the visit it brings reads as the assistant that showed it:
139
+ * `utm_source` names the client and `utm_medium` is `mcp`, which Highseat reads as the AI
140
+ * channel. A link that already has a `utm_source` is returned as it is. Pass the tool handler's
141
+ * context.
142
+ *
143
+ * ```ts
144
+ * server.registerTool("get_product", config, async ({ slug }, context) => ({
145
+ * content: [{ type: "text", text: mcpLink(`https://acme.com/products/${slug}`, context) }],
146
+ * }))
147
+ * ```
148
+ */
149
+ function mcpLink(url, context) {
150
+ const link = new URL(url);
151
+ if (link.searchParams.has("utm_source")) return url;
152
+ const { client, client_source } = callClients.get(context) ?? readClient(context);
153
+ link.searchParams.set("utm_source", client_source === "none" ? "mcp" : client);
154
+ link.searchParams.set("utm_medium", "mcp");
155
+ return link.href;
156
+ }
157
+ //#endregion
158
+ export { mcpLink, trackToolCalls };
159
+
160
+ //# sourceMappingURL=mcp.js.map
package/mcp.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp.js","names":[],"sources":["../src/mcp.ts"],"sourcesContent":["import type {\n CallToolResult,\n Implementation,\n McpServer,\n ServerContext,\n ToolAnnotations,\n} from \"@modelcontextprotocol/server\"\nimport { type ServerBuiltInEvents, type ServerEventOptions, track, type WaitUntil } from \"./server\"\n\n/**\n * MCP tool calls, recorded from your own MCP server built on the MCP TypeScript SDK v2: one\n * `mcp_tool_call` event per call, with the tool, the client, the outcome and the time it took.\n * The tool's arguments, the caller's IP and its user agent are never sent: assistant directories\n * forbid collecting conversation data, and the caller is the assistant's server, not a person.\n */\n\ntype ToolCall = ServerBuiltInEvents[\"mcp_tool_call\"]\n\n/** What the event records about the client behind a call. */\ntype ToolClient = Pick<ToolCall, \"client\" | \"client_version\" | \"client_source\">\n\n/** What tool calls are sent with: a tracker from `createTracker()`, or any `track()` like its. */\nexport interface ToolCallTracker {\n track: (name: \"mcp_tool_call\", properties: ToolCall, options?: ServerEventOptions) => void\n}\n\nexport interface TrackToolCallsOptions {\n /** The tracker events are sent with. Defaults to the one `track()` from `highseat/server` uses. */\n tracker?: ToolCallTracker\n /** A label for this MCP server, such as `public` or `admin`, when the site runs more than one. */\n server?: string\n /** The site hostname every call is filed under. Defaults to the tracker's `hostname`. */\n hostname?: string\n /**\n * Keeps a serverless function alive until each event is sent: `after` from `next/server`, or\n * `ctx.waitUntil` in a Cloudflare Worker, bound to its object.\n */\n waitUntil?: WaitUntil\n}\n\n/** The parts of a tool's registration the wrapper reads. */\ninterface ToolConfig {\n annotations?: ToolAnnotations\n}\n\ntype ToolHandler = (...args: unknown[]) => unknown\n\ntype RegisterTool = (name: string, config: ToolConfig, handler: ToolHandler) => unknown\n\n/** How a call ended, as the event records it. */\ninterface ToolOutcome {\n status: string\n results?: number\n}\n\n// The `_meta` key a client of the 2026-07-28 protocol names itself under on every request. The\n// SDK exports it as `CLIENT_INFO_META_KEY`; it is written out so this module loads no SDK code.\nconst CLIENT_INFO_KEY = \"io.modelcontextprotocol/clientInfo\"\n\n// An error code as the agent sees it, such as `NOT_FOUND` or `RATE_LIMITED`.\nconst ERROR_CODE = /^[A-Z][A-Z0-9_]{1,63}$/\nconst ERROR_CODE_PREFIX = /^([A-Z][A-Z0-9_]{1,63}):/\n\n// A text result is parsed for its count only below this length, so a large page is never parsed\n// a second time.\nconst MAX_PARSED_LENGTH = 256 * 1024\n\nconst wrapped = new WeakSet<McpServer>()\n\n// The client each call was recorded under, so `mcpLink()` tags a link with the same name.\nconst callClients = new WeakMap<ServerContext, ToolClient>()\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return !!value && typeof value === \"object\" && !Array.isArray(value)\n}\n\nfunction toClient(info: Implementation | undefined): ToolClient | undefined {\n if (!info?.name) return\n return {\n client: info.name,\n ...(info.version ? { client_version: info.version } : {}),\n client_source: \"client_info\",\n }\n}\n\n/**\n * The client behind a call. A client of the 2026-07-28 protocol names itself on every request. A\n * 2025-era client names itself only in its `initialize` handshake, which a stateful server\n * remembers and a stateless one never sees again, so its User-Agent's product name stands in.\n */\nfunction readClient(context: ServerContext, server?: McpServer): ToolClient {\n const envelope: unknown = context.mcpReq.envelope\n const declared = isRecord(envelope) ? envelope[CLIENT_INFO_KEY] : undefined\n\n const client =\n toClient(isRecord(declared) ? (declared as Implementation) : undefined) ??\n toClient(server?.server.getClientVersion())\n if (client) return client\n\n const product = context.http?.req?.headers.get(\"user-agent\")?.split(/[\\s/]/)[0]\n return product\n ? { client: product, client_source: \"user_agent\" }\n : { client: \"unknown\", client_source: \"none\" }\n}\n\n/**\n * How many items a result holds: the length of a list, or of the one list a result object holds,\n * such as `data` in a page of rows. A result with no list, or with several, has no count.\n */\nfunction countItems(data: unknown): number | undefined {\n if (Array.isArray(data)) return data.length\n if (!isRecord(data)) return\n\n const lists = Object.values(data).filter(Array.isArray)\n return lists.length === 1 ? lists[0]?.length : undefined\n}\n\n/** The structured data a result carries, or the JSON of its one text block. */\nfunction readData(result: CallToolResult): unknown {\n if (result.structuredContent !== undefined) return result.structuredContent\n\n const [block, ...rest] = result.content\n if (rest.length > 0 || block?.type !== \"text\" || block.text.length > MAX_PARSED_LENGTH) return\n\n try {\n return JSON.parse(block.text) as unknown\n } catch {\n return\n }\n}\n\n/** The error code a failed result starts its text with, such as `NOT_FOUND: …`. */\nfunction readErrorCode(result: CallToolResult) {\n const block = result.content.find(content => content.type === \"text\")\n return block?.type === \"text\" ? block.text.match(ERROR_CODE_PREFIX)?.[1] : undefined\n}\n\nfunction readOutcome(result: CallToolResult, isReadOnly: boolean): ToolOutcome {\n if (result.isError) return { status: readErrorCode(result) ?? \"TOOL_ERROR\" }\n\n const results = isReadOnly ? countItems(readData(result)) : undefined\n return { status: \"ok\", ...(results === undefined ? {} : { results }) }\n}\n\n/** The code a thrown error carries, such as an `ORPCError`'s, or a generic one. */\nfunction readThrownCode(error: unknown) {\n const code = isRecord(error) ? error.code : undefined\n return typeof code === \"string\" && ERROR_CODE.test(code) ? code : \"INTERNAL_SERVER_ERROR\"\n}\n\nfunction isCallToolResult(value: unknown): value is CallToolResult {\n return isRecord(value) && Array.isArray(value.content)\n}\n\n/**\n * Records every tool call on an MCP server built with the MCP TypeScript SDK v2. Call it once,\n * right after creating the server and before registering any tool: it wraps `registerTool`, so a\n * tool registered earlier would go unrecorded, and it throws when the server already has tools.\n *\n * ```ts\n * const server = new McpServer({ name: \"Acme\", version: \"1.0.0\" })\n * trackToolCalls(server, { server: \"public\" })\n * server.registerTool(\"search\", config, handler)\n * ```\n *\n * Each call sends `mcp_tool_call` with the tool, the client, the status, the time it took and,\n * for a read-only tool whose result is a list, how many items it returned. The tool's arguments,\n * the caller's IP and its user agent are never sent. A call still waiting for input from the\n * client is recorded once it ends. Failures to send never reach the tool: they go to the\n * tracker's `onError`.\n */\nexport function trackToolCalls(server: McpServer, options: TrackToolCallsOptions = {}): void {\n if (wrapped.has(server)) return\n\n // The SDK keeps its tools in a private record. Reading it is the only way to tell that some\n // were registered before this call, and a version without it skips the check.\n const registered = (server as unknown as { _registeredTools?: Record<string, unknown> })\n ._registeredTools\n if (registered && Object.keys(registered).length > 0) {\n throw new Error(\n \"highseat/mcp: call trackToolCalls() before registering any tool, or those tools go unrecorded.\",\n )\n }\n\n wrapped.add(server)\n\n const { tracker = { track }, server: label, hostname, waitUntil } = options\n const register = server.registerTool.bind(server) as RegisterTool\n\n function send(tool: string, client: ToolClient, outcome: ToolOutcome, startedAt: number) {\n tracker.track(\n \"mcp_tool_call\",\n {\n tool,\n ...(label ? { server: label } : {}),\n ...client,\n ...outcome,\n duration_ms: Math.round(performance.now() - startedAt),\n },\n { ...(hostname ? { hostname } : {}), ...(waitUntil ? { waitUntil } : {}) },\n )\n }\n\n function registerTracked(name: string, config: ToolConfig, handler: ToolHandler) {\n const isReadOnly = config.annotations?.readOnlyHint === true\n\n return register(name, config, async (...args: unknown[]) => {\n // The context is the handler's last argument: a tool with an input schema gets its\n // arguments first.\n const context = args.at(-1) as ServerContext\n const client = readClient(context, server)\n callClients.set(context, client)\n const startedAt = performance.now()\n\n let result: unknown\n try {\n result = await handler(...args)\n } catch (error) {\n send(name, client, { status: readThrownCode(error) }, startedAt)\n throw error\n }\n\n if (isCallToolResult(result)) send(name, client, readOutcome(result, isReadOnly), startedAt)\n return result\n })\n }\n\n server.registerTool = registerTracked as McpServer[\"registerTool\"]\n}\n\n/**\n * Tags a link a tool returns, so the visit it brings reads as the assistant that showed it:\n * `utm_source` names the client and `utm_medium` is `mcp`, which Highseat reads as the AI\n * channel. A link that already has a `utm_source` is returned as it is. Pass the tool handler's\n * context.\n *\n * ```ts\n * server.registerTool(\"get_product\", config, async ({ slug }, context) => ({\n * content: [{ type: \"text\", text: mcpLink(`https://acme.com/products/${slug}`, context) }],\n * }))\n * ```\n */\nexport function mcpLink(url: string, context: ServerContext): string {\n const link = new URL(url)\n if (link.searchParams.has(\"utm_source\")) return url\n\n const { client, client_source } = callClients.get(context) ?? readClient(context)\n link.searchParams.set(\"utm_source\", client_source === \"none\" ? \"mcp\" : client)\n link.searchParams.set(\"utm_medium\", \"mcp\")\n return link.href\n}\n"],"mappings":"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"}
package/next.d.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { ClientIp, ClientIpPreset } from "./ingest.js";
1
2
  import { HighseatProps as HighseatProps$1, useBeforeSend, useIdentify } from "./react.js";
2
- import { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions, createProxy } from "./proxy.js";
3
+ import { ProxyHandler, ProxyOptions, createProxy } from "./proxy.js";
3
4
  import { ReactElement } from "react";
4
5
  //#region src/next.d.ts
5
6
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "highseat",
3
- "version": "0.4.0",
4
- "description": "Highseat analytics for JavaScript: typed tracking, a first-party proxy and framework components.",
3
+ "version": "0.6.0",
4
+ "description": "Highseat analytics for JavaScript: typed tracking in the browser and on the server, a first-party proxy and framework components.",
5
5
  "keywords": [
6
6
  "analytics",
7
7
  "cookieless",
@@ -38,16 +38,28 @@
38
38
  "./proxy": {
39
39
  "types": "./proxy.d.ts",
40
40
  "default": "./proxy.js"
41
+ },
42
+ "./server": {
43
+ "types": "./server.d.ts",
44
+ "default": "./server.js"
45
+ },
46
+ "./mcp": {
47
+ "types": "./mcp.d.ts",
48
+ "default": "./mcp.js"
41
49
  }
42
50
  },
43
51
  "publishConfig": {
44
52
  "access": "public"
45
53
  },
46
54
  "peerDependencies": {
55
+ "@modelcontextprotocol/server": ">=2",
47
56
  "next": ">=14",
48
57
  "react": ">=18"
49
58
  },
50
59
  "peerDependenciesMeta": {
60
+ "@modelcontextprotocol/server": {
61
+ "optional": true
62
+ },
51
63
  "next": {
52
64
  "optional": true
53
65
  },
package/proxy.d.ts CHANGED
@@ -1,11 +1,5 @@
1
+ import { ClientIp, ClientIpPreset } from "./ingest.js";
1
2
  //#region src/proxy.d.ts
2
- /** A host whose own header carries the visitor's IP. */
3
- export type ClientIpPreset = "vercel" | "cloudflare" | "netlify" | "nginx";
4
- /**
5
- * Where the proxy reads the visitor's IP: a host preset, or a function for any other setup. A
6
- * function returns the IP, or nothing when the request carries none.
7
- */
8
- export type ClientIp = ClientIpPreset | ((request: Request) => string | null | undefined);
9
3
  export interface ProxyOptions {
10
4
  /**
11
5
  * The site's proxy path from Installation settings, such as `/3f9a0c1e2b`, with or without its
@@ -57,4 +51,5 @@ export type ProxyHandler = (request: Request) => Promise<Response>;
57
51
  */
58
52
  export declare function createProxy(options: ProxyOptions): ProxyHandler;
59
53
  //#endregion
54
+ export type { ClientIp, ClientIpPreset };
60
55
  //# sourceMappingURL=proxy.d.ts.map
package/proxy.js CHANGED
@@ -1,17 +1,6 @@
1
- import { findProxyPath, findSecret, isNextBuild } from "./env.js";
1
+ import { findProxyPath, isNextBuild } from "./env.js";
2
+ import { findValidSecret, getClientIpReader } from "./ingest.js";
2
3
  //#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
4
  const SCRIPT_REQUEST_HEADERS = ["user-agent", "if-none-match"];
16
5
  const EVENT_REQUEST_HEADERS = [
17
6
  "content-type",
@@ -35,18 +24,6 @@ const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
35
24
  ]);
36
25
  const UPSTREAM_TIMEOUT_MS = 1e4;
37
26
  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
27
  function copyHeaders(from, to, names) {
51
28
  for (const name of names) {
52
29
  const value = from.get(name);
@@ -62,13 +39,6 @@ function answerError(status, error) {
62
39
  function answerOtherPath() {
63
40
  return answerError(404, "Not the proxy path. The Highseat proxy answers on its own path only.");
64
41
  }
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
42
  let hasWarnedMissing = false;
73
43
  /**
74
44
  * The handler for a proxy missing a variable: every request goes to `fallback`, or the proxy
@@ -125,16 +95,16 @@ function createProxy(options) {
125
95
  const { fallback } = options;
126
96
  let foundSecret;
127
97
  try {
128
- foundSecret = findValidSecret(options.secret);
98
+ foundSecret = findValidSecret(options.secret, "highseat/proxy");
129
99
  } catch (error) {
130
100
  if (!isNextBuild()) throw error;
131
101
  return () => Promise.reject(error);
132
102
  }
133
- const upstream = new URL(options.upstream ?? DEFAULT_UPSTREAM).origin;
103
+ const upstream = new URL(options.upstream ?? "https://cdn.highseat.com").origin;
134
104
  const missing = [...fallback && path === void 0 ? ["HIGHSEAT_PATH"] : [], ...foundSecret ? [] : ["HIGHSEAT_SECRET"]];
135
105
  if (!foundSecret || missing.length) return createDisabledProxy(missing, path, fallback);
136
106
  const secret = foundSecret;
137
- const clientIpReader = getClientIpReader(options.clientIp);
107
+ const clientIpReader = getClientIpReader(options.clientIp, "highseat/proxy");
138
108
  let hasWarnedNoIp = false;
139
109
  function readClientIp(request) {
140
110
  const ip = clientIpReader.read(request);
package/proxy.js.map CHANGED
@@ -1 +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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"}
1
+ {"version":3,"file":"proxy.js","names":[],"sources":["../src/proxy.ts"],"sourcesContent":["import { findProxyPath, isNextBuild } from \"./env\"\nimport { type ClientIp, DEFAULT_UPSTREAM, findValidSecret, getClientIpReader } from \"./ingest\"\n\nexport type { ClientIp, ClientIpPreset } from \"./ingest\"\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\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\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 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, \"highseat/proxy\")\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, \"highseat/proxy\")\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/server.d.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { BuiltInEvents, TrackArgsOf, TrackNameOf } from "./index.js";
2
+ import { ClientIp, ClientIpPreset } from "./ingest.js";
3
+ //#region src/server.d.ts
4
+ /**
5
+ * Server-side events, sent from your own backend with the site secret: a server action, a route
6
+ * handler, a webhook handler or a job. Events are batched and sent in the background, retried
7
+ * when Highseat is busy or unreachable, and nothing here throws: a failure goes to `onError`.
8
+ */
9
+ /**
10
+ * Request headers in any shape a server hands them over: a `Headers` object, what Next.js's
11
+ * `headers()` returns, or Node's `request.headers` record.
12
+ */
13
+ export type HeadersLike = {
14
+ get: (name: string) => string | null;
15
+ } | Record<string, string | string[] | undefined>;
16
+ /**
17
+ * Keeps a serverless function alive until a promise settles: `waitUntil` from
18
+ * `@vercel/functions`, or `ctx.waitUntil` in a Cloudflare Worker and `context.waitUntil` in a
19
+ * Netlify function, bound to their object.
20
+ */
21
+ export type WaitUntil = (promise: Promise<unknown>) => void;
22
+ /** Who an event belongs to and where it happened, given with each `track()` call. */
23
+ export interface ServerEventOptions {
24
+ /**
25
+ * The request being handled. The visitor's IP (see `clientIp`), their user agent and the
26
+ * hostname are read from it, and a `pageview` takes its URL and referrer too.
27
+ */
28
+ request?: Request;
29
+ /** The request's headers, when there is no `Request`: `await headers()` in a server action. */
30
+ headers?: HeadersLike;
31
+ /** The visitor's IP. Wins over the one read from `request` or `headers`. */
32
+ ip?: string;
33
+ /** The visitor's user agent. Wins over the one read from `request` or `headers`. */
34
+ userAgent?: string;
35
+ /**
36
+ * A user id from your own system. An event with no visitor (no IP and no user agent, as from
37
+ * a job) counts as this user, and without one as a visitor of its own.
38
+ */
39
+ userId?: string | number;
40
+ /** A hostname of the site. Defaults to the tracker's `hostname`, then the request's host. */
41
+ hostname?: string;
42
+ /** The page's URL. A `pageview` defaults to the request's URL. */
43
+ url?: string;
44
+ /** The page the visitor came from. A `pageview` defaults to the request's `Referer`. */
45
+ referrer?: string;
46
+ /**
47
+ * The event's id, any string up to 255 characters. Defaults to a new UUID. Pass an id your
48
+ * system already holds (a job id, a webhook event id), and a retry of the same event within
49
+ * three days is stored once.
50
+ */
51
+ eventId?: string;
52
+ /** When the event happened, up to a day ago. Defaults to the moment `track()` is called. */
53
+ timestamp?: Date;
54
+ /**
55
+ * Sends this event at once and keeps the function alive until it lands. Wins over the
56
+ * tracker's `waitUntil`.
57
+ */
58
+ waitUntil?: WaitUntil;
59
+ }
60
+ /** An event Highseat did not store, as `onError` reports it. */
61
+ export interface TrackErrorEvent {
62
+ /** The event's name. */
63
+ name: string;
64
+ /** The event's id: the one passed to `track()`, or the UUID it was given. */
65
+ eventId: string;
66
+ /** Why Highseat refused or dropped it, such as `revenue.amount_not_integer` or `bot`. */
67
+ reason?: string;
68
+ }
69
+ /** A failure `track()` never throws, handed to `onError`. */
70
+ export interface TrackError {
71
+ /**
72
+ * `rejected`: refused as invalid, for good. `dropped`: accepted but not stored, for a `reason`
73
+ * of `foreign_hostname`, `bot` or `excluded` (a recognised retry is never reported). `failed`:
74
+ * never landed, after every retry or at `close()`'s deadline, or refused whole (a wrong secret).
75
+ */
76
+ type: "rejected" | "dropped" | "failed";
77
+ /** What happened, in a sentence. */
78
+ message: string;
79
+ /** The HTTP status Highseat answered, when it answered. */
80
+ status?: number;
81
+ /** The events concerned. */
82
+ events: TrackErrorEvent[];
83
+ }
84
+ export interface TrackerOptions {
85
+ /** The site id, `site_…`. Defaults to `HIGHSEAT_SITE`, then `NEXT_PUBLIC_HIGHSEAT_SITE`. */
86
+ site?: string;
87
+ /**
88
+ * The site secret, `hs_secret_…`, from the site's Installation settings. Defaults to
89
+ * `HIGHSEAT_SECRET`. Keep it on the server: it lets a caller name any visitor.
90
+ */
91
+ secret?: string;
92
+ /** The hostname every event is filed under unless it names its own. */
93
+ hostname?: string;
94
+ /**
95
+ * Where the visitor's IP is read from a request: `vercel`, `cloudflare`, `netlify`, `nginx`
96
+ * or a function, as for `createProxy()`. Defaults to `cloudflare` in a Worker, `vercel` on
97
+ * Vercel and `netlify` on Netlify. Anywhere else, set it, or pass `ip` with each event.
98
+ */
99
+ clientIp?: ClientIp;
100
+ /** The ingest origin. Defaults to `https://cdn.highseat.com`. */
101
+ upstream?: string;
102
+ /** The `fetch` events are sent with. Defaults to the global one. */
103
+ fetch?: typeof fetch;
104
+ /** Receives every failure. Defaults to a `console.warn`. */
105
+ onError?: (error: TrackError) => void;
106
+ /**
107
+ * Sends every event at once and keeps the function alive until it lands. Pass it in a
108
+ * serverless function, which may stop as soon as it answers.
109
+ */
110
+ waitUntil?: WaitUntil;
111
+ /** Milliseconds an event waits for others to share its request. Defaults to 1000. */
112
+ flushInterval?: number;
113
+ /**
114
+ * Retries of a batch Highseat answered 429 or 5xx, or never answered. Defaults to 4. Batches
115
+ * go one at a time, so a retry's wait holds back every event behind it.
116
+ */
117
+ maxRetries?: number;
118
+ /** Milliseconds one request may take. Defaults to 10000. */
119
+ timeout?: number;
120
+ }
121
+ /**
122
+ * The events Highseat knows by name that only a server sends, on top of the browser's built-in
123
+ * events. Highseat refuses them from a browser. A site overrides an entry through `Register`.
124
+ */
125
+ export interface ServerBuiltInEvents {
126
+ /**
127
+ * One call to a tool on the site's MCP server. `highseat/mcp` sends it for every call. Never
128
+ * put the tool's arguments in it: assistant directories forbid recording conversation data.
129
+ */
130
+ mcp_tool_call: {
131
+ /** The tool's name. */
132
+ tool: string;
133
+ /** A label for the MCP server, such as `public` or `admin`, when the site runs more than one. */
134
+ server?: string;
135
+ /**
136
+ * The client's name as it declares it in `clientInfo`, else the product name its User-Agent
137
+ * starts with (`Claude-User`), else `unknown`.
138
+ */
139
+ client: string;
140
+ /** The version the client declares in `clientInfo`, when it declares one. */
141
+ client_version?: string;
142
+ /**
143
+ * How `client` was found: declared in `clientInfo`, read off the User-Agent, or neither. A
144
+ * declared name is the one to trust.
145
+ */
146
+ client_source: "client_info" | "user_agent" | "none";
147
+ /** `ok`, or the error code the client saw, such as `NOT_FOUND`. */
148
+ status: "ok" | (string & {});
149
+ /**
150
+ * How many items the call returned, for a read-only tool whose result is a list. Left out
151
+ * for any other tool, so an empty list counts as an empty result and nothing else does.
152
+ */
153
+ results?: number;
154
+ /** How long the call took, in whole milliseconds. */
155
+ duration_ms: number;
156
+ };
157
+ }
158
+ type ServerEvents = BuiltInEvents & ServerBuiltInEvents;
159
+ /** The names the server's `track()` accepts: the browser's, plus {@link ServerBuiltInEvents}. */
160
+ export type ServerTrackName = TrackNameOf<ServerEvents>;
161
+ /**
162
+ * The arguments after an event's name: its properties, typed by `Register` as for the browser's
163
+ * `track()`, then the options that say who it belongs to.
164
+ */
165
+ export type ServerTrackArgs<Name extends string> = [...TrackArgsOf<ServerEvents, Name>, options?: ServerEventOptions];
166
+ export interface Tracker {
167
+ /** Records an event, queued and sent in the background. Returns at once and never throws. */
168
+ track: <Name extends ServerTrackName>(name: Name, ...args: ServerTrackArgs<Name>) => void;
169
+ /** Sends every queued event, and resolves once each has landed or failed. Never rejects. */
170
+ flush: () => Promise<void>;
171
+ /**
172
+ * Sends what is queued and stops: a later `track()` sends nothing. Call it at shutdown, with a
173
+ * `timeout` shorter than the shutdown's grace period.
174
+ */
175
+ close: (options?: CloseOptions) => Promise<void>;
176
+ }
177
+ export interface CloseOptions {
178
+ /**
179
+ * Milliseconds to wait for the queue to drain. Past it, sending stops, the events still
180
+ * unsent go to `onError` as `failed`, and `close()` resolves. Without one, `close()` waits for
181
+ * every retry, which during an outage can take minutes.
182
+ */
183
+ timeout?: number;
184
+ }
185
+ /**
186
+ * Creates a tracker for a site's backend. Credentials come from the options or from
187
+ * `HIGHSEAT_SITE` and `HIGHSEAT_SECRET`. Without them the tracker warns once and sends nothing,
188
+ * and a value that is not a site secret throws, since that is a copying mistake.
189
+ *
190
+ * ```ts
191
+ * const tracker = createTracker({ hostname: "example.com" })
192
+ * tracker.track("signup", { plan: "pro" }, { request, userId: user.id })
193
+ * ```
194
+ */
195
+ export declare function createTracker(options?: TrackerOptions): Tracker;
196
+ /**
197
+ * Records an event with the tracker made from `HIGHSEAT_SITE` and `HIGHSEAT_SECRET`. A known
198
+ * name checks its properties, as in the browser. Never throws.
199
+ *
200
+ * ```ts
201
+ * track("signup", { plan: "pro" }, { request, userId: user.id })
202
+ * ```
203
+ */
204
+ export declare function track<Name extends ServerTrackName>(name: Name, ...args: ServerTrackArgs<Name>): void;
205
+ /** Sends every event `track()` has queued. Await it before a short-lived script exits. */
206
+ export declare function flush(): Promise<void>;
207
+ /** Sends what `track()` has queued and stops sending. Call it at shutdown. See `CloseOptions`. */
208
+ export declare function close(options?: CloseOptions): Promise<void>;
209
+ //#endregion
210
+ export type { ClientIp, ClientIpPreset };
211
+ //# sourceMappingURL=server.d.ts.map