highseat 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,8 +16,9 @@ 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 |
19
20
 
20
- Each entry point loads on its own and imports no framework it does not name, so `highseat/proxy` runs in a Cloudflare Worker without React installed.
21
+ 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
22
 
22
23
  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
24
 
@@ -227,6 +228,106 @@ const attributionId = await getAttributionId()
227
228
  await createCheckout({ metadata: { attribution_id: attributionId } })
228
229
  ```
229
230
 
231
+ ## Server-side events
232
+
233
+ `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:
234
+
235
+ ```bash
236
+ HIGHSEAT_SITE=site_… # NEXT_PUBLIC_HIGHSEAT_SITE works too
237
+ HIGHSEAT_SECRET=hs_secret_...
238
+ ```
239
+
240
+ 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:
241
+
242
+ ```ts
243
+ import { track } from "highseat/server"
244
+
245
+ export async function POST(request: Request) {
246
+ const user = await createAccount(request)
247
+ track("signup", { plan: "pro" }, { request, userId: user.id })
248
+ return Response.json(user)
249
+ }
250
+ ```
251
+
252
+ In a Next.js server action, pass the headers instead:
253
+
254
+ ```ts
255
+ "use server"
256
+
257
+ import { headers } from "next/headers"
258
+ import { track } from "highseat/server"
259
+
260
+ export async function subscribe(plan: string) {
261
+ track("subscription_started", { plan }, { headers: await headers() })
262
+ }
263
+ ```
264
+
265
+ `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.
266
+
267
+ 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:
268
+
269
+ ```ts
270
+ track("invoice_paid", { plan: "pro" }, { userId: customer.id, eventId: stripeEvent.id, hostname: "example.com" })
271
+ ```
272
+
273
+ 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.
274
+
275
+ ### Serverless functions
276
+
277
+ 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:
278
+
279
+ ```ts
280
+ import { waitUntil } from "@vercel/functions"
281
+
282
+ track("signup", undefined, { request, userId: user.id, waitUntil })
283
+ ```
284
+
285
+ 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:
286
+
287
+ ```ts
288
+ process.on("SIGTERM", async () => {
289
+ await close({ timeout: 5000 })
290
+ process.exit(0)
291
+ })
292
+ ```
293
+
294
+ Past the deadline, `close()` stops sending, reports the events not confirmed sent to `onError`, and resolves.
295
+
296
+ ### Your own tracker
297
+
298
+ `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:
299
+
300
+ ```ts
301
+ import { createTracker } from "highseat/server"
302
+
303
+ const tracker = createTracker({
304
+ hostname: "example.com",
305
+ onError: error => logger.warn(error.message, { events: error.events }),
306
+ })
307
+
308
+ tracker.track("tier_created", { tier: "gold" }, { userId: publisher.id, ip, userAgent })
309
+ ```
310
+
311
+ | Option | Default | |
312
+ | --- | --- | --- |
313
+ | `site` | `HIGHSEAT_SITE`, then `NEXT_PUBLIC_HIGHSEAT_SITE` | The site id. |
314
+ | `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. |
315
+ | `hostname` | The request's host | The hostname events are filed under. An event's own `hostname` wins. |
316
+ | `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. |
317
+ | `waitUntil` | None | Sends every event at once, as above. |
318
+ | `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. |
319
+ | `fetch` | The global `fetch` | The `fetch` events are sent with, such as a mock in tests. |
320
+ | `upstream` | `https://cdn.highseat.com` | The Highseat ingest origin. |
321
+ | `flushInterval` | `1000` | Milliseconds an event waits for others to share its request. |
322
+ | `maxRetries` | `4` | Retries of a batch before it is reported as failed. |
323
+ | `timeout` | `10000` | Milliseconds one request may take. |
324
+
325
+ 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.
326
+
327
+ ### Without the package
328
+
329
+ 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.
330
+
230
331
  ## First-party proxy
231
332
 
232
333
  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
- /** The site secret for the proxy handler. */
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. */\nexport function findSecret(): string | undefined {\n if (typeof process === \"undefined\") return\n return process.env.HIGHSEAT_SECRET\n}\n\n/**\n * Whether this runs inside `next build`, which loads every route without serving one. Set in the\n * build's route workers only: `next.config.ts` receives the phase as an argument instead.\n */\nexport function isNextBuild(): boolean {\n if (typeof process === \"undefined\") return false\n return process.env.NEXT_PHASE === \"phase-production-build\"\n}\n\n/**\n * Whether the build runs in development. A bundler replaces `process.env.NODE_ENV` with a\n * literal. A browser without that replacement has no `process`, so the read throws.\n */\nexport function isDevelopment(): boolean {\n try {\n return process.env.NODE_ENV === \"development\"\n } catch {\n return false\n }\n}\n"],"mappings":";;;;;;;;;AASA,SAAgB,eAAmC;CACjD,OAAO,QAAQ,IAAI;AACrB;;;;;;;AAQA,SAAgB,cAAc,QAAqC;CACjE,MAAM,OAAO,WAAW,OAAO,YAAY,cAAc,KAAA,IAAY,QAAQ,IAAI;CACjF,IAAI,CAAC,MAAM;CACX,OAAO,KAAK,WAAW,GAAG,IAAI,OAAO,IAAI;AAC3C;;AAGA,SAAgB,aAAiC;CAC/C,IAAI,OAAO,YAAY,aAAa;CACpC,OAAO,QAAQ,IAAI;AACrB;;;;;AAMA,SAAgB,cAAuB;CACrC,IAAI,OAAO,YAAY,aAAa,OAAO;CAC3C,OAAO,QAAQ,IAAI,eAAe;AACpC;;;;;AAMA,SAAgB,gBAAyB;CACvC,IAAI;EACF,OAAO,QAAQ,IAAI,aAAa;CAClC,QAAQ;EACN,OAAO;CACT;AACF"}
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/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/next.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { HighseatProps as HighseatProps$1, useBeforeSend, useIdentify } from "./react.js";
2
- import { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions, createProxy } from "./proxy.js";
2
+ import { ClientIp, ClientIpPreset } from "./ingest.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.5.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,6 +38,10 @@
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"
41
45
  }
42
46
  },
