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/README.md +145 -1
- package/env.js +19 -2
- package/env.js.map +1 -1
- package/index.d.ts +21 -11
- package/index.js.map +1 -1
- package/ingest.d.ts +14 -0
- package/ingest.js +40 -0
- package/ingest.js.map +1 -0
- package/mcp.d.ts +61 -0
- package/mcp.js +160 -0
- package/mcp.js.map +1 -0
- package/next.d.ts +2 -1
- package/package.json +14 -2
- package/proxy.d.ts +2 -7
- package/proxy.js +5 -35
- package/proxy.js.map +1 -1
- package/server.d.ts +211 -0
- package/server.js +390 -0
- package/server.js.map +1 -0
package/README.md
CHANGED
|
@@ -16,8 +16,10 @@ npm install highseat
|
|
|
16
16
|
| `highseat/react` | `<Highseat />`, `useIdentify()` and `useBeforeSend()` | React |
|
|
17
17
|
| `highseat/next` | `<Highseat />`, `useIdentify()`, `useBeforeSend()`, `createProxy()` and `withHighseat()` for Next.js | Next.js |
|
|
18
18
|
| `highseat/proxy` | `createProxy()`, a first-party proxy for any runtime with `fetch` | Nothing |
|
|
19
|
+
| `highseat/server` | `track()`, `flush()`, `close()` and `createTracker()`, for events sent from your backend | Nothing |
|
|
20
|
+
| `highseat/mcp` | `trackToolCalls()` and `mcpLink()`, for the tool calls AI assistants make to your MCP server | The MCP TypeScript SDK v2 |
|
|
19
21
|
|
|
20
|
-
Each entry point loads on its own and imports no framework it does not name, so `highseat/proxy`
|
|
22
|
+
Each entry point loads on its own and imports no framework it does not name, so `highseat/proxy` and `highseat/server` run in a Cloudflare Worker without React installed.
|
|
21
23
|
|
|
22
24
|
The package loads the tracker from Highseat, or from your site's proxy path, rather than bundling it. A call made before the tracker has loaded waits for it, and no call ever throws: on a server, with the tracker blocked, or with tracking off, it does nothing.
|
|
23
25
|
|
|
@@ -227,6 +229,148 @@ const attributionId = await getAttributionId()
|
|
|
227
229
|
await createCheckout({ metadata: { attribution_id: attributionId } })
|
|
228
230
|
```
|
|
229
231
|
|
|
232
|
+
## Server-side events
|
|
233
|
+
|
|
234
|
+
`highseat/server` records an event from your own backend: a server action, a route handler, a webhook handler or a job. It sends with the site secret, so set both variables from your site's Installation settings on the server:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
HIGHSEAT_SITE=site_… # NEXT_PUBLIC_HIGHSEAT_SITE works too
|
|
238
|
+
HIGHSEAT_SECRET=hs_secret_...
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Pass the request you are handling, and the event takes the visitor's IP, user agent and hostname from it, so it gets their country and device:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { track } from "highseat/server"
|
|
245
|
+
|
|
246
|
+
export async function POST(request: Request) {
|
|
247
|
+
const user = await createAccount(request)
|
|
248
|
+
track("signup", { plan: "pro" }, { request, userId: user.id })
|
|
249
|
+
return Response.json(user)
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
In a Next.js server action, pass the headers instead:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
"use server"
|
|
257
|
+
|
|
258
|
+
import { headers } from "next/headers"
|
|
259
|
+
import { track } from "highseat/server"
|
|
260
|
+
|
|
261
|
+
export async function subscribe(plan: string) {
|
|
262
|
+
track("subscription_started", { plan }, { headers: await headers() })
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`track()` takes the same names and properties as in the browser, checked by the same `Register`. It returns at once and never throws. Events wait up to a second to share a request, at most 25 to one, and go one request at a time. A batch Highseat answers `429` or `5xx`, or never answers, is retried with backoff, honoring `Retry-After`, and the events behind it wait. Every event gets a UUID, so a retried batch is stored once. Up to 1000 events wait during an outage. A newer one is reported to `onError` and not kept.
|
|
267
|
+
|
|
268
|
+
An event no visitor caused, from a job or a webhook, leaves the request out. With a `userId` it counts as that user, and without one as a visitor of its own:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
track("invoice_paid", { plan: "pro" }, { userId: customer.id, eventId: stripeEvent.id, hostname: "example.com" })
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Pass an id your system already holds as `eventId` (a job id, a webhook event id), and a retry of the same event within three days is stored once. `timestamp` dates an event that happened earlier, up to a day ago.
|
|
275
|
+
|
|
276
|
+
### Serverless functions
|
|
277
|
+
|
|
278
|
+
A serverless function can stop as soon as it answers, before a batch leaves. Pass `waitUntil`, and the event is sent at once while the function stays alive until it lands:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
import { waitUntil } from "@vercel/functions"
|
|
282
|
+
|
|
283
|
+
track("signup", undefined, { request, userId: user.id, waitUntil })
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
In a Cloudflare Worker, pass `waitUntil: promise => ctx.waitUntil(promise)`, and in a Netlify function `context.waitUntil`. A script or a test that exits on its own awaits `flush()` first. A long-running server calls `close()` at shutdown, which sends what is queued. During an outage that can take minutes of retries, so give it a deadline inside your shutdown's grace period:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
process.on("SIGTERM", async () => {
|
|
290
|
+
await close({ timeout: 5000 })
|
|
291
|
+
process.exit(0)
|
|
292
|
+
})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Past the deadline, `close()` stops sending, reports the events not confirmed sent to `onError`, and resolves.
|
|
296
|
+
|
|
297
|
+
### Your own tracker
|
|
298
|
+
|
|
299
|
+
`createTracker(options)` returns a tracker with its own `track()`, `flush()` and `close()`, for credentials or a `fetch` of your own, or several sites in one process:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
import { createTracker } from "highseat/server"
|
|
303
|
+
|
|
304
|
+
const tracker = createTracker({
|
|
305
|
+
hostname: "example.com",
|
|
306
|
+
onError: error => logger.warn(error.message, { events: error.events }),
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
tracker.track("tier_created", { tier: "gold" }, { userId: publisher.id, ip, userAgent })
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
| Option | Default | |
|
|
313
|
+
| --- | --- | --- |
|
|
314
|
+
| `site` | `HIGHSEAT_SITE`, then `NEXT_PUBLIC_HIGHSEAT_SITE` | The site id. |
|
|
315
|
+
| `secret` | `HIGHSEAT_SECRET` | The site secret. A value that is not one throws. Without the site or the secret, the tracker warns once and sends nothing. |
|
|
316
|
+
| `hostname` | The request's host | The hostname events are filed under. An event's own `hostname` wins. |
|
|
317
|
+
| `clientIp` | `"cloudflare"` in a Worker, `"vercel"` on Vercel, `"netlify"` on Netlify | Where the visitor's IP is read from a request, as for `createProxy()`. Those three hosts overwrite their header, so a visitor cannot forge it. Anywhere else, set it or pass `ip` with each event: without either, an event carries no IP and no country. |
|
|
318
|
+
| `waitUntil` | None | Sends every event at once, as above. |
|
|
319
|
+
| `onError` | `console.warn` | Receives each failure. `type` is `"rejected"` for events Highseat refused as invalid, `"dropped"` for events it accepted but did not store (a hostname that is not the site's, a bot, excluded traffic), each with its `reason`, or `"failed"` for events that never landed, were refused whole (a wrong secret), or were left at `close()`'s deadline. A retry Highseat recognised as already stored is not a failure. |
|
|
320
|
+
| `fetch` | The global `fetch` | The `fetch` events are sent with, such as a mock in tests. |
|
|
321
|
+
| `upstream` | `https://cdn.highseat.com` | The Highseat ingest origin. |
|
|
322
|
+
| `flushInterval` | `1000` | Milliseconds an event waits for others to share its request. |
|
|
323
|
+
| `maxRetries` | `4` | Retries of a batch before it is reported as failed. |
|
|
324
|
+
| `timeout` | `10000` | Milliseconds one request may take. |
|
|
325
|
+
|
|
326
|
+
Each event takes `request` or `headers`, then `ip`, `userAgent`, `userId`, `hostname`, `url`, `referrer`, `eventId`, `timestamp` and `waitUntil`. An explicit `ip` or `userAgent` wins over the request's. A `pageview` takes its `url` and `referrer` from the request too.
|
|
327
|
+
|
|
328
|
+
### Without the package
|
|
329
|
+
|
|
330
|
+
Any language can send the same request. `POST https://cdn.highseat.com/api/event` with `x-site-id` and `x-hs-secret` headers and a JSON array of up to 25 events. Each event names `event_name`, `hostname` (or a `page_url`), and optionally `event_id`, `user_id`, `ip`, `user_agent`, `page_url`, `referrer`, `properties` (string values) and `revenue`. The answer lists each refused event in `rejections` and each event it did not store in `dropped`, by its index in the batch.
|
|
331
|
+
|
|
332
|
+
## MCP tool calls
|
|
333
|
+
|
|
334
|
+
`highseat/mcp` records each call an AI assistant (Claude, ChatGPT, Cursor and others) makes to a tool on your MCP server, built with the MCP TypeScript SDK v2. It sends through `highseat/server`, so set `HIGHSEAT_SITE` and `HIGHSEAT_SECRET` on the server as above. Call `trackToolCalls()` once, after creating the server and before registering any tool:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { McpServer } from "@modelcontextprotocol/server"
|
|
338
|
+
import { mcpLink, trackToolCalls } from "highseat/mcp"
|
|
339
|
+
import { z } from "zod"
|
|
340
|
+
|
|
341
|
+
const server = new McpServer({ name: "Acme", version: "1.0.0" })
|
|
342
|
+
trackToolCalls(server, { hostname: "acme.com" })
|
|
343
|
+
|
|
344
|
+
server.registerTool(
|
|
345
|
+
"get_product",
|
|
346
|
+
{
|
|
347
|
+
description: "One product, by its slug.",
|
|
348
|
+
inputSchema: z.object({ slug: z.string() }),
|
|
349
|
+
annotations: { readOnlyHint: true },
|
|
350
|
+
},
|
|
351
|
+
async ({ slug }, context) => ({
|
|
352
|
+
content: [{ type: "text", text: mcpLink(`https://acme.com/products/${slug}`, context) }],
|
|
353
|
+
}),
|
|
354
|
+
)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Each call sends one `mcp_tool_call` event with these properties:
|
|
358
|
+
|
|
359
|
+
| Property | |
|
|
360
|
+
| --- | --- |
|
|
361
|
+
| `tool` | The tool's name. |
|
|
362
|
+
| `server` | The `server` option, a label such as `public` or `admin` for a site with more than one MCP server. |
|
|
363
|
+
| `client` | The name the client declares in `clientInfo`, else the product name its User-Agent starts with, such as `Claude-User`, else `unknown`. |
|
|
364
|
+
| `client_version` | The version the client declares, when it declares one. |
|
|
365
|
+
| `client_source` | `client_info`, `user_agent` or `none`: how `client` was found. |
|
|
366
|
+
| `status` | `ok`, or the error code the client saw: the `code` of a thrown error, the `CODE:` an error result's text starts with, else `TOOL_ERROR` or `INTERNAL_SERVER_ERROR`. |
|
|
367
|
+
| `results` | How many items a read-only tool returned, when its result is a list, or an object holding exactly one list. |
|
|
368
|
+
| `duration_ms` | How long the call took, in whole milliseconds. |
|
|
369
|
+
|
|
370
|
+
It never sends the tool's arguments, its result, the caller's IP or its user agent, and it never reads ChatGPT's user or conversation ids. Assistant directories forbid collecting conversation data, and every call comes from the assistant's servers rather than from a person, so a tool call counts as an event, never as a visitor. Highseat accepts `mcp_tool_call` only from a server holding the site secret.
|
|
371
|
+
|
|
372
|
+
`mcpLink(url, context)` tags a link a tool returns with `utm_source` set to the client and `utm_medium=mcp`, so a visit it brings reads as that assistant in the AI channel. A link that already names a `utm_source` is returned as it is. Pass `waitUntil` to `trackToolCalls()` in a serverless function, and `tracker` to send with a tracker from `createTracker()`.
|
|
373
|
+
|
|
230
374
|
## First-party proxy
|
|
231
375
|
|
|
232
376
|
The proxy serves the tracker and forwards its events from your own domain, on the site's own path, so a filter list that blocks Highseat's host does not block your site. A `GET` on the path serves the script and a `POST` forwards events. The proxy sends the visitor's IP under your site secret, which a rewrite or a redirect rule cannot do: without it, every visitor would get your server's country.
|
package/env.js
CHANGED
|
@@ -21,7 +21,24 @@ function findProxyPath(option) {
|
|
|
21
21
|
if (!path) return;
|
|
22
22
|
return path.startsWith("/") ? path : `/${path}`;
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The site id for `highseat/server`: `HIGHSEAT_SITE`, else the Next.js variable `<Highseat />`
|
|
26
|
+
* reads, so a Next.js app sets it once.
|
|
27
|
+
*/
|
|
28
|
+
function findServerSite() {
|
|
29
|
+
if (typeof process === "undefined") return;
|
|
30
|
+
return process.env.HIGHSEAT_SITE ?? process.env.NEXT_PUBLIC_HIGHSEAT_SITE;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The host the server runs on, when its platform sets a variable that says so and a header of
|
|
34
|
+
* its own carries the visitor's IP.
|
|
35
|
+
*/
|
|
36
|
+
function findHostPlatform() {
|
|
37
|
+
if (typeof process === "undefined") return;
|
|
38
|
+
if (process.env.VERCEL) return "vercel";
|
|
39
|
+
if (process.env.NETLIFY) return "netlify";
|
|
40
|
+
}
|
|
41
|
+
/** The site secret for the proxy handler and `highseat/server`. */
|
|
25
42
|
function findSecret() {
|
|
26
43
|
if (typeof process === "undefined") return;
|
|
27
44
|
return process.env.HIGHSEAT_SECRET;
|
|
@@ -46,6 +63,6 @@ function isDevelopment() {
|
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
//#endregion
|
|
49
|
-
export { findNextSite, findProxyPath, findSecret, isDevelopment, isNextBuild };
|
|
66
|
+
export { findHostPlatform, findNextSite, findProxyPath, findSecret, findServerSite, isDevelopment, isNextBuild };
|
|
50
67
|
|
|
51
68
|
//# sourceMappingURL=env.js.map
|
package/env.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"env.js","names":[],"sources":["../src/env.ts"],"sourcesContent":["/**\n * The variables the package reads from a customer's environment. Each is named in full, never\n * looked up by a computed key: Next.js inlines a `NEXT_PUBLIC_` variable into browser code only\n * where the source spells out `process.env.NEXT_PUBLIC_…`. The server variables return\n * `undefined` where the runtime has no `process`: a Cloudflare Worker without `nodejs_compat`,\n * whose options carry the values instead.\n */\n\n/** The site id for `<Highseat />` in Next.js. Public, so it reaches the browser. */\nexport function findNextSite(): string | undefined {\n return process.env.NEXT_PUBLIC_HIGHSEAT_SITE\n}\n\n/**\n * The site's proxy path: `option` when given, else `HIGHSEAT_PATH`, a server variable shared by\n * `<Highseat />`, `withHighseat()` and the proxy handler. A value written without its leading\n * slash gets one, as Installation settings shows it (`/3f9a0c1e2b`). An empty value counts as\n * unset.\n */\nexport function findProxyPath(option?: string): string | undefined {\n const path = option ?? (typeof process === \"undefined\" ? undefined : process.env.HIGHSEAT_PATH)\n if (!path) return\n return path.startsWith(\"/\") ? path : `/${path}`\n}\n\n/** The site secret for the proxy handler
|
|
1
|
+
{"version":3,"file":"env.js","names":[],"sources":["../src/env.ts"],"sourcesContent":["/**\n * The variables the package reads from a customer's environment. Each is named in full, never\n * looked up by a computed key: Next.js inlines a `NEXT_PUBLIC_` variable into browser code only\n * where the source spells out `process.env.NEXT_PUBLIC_…`. The server variables return\n * `undefined` where the runtime has no `process`: a Cloudflare Worker without `nodejs_compat`,\n * whose options carry the values instead.\n */\n\n/** The site id for `<Highseat />` in Next.js. Public, so it reaches the browser. */\nexport function findNextSite(): string | undefined {\n return process.env.NEXT_PUBLIC_HIGHSEAT_SITE\n}\n\n/**\n * The site's proxy path: `option` when given, else `HIGHSEAT_PATH`, a server variable shared by\n * `<Highseat />`, `withHighseat()` and the proxy handler. A value written without its leading\n * slash gets one, as Installation settings shows it (`/3f9a0c1e2b`). An empty value counts as\n * unset.\n */\nexport function findProxyPath(option?: string): string | undefined {\n const path = option ?? (typeof process === \"undefined\" ? undefined : process.env.HIGHSEAT_PATH)\n if (!path) return\n return path.startsWith(\"/\") ? path : `/${path}`\n}\n\n/**\n * The site id for `highseat/server`: `HIGHSEAT_SITE`, else the Next.js variable `<Highseat />`\n * reads, so a Next.js app sets it once.\n */\nexport function findServerSite(): string | undefined {\n if (typeof process === \"undefined\") return\n return process.env.HIGHSEAT_SITE ?? process.env.NEXT_PUBLIC_HIGHSEAT_SITE\n}\n\n/**\n * The host the server runs on, when its platform sets a variable that says so and a header of\n * its own carries the visitor's IP.\n */\nexport function findHostPlatform(): \"vercel\" | \"netlify\" | undefined {\n if (typeof process === \"undefined\") return\n if (process.env.VERCEL) return \"vercel\"\n if (process.env.NETLIFY) return \"netlify\"\n return\n}\n\n/** The site secret for the proxy handler and `highseat/server`. */\nexport function findSecret(): string | undefined {\n if (typeof process === \"undefined\") return\n return process.env.HIGHSEAT_SECRET\n}\n\n/**\n * Whether this runs inside `next build`, which loads every route without serving one. Set in the\n * build's route workers only: `next.config.ts` receives the phase as an argument instead.\n */\nexport function isNextBuild(): boolean {\n if (typeof process === \"undefined\") return false\n return process.env.NEXT_PHASE === \"phase-production-build\"\n}\n\n/**\n * Whether the build runs in development. A bundler replaces `process.env.NODE_ENV` with a\n * literal. A browser without that replacement has no `process`, so the read throws.\n */\nexport function isDevelopment(): boolean {\n try {\n return process.env.NODE_ENV === \"development\"\n } catch {\n return false\n }\n}\n"],"mappings":";;;;;;;;;AASA,SAAgB,eAAmC;CACjD,OAAO,QAAQ,IAAI;AACrB;;;;;;;AAQA,SAAgB,cAAc,QAAqC;CACjE,MAAM,OAAO,WAAW,OAAO,YAAY,cAAc,KAAA,IAAY,QAAQ,IAAI;CACjF,IAAI,CAAC,MAAM;CACX,OAAO,KAAK,WAAW,GAAG,IAAI,OAAO,IAAI;AAC3C;;;;;AAMA,SAAgB,iBAAqC;CACnD,IAAI,OAAO,YAAY,aAAa;CACpC,OAAO,QAAQ,IAAI,iBAAiB,QAAQ,IAAI;AAClD;;;;;AAMA,SAAgB,mBAAqD;CACnE,IAAI,OAAO,YAAY,aAAa;CACpC,IAAI,QAAQ,IAAI,QAAQ,OAAO;CAC/B,IAAI,QAAQ,IAAI,SAAS,OAAO;AAElC;;AAGA,SAAgB,aAAiC;CAC/C,IAAI,OAAO,YAAY,aAAa;CACpC,OAAO,QAAQ,IAAI;AACrB;;;;;AAMA,SAAgB,cAAuB;CACrC,IAAI,OAAO,YAAY,aAAa,OAAO;CAC3C,OAAO,QAAQ,IAAI,eAAe;AACpC;;;;;AAMA,SAAgB,gBAAyB;CACvC,IAAI;EACF,OAAO,QAAQ,IAAI,aAAa;CAClC,QAAQ;EACN,OAAO;CACT;AACF"}
|
package/index.d.ts
CHANGED
|
@@ -81,23 +81,33 @@ type CheckedProperties<Shape> = [keyof Shape] extends [never] ? Record<string, n
|
|
|
81
81
|
type RegisteredEvents = Register extends {
|
|
82
82
|
events: infer Registered;
|
|
83
83
|
} ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]>; } : {};
|
|
84
|
-
type KnownEvents = Omit<
|
|
84
|
+
type KnownEvents<BuiltIns> = Omit<BuiltIns, keyof RegisteredEvents> & RegisteredEvents;
|
|
85
|
+
/**
|
|
86
|
+
* The event names a `track` with the given built-in events knows, built-in or registered.
|
|
87
|
+
* `highseat/server` passes its server-only events on top of {@link BuiltInEvents}.
|
|
88
|
+
*/
|
|
89
|
+
export type KnownEventName<BuiltIns> = Extract<keyof KnownEvents<BuiltIns>, string>;
|
|
85
90
|
/** Every event name `track` knows, built-in or registered. */
|
|
86
|
-
export type EventName =
|
|
91
|
+
export type EventName = KnownEventName<BuiltInEvents>;
|
|
87
92
|
/**
|
|
88
|
-
* The names `track` accepts: known names, and any other string
|
|
89
|
-
* `string & {}` keeps the known names in the editor's suggestions,
|
|
90
|
-
* union would absorb.
|
|
93
|
+
* The names a `track` with the given built-in events accepts: known names, and any other string
|
|
94
|
+
* unless the site is strict. `string & {}` keeps the known names in the editor's suggestions,
|
|
95
|
+
* which a plain `string` in the union would absorb.
|
|
91
96
|
*/
|
|
92
|
-
export type
|
|
97
|
+
export type TrackNameOf<BuiltIns> = Register extends {
|
|
93
98
|
strict: true;
|
|
94
|
-
} ?
|
|
99
|
+
} ? KnownEventName<BuiltIns> : KnownEventName<BuiltIns> | (string & {});
|
|
100
|
+
/** The names `track` accepts: known names, and any other string unless the site is strict. */
|
|
101
|
+
export type TrackName = TrackNameOf<BuiltInEvents>;
|
|
95
102
|
/**
|
|
96
|
-
* The arguments after an event's name
|
|
97
|
-
* when any of them is; any other name takes
|
|
98
|
-
* names is checked as a whole rather than
|
|
103
|
+
* The arguments after an event's name, for a `track` with the given built-in events. A known
|
|
104
|
+
* name takes its declared properties, required when any of them is; any other name takes
|
|
105
|
+
* {@link Properties}. Wrapped in tuples so a union of names is checked as a whole rather than
|
|
106
|
+
* name by name.
|
|
99
107
|
*/
|
|
100
|
-
export type
|
|
108
|
+
export type TrackArgsOf<BuiltIns, Name extends string> = [Name] extends [KnownEventName<BuiltIns>] ? {} extends KnownEvents<BuiltIns>[Name] ? [properties?: KnownEvents<BuiltIns>[Name]] : [properties: KnownEvents<BuiltIns>[Name]] : [properties?: Properties];
|
|
109
|
+
/** The arguments after an event's name: its properties. See {@link TrackArgsOf}. */
|
|
110
|
+
export type TrackArgs<Name extends string> = TrackArgsOf<BuiltInEvents, Name>;
|
|
101
111
|
/**
|
|
102
112
|
* A user id from your own system, as {@link identify} takes it. A string or a number identifies
|
|
103
113
|
* the visitor, `null` clears the id, and `undefined` or an empty string changes nothing. Pass your
|
package/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { isDevelopment } from \"./env\"\n\n/**\n * The browser client. Every call goes to the hosted tracker on `window.highseat`; before the\n * tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:\n * on a server, with the tracker blocked, or with tracking off, a call does nothing.\n */\n\n/** A value an event property can hold. A `null` or `undefined` value is left out of the event. */\nexport type PropertyValue = string | number | boolean | null | undefined\n\n/**\n * Money attached to an event. `amount` is an integer in the currency's smallest unit, so\n * `{ amount: 4999, currency: \"USD\" }` is $49.99 and `{ amount: 5000, currency: \"JPY\" }` is ¥5000.\n */\nexport interface Revenue {\n /** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */\n amount: number\n /** An ISO 4217 code, such as `USD` or `EUR`. */\n currency: string\n}\n\n/** The properties of an event with no declared shape: any string keys, plus optional revenue. */\nexport interface Properties {\n /** Money the event brought in. */\n revenue?: Revenue\n [key: string]: PropertyValue | Revenue\n}\n\n/**\n * The events Highseat knows by name. A site extends it, or overrides an entry, through\n * {@link Register}. Type-only: it adds nothing to the bundle.\n */\nexport interface BuiltInEvents {\n /** A visitor created an account. */\n signup: Properties\n /** A visitor signed in. */\n login: Properties\n /**\n * A sale. With a payment provider connected, leave `revenue` out: the provider's webhook\n * records the money, and revenue sent here as well would count the sale twice. Send `revenue`\n * only for a checkout no provider reports, and then with an `order_id`, so a provider sale\n * added later for the same order is not counted again.\n */\n purchase: Properties\n /** A paid subscription began. */\n subscription_started: Properties\n /** A free trial began. */\n trial_started: Properties\n /** A file download. The tracker records links to files on its own. */\n file_download: { url: string }\n /** A click on a link to another site. The tracker records these on its own. */\n outbound_link_click: { url: string }\n /** A visit to a missing page. Call it from a single-page app's not-found route. */\n \"404\": Properties\n}\n\n/**\n * Declares a site's own events. Augment it from any file the compiler sees:\n *\n * ```ts\n * declare module \"highseat\" {\n * interface Register {\n * events: {\n * signup: { plan: \"free\" | \"pro\" }\n * }\n * strict: true\n * }\n * }\n * ```\n *\n * `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`\n * accept only built-in and registered names, so a misspelt name is a type error.\n */\nexport interface Register {}\n\n/**\n * A registered event's properties as `track` accepts them. A shape with no properties takes none,\n * and a property whose type an event cannot carry becomes `never`, so a call passing it fails\n * instead of sending `\"[object Object]\"`. The shape is not rejected at registration, where a\n * failed check would silently drop every registered event at once.\n */\ntype CheckedProperties<Shape> = [keyof Shape] extends [never]\n ? Record<string, never>\n : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never }\n\ntype RegisteredEvents = Register extends { events: infer Registered }\n ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]> }\n : {}\n\ntype KnownEvents = Omit<BuiltInEvents, keyof RegisteredEvents> & RegisteredEvents\n\n/** Every event name `track` knows, built-in or registered. */\nexport type EventName = Extract<keyof KnownEvents, string>\n\n/**\n * The names `track` accepts: known names, and any other string unless the site is strict.\n * `string & {}` keeps the known names in the editor's suggestions, which a plain `string` in the\n * union would absorb.\n */\nexport type TrackName = Register extends { strict: true } ? EventName : EventName | (string & {})\n\n/**\n * The arguments after an event's name. A known name takes its declared properties, required\n * when any of them is; any other name takes {@link Properties}. Wrapped in tuples so a union of\n * names is checked as a whole rather than name by name.\n */\nexport type TrackArgs<Name extends string> = [Name] extends [EventName]\n ? {} extends KnownEvents[Name]\n ? [properties?: KnownEvents[Name]]\n : [properties: KnownEvents[Name]]\n : [properties?: Properties]\n\n/**\n * A user id from your own system, as {@link identify} takes it. A string or a number identifies\n * the visitor, `null` clears the id, and `undefined` or an empty string changes nothing. Pass your\n * own database id, never an email or other personal data.\n */\nexport type UserId = string | number | null | undefined\n\n/**\n * The part of an event {@link BeforeSend} sees and may change. The site, the visitor's user id\n * and the attribution id are out of reach, so a hook cannot change who an event belongs to.\n */\nexport interface BeforeSendEvent {\n /** The event's name: `pageview`, `engagement`, or a name passed to `track`. Read-only. */\n readonly name: string\n /** The page's full URL. The page path is read off the URL you return. */\n url: string\n /** The page the visitor came from, or the page left on a client-side navigation. */\n referrer?: string\n /** The event's properties, each value a string. */\n properties?: Record<string, string>\n}\n\n/**\n * Runs in the browser on every event before the tracker sends it, page views from client-side\n * navigation included. Return the event, changed or not, or `null` to drop it. Any other falsy\n * answer drops it too, and so does a hook that throws: a hook that keeps a secret out of the\n * data must not leak it when it fails. The error still reaches the console.\n *\n * ```ts\n * function beforeSend(event: BeforeSendEvent) {\n * const url = new URL(event.url)\n * if (url.pathname.startsWith(\"/admin/\")) return null\n * url.searchParams.delete(\"email\")\n * return { ...event, url: url.href }\n * }\n * ```\n */\nexport type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null\n\n/** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */\nexport interface HighseatGlobal {\n /** Records an event. */\n track?: (name: string, properties?: Properties) => void\n /** Sets or clears the visitor's user id. */\n identify?: (id: UserId) => void\n /** The id that joins a payment to this tab's visit. */\n getAttributionId?: () => string | undefined\n /** Calls made before the tracker loaded, drained in order when it starts. */\n q?: [string, unknown[]][]\n /** The site's {@link BeforeSend} hook, read on every event. */\n beforeSend?: BeforeSend\n}\n\n/** How long {@link getAttributionId} waits for the tracker. */\nexport interface AttributionOptions {\n /** Milliseconds to wait for the tracker to load. Defaults to 3000. */\n timeout?: number\n}\n\n/** How the tracker is loaded. Each option maps to an attribute on its script tag. */\nexport interface InjectOptions {\n /** The site id, `site_…`, from the site's Installation settings. */\n site: string\n /**\n * Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party\n * delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back\n * to the same place.\n */\n src?: string\n /** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */\n outbound?: boolean\n /** Records clicks on links to files as `file_download`. Defaults to `true`. */\n downloads?: boolean\n /** Counts a change of `#/route` as a page view, for an app that routes by hash. */\n hashRouting?: boolean\n /** Changes or drops each event before it leaves the browser. See {@link BeforeSend}. */\n beforeSend?: BeforeSend\n /**\n * Logs every `track()` and `identify()` call to the console, and warns once when no tracker\n * script is on the page or the script failed to load. Defaults to `true` when\n * `process.env.NODE_ENV` is `development`, so a production build logs nothing.\n */\n debug?: boolean\n}\n\ndeclare global {\n interface Window {\n highseat?: HighseatGlobal\n }\n}\n\n/** Where the tracker loads from when the site has no proxy path. */\nexport const SCRIPT_URL = \"https://cdn.highseat.com/script.js\"\n\n// The tracker marks its own API with this symbol. A site's own stub can define methods too, so\n// only the symbol proves the tracker is running.\nconst LIVE = Symbol.for(\"highseat\")\n\nconst SCRIPT_HOST = new URL(SCRIPT_URL).host\n// A site's proxy path: `/` and ten hex characters.\nconst PROXY_PATH = /^\\/[0-9a-f]{10}$/\n\nconst TRACKER_TIMEOUT = 3000\nconst TRACKER_POLL_INTERVAL = 50\n\nconst MISSING_SCRIPT_WARNING = [\n \"highseat: no tracker script on this page, so no event reaches Highseat.\",\n \"Render <Highseat />, call inject({ site }), or add the script tag from the site's Installation settings.\",\n].join(\" \")\nconst FAILED_SCRIPT_WARNING = [\n \"highseat: the tracker script failed to load, so no event reaches Highseat.\",\n \"An ad blocker may have stopped it: load the tracker from the site's proxy path instead.\",\n].join(\" \")\nconst SLOW_SCRIPT_WARNING = [\n `highseat: the tracker script has not loaded after ${TRACKER_TIMEOUT / 1000} seconds,`,\n \"so every call waits in the queue. Check the network panel for a blocked or failed request.\",\n].join(\" \")\n\n// The script tags `inject` added, and every tracker script whose request failed, such as one an\n// ad blocker stopped. A browser keeps no record of a failed script, so a failure is only known\n// to a listener attached before it happened.\nconst injectedScripts = new WeakSet<HTMLScriptElement>()\nconst failedScripts = new WeakSet<HTMLScriptElement>()\n\n// The `debug` option of the last `inject` call. Unset falls back to the build's environment.\nlet debugOption: boolean | undefined\n// The windows whose tracker is being watched, so a page warns about its script once.\nconst watchedWindows = new WeakSet<Window>()\n\nfunction findGlobal(): HighseatGlobal | undefined {\n if (typeof window === \"undefined\") return\n return (window.highseat ??= {})\n}\n\nfunction isLive(api: HighseatGlobal | undefined): api is HighseatGlobal {\n return !!api && (api as Record<symbol, unknown>)[LIVE] === true\n}\n\n// One function for every script, so attaching it on each poll adds no second listener.\nfunction markFailed(event: Event) {\n failedScripts.add(event.currentTarget as HTMLScriptElement)\n}\n\n/**\n * Whether a script tag loads the tracker, judged as the tracker judges itself. Fathom's snippet\n * carries a `data-site` too, so a tag is matched by a `site_` id, or by a `src` on a proxy path\n * or on Highseat's host: an older site's id is a UUID, which any tool could use.\n */\nfunction isTrackerScript(script: HTMLScriptElement) {\n const { site } = script.dataset\n if (injectedScripts.has(script) || site?.startsWith(\"site_\")) return true\n if (site === undefined) return false\n\n try {\n // A browser hands back `src` resolved; the base only matters to a relative one.\n const url = new URL(script.src, \"http://localhost\")\n return url.host === SCRIPT_HOST || PROXY_PATH.test(url.pathname)\n } catch {\n return false\n }\n}\n\n/** The page's tracker scripts, each one listened to from now on for a failed load. */\nfunction getTrackerScripts() {\n const scripts = Array.from(document.scripts).filter(isTrackerScript)\n for (const script of scripts) script.addEventListener(\"error\", markFailed)\n return scripts\n}\n\nfunction isDebug() {\n return debugOption ?? isDevelopment()\n}\n\n/**\n * Warns once per page when the tracker cannot arrive: no tracker script, or only failed ones. A\n * missing script is judged at the timeout, because `<Highseat />` adds its tag in an effect that\n * runs after the effects of the components inside it.\n */\nfunction watchTracker() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n if (watchedWindows.has(window)) return\n watchedWindows.add(window)\n\n const page = window\n const deadline = Date.now() + TRACKER_TIMEOUT\n\n function check() {\n // A replaced global window, as a test sets up for each case, is another page.\n if (window !== page || isLive(page.highseat)) return\n\n const scripts = getTrackerScripts()\n\n if (scripts.length > 0 && scripts.every(script => failedScripts.has(script))) {\n console.warn(FAILED_SCRIPT_WARNING)\n return\n }\n if (Date.now() < deadline) {\n setTimeout(check, TRACKER_POLL_INTERVAL)\n return\n }\n console.warn(scripts.length === 0 ? MISSING_SCRIPT_WARNING : SLOW_SCRIPT_WARNING)\n }\n\n setTimeout(check, TRACKER_POLL_INTERVAL)\n}\n\n/**\n * Logs a call when debugging is on. The check waits a microtask, so a `debug` option that\n * `<Highseat />` passes in its effect applies to the calls its children make in theirs.\n */\nfunction logCall(method: \"track\" | \"identify\", args: unknown[], isQueued: boolean) {\n queueMicrotask(() => {\n if (!isDebug()) return\n\n const status = isQueued ? \"queued until the tracker loads\" : \"sent to the tracker\"\n const [value, properties] = args\n // `JSON.stringify` quotes a name or a string id, and returns `undefined` for an unset id.\n const message = `highseat: ${method} ${String(JSON.stringify(value))}, ${status}`\n\n if (properties === undefined) console.info(message)\n else console.info(message, properties)\n watchTracker()\n })\n}\n\nfunction call(method: \"track\" | \"identify\", args: unknown[]) {\n const api = findGlobal()\n if (!api) return\n\n const handler = api[method] as ((...values: unknown[]) => void) | undefined\n if (typeof handler !== \"function\") {\n api.q ??= []\n api.q.push([method, args])\n logCall(method, args, true)\n return\n }\n\n logCall(method, args, false)\n try {\n handler(...args)\n } catch {}\n}\n\n/**\n * Records an event. A known name autocompletes and checks its properties; any other name is a\n * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so\n * `Purchase` and `purchase` are two events.\n */\nexport function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void {\n const [properties] = args\n call(\"track\", properties === undefined ? [name] : [name, properties])\n}\n\n/**\n * Links the visitor to a user id from your own system, on this event and every later one, across\n * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so\n * `identify(user?.id)` before a session loads does not sign the visitor out.\n */\nexport function identify(id: UserId): void {\n call(\"identify\", [id])\n}\n\n/**\n * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata\n * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.\n * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has\n * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout\n * when the script is still loading, and on a server or with tracking off.\n */\nexport async function getAttributionId(\n options: AttributionOptions = {},\n): Promise<string | undefined> {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n\n const deadline = Date.now() + (options.timeout ?? TRACKER_TIMEOUT)\n let api = window.highseat\n\n while (!isLive(api)) {\n if (Date.now() >= deadline) return\n\n const scripts = getTrackerScripts()\n // No tracker script at all, or only failed ones: the tracker cannot arrive.\n if (scripts.every(script => failedScripts.has(script))) return\n\n await new Promise(resolve => setTimeout(resolve, TRACKER_POLL_INTERVAL))\n api = window.highseat\n }\n\n try {\n return api.getAttributionId?.()\n } catch {\n return\n }\n}\n\n/**\n * Sets the {@link BeforeSend} hook, or clears it with `undefined`. Works before the tracker has\n * loaded, which picks the hook up when it starts, and applies from the next event once it runs.\n * Does nothing on a server.\n */\nexport function setBeforeSend(beforeSend: BeforeSend | undefined): void {\n const api = findGlobal()\n if (api) api.beforeSend = beforeSend\n}\n\n/**\n * Adds the tracker's script tag to the page, once. A second call for the same site, such as a\n * component mounting again, finds the tag and adds nothing, but its `debug` option still applies.\n * Does nothing on a server.\n */\nexport function inject(options: InjectOptions): void {\n if (typeof document === \"undefined\") return\n\n const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, beforeSend, debug } = options\n debugOption = debug\n // Set before the script tag is added, so the first page view already passes through it.\n if (beforeSend) setBeforeSend(beforeSend)\n if (isDebug()) watchTracker()\n if (Array.from(document.scripts).some(script => script.dataset.site === site)) return\n\n const script = document.createElement(\"script\")\n script.addEventListener(\"error\", markFailed)\n injectedScripts.add(script)\n script.src = src\n script.dataset.site = site\n if (outbound === false) script.dataset.outbound = \"false\"\n if (downloads === false) script.dataset.downloads = \"false\"\n if (hashRouting) script.dataset.hashRouting = \"\"\n document.head.append(script)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { isDevelopment } from \"./env\"\n\n/**\n * The browser client. Every call goes to the hosted tracker on `window.highseat`; before the\n * tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:\n * on a server, with the tracker blocked, or with tracking off, a call does nothing.\n */\n\n/** A value an event property can hold. A `null` or `undefined` value is left out of the event. */\nexport type PropertyValue = string | number | boolean | null | undefined\n\n/**\n * Money attached to an event. `amount` is an integer in the currency's smallest unit, so\n * `{ amount: 4999, currency: \"USD\" }` is $49.99 and `{ amount: 5000, currency: \"JPY\" }` is ¥5000.\n */\nexport interface Revenue {\n /** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */\n amount: number\n /** An ISO 4217 code, such as `USD` or `EUR`. */\n currency: string\n}\n\n/** The properties of an event with no declared shape: any string keys, plus optional revenue. */\nexport interface Properties {\n /** Money the event brought in. */\n revenue?: Revenue\n [key: string]: PropertyValue | Revenue\n}\n\n/**\n * The events Highseat knows by name. A site extends it, or overrides an entry, through\n * {@link Register}. Type-only: it adds nothing to the bundle.\n */\nexport interface BuiltInEvents {\n /** A visitor created an account. */\n signup: Properties\n /** A visitor signed in. */\n login: Properties\n /**\n * A sale. With a payment provider connected, leave `revenue` out: the provider's webhook\n * records the money, and revenue sent here as well would count the sale twice. Send `revenue`\n * only for a checkout no provider reports, and then with an `order_id`, so a provider sale\n * added later for the same order is not counted again.\n */\n purchase: Properties\n /** A paid subscription began. */\n subscription_started: Properties\n /** A free trial began. */\n trial_started: Properties\n /** A file download. The tracker records links to files on its own. */\n file_download: { url: string }\n /** A click on a link to another site. The tracker records these on its own. */\n outbound_link_click: { url: string }\n /** A visit to a missing page. Call it from a single-page app's not-found route. */\n \"404\": Properties\n}\n\n/**\n * Declares a site's own events. Augment it from any file the compiler sees:\n *\n * ```ts\n * declare module \"highseat\" {\n * interface Register {\n * events: {\n * signup: { plan: \"free\" | \"pro\" }\n * }\n * strict: true\n * }\n * }\n * ```\n *\n * `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`\n * accept only built-in and registered names, so a misspelt name is a type error.\n */\nexport interface Register {}\n\n/**\n * A registered event's properties as `track` accepts them. A shape with no properties takes none,\n * and a property whose type an event cannot carry becomes `never`, so a call passing it fails\n * instead of sending `\"[object Object]\"`. The shape is not rejected at registration, where a\n * failed check would silently drop every registered event at once.\n */\ntype CheckedProperties<Shape> = [keyof Shape] extends [never]\n ? Record<string, never>\n : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never }\n\ntype RegisteredEvents = Register extends { events: infer Registered }\n ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]> }\n : {}\n\n// A registered event replaces a built-in one of the same name.\ntype KnownEvents<BuiltIns> = Omit<BuiltIns, keyof RegisteredEvents> & RegisteredEvents\n\n/**\n * The event names a `track` with the given built-in events knows, built-in or registered.\n * `highseat/server` passes its server-only events on top of {@link BuiltInEvents}.\n */\nexport type KnownEventName<BuiltIns> = Extract<keyof KnownEvents<BuiltIns>, string>\n\n/** Every event name `track` knows, built-in or registered. */\nexport type EventName = KnownEventName<BuiltInEvents>\n\n/**\n * The names a `track` with the given built-in events accepts: known names, and any other string\n * unless the site is strict. `string & {}` keeps the known names in the editor's suggestions,\n * which a plain `string` in the union would absorb.\n */\nexport type TrackNameOf<BuiltIns> = Register extends { strict: true }\n ? KnownEventName<BuiltIns>\n : KnownEventName<BuiltIns> | (string & {})\n\n/** The names `track` accepts: known names, and any other string unless the site is strict. */\nexport type TrackName = TrackNameOf<BuiltInEvents>\n\n/**\n * The arguments after an event's name, for a `track` with the given built-in events. A known\n * name takes its declared properties, required when any of them is; any other name takes\n * {@link Properties}. Wrapped in tuples so a union of names is checked as a whole rather than\n * name by name.\n */\nexport type TrackArgsOf<BuiltIns, Name extends string> = [Name] extends [KnownEventName<BuiltIns>]\n ? {} extends KnownEvents<BuiltIns>[Name]\n ? [properties?: KnownEvents<BuiltIns>[Name]]\n : [properties: KnownEvents<BuiltIns>[Name]]\n : [properties?: Properties]\n\n/** The arguments after an event's name: its properties. See {@link TrackArgsOf}. */\nexport type TrackArgs<Name extends string> = TrackArgsOf<BuiltInEvents, Name>\n\n/**\n * A user id from your own system, as {@link identify} takes it. A string or a number identifies\n * the visitor, `null` clears the id, and `undefined` or an empty string changes nothing. Pass your\n * own database id, never an email or other personal data.\n */\nexport type UserId = string | number | null | undefined\n\n/**\n * The part of an event {@link BeforeSend} sees and may change. The site, the visitor's user id\n * and the attribution id are out of reach, so a hook cannot change who an event belongs to.\n */\nexport interface BeforeSendEvent {\n /** The event's name: `pageview`, `engagement`, or a name passed to `track`. Read-only. */\n readonly name: string\n /** The page's full URL. The page path is read off the URL you return. */\n url: string\n /** The page the visitor came from, or the page left on a client-side navigation. */\n referrer?: string\n /** The event's properties, each value a string. */\n properties?: Record<string, string>\n}\n\n/**\n * Runs in the browser on every event before the tracker sends it, page views from client-side\n * navigation included. Return the event, changed or not, or `null` to drop it. Any other falsy\n * answer drops it too, and so does a hook that throws: a hook that keeps a secret out of the\n * data must not leak it when it fails. The error still reaches the console.\n *\n * ```ts\n * function beforeSend(event: BeforeSendEvent) {\n * const url = new URL(event.url)\n * if (url.pathname.startsWith(\"/admin/\")) return null\n * url.searchParams.delete(\"email\")\n * return { ...event, url: url.href }\n * }\n * ```\n */\nexport type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null\n\n/** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */\nexport interface HighseatGlobal {\n /** Records an event. */\n track?: (name: string, properties?: Properties) => void\n /** Sets or clears the visitor's user id. */\n identify?: (id: UserId) => void\n /** The id that joins a payment to this tab's visit. */\n getAttributionId?: () => string | undefined\n /** Calls made before the tracker loaded, drained in order when it starts. */\n q?: [string, unknown[]][]\n /** The site's {@link BeforeSend} hook, read on every event. */\n beforeSend?: BeforeSend\n}\n\n/** How long {@link getAttributionId} waits for the tracker. */\nexport interface AttributionOptions {\n /** Milliseconds to wait for the tracker to load. Defaults to 3000. */\n timeout?: number\n}\n\n/** How the tracker is loaded. Each option maps to an attribute on its script tag. */\nexport interface InjectOptions {\n /** The site id, `site_…`, from the site's Installation settings. */\n site: string\n /**\n * Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party\n * delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back\n * to the same place.\n */\n src?: string\n /** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */\n outbound?: boolean\n /** Records clicks on links to files as `file_download`. Defaults to `true`. */\n downloads?: boolean\n /** Counts a change of `#/route` as a page view, for an app that routes by hash. */\n hashRouting?: boolean\n /** Changes or drops each event before it leaves the browser. See {@link BeforeSend}. */\n beforeSend?: BeforeSend\n /**\n * Logs every `track()` and `identify()` call to the console, and warns once when no tracker\n * script is on the page or the script failed to load. Defaults to `true` when\n * `process.env.NODE_ENV` is `development`, so a production build logs nothing.\n */\n debug?: boolean\n}\n\ndeclare global {\n interface Window {\n highseat?: HighseatGlobal\n }\n}\n\n/** Where the tracker loads from when the site has no proxy path. */\nexport const SCRIPT_URL = \"https://cdn.highseat.com/script.js\"\n\n// The tracker marks its own API with this symbol. A site's own stub can define methods too, so\n// only the symbol proves the tracker is running.\nconst LIVE = Symbol.for(\"highseat\")\n\nconst SCRIPT_HOST = new URL(SCRIPT_URL).host\n// A site's proxy path: `/` and ten hex characters.\nconst PROXY_PATH = /^\\/[0-9a-f]{10}$/\n\nconst TRACKER_TIMEOUT = 3000\nconst TRACKER_POLL_INTERVAL = 50\n\nconst MISSING_SCRIPT_WARNING = [\n \"highseat: no tracker script on this page, so no event reaches Highseat.\",\n \"Render <Highseat />, call inject({ site }), or add the script tag from the site's Installation settings.\",\n].join(\" \")\nconst FAILED_SCRIPT_WARNING = [\n \"highseat: the tracker script failed to load, so no event reaches Highseat.\",\n \"An ad blocker may have stopped it: load the tracker from the site's proxy path instead.\",\n].join(\" \")\nconst SLOW_SCRIPT_WARNING = [\n `highseat: the tracker script has not loaded after ${TRACKER_TIMEOUT / 1000} seconds,`,\n \"so every call waits in the queue. Check the network panel for a blocked or failed request.\",\n].join(\" \")\n\n// The script tags `inject` added, and every tracker script whose request failed, such as one an\n// ad blocker stopped. A browser keeps no record of a failed script, so a failure is only known\n// to a listener attached before it happened.\nconst injectedScripts = new WeakSet<HTMLScriptElement>()\nconst failedScripts = new WeakSet<HTMLScriptElement>()\n\n// The `debug` option of the last `inject` call. Unset falls back to the build's environment.\nlet debugOption: boolean | undefined\n// The windows whose tracker is being watched, so a page warns about its script once.\nconst watchedWindows = new WeakSet<Window>()\n\nfunction findGlobal(): HighseatGlobal | undefined {\n if (typeof window === \"undefined\") return\n return (window.highseat ??= {})\n}\n\nfunction isLive(api: HighseatGlobal | undefined): api is HighseatGlobal {\n return !!api && (api as Record<symbol, unknown>)[LIVE] === true\n}\n\n// One function for every script, so attaching it on each poll adds no second listener.\nfunction markFailed(event: Event) {\n failedScripts.add(event.currentTarget as HTMLScriptElement)\n}\n\n/**\n * Whether a script tag loads the tracker, judged as the tracker judges itself. Fathom's snippet\n * carries a `data-site` too, so a tag is matched by a `site_` id, or by a `src` on a proxy path\n * or on Highseat's host: an older site's id is a UUID, which any tool could use.\n */\nfunction isTrackerScript(script: HTMLScriptElement) {\n const { site } = script.dataset\n if (injectedScripts.has(script) || site?.startsWith(\"site_\")) return true\n if (site === undefined) return false\n\n try {\n // A browser hands back `src` resolved; the base only matters to a relative one.\n const url = new URL(script.src, \"http://localhost\")\n return url.host === SCRIPT_HOST || PROXY_PATH.test(url.pathname)\n } catch {\n return false\n }\n}\n\n/** The page's tracker scripts, each one listened to from now on for a failed load. */\nfunction getTrackerScripts() {\n const scripts = Array.from(document.scripts).filter(isTrackerScript)\n for (const script of scripts) script.addEventListener(\"error\", markFailed)\n return scripts\n}\n\nfunction isDebug() {\n return debugOption ?? isDevelopment()\n}\n\n/**\n * Warns once per page when the tracker cannot arrive: no tracker script, or only failed ones. A\n * missing script is judged at the timeout, because `<Highseat />` adds its tag in an effect that\n * runs after the effects of the components inside it.\n */\nfunction watchTracker() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n if (watchedWindows.has(window)) return\n watchedWindows.add(window)\n\n const page = window\n const deadline = Date.now() + TRACKER_TIMEOUT\n\n function check() {\n // A replaced global window, as a test sets up for each case, is another page.\n if (window !== page || isLive(page.highseat)) return\n\n const scripts = getTrackerScripts()\n\n if (scripts.length > 0 && scripts.every(script => failedScripts.has(script))) {\n console.warn(FAILED_SCRIPT_WARNING)\n return\n }\n if (Date.now() < deadline) {\n setTimeout(check, TRACKER_POLL_INTERVAL)\n return\n }\n console.warn(scripts.length === 0 ? MISSING_SCRIPT_WARNING : SLOW_SCRIPT_WARNING)\n }\n\n setTimeout(check, TRACKER_POLL_INTERVAL)\n}\n\n/**\n * Logs a call when debugging is on. The check waits a microtask, so a `debug` option that\n * `<Highseat />` passes in its effect applies to the calls its children make in theirs.\n */\nfunction logCall(method: \"track\" | \"identify\", args: unknown[], isQueued: boolean) {\n queueMicrotask(() => {\n if (!isDebug()) return\n\n const status = isQueued ? \"queued until the tracker loads\" : \"sent to the tracker\"\n const [value, properties] = args\n // `JSON.stringify` quotes a name or a string id, and returns `undefined` for an unset id.\n const message = `highseat: ${method} ${String(JSON.stringify(value))}, ${status}`\n\n if (properties === undefined) console.info(message)\n else console.info(message, properties)\n watchTracker()\n })\n}\n\nfunction call(method: \"track\" | \"identify\", args: unknown[]) {\n const api = findGlobal()\n if (!api) return\n\n const handler = api[method] as ((...values: unknown[]) => void) | undefined\n if (typeof handler !== \"function\") {\n api.q ??= []\n api.q.push([method, args])\n logCall(method, args, true)\n return\n }\n\n logCall(method, args, false)\n try {\n handler(...args)\n } catch {}\n}\n\n/**\n * Records an event. A known name autocompletes and checks its properties; any other name is a\n * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so\n * `Purchase` and `purchase` are two events.\n */\nexport function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void {\n const [properties] = args\n call(\"track\", properties === undefined ? [name] : [name, properties])\n}\n\n/**\n * Links the visitor to a user id from your own system, on this event and every later one, across\n * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so\n * `identify(user?.id)` before a session loads does not sign the visitor out.\n */\nexport function identify(id: UserId): void {\n call(\"identify\", [id])\n}\n\n/**\n * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata\n * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.\n * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has\n * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout\n * when the script is still loading, and on a server or with tracking off.\n */\nexport async function getAttributionId(\n options: AttributionOptions = {},\n): Promise<string | undefined> {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n\n const deadline = Date.now() + (options.timeout ?? TRACKER_TIMEOUT)\n let api = window.highseat\n\n while (!isLive(api)) {\n if (Date.now() >= deadline) return\n\n const scripts = getTrackerScripts()\n // No tracker script at all, or only failed ones: the tracker cannot arrive.\n if (scripts.every(script => failedScripts.has(script))) return\n\n await new Promise(resolve => setTimeout(resolve, TRACKER_POLL_INTERVAL))\n api = window.highseat\n }\n\n try {\n return api.getAttributionId?.()\n } catch {\n return\n }\n}\n\n/**\n * Sets the {@link BeforeSend} hook, or clears it with `undefined`. Works before the tracker has\n * loaded, which picks the hook up when it starts, and applies from the next event once it runs.\n * Does nothing on a server.\n */\nexport function setBeforeSend(beforeSend: BeforeSend | undefined): void {\n const api = findGlobal()\n if (api) api.beforeSend = beforeSend\n}\n\n/**\n * Adds the tracker's script tag to the page, once. A second call for the same site, such as a\n * component mounting again, finds the tag and adds nothing, but its `debug` option still applies.\n * Does nothing on a server.\n */\nexport function inject(options: InjectOptions): void {\n if (typeof document === \"undefined\") return\n\n const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, beforeSend, debug } = options\n debugOption = debug\n // Set before the script tag is added, so the first page view already passes through it.\n if (beforeSend) setBeforeSend(beforeSend)\n if (isDebug()) watchTracker()\n if (Array.from(document.scripts).some(script => script.dataset.site === site)) return\n\n const script = document.createElement(\"script\")\n script.addEventListener(\"error\", markFailed)\n injectedScripts.add(script)\n script.src = src\n script.dataset.site = site\n if (outbound === false) script.dataset.outbound = \"false\"\n if (downloads === false) script.dataset.downloads = \"false\"\n if (hashRouting) script.dataset.hashRouting = \"\"\n document.head.append(script)\n}\n"],"mappings":"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"}
|
package/ingest.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/ingest.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* What `highseat/proxy` and `highseat/server` share about reaching ingest: its host, the site
|
|
4
|
+
* secret's prefixes and where the visitor's IP is read.
|
|
5
|
+
*/
|
|
6
|
+
/** A host whose own header carries the visitor's IP. */
|
|
7
|
+
export type ClientIpPreset = "vercel" | "cloudflare" | "netlify" | "nginx";
|
|
8
|
+
/**
|
|
9
|
+
* Where the visitor's IP is read: a host preset, or a function for any other setup. A function
|
|
10
|
+
* returns the IP, or nothing when the request carries none.
|
|
11
|
+
*/
|
|
12
|
+
export type ClientIp = ClientIpPreset | ((request: Request) => string | null | undefined);
|
|
13
|
+
//#endregion
|
|
14
|
+
//# sourceMappingURL=ingest.d.ts.map
|
package/ingest.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { findSecret } from "./env.js";
|
|
2
|
+
//#region src/ingest.ts
|
|
3
|
+
/** The ingest origin both handlers send to unless told otherwise. */
|
|
4
|
+
const DEFAULT_UPSTREAM = "https://cdn.highseat.com";
|
|
5
|
+
const SECRET_PREFIXES = [
|
|
6
|
+
"hs_secret_",
|
|
7
|
+
"hs_proxy_",
|
|
8
|
+
"ana_proxy_"
|
|
9
|
+
];
|
|
10
|
+
const CLIENT_IP_HEADERS = {
|
|
11
|
+
vercel: "x-real-ip",
|
|
12
|
+
cloudflare: "cf-connecting-ip",
|
|
13
|
+
netlify: "x-nf-client-connection-ip",
|
|
14
|
+
nginx: "x-real-ip"
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The site secret, `option` or else `HIGHSEAT_SECRET`, or `undefined` when neither is set. Throws
|
|
18
|
+
* on a value that is not one, naming the entry point that read it: that is a copying mistake.
|
|
19
|
+
*/
|
|
20
|
+
function findValidSecret(option, entry) {
|
|
21
|
+
const secret = option ?? findSecret();
|
|
22
|
+
if (!secret || SECRET_PREFIXES.some((prefix) => secret.startsWith(prefix))) return secret;
|
|
23
|
+
throw new Error(`${entry}: not a site secret. Copy it from the site's Installation settings; it starts with "${SECRET_PREFIXES[0]}".`);
|
|
24
|
+
}
|
|
25
|
+
function getClientIpReader(clientIp, entry) {
|
|
26
|
+
if (typeof clientIp === "function") return {
|
|
27
|
+
read: (request) => clientIp(request)?.trim() ?? "",
|
|
28
|
+
source: "the clientIp function"
|
|
29
|
+
};
|
|
30
|
+
const header = CLIENT_IP_HEADERS[clientIp];
|
|
31
|
+
if (!header) throw new Error(`${entry}: unknown clientIp preset "${String(clientIp)}".`);
|
|
32
|
+
return {
|
|
33
|
+
read: (request) => request.headers.get(header)?.trim() ?? "",
|
|
34
|
+
source: `the "${clientIp}" preset's ${header} header`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
export { DEFAULT_UPSTREAM, findValidSecret, getClientIpReader };
|
|
39
|
+
|
|
40
|
+
//# sourceMappingURL=ingest.js.map
|
package/ingest.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingest.js","names":[],"sources":["../src/ingest.ts"],"sourcesContent":["import { findSecret } from \"./env\"\n\n/**\n * What `highseat/proxy` and `highseat/server` share about reaching ingest: its host, the site\n * secret's prefixes and where the visitor's IP is read.\n */\n\n/** A host whose own header carries the visitor's IP. */\nexport type ClientIpPreset = \"vercel\" | \"cloudflare\" | \"netlify\" | \"nginx\"\n\n/**\n * Where the visitor's IP is read: a host preset, or a function for any other setup. A function\n * returns the IP, or nothing when the request carries none.\n */\nexport type ClientIp = ClientIpPreset | ((request: Request) => string | null | undefined)\n\n/** The ingest origin both handlers send to unless told otherwise. */\nexport const DEFAULT_UPSTREAM = \"https://cdn.highseat.com\"\n\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 package.\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\ninterface ClientIpReader {\n read: (request: Request) => string\n /** Where `read` looks, for the warning about an empty IP. */\n source: string\n}\n\n/**\n * The site secret, `option` or else `HIGHSEAT_SECRET`, or `undefined` when neither is set. Throws\n * on a value that is not one, naming the entry point that read it: that is a copying mistake.\n */\nexport function findValidSecret(option: string | undefined, entry: string): string | undefined {\n const secret = option ?? findSecret()\n if (!secret || SECRET_PREFIXES.some(prefix => secret.startsWith(prefix))) return secret\n\n throw new Error(\n `${entry}: not a site secret. Copy it from the site's Installation settings; it starts with \"${SECRET_PREFIXES[0]}\".`,\n )\n}\n\nexport function getClientIpReader(clientIp: ClientIp, entry: string): 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(`${entry}: 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"],"mappings":";;;AAiBA,MAAa,mBAAmB;AAIhC,MAAM,kBAAkB;CAAC;CAAc;CAAa;AAAY;AAEhE,MAAM,oBAAoD;CACxD,QAAQ;CAER,YAAY;CACZ,SAAS;CACT,OAAO;AACT;;;;;AAYA,SAAgB,gBAAgB,QAA4B,OAAmC;CAC7F,MAAM,SAAS,UAAU,WAAW;CACpC,IAAI,CAAC,UAAU,gBAAgB,MAAK,WAAU,OAAO,WAAW,MAAM,CAAC,GAAG,OAAO;CAEjF,MAAM,IAAI,MACR,GAAG,MAAM,sFAAsF,gBAAgB,GAAG,GACpH;AACF;AAEA,SAAgB,kBAAkB,UAAoB,OAA+B;CACnF,IAAI,OAAO,aAAa,YACtB,OAAO;EAAE,OAAM,YAAW,SAAS,OAAO,CAAC,EAAE,KAAK,KAAK;EAAI,QAAQ;CAAwB;CAG7F,MAAM,SAAS,kBAAkB;CACjC,IAAI,CAAC,QAAQ,MAAM,IAAI,MAAM,GAAG,MAAM,6BAA6B,OAAO,QAAQ,EAAE,GAAG;CACvF,OAAO;EACL,OAAM,YAAW,QAAQ,QAAQ,IAAI,MAAM,CAAC,EAAE,KAAK,KAAK;EACxD,QAAQ,QAAQ,SAAS,aAAa,OAAO;CAC/C;AACF"}
|
package/mcp.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ServerBuiltInEvents, ServerEventOptions, WaitUntil } from "./server.js";
|
|
2
|
+
import { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
|
3
|
+
//#region src/mcp.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* MCP tool calls, recorded from your own MCP server built on the MCP TypeScript SDK v2: one
|
|
6
|
+
* `mcp_tool_call` event per call, with the tool, the client, the outcome and the time it took.
|
|
7
|
+
* The tool's arguments, the caller's IP and its user agent are never sent: assistant directories
|
|
8
|
+
* forbid collecting conversation data, and the caller is the assistant's server, not a person.
|
|
9
|
+
*/
|
|
10
|
+
type ToolCall = ServerBuiltInEvents["mcp_tool_call"];
|
|
11
|
+
/** What tool calls are sent with: a tracker from `createTracker()`, or any `track()` like its. */
|
|
12
|
+
export interface ToolCallTracker {
|
|
13
|
+
track: (name: "mcp_tool_call", properties: ToolCall, options?: ServerEventOptions) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface TrackToolCallsOptions {
|
|
16
|
+
/** The tracker events are sent with. Defaults to the one `track()` from `highseat/server` uses. */
|
|
17
|
+
tracker?: ToolCallTracker;
|
|
18
|
+
/** A label for this MCP server, such as `public` or `admin`, when the site runs more than one. */
|
|
19
|
+
server?: string;
|
|
20
|
+
/** The site hostname every call is filed under. Defaults to the tracker's `hostname`. */
|
|
21
|
+
hostname?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Keeps a serverless function alive until each event is sent: `after` from `next/server`, or
|
|
24
|
+
* `ctx.waitUntil` in a Cloudflare Worker, bound to its object.
|
|
25
|
+
*/
|
|
26
|
+
waitUntil?: WaitUntil;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Records every tool call on an MCP server built with the MCP TypeScript SDK v2. Call it once,
|
|
30
|
+
* right after creating the server and before registering any tool: it wraps `registerTool`, so a
|
|
31
|
+
* tool registered earlier would go unrecorded, and it throws when the server already has tools.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* const server = new McpServer({ name: "Acme", version: "1.0.0" })
|
|
35
|
+
* trackToolCalls(server, { server: "public" })
|
|
36
|
+
* server.registerTool("search", config, handler)
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* Each call sends `mcp_tool_call` with the tool, the client, the status, the time it took and,
|
|
40
|
+
* for a read-only tool whose result is a list, how many items it returned. The tool's arguments,
|
|
41
|
+
* the caller's IP and its user agent are never sent. A call still waiting for input from the
|
|
42
|
+
* client is recorded once it ends. Failures to send never reach the tool: they go to the
|
|
43
|
+
* tracker's `onError`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function trackToolCalls(server: McpServer, options?: TrackToolCallsOptions): void;
|
|
46
|
+
/**
|
|
47
|
+
* Tags a link a tool returns, so the visit it brings reads as the assistant that showed it:
|
|
48
|
+
* `utm_source` names the client and `utm_medium` is `mcp`, which Highseat reads as the AI
|
|
49
|
+
* channel. A link that already has a `utm_source` is returned as it is. Pass the tool handler's
|
|
50
|
+
* context.
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* server.registerTool("get_product", config, async ({ slug }, context) => ({
|
|
54
|
+
* content: [{ type: "text", text: mcpLink(`https://acme.com/products/${slug}`, context) }],
|
|
55
|
+
* }))
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function mcpLink(url: string, context: ServerContext): string;
|
|
59
|
+
//#endregion
|
|
60
|
+
export {};
|
|
61
|
+
//# sourceMappingURL=mcp.d.ts.map
|