43
47
  "publishConfig": {
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,171 @@
1
+ import { TrackArgs, TrackName } 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 arguments after an event's name: its properties, typed by `Register` as for the browser's
123
+ * `track()`, then the options that say who it belongs to.
124
+ */
125
+ export type ServerTrackArgs<Name extends string> = [...TrackArgs<Name>, options?: ServerEventOptions];
126
+ export interface Tracker {
127
+ /** Records an event, queued and sent in the background. Returns at once and never throws. */
128
+ track: <Name extends TrackName>(name: Name, ...args: ServerTrackArgs<Name>) => void;
129
+ /** Sends every queued event, and resolves once each has landed or failed. Never rejects. */
130
+ flush: () => Promise<void>;
131
+ /**
132
+ * Sends what is queued and stops: a later `track()` sends nothing. Call it at shutdown, with a
133
+ * `timeout` shorter than the shutdown's grace period.
134
+ */
135
+ close: (options?: CloseOptions) => Promise<void>;
136
+ }
137
+ export interface CloseOptions {
138
+ /**
139
+ * Milliseconds to wait for the queue to drain. Past it, sending stops, the events still
140
+ * unsent go to `onError` as `failed`, and `close()` resolves. Without one, `close()` waits for
141
+ * every retry, which during an outage can take minutes.
142
+ */
143
+ timeout?: number;
144
+ }
145
+ /**
146
+ * Creates a tracker for a site's backend. Credentials come from the options or from
147
+ * `HIGHSEAT_SITE` and `HIGHSEAT_SECRET`. Without them the tracker warns once and sends nothing,
148
+ * and a value that is not a site secret throws, since that is a copying mistake.
149
+ *
150
+ * ```ts
151
+ * const tracker = createTracker({ hostname: "example.com" })
152
+ * tracker.track("signup", { plan: "pro" }, { request, userId: user.id })
153
+ * ```
154
+ */
155
+ export declare function createTracker(options?: TrackerOptions): Tracker;
156
+ /**
157
+ * Records an event with the tracker made from `HIGHSEAT_SITE` and `HIGHSEAT_SECRET`. A known
158
+ * name checks its properties, as in the browser. Never throws.
159
+ *
160
+ * ```ts
161
+ * track("signup", { plan: "pro" }, { request, userId: user.id })
162
+ * ```
163
+ */
164
+ export declare function track<Name extends TrackName>(name: Name, ...args: ServerTrackArgs<Name>): void;
165
+ /** Sends every event `track()` has queued. Await it before a short-lived script exits. */
166
+ export declare function flush(): Promise<void>;
167
+ /** Sends what `track()` has queued and stops sending. Call it at shutdown. See `CloseOptions`. */
168
+ export declare function close(options?: CloseOptions): Promise<void>;
169
+ //#endregion
170
+ export type { ClientIp, ClientIpPreset };
171
+ //# sourceMappingURL=server.d.ts.map
package/server.js ADDED
@@ -0,0 +1,390 @@
1
+ import { findHostPlatform, findServerSite, isNextBuild } from "./env.js";
2
+ import { findValidSecret, getClientIpReader } from "./ingest.js";
3
+ //#region src/server.ts
4
+ const ENTRY = "highseat/server";
5
+ const MAX_BATCH_SIZE = 25;
6
+ const MAX_QUEUE_SIZE = 1e3;
7
+ const MAX_PROPERTY_LENGTH = 500;
8
+ const DEFAULT_FLUSH_INTERVAL_MS = 1e3;
9
+ const DEFAULT_MAX_RETRIES = 4;
10
+ const DEFAULT_TIMEOUT_MS = 1e4;
11
+ const RETRY_BASE_MS = 500;
12
+ const MAX_RETRY_DELAY_MS = 3e4;
13
+ function isRetryable(status) {
14
+ return status === 429 || status >= 500;
15
+ }
16
+ /** The wait `Retry-After` asks for, in seconds or as a date, or `undefined` when there is none. */
17
+ function parseRetryAfter(value) {
18
+ if (value === null) return;
19
+ const seconds = Number(value);
20
+ if (value.trim() !== "" && Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
21
+ const date = Date.parse(value);
22
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
23
+ }
24
+ function getRetryDelay(attempt, retryAfter) {
25
+ const backoff = RETRY_BASE_MS * 2 ** attempt * (1 + Math.random() / 2);
26
+ return Math.min(retryAfter ?? backoff, MAX_RETRY_DELAY_MS);
27
+ }
28
+ const READ_HEADERS = [
29
+ "user-agent",
30
+ "host",
31
+ "x-forwarded-host",
32
+ "referer",
33
+ "x-real-ip",
34
+ "cf-connecting-ip",
35
+ "x-nf-client-connection-ip"
36
+ ];
37
+ function toHeaders(headers) {
38
+ if (headers instanceof Headers) return headers;
39
+ const copy = new Headers();
40
+ if (typeof headers.get === "function") {
41
+ const source = headers;
42
+ for (const name of READ_HEADERS) {
43
+ const value = source.get(name);
44
+ if (value !== null) copy.set(name, value);
45
+ }
46
+ return copy;
47
+ }
48
+ for (const [name, value] of Object.entries(headers)) if (value !== void 0) copy.set(name, Array.isArray(value) ? value.join(", ") : value);
49
+ return copy;
50
+ }
51
+ /** The host a request was sent to, without its port. */
52
+ function findRequestHost(request) {
53
+ return (request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ?? request.headers.get("host") ?? new URL(request.url).host).replace(/:\d+$/, "") || void 0;
54
+ }
55
+ /** The event's request, built from `headers` when it has none, so `clientIp` reads either. */
56
+ function findRequest({ request, headers }) {
57
+ if (request) return request;
58
+ if (!headers) return;
59
+ const copy = toHeaders(headers);
60
+ const host = copy.get("x-forwarded-host") ?? copy.get("host") ?? "localhost";
61
+ return new Request(`https://${host}/`, { headers: copy });
62
+ }
63
+ /** Properties as ingest stores them: strings, cut to its cap, with revenue on the event itself. */
64
+ function toWireProperties(properties) {
65
+ const { revenue, ...rest } = properties ?? {};
66
+ const values = {};
67
+ for (const [key, value] of Object.entries(rest)) {
68
+ const property = value;
69
+ if (property != null) values[key] = String(property).slice(0, MAX_PROPERTY_LENGTH);
70
+ }
71
+ return {
72
+ ...Object.keys(values).length ? { properties: values } : {},
73
+ ...revenue && typeof revenue === "object" ? { revenue } : {}
74
+ };
75
+ }
76
+ function warnOnError(error) {
77
+ console.warn(`${ENTRY}: ${error.message}`, error.events);
78
+ }
79
+ /** Whether a request ran inside a Cloudflare Worker, which gives every request a `cf` object. */
80
+ function isWorkerRequest(request) {
81
+ return "cf" in request && !!request.cf;
82
+ }
83
+ let hasWarnedMissing = false;
84
+ function createDisabledTracker(missing) {
85
+ if (!hasWarnedMissing && !isNextBuild()) {
86
+ hasWarnedMissing = true;
87
+ console.warn(`${ENTRY}: ${missing.join(" and ")} not set, so events are not sent.`);
88
+ }
89
+ return {
90
+ track: () => {},
91
+ flush: async () => {},
92
+ close: async () => {}
93
+ };
94
+ }
95
+ /**
96
+ * Creates a tracker for a site's backend. Credentials come from the options or from
97
+ * `HIGHSEAT_SITE` and `HIGHSEAT_SECRET`. Without them the tracker warns once and sends nothing,
98
+ * and a value that is not a site secret throws, since that is a copying mistake.
99
+ *
100
+ * ```ts
101
+ * const tracker = createTracker({ hostname: "example.com" })
102
+ * tracker.track("signup", { plan: "pro" }, { request, userId: user.id })
103
+ * ```
104
+ */
105
+ function createTracker(options = {}) {
106
+ const site = options.site ?? findServerSite();
107
+ const secret = findValidSecret(options.secret, ENTRY);
108
+ const missing = [...site ? [] : ["HIGHSEAT_SITE"], ...secret ? [] : ["HIGHSEAT_SECRET"]];
109
+ if (!site || !secret) return createDisabledTracker(missing);
110
+ const headers = {
111
+ "content-type": "application/json",
112
+ "x-site-id": site,
113
+ "x-hs-secret": secret
114
+ };
115
+ const endpoint = `${new URL(options.upstream ?? "https://cdn.highseat.com").origin}/api/event`;
116
+ const send = options.fetch ?? ((input, init) => fetch(input, init));
117
+ const onError = options.onError ?? warnOnError;
118
+ const flushInterval = options.flushInterval ?? DEFAULT_FLUSH_INTERVAL_MS;
119
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
120
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
121
+ let queue = [];
122
+ let sending = [];
123
+ let timer;
124
+ let sender;
125
+ let closed = false;
126
+ let stopped = false;
127
+ const stopping = new AbortController();
128
+ let endPause;
129
+ let hasWarnedNoClientIp = false;
130
+ function report(error) {
131
+ try {
132
+ onError(error);
133
+ } catch {}
134
+ }
135
+ function listEvents(batch) {
136
+ return batch.map(({ event }) => ({
137
+ name: event.event_name,
138
+ eventId: event.event_id
139
+ }));
140
+ }
141
+ function readClientIp(request) {
142
+ const clientIp = options.clientIp ?? (isWorkerRequest(request) ? "cloudflare" : findHostPlatform());
143
+ if (!clientIp) {
144
+ if (!hasWarnedNoClientIp) {
145
+ hasWarnedNoClientIp = true;
146
+ console.warn(`${ENTRY}: no clientIp set, so events from a request carry no visitor IP and no country. Set clientIp, or pass ip with each event.`);
147
+ }
148
+ return;
149
+ }
150
+ return getClientIpReader(clientIp, ENTRY).read(request) || void 0;
151
+ }
152
+ function buildEvent(name, properties, eventOptions) {
153
+ const request = findRequest(eventOptions);
154
+ const isPageview = name === "pageview";
155
+ const userId = eventOptions.userId === void 0 ? "" : String(eventOptions.userId);
156
+ return {
157
+ event_name: name,
158
+ event_id: eventOptions.eventId ?? crypto.randomUUID(),
159
+ hostname: eventOptions.hostname ?? options.hostname ?? (request && findRequestHost(request)),
160
+ ...userId ? { user_id: userId } : {},
161
+ ip: eventOptions.ip ?? (request && readClientIp(request)),
162
+ user_agent: eventOptions.userAgent ?? request?.headers.get("user-agent") ?? void 0,
163
+ page_url: eventOptions.url ?? (isPageview && eventOptions.request ? request?.url : void 0),
164
+ referrer: eventOptions.referrer ?? (isPageview ? request?.headers.get("referer") ?? void 0 : void 0),
165
+ ...toWireProperties(properties)
166
+ };
167
+ }
168
+ function toErrorEvents(batch, entries) {
169
+ return entries.flatMap(({ index, reason }) => {
170
+ const queued = batch[index];
171
+ return queued ? [{
172
+ name: queued.event.event_name,
173
+ eventId: queued.event.event_id,
174
+ reason
175
+ }] : [];
176
+ });
177
+ }
178
+ /** Reports the events ingest refused or did not store, by their place in the batch. */
179
+ async function readAnswer(response, batch) {
180
+ let answer;
181
+ try {
182
+ answer = await response.json();
183
+ } catch {
184
+ return;
185
+ }
186
+ const { status } = response;
187
+ const rejected = toErrorEvents(batch, answer.rejections ?? []);
188
+ if (rejected.length) report({
189
+ type: "rejected",
190
+ message: "Highseat rejected events.",
191
+ status,
192
+ events: rejected
193
+ });
194
+ const dropped = toErrorEvents(batch, (answer.dropped ?? []).filter(({ reason }) => reason !== "duplicate"));
195
+ if (dropped.length) report({
196
+ type: "dropped",
197
+ message: "Highseat did not store events.",
198
+ status,
199
+ events: dropped
200
+ });
201
+ }
202
+ async function readRefusal(response) {
203
+ try {
204
+ const body = await response.json();
205
+ if (typeof body.error === "string") return body.error;
206
+ } catch {}
207
+ return `Highseat answered ${response.status}.`;
208
+ }
209
+ /** Waits before a retry, ended early when `close()` gives up. */
210
+ function pause(ms) {
211
+ return new Promise((resolve) => {
212
+ const wait = setTimeout(resolve, ms);
213
+ endPause = () => {
214
+ clearTimeout(wait);
215
+ resolve();
216
+ };
217
+ });
218
+ }
219
+ /** Sends one batch until it lands, fails for good, or `close()` gives up on it. */
220
+ async function sendBatch(batch) {
221
+ for (let attempt = 0; !stopped; attempt++) {
222
+ const now = Date.now();
223
+ const body = JSON.stringify(batch.map(({ event, at }) => ({
224
+ ...event,
225
+ age: Math.max(0, now - at)
226
+ })));
227
+ let response;
228
+ let failure = "Highseat is unreachable.";
229
+ try {
230
+ response = await send(endpoint, {
231
+ method: "POST",
232
+ headers,
233
+ body,
234
+ signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(timeout)])
235
+ });
236
+ } catch (error) {
237
+ if (error instanceof Error && error.name === "TimeoutError") failure = `Highseat did not answer within ${timeout} ms.`;
238
+ }
239
+ if (stopped) return;
240
+ if (response?.ok) return readAnswer(response, batch);
241
+ if (response && !isRetryable(response.status) || attempt >= maxRetries) {
242
+ const message = response ? await readRefusal(response) : failure;
243
+ const status = response?.status;
244
+ return report({
245
+ type: "failed",
246
+ message,
247
+ status,
248
+ events: listEvents(batch)
249
+ });
250
+ }
251
+ await response?.body?.cancel().catch(() => {});
252
+ await pause(getRetryDelay(attempt, parseRetryAfter(response?.headers.get("retry-after") ?? null)));
253
+ }
254
+ }
255
+ function settle(batch) {
256
+ for (const { settle } of batch) settle?.();
257
+ }
258
+ async function runSender() {
259
+ while (queue.length && !stopped) {
260
+ const batch = queue.slice(0, MAX_BATCH_SIZE);
261
+ queue = queue.slice(MAX_BATCH_SIZE);
262
+ sending = batch;
263
+ try {
264
+ await sendBatch(batch);
265
+ } catch {}
266
+ sending = [];
267
+ settle(batch);
268
+ }
269
+ }
270
+ /** Starts the sender unless it runs, and resolves when it next stops. */
271
+ function startSender() {
272
+ if (timer !== void 0) {
273
+ clearTimeout(timer);
274
+ timer = void 0;
275
+ }
276
+ sender ??= runSender().finally(() => {
277
+ sender = void 0;
278
+ if (queue.length && !stopped) startSender();
279
+ });
280
+ return sender;
281
+ }
282
+ function schedule() {
283
+ if (timer !== void 0) return;
284
+ timer = setTimeout(() => {
285
+ timer = void 0;
286
+ startSender();
287
+ }, flushInterval);
288
+ if (typeof timer === "object") timer.unref?.();
289
+ }
290
+ async function flush() {
291
+ while ((queue.length || sending.length) && !stopped) await startSender();
292
+ }
293
+ /** Stops sending, and reports and settles every event not yet confirmed sent. */
294
+ function giveUp(deadline) {
295
+ stopped = true;
296
+ stopping.abort();
297
+ endPause?.();
298
+ if (timer !== void 0) clearTimeout(timer);
299
+ const unsent = [...sending, ...queue];
300
+ sending = [];
301
+ queue = [];
302
+ if (unsent.length) report({
303
+ type: "failed",
304
+ message: `close() stopped after ${deadline} ms with ${unsent.length} events not confirmed sent.`,
305
+ events: listEvents(unsent)
306
+ });
307
+ settle(unsent);
308
+ }
309
+ async function close({ timeout: deadline } = {}) {
310
+ closed = true;
311
+ const drained = flush();
312
+ if (deadline === void 0) return drained;
313
+ let wait;
314
+ const expired = new Promise((resolve) => {
315
+ wait = setTimeout(() => resolve(true), deadline);
316
+ });
317
+ const timedOut = await Promise.race([drained.then(() => false), expired]);
318
+ clearTimeout(wait);
319
+ if (timedOut) giveUp(deadline);
320
+ }
321
+ function track(name, ...args) {
322
+ try {
323
+ if (closed) return;
324
+ const [properties, eventOptions = {}] = args;
325
+ const at = eventOptions.timestamp?.getTime() ?? Date.now();
326
+ const queued = {
327
+ event: buildEvent(name, properties, eventOptions),
328
+ at
329
+ };
330
+ if (queue.length + sending.length >= MAX_QUEUE_SIZE) {
331
+ report({
332
+ type: "failed",
333
+ message: `More than ${MAX_QUEUE_SIZE} events are waiting to be sent, so this one was not kept.`,
334
+ events: listEvents([queued])
335
+ });
336
+ return;
337
+ }
338
+ queue.push(queued);
339
+ const waitUntil = eventOptions.waitUntil ?? options.waitUntil;
340
+ if (waitUntil) waitUntil(new Promise((resolve) => queued.settle = resolve));
341
+ if (waitUntil || queue.length >= MAX_BATCH_SIZE) startSender();
342
+ else schedule();
343
+ } catch (error) {
344
+ report({
345
+ type: "failed",
346
+ message: error instanceof Error ? error.message : String(error),
347
+ events: []
348
+ });
349
+ }
350
+ }
351
+ return {
352
+ track,
353
+ flush,
354
+ close
355
+ };
356
+ }
357
+ let defaultTracker;
358
+ function getDefaultTracker() {
359
+ if (defaultTracker) return defaultTracker;
360
+ try {
361
+ defaultTracker = createTracker();
362
+ } catch (error) {
363
+ console.warn(error instanceof Error ? error.message : error);
364
+ defaultTracker = createDisabledTracker([]);
365
+ }
366
+ return defaultTracker;
367
+ }
368
+ /**
369
+ * Records an event with the tracker made from `HIGHSEAT_SITE` and `HIGHSEAT_SECRET`. A known
370
+ * name checks its properties, as in the browser. Never throws.
371
+ *
372
+ * ```ts
373
+ * track("signup", { plan: "pro" }, { request, userId: user.id })
374
+ * ```
375
+ */
376
+ function track(name, ...args) {
377
+ getDefaultTracker().track(name, ...args);
378
+ }
379
+ /** Sends every event `track()` has queued. Await it before a short-lived script exits. */
380
+ function flush() {
381
+ return defaultTracker ? defaultTracker.flush() : Promise.resolve();
382
+ }
383
+ /** Sends what `track()` has queued and stops sending. Call it at shutdown. See `CloseOptions`. */
384
+ function close(options) {
385
+ return defaultTracker ? defaultTracker.close(options) : Promise.resolve();
386
+ }
387
+ //#endregion
388
+ export { close, createTracker, flush, track };
389
+
390
+ //# sourceMappingURL=server.js.map
package/server.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","names":[],"sources":["../src/server.ts"],"sourcesContent":["import { findHostPlatform, findServerSite, isNextBuild } from \"./env\"\nimport type { PropertyValue, Revenue, TrackArgs, TrackName } from \"./index\"\nimport { type ClientIp, DEFAULT_UPSTREAM, findValidSecret, getClientIpReader } from \"./ingest\"\n\nexport type { ClientIp, ClientIpPreset } from \"./ingest\"\n\n/**\n * Server-side events, sent from your own backend with the site secret: a server action, a route\n * handler, a webhook handler or a job. Events are batched and sent in the background, retried\n * when Highseat is busy or unreachable, and nothing here throws: a failure goes to `onError`.\n */\n\n/**\n * Request headers in any shape a server hands them over: a `Headers` object, what Next.js's\n * `headers()` returns, or Node's `request.headers` record.\n */\nexport type HeadersLike =\n | { get: (name: string) => string | null }\n | Record<string, string | string[] | undefined>\n\n/**\n * Keeps a serverless function alive until a promise settles: `waitUntil` from\n * `@vercel/functions`, or `ctx.waitUntil` in a Cloudflare Worker and `context.waitUntil` in a\n * Netlify function, bound to their object.\n */\nexport type WaitUntil = (promise: Promise<unknown>) => void\n\n/** Who an event belongs to and where it happened, given with each `track()` call. */\nexport interface ServerEventOptions {\n /**\n * The request being handled. The visitor's IP (see `clientIp`), their user agent and the\n * hostname are read from it, and a `pageview` takes its URL and referrer too.\n */\n request?: Request\n /** The request's headers, when there is no `Request`: `await headers()` in a server action. */\n headers?: HeadersLike\n /** The visitor's IP. Wins over the one read from `request` or `headers`. */\n ip?: string\n /** The visitor's user agent. Wins over the one read from `request` or `headers`. */\n userAgent?: string\n /**\n * A user id from your own system. An event with no visitor (no IP and no user agent, as from\n * a job) counts as this user, and without one as a visitor of its own.\n */\n userId?: string | number\n /** A hostname of the site. Defaults to the tracker's `hostname`, then the request's host. */\n hostname?: string\n /** The page's URL. A `pageview` defaults to the request's URL. */\n url?: string\n /** The page the visitor came from. A `pageview` defaults to the request's `Referer`. */\n referrer?: string\n /**\n * The event's id, any string up to 255 characters. Defaults to a new UUID. Pass an id your\n * system already holds (a job id, a webhook event id), and a retry of the same event within\n * three days is stored once.\n */\n eventId?: string\n /** When the event happened, up to a day ago. Defaults to the moment `track()` is called. */\n timestamp?: Date\n /**\n * Sends this event at once and keeps the function alive until it lands. Wins over the\n * tracker's `waitUntil`.\n */\n waitUntil?: WaitUntil\n}\n\n/** An event Highseat did not store, as `onError` reports it. */\nexport interface TrackErrorEvent {\n /** The event's name. */\n name: string\n /** The event's id: the one passed to `track()`, or the UUID it was given. */\n eventId: string\n /** Why Highseat refused or dropped it, such as `revenue.amount_not_integer` or `bot`. */\n reason?: string\n}\n\n/** A failure `track()` never throws, handed to `onError`. */\nexport interface TrackError {\n /**\n * `rejected`: refused as invalid, for good. `dropped`: accepted but not stored, for a `reason`\n * of `foreign_hostname`, `bot` or `excluded` (a recognised retry is never reported). `failed`:\n * never landed, after every retry or at `close()`'s deadline, or refused whole (a wrong secret).\n */\n type: \"rejected\" | \"dropped\" | \"failed\"\n /** What happened, in a sentence. */\n message: string\n /** The HTTP status Highseat answered, when it answered. */\n status?: number\n /** The events concerned. */\n events: TrackErrorEvent[]\n}\n\nexport interface TrackerOptions {\n /** The site id, `site_…`. Defaults to `HIGHSEAT_SITE`, then `NEXT_PUBLIC_HIGHSEAT_SITE`. */\n site?: string\n /**\n * The site secret, `hs_secret_…`, from the site's Installation settings. Defaults to\n * `HIGHSEAT_SECRET`. Keep it on the server: it lets a caller name any visitor.\n */\n secret?: string\n /** The hostname every event is filed under unless it names its own. */\n hostname?: string\n /**\n * Where the visitor's IP is read from a request: `vercel`, `cloudflare`, `netlify`, `nginx`\n * or a function, as for `createProxy()`. Defaults to `cloudflare` in a Worker, `vercel` on\n * Vercel and `netlify` on Netlify. Anywhere else, set it, or pass `ip` with each event.\n */\n clientIp?: ClientIp\n /** The ingest origin. Defaults to `https://cdn.highseat.com`. */\n upstream?: string\n /** The `fetch` events are sent with. Defaults to the global one. */\n fetch?: typeof fetch\n /** Receives every failure. Defaults to a `console.warn`. */\n onError?: (error: TrackError) => void\n /**\n * Sends every event at once and keeps the function alive until it lands. Pass it in a\n * serverless function, which may stop as soon as it answers.\n */\n waitUntil?: WaitUntil\n /** Milliseconds an event waits for others to share its request. Defaults to 1000. */\n flushInterval?: number\n /**\n * Retries of a batch Highseat answered 429 or 5xx, or never answered. Defaults to 4. Batches\n * go one at a time, so a retry's wait holds back every event behind it.\n */\n maxRetries?: number\n /** Milliseconds one request may take. Defaults to 10000. */\n timeout?: number\n}\n\n/**\n * The arguments after an event's name: its properties, typed by `Register` as for the browser's\n * `track()`, then the options that say who it belongs to.\n */\nexport type ServerTrackArgs<Name extends string> = [\n ...TrackArgs<Name>,\n options?: ServerEventOptions,\n]\n\nexport interface Tracker {\n /** Records an event, queued and sent in the background. Returns at once and never throws. */\n track: <Name extends TrackName>(name: Name, ...args: ServerTrackArgs<Name>) => void\n /** Sends every queued event, and resolves once each has landed or failed. Never rejects. */\n flush: () => Promise<void>\n /**\n * Sends what is queued and stops: a later `track()` sends nothing. Call it at shutdown, with a\n * `timeout` shorter than the shutdown's grace period.\n */\n close: (options?: CloseOptions) => Promise<void>\n}\n\nexport interface CloseOptions {\n /**\n * Milliseconds to wait for the queue to drain. Past it, sending stops, the events still\n * unsent go to `onError` as `failed`, and `close()` resolves. Without one, `close()` waits for\n * every retry, which during an outage can take minutes.\n */\n timeout?: number\n}\n\n/** One event as ingest takes it, before its age is known. */\ninterface WireEvent {\n event_name: string\n event_id: string\n hostname?: string\n user_id?: string\n ip?: string\n user_agent?: string\n page_url?: string\n referrer?: string\n properties?: Record<string, string>\n revenue?: Revenue\n}\n\ninterface QueuedEvent {\n event: WireEvent\n /** When it happened, which its `age` is measured from on every attempt. */\n at: number\n /** Resolves the promise handed to `waitUntil`, once the event has landed or failed. */\n settle?: () => void\n}\n\ninterface BatchEntry {\n index: number\n reason: string\n}\n\ninterface IngestAnswer {\n rejections?: BatchEntry[]\n dropped?: BatchEntry[]\n}\n\nconst ENTRY = \"highseat/server\"\n\n// Ingest's limit per request.\nconst MAX_BATCH_SIZE = 25\n\n// Events held while Highseat is unreachable, the batch being sent included. A newer one past\n// this is reported and not kept, so an outage cannot grow the process without bound.\nconst MAX_QUEUE_SIZE = 1000\n\n// Ingest cuts a property value to this, and the tracker does too.\nconst MAX_PROPERTY_LENGTH = 500\n\nconst DEFAULT_FLUSH_INTERVAL_MS = 1000\nconst DEFAULT_MAX_RETRIES = 4\nconst DEFAULT_TIMEOUT_MS = 10_000\n\n// The first retry's wait, doubled on each one after it, and the longest any wait lasts,\n// `Retry-After` included.\nconst RETRY_BASE_MS = 500\nconst MAX_RETRY_DELAY_MS = 30_000\n\nfunction isRetryable(status: number) {\n return status === 429 || status >= 500\n}\n\n/** The wait `Retry-After` asks for, in seconds or as a date, or `undefined` when there is none. */\nfunction parseRetryAfter(value: string | null): number | undefined {\n if (value === null) return\n const seconds = Number(value)\n if (value.trim() !== \"\" && Number.isFinite(seconds)) return Math.max(0, seconds * 1000)\n const date = Date.parse(value)\n return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now())\n}\n\nfunction getRetryDelay(attempt: number, retryAfter?: number) {\n const backoff = RETRY_BASE_MS * 2 ** attempt * (1 + Math.random() / 2)\n return Math.min(retryAfter ?? backoff, MAX_RETRY_DELAY_MS)\n}\n\n// The headers read off an object that offers only `get`: the ones the presets and the event's\n// own fields read.\nconst READ_HEADERS = [\n \"user-agent\",\n \"host\",\n \"x-forwarded-host\",\n \"referer\",\n \"x-real-ip\",\n \"cf-connecting-ip\",\n \"x-nf-client-connection-ip\",\n]\n\nfunction toHeaders(headers: HeadersLike): Headers {\n if (headers instanceof Headers) return headers\n\n const copy = new Headers()\n if (typeof headers.get === \"function\") {\n const source = headers as { get: (name: string) => string | null }\n for (const name of READ_HEADERS) {\n const value = source.get(name)\n if (value !== null) copy.set(name, value)\n }\n return copy\n }\n\n for (const [name, value] of Object.entries(headers)) {\n if (value !== undefined) copy.set(name, Array.isArray(value) ? value.join(\", \") : value)\n }\n return copy\n}\n\n/** The host a request was sent to, without its port. */\nfunction findRequestHost(request: Request) {\n const forwarded = request.headers.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim()\n const host = forwarded ?? request.headers.get(\"host\") ?? new URL(request.url).host\n return host.replace(/:\\d+$/, \"\") || undefined\n}\n\n/** The event's request, built from `headers` when it has none, so `clientIp` reads either. */\nfunction findRequest({ request, headers }: ServerEventOptions): Request | undefined {\n if (request) return request\n if (!headers) return\n\n const copy = toHeaders(headers)\n const host = copy.get(\"x-forwarded-host\") ?? copy.get(\"host\") ?? \"localhost\"\n return new Request(`https://${host}/`, { headers: copy })\n}\n\n/** Properties as ingest stores them: strings, cut to its cap, with revenue on the event itself. */\nfunction toWireProperties(properties: Record<string, PropertyValue | Revenue> | undefined) {\n const { revenue, ...rest } = properties ?? {}\n const values: Record<string, string> = {}\n for (const [key, value] of Object.entries(rest)) {\n // `revenue` is out of `rest`, so every value left is a property value.\n const property = value as PropertyValue\n if (property != null) values[key] = String(property).slice(0, MAX_PROPERTY_LENGTH)\n }\n return {\n ...(Object.keys(values).length ? { properties: values } : {}),\n ...(revenue && typeof revenue === \"object\" ? { revenue } : {}),\n }\n}\n\nfunction warnOnError(error: TrackError) {\n console.warn(`${ENTRY}: ${error.message}`, error.events)\n}\n\n/** Whether a request ran inside a Cloudflare Worker, which gives every request a `cf` object. */\nfunction isWorkerRequest(request: Request) {\n return \"cf\" in request && !!(request as { cf?: unknown }).cf\n}\n\n// A missing variable is reported once per process, as `createProxy()` does.\nlet hasWarnedMissing = false\n\nfunction createDisabledTracker(missing: string[]): Tracker {\n if (!hasWarnedMissing && !isNextBuild()) {\n hasWarnedMissing = true\n console.warn(`${ENTRY}: ${missing.join(\" and \")} not set, so events are not sent.`)\n }\n\n return {\n track: () => {},\n flush: async () => {},\n close: async () => {},\n }\n}\n\n/**\n * Creates a tracker for a site's backend. Credentials come from the options or from\n * `HIGHSEAT_SITE` and `HIGHSEAT_SECRET`. Without them the tracker warns once and sends nothing,\n * and a value that is not a site secret throws, since that is a copying mistake.\n *\n * ```ts\n * const tracker = createTracker({ hostname: \"example.com\" })\n * tracker.track(\"signup\", { plan: \"pro\" }, { request, userId: user.id })\n * ```\n */\nexport function createTracker(options: TrackerOptions = {}): Tracker {\n const site = options.site ?? findServerSite()\n const secret = findValidSecret(options.secret, ENTRY)\n const missing = [...(site ? [] : [\"HIGHSEAT_SITE\"]), ...(secret ? [] : [\"HIGHSEAT_SECRET\"])]\n if (!site || !secret) return createDisabledTracker(missing)\n\n const headers = { \"content-type\": \"application/json\", \"x-site-id\": site, \"x-hs-secret\": secret }\n\n const endpoint = `${new URL(options.upstream ?? DEFAULT_UPSTREAM).origin}/api/event`\n const send =\n options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init))\n const onError = options.onError ?? warnOnError\n const flushInterval = options.flushInterval ?? DEFAULT_FLUSH_INTERVAL_MS\n const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES\n const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS\n\n // The events waiting for the sender and the batch it holds, which both count toward the cap.\n let queue: QueuedEvent[] = []\n let sending: QueuedEvent[] = []\n let timer: ReturnType<typeof setTimeout> | undefined\n // The one sender loop, while it runs. Batches go one at a time, so a 429's `Retry-After`\n // pauses the whole tracker rather than one batch among many retrying at once.\n let sender: Promise<void> | undefined\n let closed = false\n // Set when `close()` reaches its deadline: the sender stops, and what is left is reported.\n let stopped = false\n const stopping = new AbortController()\n let endPause: (() => void) | undefined\n let hasWarnedNoClientIp = false\n\n function report(error: TrackError) {\n try {\n onError(error)\n } catch {}\n }\n\n function listEvents(batch: QueuedEvent[]): TrackErrorEvent[] {\n return batch.map(({ event }) => ({ name: event.event_name, eventId: event.event_id }))\n }\n\n function readClientIp(request: Request): string | undefined {\n const clientIp =\n options.clientIp ?? (isWorkerRequest(request) ? \"cloudflare\" : findHostPlatform())\n\n if (!clientIp) {\n if (!hasWarnedNoClientIp) {\n hasWarnedNoClientIp = true\n console.warn(\n `${ENTRY}: no clientIp set, so events from a request carry no visitor IP and no country. Set clientIp, or pass ip with each event.`,\n )\n }\n return\n }\n\n return getClientIpReader(clientIp, ENTRY).read(request) || undefined\n }\n\n function buildEvent(name: string, properties: unknown, eventOptions: ServerEventOptions) {\n const request = findRequest(eventOptions)\n const isPageview = name === \"pageview\"\n const userId = eventOptions.userId === undefined ? \"\" : String(eventOptions.userId)\n const event: WireEvent = {\n event_name: name,\n event_id: eventOptions.eventId ?? crypto.randomUUID(),\n hostname: eventOptions.hostname ?? options.hostname ?? (request && findRequestHost(request)),\n ...(userId ? { user_id: userId } : {}),\n ip: eventOptions.ip ?? (request && readClientIp(request)),\n user_agent: eventOptions.userAgent ?? request?.headers.get(\"user-agent\") ?? undefined,\n page_url: eventOptions.url ?? (isPageview && eventOptions.request ? request?.url : undefined),\n referrer:\n eventOptions.referrer ??\n (isPageview ? (request?.headers.get(\"referer\") ?? undefined) : undefined),\n ...toWireProperties(properties as Record<string, PropertyValue | Revenue> | undefined),\n }\n return event\n }\n\n function toErrorEvents(batch: QueuedEvent[], entries: BatchEntry[]): TrackErrorEvent[] {\n return entries.flatMap(({ index, reason }) => {\n const queued = batch[index]\n return queued\n ? [{ name: queued.event.event_name, eventId: queued.event.event_id, reason }]\n : []\n })\n }\n\n /** Reports the events ingest refused or did not store, by their place in the batch. */\n async function readAnswer(response: Response, batch: QueuedEvent[]) {\n let answer: IngestAnswer\n try {\n answer = (await response.json()) as IngestAnswer\n } catch {\n return\n }\n\n const { status } = response\n const rejected = toErrorEvents(batch, answer.rejections ?? [])\n if (rejected.length) {\n report({ type: \"rejected\", message: \"Highseat rejected events.\", status, events: rejected })\n }\n\n const dropped = toErrorEvents(\n batch,\n (answer.dropped ?? []).filter(({ reason }) => reason !== \"duplicate\"),\n )\n if (dropped.length) {\n report({\n type: \"dropped\",\n message: \"Highseat did not store events.\",\n status,\n events: dropped,\n })\n }\n }\n\n async function readRefusal(response: Response) {\n try {\n const body = (await response.json()) as { error?: unknown }\n if (typeof body.error === \"string\") return body.error\n } catch {}\n return `Highseat answered ${response.status}.`\n }\n\n /** Waits before a retry, ended early when `close()` gives up. */\n function pause(ms: number) {\n return new Promise<void>(resolve => {\n const wait = setTimeout(resolve, ms)\n endPause = () => {\n clearTimeout(wait)\n resolve()\n }\n })\n }\n\n /** Sends one batch until it lands, fails for good, or `close()` gives up on it. */\n async function sendBatch(batch: QueuedEvent[]) {\n for (let attempt = 0; !stopped; attempt++) {\n const now = Date.now()\n // The age is measured again on each attempt, so a retried event keeps its own moment.\n const body = JSON.stringify(\n batch.map(({ event, at }) => ({ ...event, age: Math.max(0, now - at) })),\n )\n let response: Response | undefined\n let failure = \"Highseat is unreachable.\"\n\n try {\n response = await send(endpoint, {\n method: \"POST\",\n headers,\n body,\n signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(timeout)]),\n })\n } catch (error) {\n if (error instanceof Error && error.name === \"TimeoutError\") {\n failure = `Highseat did not answer within ${timeout} ms.`\n }\n }\n\n // `close()` reports the batch it gave up on.\n if (stopped) return\n if (response?.ok) return readAnswer(response, batch)\n\n if ((response && !isRetryable(response.status)) || attempt >= maxRetries) {\n const message = response ? await readRefusal(response) : failure\n const status = response?.status\n return report({ type: \"failed\", message, status, events: listEvents(batch) })\n }\n\n // Released, so a runtime that pools connections can reuse this one.\n await response?.body?.cancel().catch(() => {})\n await pause(\n getRetryDelay(attempt, parseRetryAfter(response?.headers.get(\"retry-after\") ?? null)),\n )\n }\n }\n\n function settle(batch: QueuedEvent[]) {\n for (const { settle } of batch) settle?.()\n }\n\n async function runSender() {\n while (queue.length && !stopped) {\n const batch = queue.slice(0, MAX_BATCH_SIZE)\n queue = queue.slice(MAX_BATCH_SIZE)\n sending = batch\n try {\n await sendBatch(batch)\n } catch {}\n sending = []\n settle(batch)\n }\n }\n\n /** Starts the sender unless it runs, and resolves when it next stops. */\n function startSender(): Promise<void> {\n if (timer !== undefined) {\n clearTimeout(timer)\n timer = undefined\n }\n\n sender ??= runSender().finally(() => {\n sender = undefined\n // An event queued in the tick the loop ended in found it still set, so it runs again.\n if (queue.length && !stopped) void startSender()\n })\n return sender\n }\n\n function schedule() {\n if (timer !== undefined) return\n timer = setTimeout(() => {\n timer = undefined\n void startSender()\n }, flushInterval)\n // A queued event must not keep a script or a test runner alive by itself.\n if (typeof timer === \"object\") timer.unref?.()\n }\n\n async function flush() {\n while ((queue.length || sending.length) && !stopped) await startSender()\n }\n\n /** Stops sending, and reports and settles every event not yet confirmed sent. */\n function giveUp(deadline: number) {\n stopped = true\n stopping.abort()\n endPause?.()\n if (timer !== undefined) clearTimeout(timer)\n\n const unsent = [...sending, ...queue]\n sending = []\n queue = []\n if (unsent.length) {\n report({\n type: \"failed\",\n message: `close() stopped after ${deadline} ms with ${unsent.length} events not confirmed sent.`,\n events: listEvents(unsent),\n })\n }\n settle(unsent)\n }\n\n async function close({ timeout: deadline }: CloseOptions = {}) {\n closed = true\n const drained = flush()\n if (deadline === undefined) return drained\n\n let wait: ReturnType<typeof setTimeout> | undefined\n const expired = new Promise<boolean>(resolve => {\n wait = setTimeout(() => resolve(true), deadline)\n })\n const timedOut = await Promise.race([drained.then(() => false), expired])\n clearTimeout(wait)\n if (timedOut) giveUp(deadline)\n }\n\n function track(name: string, ...args: unknown[]) {\n try {\n if (closed) return\n const [properties, eventOptions = {}] = args as [unknown, ServerEventOptions | undefined]\n const at = eventOptions.timestamp?.getTime() ?? Date.now()\n const queued: QueuedEvent = { event: buildEvent(name, properties, eventOptions), at }\n\n if (queue.length + sending.length >= MAX_QUEUE_SIZE) {\n report({\n type: \"failed\",\n message: `More than ${MAX_QUEUE_SIZE} events are waiting to be sent, so this one was not kept.`,\n events: listEvents([queued]),\n })\n return\n }\n\n queue.push(queued)\n const waitUntil = eventOptions.waitUntil ?? options.waitUntil\n if (waitUntil) waitUntil(new Promise<void>(resolve => (queued.settle = resolve)))\n\n if (waitUntil || queue.length >= MAX_BATCH_SIZE) {\n void startSender()\n } else {\n schedule()\n }\n } catch (error) {\n report({\n type: \"failed\",\n message: error instanceof Error ? error.message : String(error),\n events: [],\n })\n }\n }\n\n return { track, flush, close }\n}\n\n// The tracker the module-level functions share, made from the environment on first use.\nlet defaultTracker: Tracker | undefined\n\nfunction getDefaultTracker(): Tracker {\n if (defaultTracker) return defaultTracker\n try {\n defaultTracker = createTracker()\n } catch (error) {\n console.warn(error instanceof Error ? error.message : error)\n defaultTracker = createDisabledTracker([])\n }\n return defaultTracker\n}\n\n/**\n * Records an event with the tracker made from `HIGHSEAT_SITE` and `HIGHSEAT_SECRET`. A known\n * name checks its properties, as in the browser. Never throws.\n *\n * ```ts\n * track(\"signup\", { plan: \"pro\" }, { request, userId: user.id })\n * ```\n */\nexport function track<Name extends TrackName>(name: Name, ...args: ServerTrackArgs<Name>): void {\n getDefaultTracker().track(name, ...args)\n}\n\n/** Sends every event `track()` has queued. Await it before a short-lived script exits. */\nexport function flush(): Promise<void> {\n return defaultTracker ? defaultTracker.flush() : Promise.resolve()\n}\n\n/** Sends what `track()` has queued and stops sending. Call it at shutdown. See `CloseOptions`. */\nexport function close(options?: CloseOptions): Promise<void> {\n return defaultTracker ? defaultTracker.close(options) : Promise.resolve()\n}\n"],"mappings":"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"}