highseat 0.6.0 → 0.7.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
@@ -13,8 +13,8 @@ npm install highseat
13
13
  | Import | Contents | Needs |
14
14
  | --- | --- | --- |
15
15
  | `highseat` | `track()`, `identify()`, `getAttributionId()`, `inject()` and `setBeforeSend()` | Nothing |
16
- | `highseat/react` | `<Highseat />`, `useIdentify()` and `useBeforeSend()` | React |
17
- | `highseat/next` | `<Highseat />`, `useIdentify()`, `useBeforeSend()`, `createProxy()` and `withHighseat()` for Next.js | Next.js |
16
+ | `highseat/react` | `<Highseat />`, `<Embed />`, `useIdentify()` and `useBeforeSend()` | React |
17
+ | `highseat/next` | `<Highseat />`, `<Embed />`, `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
19
  | `highseat/server` | `track()`, `flush()`, `close()` and `createTracker()`, for events sent from your backend | Nothing |
20
20
  | `highseat/mcp` | `trackToolCalls()` and `mcpLink()`, for the tool calls AI assistants make to your MCP server | The MCP TypeScript SDK v2 |
@@ -96,6 +96,47 @@ A site without a bundler, or a website builder such as Webflow or Shopify, needs
96
96
  <script defer src="https://cdn.highseat.com/script.js" data-site="site_…"></script>
97
97
  ```
98
98
 
99
+ ## Embeds
100
+
101
+ `<Embed />` shows a share link or a widget on your page. Its `token` is the last part of the `/share/` address in the embed code, and `title` names the frame for screen readers. It needs no script tag: the component fits the frame to the embed and follows it as it resizes.
102
+
103
+ Pass your page's theme when the page has its own switch, so the frame's colours match the page:
104
+
105
+ ```tsx
106
+ "use client"
107
+
108
+ import { Embed } from "highseat/react"
109
+ import { useTheme } from "next-themes"
110
+
111
+ export function VisitorsChart() {
112
+ const { resolvedTheme } = useTheme()
113
+
114
+ return (
115
+ <Embed
116
+ token="YOUR_WIDGET_TOKEN"
117
+ title="Visitors in the last 30 days"
118
+ widget="chart"
119
+ theme={resolvedTheme === "dark" ? "dark" : "light"}
120
+ />
121
+ )
122
+ }
123
+ ```
124
+
125
+ The server renders an empty box at the frame's height, and the frame replaces it after mount. So a theme known only in the browser needs no mounted check of your own and causes no hydration mismatch. If the theme is still unknown after mount, pass `theme={null}`: the box stays empty until you pass a theme, and the frame loads once. Leave `theme` out to follow the reader's own light or dark setting.
126
+
127
+ | Prop | Default | Does |
128
+ | --- | --- | --- |
129
+ | `token` | Required | The share link or widget to show |
130
+ | `title` | Required | The frame's accessible name |
131
+ | `theme` | `"system"` | `"system"`, `"light"`, `"dark"`, or `null` while your page's theme is unknown |
132
+ | `widget` | None, for a whole board | `"chart"` or `"figure"`, which sets the height held before the frame loads: 360px, 80px, or 1600px for a board |
133
+ | `background` | `"transparent"` for a widget, otherwise `"default"` | `"transparent"` lets your page's background show through |
134
+ | `height` | Set by `widget` | The height in pixels to hold until the embed reports its own |
135
+ | `credit` | `true` | Shows the "Analytics by Highseat" link under the frame |
136
+ | `creditClassName` | None | A class for the credit link, which replaces its default spacing and size |
137
+
138
+ `className` and `style` go to the box around the frame and the credit link. Other attributes, such as `id` or `loading`, go to the frame. The credit link is a plain, followed link in the server's HTML, and it is yours to remove: pass `credit={false}`.
139
+
99
140
  ## Debugging
100
141
 
101
142
  In development, the package logs every `track()` and `identify()` call to the console, with its properties or id, and says whether the call went to the tracker or waits in the queue for it to load. It also warns once per page when nothing can reach Highseat: the page has no tracker script, or the script failed to load, as one an ad blocker stopped does.
@@ -400,7 +441,7 @@ To rotate the path, rotate it in Installation settings, update `HIGHSEAT_PATH` a
400
441
  | --- | --- | --- |
401
442
  | `clientIp` | Required | Where the visitor's IP comes from: `"vercel"`, `"cloudflare"`, `"netlify"`, `"nginx"` (the `X-Real-IP` header your nginx sets), or a function `(request) => string`. The outermost proxy that sets the header decides the preset: behind a proxied Cloudflare record, use `"cloudflare"` on any origin (Next.js, TanStack Start, a Node server, with or without Traefik or nginx in between), whose `X-Real-IP` holds a Cloudflare edge address. A visitor can write any header the host does not overwrite, so there is no default and no fallback to `X-Forwarded-For`. |
402
443
  | `path` | `HIGHSEAT_PATH` | The proxy path. Any other path answers 404, or goes to `fallback`. Leave it unset where your router already sends only the proxy path to the handler. |
403
- | `secret` | `HIGHSEAT_SECRET` | The site secret. An older secret that starts `hs_proxy_` or `ana_proxy_` works too. A value with neither prefix throws. |
444
+ | `secret` | `HIGHSEAT_SECRET` | The site secret. A value that does not start with `hs_secret_` throws. |
404
445
  | `upstream` | `https://cdn.highseat.com` | The Highseat ingest origin. |
405
446
  | `fallback` | None | Answers every other path, for a handler that sees every request on the site. Needs `path`. |
406
447
 
package/embed.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ //#region src/embed.d.ts
2
+ /**
3
+ * What `<Embed />` knows about a Highseat embed frame: its URL, its message and the credit link.
4
+ * The dashboard's own copy lives in the monorepo, which this package cannot import because it
5
+ * builds alone in the public mirror. `scripts/embed-mirror.test.ts` at the monorepo's root fails
6
+ * when the two differ.
7
+ */
8
+ /** The theme of an embed: `system` follows the reader's own light or dark setting. */
9
+ export type EmbedTheme = "system" | "light" | "dark";
10
+ /** The background of an embed: `transparent` lets the host page's own show through. */
11
+ export type EmbedBackground = "default" | "transparent";
12
+ /** The kind of widget a token shows. */
13
+ export type EmbedWidget = "chart" | "figure";
14
+ //#endregion
15
+ //# sourceMappingURL=embed.d.ts.map
package/embed.js ADDED
@@ -0,0 +1,49 @@
1
+ //#region src/embed.ts
2
+ /** The origin that serves every embed page, and so the only one a resize message can come from. */
3
+ const EMBED_ORIGIN = "https://app.highseat.com";
4
+ /** The `type` of the message an embed posts to its parent whenever its height changes. */
5
+ const EMBED_RESIZE_MESSAGE = "highseat:embed-resize";
6
+ /** The height of a whole board's frame before the board reports its own. */
7
+ const EMBED_FALLBACK_HEIGHT = 1600;
8
+ /** The height of a widget's frame before the widget reports its own, close to its usual one. */
9
+ const WIDGET_FALLBACK_HEIGHTS = {
10
+ figure: 80,
11
+ chart: 360
12
+ };
13
+ /** Where the link under an embed goes, tagged so the visits it sends are attributed. */
14
+ const EMBED_CREDIT_URL = "https://highseat.com/?ref=embed";
15
+ /** The text of the link under an embed. */
16
+ const EMBED_CREDIT_TEXT = "Analytics by Highseat";
17
+ /** An embeddable page's URL with the keys that frame it: `embed=1` and the chosen look. */
18
+ function formatEmbedUrl(pageUrl, { theme, background }) {
19
+ const params = new URLSearchParams({ embed: "1" });
20
+ if (theme !== "system") params.set("theme", theme);
21
+ if (background !== "default") params.set("background", background);
22
+ return `${pageUrl}?${params}`;
23
+ }
24
+ /** The URL of the embed page a token opens. */
25
+ function formatEmbedSrc(token, look) {
26
+ return formatEmbedUrl(`${EMBED_ORIGIN}/share/${encodeURIComponent(token)}`, look);
27
+ }
28
+ /**
29
+ * The colour scheme a see-through frame declares. A browser paints an opaque backdrop behind a
30
+ * frame whose scheme differs from the frame element's, so the element takes the page's scheme.
31
+ */
32
+ function getFrameColorScheme(theme) {
33
+ return theme === "system" ? "light dark" : theme;
34
+ }
35
+ /**
36
+ * The height a message asks `frameWindow` to take, rounded up. Any page or frame can post to the
37
+ * host page, so a message counts only from the embed origin and from that exact frame. Without
38
+ * the source check, a second embed on the page would resize this one.
39
+ */
40
+ function findEmbedHeight({ data, origin, source }, frameWindow) {
41
+ if (origin !== "https://app.highseat.com" || !frameWindow || source !== frameWindow) return;
42
+ if (data?.type !== "highseat:embed-resize") return;
43
+ if (!Number.isFinite(data.height) || data.height <= 0) return;
44
+ return Math.ceil(data.height);
45
+ }
46
+ //#endregion
47
+ export { EMBED_CREDIT_TEXT, EMBED_CREDIT_URL, EMBED_FALLBACK_HEIGHT, EMBED_ORIGIN, EMBED_RESIZE_MESSAGE, WIDGET_FALLBACK_HEIGHTS, findEmbedHeight, formatEmbedSrc, formatEmbedUrl, getFrameColorScheme };
48
+
49
+ //# sourceMappingURL=embed.js.map
package/embed.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embed.js","names":[],"sources":["../src/embed.ts"],"sourcesContent":["/**\n * What `<Embed />` knows about a Highseat embed frame: its URL, its message and the credit link.\n * The dashboard's own copy lives in the monorepo, which this package cannot import because it\n * builds alone in the public mirror. `scripts/embed-mirror.test.ts` at the monorepo's root fails\n * when the two differ.\n */\n\n/** The theme of an embed: `system` follows the reader's own light or dark setting. */\nexport type EmbedTheme = \"system\" | \"light\" | \"dark\"\n\n/** The background of an embed: `transparent` lets the host page's own show through. */\nexport type EmbedBackground = \"default\" | \"transparent\"\n\n/** The kind of widget a token shows. */\nexport type EmbedWidget = \"chart\" | \"figure\"\n\ninterface EmbedLook {\n theme: EmbedTheme\n background: EmbedBackground\n}\n\n/** The part of a `MessageEvent` a resize message is judged on. */\ntype FrameMessage = Pick<MessageEvent, \"data\" | \"origin\" | \"source\">\n\n/** The origin that serves every embed page, and so the only one a resize message can come from. */\nexport const EMBED_ORIGIN = \"https://app.highseat.com\"\n\n/** The `type` of the message an embed posts to its parent whenever its height changes. */\nexport const EMBED_RESIZE_MESSAGE = \"highseat:embed-resize\"\n\n/** The height of a whole board's frame before the board reports its own. */\nexport const EMBED_FALLBACK_HEIGHT = 1600\n\n/** The height of a widget's frame before the widget reports its own, close to its usual one. */\nexport const WIDGET_FALLBACK_HEIGHTS: Record<EmbedWidget, number> = {\n figure: 80,\n chart: 360,\n}\n\n/** Where the link under an embed goes, tagged so the visits it sends are attributed. */\nexport const EMBED_CREDIT_URL = \"https://highseat.com/?ref=embed\"\n\n/** The text of the link under an embed. */\nexport const EMBED_CREDIT_TEXT = \"Analytics by Highseat\"\n\n/** An embeddable page's URL with the keys that frame it: `embed=1` and the chosen look. */\nexport function formatEmbedUrl(pageUrl: string, { theme, background }: EmbedLook): string {\n const params = new URLSearchParams({ embed: \"1\" })\n if (theme !== \"system\") params.set(\"theme\", theme)\n if (background !== \"default\") params.set(\"background\", background)\n return `${pageUrl}?${params}`\n}\n\n/** The URL of the embed page a token opens. */\nexport function formatEmbedSrc(token: string, look: EmbedLook): string {\n return formatEmbedUrl(`${EMBED_ORIGIN}/share/${encodeURIComponent(token)}`, look)\n}\n\n/**\n * The colour scheme a see-through frame declares. A browser paints an opaque backdrop behind a\n * frame whose scheme differs from the frame element's, so the element takes the page's scheme.\n */\nexport function getFrameColorScheme(theme: EmbedTheme): string {\n return theme === \"system\" ? \"light dark\" : theme\n}\n\n/**\n * The height a message asks `frameWindow` to take, rounded up. Any page or frame can post to the\n * host page, so a message counts only from the embed origin and from that exact frame. Without\n * the source check, a second embed on the page would resize this one.\n */\nexport function findEmbedHeight(\n { data, origin, source }: FrameMessage,\n frameWindow: Window | null | undefined,\n): number | undefined {\n if (origin !== EMBED_ORIGIN || !frameWindow || source !== frameWindow) return\n if (data?.type !== EMBED_RESIZE_MESSAGE) return\n if (!Number.isFinite(data.height) || data.height <= 0) return\n return Math.ceil(data.height)\n}\n"],"mappings":";;AAyBA,MAAa,eAAe;;AAG5B,MAAa,uBAAuB;;AAGpC,MAAa,wBAAwB;;AAGrC,MAAa,0BAAuD;CAClE,QAAQ;CACR,OAAO;AACT;;AAGA,MAAa,mBAAmB;;AAGhC,MAAa,oBAAoB;;AAGjC,SAAgB,eAAe,SAAiB,EAAE,OAAO,cAAiC;CACxF,MAAM,SAAS,IAAI,gBAAgB,EAAE,OAAO,IAAI,CAAC;CACjD,IAAI,UAAU,UAAU,OAAO,IAAI,SAAS,KAAK;CACjD,IAAI,eAAe,WAAW,OAAO,IAAI,cAAc,UAAU;CACjE,OAAO,GAAG,QAAQ,GAAG;AACvB;;AAGA,SAAgB,eAAe,OAAe,MAAyB;CACrE,OAAO,eAAe,GAAG,aAAa,SAAS,mBAAmB,KAAK,KAAK,IAAI;AAClF;;;;;AAMA,SAAgB,oBAAoB,OAA2B;CAC7D,OAAO,UAAU,WAAW,eAAe;AAC7C;;;;;;AAOA,SAAgB,gBACd,EAAE,MAAM,QAAQ,UAChB,aACoB;CACpB,IAAI,WAAA,8BAA2B,CAAC,eAAe,WAAW,aAAa;CACvE,IAAI,MAAM,SAAA,yBAA+B;CACzC,IAAI,CAAC,OAAO,SAAS,KAAK,MAAM,KAAK,KAAK,UAAU,GAAG;CACvD,OAAO,KAAK,KAAK,KAAK,MAAM;AAC9B"}
package/index.d.ts CHANGED
@@ -192,8 +192,6 @@ declare global {
192
192
  highseat?: HighseatGlobal;
193
193
  }
194
194
  }
195
- /** Where the tracker loads from when the site has no proxy path. */
196
- export declare const SCRIPT_URL = "https://cdn.highseat.com/script.js";
197
195
  /**
198
196
  * Records an event. A known name autocompletes and checks its properties; any other name is a
199
197
  * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so
package/index.js CHANGED
@@ -177,6 +177,6 @@ function inject(options) {
177
177
  document.head.append(script);
178
178
  }
179
179
  //#endregion
180
- export { SCRIPT_URL, getAttributionId, identify, inject, setBeforeSend, track };
180
+ export { getAttributionId, identify, inject, setBeforeSend, track };
181
181
 
182
182
  //# sourceMappingURL=index.js.map
package/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { isDevelopment } from \"./env\"\n\n/**\n * The browser client. Every call goes to the hosted tracker on `window.highseat`; before the\n * tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:\n * on a server, with the tracker blocked, or with tracking off, a call does nothing.\n */\n\n/** A value an event property can hold. A `null` or `undefined` value is left out of the event. */\nexport type PropertyValue = string | number | boolean | null | undefined\n\n/**\n * Money attached to an event. `amount` is an integer in the currency's smallest unit, so\n * `{ amount: 4999, currency: \"USD\" }` is $49.99 and `{ amount: 5000, currency: \"JPY\" }` is ¥5000.\n */\nexport interface Revenue {\n /** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */\n amount: number\n /** An ISO 4217 code, such as `USD` or `EUR`. */\n currency: string\n}\n\n/** The properties of an event with no declared shape: any string keys, plus optional revenue. */\nexport interface Properties {\n /** Money the event brought in. */\n revenue?: Revenue\n [key: string]: PropertyValue | Revenue\n}\n\n/**\n * The events Highseat knows by name. A site extends it, or overrides an entry, through\n * {@link Register}. Type-only: it adds nothing to the bundle.\n */\nexport interface BuiltInEvents {\n /** A visitor created an account. */\n signup: Properties\n /** A visitor signed in. */\n login: Properties\n /**\n * A sale. With a payment provider connected, leave `revenue` out: the provider's webhook\n * records the money, and revenue sent here as well would count the sale twice. Send `revenue`\n * only for a checkout no provider reports, and then with an `order_id`, so a provider sale\n * added later for the same order is not counted again.\n */\n purchase: Properties\n /** A paid subscription began. */\n subscription_started: Properties\n /** A free trial began. */\n trial_started: Properties\n /** A file download. The tracker records links to files on its own. */\n file_download: { url: string }\n /** A click on a link to another site. The tracker records these on its own. */\n outbound_link_click: { url: string }\n /** A visit to a missing page. Call it from a single-page app's not-found route. */\n \"404\": Properties\n}\n\n/**\n * Declares a site's own events. Augment it from any file the compiler sees:\n *\n * ```ts\n * declare module \"highseat\" {\n * interface Register {\n * events: {\n * signup: { plan: \"free\" | \"pro\" }\n * }\n * strict: true\n * }\n * }\n * ```\n *\n * `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`\n * accept only built-in and registered names, so a misspelt name is a type error.\n */\nexport interface Register {}\n\n/**\n * A registered event's properties as `track` accepts them. A shape with no properties takes none,\n * and a property whose type an event cannot carry becomes `never`, so a call passing it fails\n * instead of sending `\"[object Object]\"`. The shape is not rejected at registration, where a\n * failed check would silently drop every registered event at once.\n */\ntype CheckedProperties<Shape> = [keyof Shape] extends [never]\n ? Record<string, never>\n : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never }\n\ntype RegisteredEvents = Register extends { events: infer Registered }\n ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]> }\n : {}\n\n// A registered event replaces a built-in one of the same name.\ntype KnownEvents<BuiltIns> = Omit<BuiltIns, keyof RegisteredEvents> & RegisteredEvents\n\n/**\n * The event names a `track` with the given built-in events knows, built-in or registered.\n * `highseat/server` passes its server-only events on top of {@link BuiltInEvents}.\n */\nexport type KnownEventName<BuiltIns> = Extract<keyof KnownEvents<BuiltIns>, string>\n\n/** Every event name `track` knows, built-in or registered. */\nexport type EventName = KnownEventName<BuiltInEvents>\n\n/**\n * The names a `track` with the given built-in events accepts: known names, and any other string\n * unless the site is strict. `string & {}` keeps the known names in the editor's suggestions,\n * which a plain `string` in the union would absorb.\n */\nexport type TrackNameOf<BuiltIns> = Register extends { strict: true }\n ? KnownEventName<BuiltIns>\n : KnownEventName<BuiltIns> | (string & {})\n\n/** The names `track` accepts: known names, and any other string unless the site is strict. */\nexport type TrackName = TrackNameOf<BuiltInEvents>\n\n/**\n * The arguments after an event's name, for a `track` with the given built-in events. A known\n * name takes its declared properties, required when any of them is; any other name takes\n * {@link Properties}. Wrapped in tuples so a union of names is checked as a whole rather than\n * name by name.\n */\nexport type TrackArgsOf<BuiltIns, Name extends string> = [Name] extends [KnownEventName<BuiltIns>]\n ? {} extends KnownEvents<BuiltIns>[Name]\n ? [properties?: KnownEvents<BuiltIns>[Name]]\n : [properties: KnownEvents<BuiltIns>[Name]]\n : [properties?: Properties]\n\n/** The arguments after an event's name: its properties. See {@link TrackArgsOf}. */\nexport type TrackArgs<Name extends string> = TrackArgsOf<BuiltInEvents, Name>\n\n/**\n * A user id from your own system, as {@link identify} takes it. A string or a number identifies\n * the visitor, `null` clears the id, and `undefined` or an empty string changes nothing. Pass your\n * own database id, never an email or other personal data.\n */\nexport type UserId = string | number | null | undefined\n\n/**\n * The part of an event {@link BeforeSend} sees and may change. The site, the visitor's user id\n * and the attribution id are out of reach, so a hook cannot change who an event belongs to.\n */\nexport interface BeforeSendEvent {\n /** The event's name: `pageview`, `engagement`, or a name passed to `track`. Read-only. */\n readonly name: string\n /** The page's full URL. The page path is read off the URL you return. */\n url: string\n /** The page the visitor came from, or the page left on a client-side navigation. */\n referrer?: string\n /** The event's properties, each value a string. */\n properties?: Record<string, string>\n}\n\n/**\n * Runs in the browser on every event before the tracker sends it, page views from client-side\n * navigation included. Return the event, changed or not, or `null` to drop it. Any other falsy\n * answer drops it too, and so does a hook that throws: a hook that keeps a secret out of the\n * data must not leak it when it fails. The error still reaches the console.\n *\n * ```ts\n * function beforeSend(event: BeforeSendEvent) {\n * const url = new URL(event.url)\n * if (url.pathname.startsWith(\"/admin/\")) return null\n * url.searchParams.delete(\"email\")\n * return { ...event, url: url.href }\n * }\n * ```\n */\nexport type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null\n\n/** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */\nexport interface HighseatGlobal {\n /** Records an event. */\n track?: (name: string, properties?: Properties) => void\n /** Sets or clears the visitor's user id. */\n identify?: (id: UserId) => void\n /** The id that joins a payment to this tab's visit. */\n getAttributionId?: () => string | undefined\n /** Calls made before the tracker loaded, drained in order when it starts. */\n q?: [string, unknown[]][]\n /** The site's {@link BeforeSend} hook, read on every event. */\n beforeSend?: BeforeSend\n}\n\n/** How long {@link getAttributionId} waits for the tracker. */\nexport interface AttributionOptions {\n /** Milliseconds to wait for the tracker to load. Defaults to 3000. */\n timeout?: number\n}\n\n/** How the tracker is loaded. Each option maps to an attribute on its script tag. */\nexport interface InjectOptions {\n /** The site id, `site_…`, from the site's Installation settings. */\n site: string\n /**\n * Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party\n * delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back\n * to the same place.\n */\n src?: string\n /** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */\n outbound?: boolean\n /** Records clicks on links to files as `file_download`. Defaults to `true`. */\n downloads?: boolean\n /** Counts a change of `#/route` as a page view, for an app that routes by hash. */\n hashRouting?: boolean\n /** Changes or drops each event before it leaves the browser. See {@link BeforeSend}. */\n beforeSend?: BeforeSend\n /**\n * Logs every `track()` and `identify()` call to the console, and warns once when no tracker\n * script is on the page or the script failed to load. Defaults to `true` when\n * `process.env.NODE_ENV` is `development`, so a production build logs nothing.\n */\n debug?: boolean\n}\n\ndeclare global {\n interface Window {\n highseat?: HighseatGlobal\n }\n}\n\n/** Where the tracker loads from when the site has no proxy path. */\nexport const SCRIPT_URL = \"https://cdn.highseat.com/script.js\"\n\n// The tracker marks its own API with this symbol. A site's own stub can define methods too, so\n// only the symbol proves the tracker is running.\nconst LIVE = Symbol.for(\"highseat\")\n\nconst SCRIPT_HOST = new URL(SCRIPT_URL).host\n// A site's proxy path: `/` and ten hex characters.\nconst PROXY_PATH = /^\\/[0-9a-f]{10}$/\n\nconst TRACKER_TIMEOUT = 3000\nconst TRACKER_POLL_INTERVAL = 50\n\nconst MISSING_SCRIPT_WARNING = [\n \"highseat: no tracker script on this page, so no event reaches Highseat.\",\n \"Render <Highseat />, call inject({ site }), or add the script tag from the site's Installation settings.\",\n].join(\" \")\nconst FAILED_SCRIPT_WARNING = [\n \"highseat: the tracker script failed to load, so no event reaches Highseat.\",\n \"An ad blocker may have stopped it: load the tracker from the site's proxy path instead.\",\n].join(\" \")\nconst SLOW_SCRIPT_WARNING = [\n `highseat: the tracker script has not loaded after ${TRACKER_TIMEOUT / 1000} seconds,`,\n \"so every call waits in the queue. Check the network panel for a blocked or failed request.\",\n].join(\" \")\n\n// The script tags `inject` added, and every tracker script whose request failed, such as one an\n// ad blocker stopped. A browser keeps no record of a failed script, so a failure is only known\n// to a listener attached before it happened.\nconst injectedScripts = new WeakSet<HTMLScriptElement>()\nconst failedScripts = new WeakSet<HTMLScriptElement>()\n\n// The `debug` option of the last `inject` call. Unset falls back to the build's environment.\nlet debugOption: boolean | undefined\n// The windows whose tracker is being watched, so a page warns about its script once.\nconst watchedWindows = new WeakSet<Window>()\n\nfunction findGlobal(): HighseatGlobal | undefined {\n if (typeof window === \"undefined\") return\n return (window.highseat ??= {})\n}\n\nfunction isLive(api: HighseatGlobal | undefined): api is HighseatGlobal {\n return !!api && (api as Record<symbol, unknown>)[LIVE] === true\n}\n\n// One function for every script, so attaching it on each poll adds no second listener.\nfunction markFailed(event: Event) {\n failedScripts.add(event.currentTarget as HTMLScriptElement)\n}\n\n/**\n * Whether a script tag loads the tracker, judged as the tracker judges itself. Fathom's snippet\n * carries a `data-site` too, so a tag is matched by a `site_` id, or by a `src` on a proxy path\n * or on Highseat's host: an older site's id is a UUID, which any tool could use.\n */\nfunction isTrackerScript(script: HTMLScriptElement) {\n const { site } = script.dataset\n if (injectedScripts.has(script) || site?.startsWith(\"site_\")) return true\n if (site === undefined) return false\n\n try {\n // A browser hands back `src` resolved; the base only matters to a relative one.\n const url = new URL(script.src, \"http://localhost\")\n return url.host === SCRIPT_HOST || PROXY_PATH.test(url.pathname)\n } catch {\n return false\n }\n}\n\n/** The page's tracker scripts, each one listened to from now on for a failed load. */\nfunction getTrackerScripts() {\n const scripts = Array.from(document.scripts).filter(isTrackerScript)\n for (const script of scripts) script.addEventListener(\"error\", markFailed)\n return scripts\n}\n\nfunction isDebug() {\n return debugOption ?? isDevelopment()\n}\n\n/**\n * Warns once per page when the tracker cannot arrive: no tracker script, or only failed ones. A\n * missing script is judged at the timeout, because `<Highseat />` adds its tag in an effect that\n * runs after the effects of the components inside it.\n */\nfunction watchTracker() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n if (watchedWindows.has(window)) return\n watchedWindows.add(window)\n\n const page = window\n const deadline = Date.now() + TRACKER_TIMEOUT\n\n function check() {\n // A replaced global window, as a test sets up for each case, is another page.\n if (window !== page || isLive(page.highseat)) return\n\n const scripts = getTrackerScripts()\n\n if (scripts.length > 0 && scripts.every(script => failedScripts.has(script))) {\n console.warn(FAILED_SCRIPT_WARNING)\n return\n }\n if (Date.now() < deadline) {\n setTimeout(check, TRACKER_POLL_INTERVAL)\n return\n }\n console.warn(scripts.length === 0 ? MISSING_SCRIPT_WARNING : SLOW_SCRIPT_WARNING)\n }\n\n setTimeout(check, TRACKER_POLL_INTERVAL)\n}\n\n/**\n * Logs a call when debugging is on. The check waits a microtask, so a `debug` option that\n * `<Highseat />` passes in its effect applies to the calls its children make in theirs.\n */\nfunction logCall(method: \"track\" | \"identify\", args: unknown[], isQueued: boolean) {\n queueMicrotask(() => {\n if (!isDebug()) return\n\n const status = isQueued ? \"queued until the tracker loads\" : \"sent to the tracker\"\n const [value, properties] = args\n // `JSON.stringify` quotes a name or a string id, and returns `undefined` for an unset id.\n const message = `highseat: ${method} ${String(JSON.stringify(value))}, ${status}`\n\n if (properties === undefined) console.info(message)\n else console.info(message, properties)\n watchTracker()\n })\n}\n\nfunction call(method: \"track\" | \"identify\", args: unknown[]) {\n const api = findGlobal()\n if (!api) return\n\n const handler = api[method] as ((...values: unknown[]) => void) | undefined\n if (typeof handler !== \"function\") {\n api.q ??= []\n api.q.push([method, args])\n logCall(method, args, true)\n return\n }\n\n logCall(method, args, false)\n try {\n handler(...args)\n } catch {}\n}\n\n/**\n * Records an event. A known name autocompletes and checks its properties; any other name is a\n * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so\n * `Purchase` and `purchase` are two events.\n */\nexport function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void {\n const [properties] = args\n call(\"track\", properties === undefined ? [name] : [name, properties])\n}\n\n/**\n * Links the visitor to a user id from your own system, on this event and every later one, across\n * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so\n * `identify(user?.id)` before a session loads does not sign the visitor out.\n */\nexport function identify(id: UserId): void {\n call(\"identify\", [id])\n}\n\n/**\n * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata\n * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.\n * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has\n * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout\n * when the script is still loading, and on a server or with tracking off.\n */\nexport async function getAttributionId(\n options: AttributionOptions = {},\n): Promise<string | undefined> {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n\n const deadline = Date.now() + (options.timeout ?? TRACKER_TIMEOUT)\n let api = window.highseat\n\n while (!isLive(api)) {\n if (Date.now() >= deadline) return\n\n const scripts = getTrackerScripts()\n // No tracker script at all, or only failed ones: the tracker cannot arrive.\n if (scripts.every(script => failedScripts.has(script))) return\n\n await new Promise(resolve => setTimeout(resolve, TRACKER_POLL_INTERVAL))\n api = window.highseat\n }\n\n try {\n return api.getAttributionId?.()\n } catch {\n return\n }\n}\n\n/**\n * Sets the {@link BeforeSend} hook, or clears it with `undefined`. Works before the tracker has\n * loaded, which picks the hook up when it starts, and applies from the next event once it runs.\n * Does nothing on a server.\n */\nexport function setBeforeSend(beforeSend: BeforeSend | undefined): void {\n const api = findGlobal()\n if (api) api.beforeSend = beforeSend\n}\n\n/**\n * Adds the tracker's script tag to the page, once. A second call for the same site, such as a\n * component mounting again, finds the tag and adds nothing, but its `debug` option still applies.\n * Does nothing on a server.\n */\nexport function inject(options: InjectOptions): void {\n if (typeof document === \"undefined\") return\n\n const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, beforeSend, debug } = options\n debugOption = debug\n // Set before the script tag is added, so the first page view already passes through it.\n if (beforeSend) setBeforeSend(beforeSend)\n if (isDebug()) watchTracker()\n if (Array.from(document.scripts).some(script => script.dataset.site === site)) return\n\n const script = document.createElement(\"script\")\n script.addEventListener(\"error\", markFailed)\n injectedScripts.add(script)\n script.src = src\n script.dataset.site = site\n if (outbound === false) script.dataset.outbound = \"false\"\n if (downloads === false) script.dataset.downloads = \"false\"\n if (hashRouting) script.dataset.hashRouting = \"\"\n document.head.append(script)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { isDevelopment } from \"./env\"\n\n/**\n * The browser client. Every call goes to the hosted tracker on `window.highseat`; before the\n * tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:\n * on a server, with the tracker blocked, or with tracking off, a call does nothing.\n */\n\n/** A value an event property can hold. A `null` or `undefined` value is left out of the event. */\nexport type PropertyValue = string | number | boolean | null | undefined\n\n/**\n * Money attached to an event. `amount` is an integer in the currency's smallest unit, so\n * `{ amount: 4999, currency: \"USD\" }` is $49.99 and `{ amount: 5000, currency: \"JPY\" }` is ¥5000.\n */\nexport interface Revenue {\n /** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */\n amount: number\n /** An ISO 4217 code, such as `USD` or `EUR`. */\n currency: string\n}\n\n/** The properties of an event with no declared shape: any string keys, plus optional revenue. */\nexport interface Properties {\n /** Money the event brought in. */\n revenue?: Revenue\n [key: string]: PropertyValue | Revenue\n}\n\n/**\n * The events Highseat knows by name. A site extends it, or overrides an entry, through\n * {@link Register}. Type-only: it adds nothing to the bundle.\n */\nexport interface BuiltInEvents {\n /** A visitor created an account. */\n signup: Properties\n /** A visitor signed in. */\n login: Properties\n /**\n * A sale. With a payment provider connected, leave `revenue` out: the provider's webhook\n * records the money, and revenue sent here as well would count the sale twice. Send `revenue`\n * only for a checkout no provider reports, and then with an `order_id`, so a provider sale\n * added later for the same order is not counted again.\n */\n purchase: Properties\n /** A paid subscription began. */\n subscription_started: Properties\n /** A free trial began. */\n trial_started: Properties\n /** A file download. The tracker records links to files on its own. */\n file_download: { url: string }\n /** A click on a link to another site. The tracker records these on its own. */\n outbound_link_click: { url: string }\n /** A visit to a missing page. Call it from a single-page app's not-found route. */\n \"404\": Properties\n}\n\n/**\n * Declares a site's own events. Augment it from any file the compiler sees:\n *\n * ```ts\n * declare module \"highseat\" {\n * interface Register {\n * events: {\n * signup: { plan: \"free\" | \"pro\" }\n * }\n * strict: true\n * }\n * }\n * ```\n *\n * `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`\n * accept only built-in and registered names, so a misspelt name is a type error.\n */\nexport interface Register {}\n\n/**\n * A registered event's properties as `track` accepts them. A shape with no properties takes none,\n * and a property whose type an event cannot carry becomes `never`, so a call passing it fails\n * instead of sending `\"[object Object]\"`. The shape is not rejected at registration, where a\n * failed check would silently drop every registered event at once.\n */\ntype CheckedProperties<Shape> = [keyof Shape] extends [never]\n ? Record<string, never>\n : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never }\n\ntype RegisteredEvents = Register extends { events: infer Registered }\n ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]> }\n : {}\n\n// A registered event replaces a built-in one of the same name.\ntype KnownEvents<BuiltIns> = Omit<BuiltIns, keyof RegisteredEvents> & RegisteredEvents\n\n/**\n * The event names a `track` with the given built-in events knows, built-in or registered.\n * `highseat/server` passes its server-only events on top of {@link BuiltInEvents}.\n */\nexport type KnownEventName<BuiltIns> = Extract<keyof KnownEvents<BuiltIns>, string>\n\n/** Every event name `track` knows, built-in or registered. */\nexport type EventName = KnownEventName<BuiltInEvents>\n\n/**\n * The names a `track` with the given built-in events accepts: known names, and any other string\n * unless the site is strict. `string & {}` keeps the known names in the editor's suggestions,\n * which a plain `string` in the union would absorb.\n */\nexport type TrackNameOf<BuiltIns> = Register extends { strict: true }\n ? KnownEventName<BuiltIns>\n : KnownEventName<BuiltIns> | (string & {})\n\n/** The names `track` accepts: known names, and any other string unless the site is strict. */\nexport type TrackName = TrackNameOf<BuiltInEvents>\n\n/**\n * The arguments after an event's name, for a `track` with the given built-in events. A known\n * name takes its declared properties, required when any of them is; any other name takes\n * {@link Properties}. Wrapped in tuples so a union of names is checked as a whole rather than\n * name by name.\n */\nexport type TrackArgsOf<BuiltIns, Name extends string> = [Name] extends [KnownEventName<BuiltIns>]\n ? {} extends KnownEvents<BuiltIns>[Name]\n ? [properties?: KnownEvents<BuiltIns>[Name]]\n : [properties: KnownEvents<BuiltIns>[Name]]\n : [properties?: Properties]\n\n/** The arguments after an event's name: its properties. See {@link TrackArgsOf}. */\nexport type TrackArgs<Name extends string> = TrackArgsOf<BuiltInEvents, Name>\n\n/**\n * A user id from your own system, as {@link identify} takes it. A string or a number identifies\n * the visitor, `null` clears the id, and `undefined` or an empty string changes nothing. Pass your\n * own database id, never an email or other personal data.\n */\nexport type UserId = string | number | null | undefined\n\n/**\n * The part of an event {@link BeforeSend} sees and may change. The site, the visitor's user id\n * and the attribution id are out of reach, so a hook cannot change who an event belongs to.\n */\nexport interface BeforeSendEvent {\n /** The event's name: `pageview`, `engagement`, or a name passed to `track`. Read-only. */\n readonly name: string\n /** The page's full URL. The page path is read off the URL you return. */\n url: string\n /** The page the visitor came from, or the page left on a client-side navigation. */\n referrer?: string\n /** The event's properties, each value a string. */\n properties?: Record<string, string>\n}\n\n/**\n * Runs in the browser on every event before the tracker sends it, page views from client-side\n * navigation included. Return the event, changed or not, or `null` to drop it. Any other falsy\n * answer drops it too, and so does a hook that throws: a hook that keeps a secret out of the\n * data must not leak it when it fails. The error still reaches the console.\n *\n * ```ts\n * function beforeSend(event: BeforeSendEvent) {\n * const url = new URL(event.url)\n * if (url.pathname.startsWith(\"/admin/\")) return null\n * url.searchParams.delete(\"email\")\n * return { ...event, url: url.href }\n * }\n * ```\n */\nexport type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null\n\n/** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */\nexport interface HighseatGlobal {\n /** Records an event. */\n track?: (name: string, properties?: Properties) => void\n /** Sets or clears the visitor's user id. */\n identify?: (id: UserId) => void\n /** The id that joins a payment to this tab's visit. */\n getAttributionId?: () => string | undefined\n /** Calls made before the tracker loaded, drained in order when it starts. */\n q?: [string, unknown[]][]\n /** The site's {@link BeforeSend} hook, read on every event. */\n beforeSend?: BeforeSend\n}\n\n/** How long {@link getAttributionId} waits for the tracker. */\nexport interface AttributionOptions {\n /** Milliseconds to wait for the tracker to load. Defaults to 3000. */\n timeout?: number\n}\n\n/** How the tracker is loaded. Each option maps to an attribute on its script tag. */\nexport interface InjectOptions {\n /** The site id, `site_…`, from the site's Installation settings. */\n site: string\n /**\n * Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party\n * delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back\n * to the same place.\n */\n src?: string\n /** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */\n outbound?: boolean\n /** Records clicks on links to files as `file_download`. Defaults to `true`. */\n downloads?: boolean\n /** Counts a change of `#/route` as a page view, for an app that routes by hash. */\n hashRouting?: boolean\n /** Changes or drops each event before it leaves the browser. See {@link BeforeSend}. */\n beforeSend?: BeforeSend\n /**\n * Logs every `track()` and `identify()` call to the console, and warns once when no tracker\n * script is on the page or the script failed to load. Defaults to `true` when\n * `process.env.NODE_ENV` is `development`, so a production build logs nothing.\n */\n debug?: boolean\n}\n\ndeclare global {\n interface Window {\n highseat?: HighseatGlobal\n }\n}\n\n/** Where the tracker loads from when the site has no proxy path. */\nconst SCRIPT_URL = \"https://cdn.highseat.com/script.js\"\n\n// The tracker marks its own API with this symbol. A site's own stub can define methods too, so\n// only the symbol proves the tracker is running.\nconst LIVE = Symbol.for(\"highseat\")\n\nconst SCRIPT_HOST = new URL(SCRIPT_URL).host\n// A site's proxy path: `/` and ten hex characters.\nconst PROXY_PATH = /^\\/[0-9a-f]{10}$/\n\nconst TRACKER_TIMEOUT = 3000\nconst TRACKER_POLL_INTERVAL = 50\n\nconst MISSING_SCRIPT_WARNING = [\n \"highseat: no tracker script on this page, so no event reaches Highseat.\",\n \"Render <Highseat />, call inject({ site }), or add the script tag from the site's Installation settings.\",\n].join(\" \")\nconst FAILED_SCRIPT_WARNING = [\n \"highseat: the tracker script failed to load, so no event reaches Highseat.\",\n \"An ad blocker may have stopped it: load the tracker from the site's proxy path instead.\",\n].join(\" \")\nconst SLOW_SCRIPT_WARNING = [\n `highseat: the tracker script has not loaded after ${TRACKER_TIMEOUT / 1000} seconds,`,\n \"so every call waits in the queue. Check the network panel for a blocked or failed request.\",\n].join(\" \")\n\n// The script tags `inject` added, and every tracker script whose request failed, such as one an\n// ad blocker stopped. A browser keeps no record of a failed script, so a failure is only known\n// to a listener attached before it happened.\nconst injectedScripts = new WeakSet<HTMLScriptElement>()\nconst failedScripts = new WeakSet<HTMLScriptElement>()\n\n// The `debug` option of the last `inject` call. Unset falls back to the build's environment.\nlet debugOption: boolean | undefined\n// The windows whose tracker is being watched, so a page warns about its script once.\nconst watchedWindows = new WeakSet<Window>()\n\nfunction findGlobal(): HighseatGlobal | undefined {\n if (typeof window === \"undefined\") return\n return (window.highseat ??= {})\n}\n\nfunction isLive(api: HighseatGlobal | undefined): api is HighseatGlobal {\n return !!api && (api as Record<symbol, unknown>)[LIVE] === true\n}\n\n// One function for every script, so attaching it on each poll adds no second listener.\nfunction markFailed(event: Event) {\n failedScripts.add(event.currentTarget as HTMLScriptElement)\n}\n\n/**\n * Whether a script tag loads the tracker, judged as the tracker judges itself. Fathom's snippet\n * carries a `data-site` too, so a tag is matched by a `site_` id, or by a `src` on a proxy path\n * or on Highseat's host: an older site's id is a UUID, which any tool could use.\n */\nfunction isTrackerScript(script: HTMLScriptElement) {\n const { site } = script.dataset\n if (injectedScripts.has(script) || site?.startsWith(\"site_\")) return true\n if (site === undefined) return false\n\n try {\n // A browser hands back `src` resolved; the base only matters to a relative one.\n const url = new URL(script.src, \"http://localhost\")\n return url.host === SCRIPT_HOST || PROXY_PATH.test(url.pathname)\n } catch {\n return false\n }\n}\n\n/** The page's tracker scripts, each one listened to from now on for a failed load. */\nfunction getTrackerScripts() {\n const scripts = Array.from(document.scripts).filter(isTrackerScript)\n for (const script of scripts) script.addEventListener(\"error\", markFailed)\n return scripts\n}\n\nfunction isDebug() {\n return debugOption ?? isDevelopment()\n}\n\n/**\n * Warns once per page when the tracker cannot arrive: no tracker script, or only failed ones. A\n * missing script is judged at the timeout, because `<Highseat />` adds its tag in an effect that\n * runs after the effects of the components inside it.\n */\nfunction watchTracker() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n if (watchedWindows.has(window)) return\n watchedWindows.add(window)\n\n const page = window\n const deadline = Date.now() + TRACKER_TIMEOUT\n\n function check() {\n // A replaced global window, as a test sets up for each case, is another page.\n if (window !== page || isLive(page.highseat)) return\n\n const scripts = getTrackerScripts()\n\n if (scripts.length > 0 && scripts.every(script => failedScripts.has(script))) {\n console.warn(FAILED_SCRIPT_WARNING)\n return\n }\n if (Date.now() < deadline) {\n setTimeout(check, TRACKER_POLL_INTERVAL)\n return\n }\n console.warn(scripts.length === 0 ? MISSING_SCRIPT_WARNING : SLOW_SCRIPT_WARNING)\n }\n\n setTimeout(check, TRACKER_POLL_INTERVAL)\n}\n\n/**\n * Logs a call when debugging is on. The check waits a microtask, so a `debug` option that\n * `<Highseat />` passes in its effect applies to the calls its children make in theirs.\n */\nfunction logCall(method: \"track\" | \"identify\", args: unknown[], isQueued: boolean) {\n queueMicrotask(() => {\n if (!isDebug()) return\n\n const status = isQueued ? \"queued until the tracker loads\" : \"sent to the tracker\"\n const [value, properties] = args\n // `JSON.stringify` quotes a name or a string id, and returns `undefined` for an unset id.\n const message = `highseat: ${method} ${String(JSON.stringify(value))}, ${status}`\n\n if (properties === undefined) console.info(message)\n else console.info(message, properties)\n watchTracker()\n })\n}\n\nfunction call(method: \"track\" | \"identify\", args: unknown[]) {\n const api = findGlobal()\n if (!api) return\n\n const handler = api[method] as ((...values: unknown[]) => void) | undefined\n if (typeof handler !== \"function\") {\n api.q ??= []\n api.q.push([method, args])\n logCall(method, args, true)\n return\n }\n\n logCall(method, args, false)\n try {\n handler(...args)\n } catch {}\n}\n\n/**\n * Records an event. A known name autocompletes and checks its properties; any other name is a\n * custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so\n * `Purchase` and `purchase` are two events.\n */\nexport function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void {\n const [properties] = args\n call(\"track\", properties === undefined ? [name] : [name, properties])\n}\n\n/**\n * Links the visitor to a user id from your own system, on this event and every later one, across\n * visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so\n * `identify(user?.id)` before a session loads does not sign the visitor out.\n */\nexport function identify(id: UserId): void {\n call(\"identify\", [id])\n}\n\n/**\n * The id that joins a payment to this tab's visit. Pass it to the checkout as metadata\n * (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.\n * Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has\n * no tracker script or its script fails to load (an ad blocker stopping it), after the timeout\n * when the script is still loading, and on a server or with tracking off.\n */\nexport async function getAttributionId(\n options: AttributionOptions = {},\n): Promise<string | undefined> {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return\n\n const deadline = Date.now() + (options.timeout ?? TRACKER_TIMEOUT)\n let api = window.highseat\n\n while (!isLive(api)) {\n if (Date.now() >= deadline) return\n\n const scripts = getTrackerScripts()\n // No tracker script at all, or only failed ones: the tracker cannot arrive.\n if (scripts.every(script => failedScripts.has(script))) return\n\n await new Promise(resolve => setTimeout(resolve, TRACKER_POLL_INTERVAL))\n api = window.highseat\n }\n\n try {\n return api.getAttributionId?.()\n } catch {\n return\n }\n}\n\n/**\n * Sets the {@link BeforeSend} hook, or clears it with `undefined`. Works before the tracker has\n * loaded, which picks the hook up when it starts, and applies from the next event once it runs.\n * Does nothing on a server.\n */\nexport function setBeforeSend(beforeSend: BeforeSend | undefined): void {\n const api = findGlobal()\n if (api) api.beforeSend = beforeSend\n}\n\n/**\n * Adds the tracker's script tag to the page, once. A second call for the same site, such as a\n * component mounting again, finds the tag and adds nothing, but its `debug` option still applies.\n * Does nothing on a server.\n */\nexport function inject(options: InjectOptions): void {\n if (typeof document === \"undefined\") return\n\n const { site, src = SCRIPT_URL, outbound, downloads, hashRouting, beforeSend, debug } = options\n debugOption = debug\n // Set before the script tag is added, so the first page view already passes through it.\n if (beforeSend) setBeforeSend(beforeSend)\n if (isDebug()) watchTracker()\n if (Array.from(document.scripts).some(script => script.dataset.site === site)) return\n\n const script = document.createElement(\"script\")\n script.addEventListener(\"error\", markFailed)\n injectedScripts.add(script)\n script.src = src\n script.dataset.site = site\n if (outbound === false) script.dataset.outbound = \"false\"\n if (downloads === false) script.dataset.downloads = \"false\"\n if (hashRouting) script.dataset.hashRouting = \"\"\n document.head.append(script)\n}\n"],"mappings":"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"}
package/ingest.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  //#region src/ingest.d.ts
2
2
  /**
3
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.
4
+ * secret's prefix and where the visitor's IP is read.
5
5
  */
6
6
  /** A host whose own header carries the visitor's IP. */
7
7
  export type ClientIpPreset = "vercel" | "cloudflare" | "netlify" | "nginx";
package/ingest.js CHANGED
@@ -2,11 +2,7 @@ import { findSecret } from "./env.js";
2
2
  //#region src/ingest.ts
3
3
  /** The ingest origin both handlers send to unless told otherwise. */
4
4
  const DEFAULT_UPSTREAM = "https://cdn.highseat.com";
5
- const SECRET_PREFIXES = [
6
- "hs_secret_",
7
- "hs_proxy_",
8
- "ana_proxy_"
9
- ];
5
+ const SECRET_PREFIX = "hs_secret_";
10
6
  const CLIENT_IP_HEADERS = {
11
7
  vercel: "x-real-ip",
12
8
  cloudflare: "cf-connecting-ip",
@@ -19,8 +15,8 @@ const CLIENT_IP_HEADERS = {
19
15
  */
20
16
  function findValidSecret(option, entry) {
21
17
  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]}".`);
18
+ if (!secret || secret.startsWith(SECRET_PREFIX)) return secret;
19
+ throw new Error(`${entry}: not a site secret. Copy it from the site's Installation settings; it starts with "${SECRET_PREFIX}".`);
24
20
  }
25
21
  function getClientIpReader(clientIp, entry) {
26
22
  if (typeof clientIp === "function") return {
package/ingest.js.map CHANGED
@@ -1 +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"}
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 prefix 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\nconst SECRET_PREFIX = \"hs_secret_\"\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.startsWith(SECRET_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_PREFIX}\".`,\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;AAEhC,MAAM,gBAAgB;AAEtB,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,OAAO,WAAW,aAAa,GAAG,OAAO;CAExD,MAAM,IAAI,MACR,GAAG,MAAM,sFAAsF,cAAc,GAC/G;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 { ClientIp, ClientIpPreset } from "./ingest.js";
2
- import { HighseatProps as HighseatProps$1, useBeforeSend, useIdentify } from "./react.js";
2
+ import { EmbedBackground, EmbedTheme, EmbedWidget } from "./embed.js";
3
+ import { Embed, EmbedProps, HighseatProps as HighseatProps$1, useBeforeSend, useIdentify } from "./react.js";
3
4
  import { ProxyHandler, ProxyOptions, createProxy } from "./proxy.js";
4
5
  import { ReactElement } from "react";
5
6
  //#region src/next.d.ts
@@ -66,5 +67,5 @@ export interface WithHighseatOptions {
66
67
  */
67
68
  export declare function withHighseat<Config extends object>(config: Config, options?: WithHighseatOptions): WithHighseatConfig<Config>;
68
69
  //#endregion
69
- export { type ClientIp, type ClientIpPreset, type ProxyHandler, type ProxyOptions, createProxy, useBeforeSend, useIdentify };
70
+ export { type ClientIp, type ClientIpPreset, Embed, type EmbedBackground, type EmbedProps, type EmbedTheme, type EmbedWidget, type ProxyHandler, type ProxyOptions, createProxy, useBeforeSend, useIdentify };
70
71
  //# sourceMappingURL=next.d.ts.map
package/next.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { findNextSite, findProxyPath } from "./env.js";
2
- import { Highseat as Highseat$1, useBeforeSend, useIdentify } from "./react.js";
2
+ import { Embed, Highseat as Highseat$1, useBeforeSend, useIdentify } from "./react.js";
3
3
  import { createProxy } from "./proxy.js";
4
4
  import { createElement } from "react";
5
5
  //#region src/next.ts
@@ -83,6 +83,6 @@ function withHighseat(config, options = {}) {
83
83
  };
84
84
  }
85
85
  //#endregion
86
- export { Highseat, createProxy, useBeforeSend, useIdentify, withHighseat };
86
+ export { Embed, Highseat, createProxy, useBeforeSend, useIdentify, withHighseat };
87
87
 
88
88
  //# sourceMappingURL=next.js.map
package/next.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"next.js","names":["ReactHighseat"],"sources":["../src/next.ts"],"sourcesContent":["import { createElement, type ReactElement } from \"react\"\nimport { findNextSite, findProxyPath } from \"./env\"\nimport { Highseat as ReactHighseat, type HighseatProps as ReactHighseatProps } from \"./react\"\n\nexport { createProxy } from \"./proxy\"\nexport { useBeforeSend, useIdentify } from \"./react\"\nexport type { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions } from \"./proxy\"\n\n/**\n * The props of `<Highseat />` in a server layout. `beforeSend` is left out: a server component\n * cannot pass a function to the browser, so the hook is set with {@link useBeforeSend} in a client\n * component instead.\n */\nexport interface HighseatProps extends Partial<Omit<ReactHighseatProps, \"beforeSend\">> {}\n\n/**\n * Loads the Highseat tracker in a Next.js app. Render it once, in the root layout. The site id\n * comes from `NEXT_PUBLIC_HIGHSEAT_SITE`, and the script loads from the site's proxy path in\n * `HIGHSEAT_PATH` when one is set. `HIGHSEAT_PATH` is a server variable, so in the Pages Router,\n * where the layout also renders in the browser, pass `src` instead. A prop wins over its variable.\n * `userId` takes an id the server already knows. For a session read in the browser, call\n * `useIdentify` in a client component instead, and set a `beforeSend` hook with `useBeforeSend`.\n */\nexport function Highseat(props: HighseatProps): ReactElement | null {\n const site = props.site ?? findNextSite()\n const src = props.src ?? findProxyPath()\n\n if (!site) {\n console.warn(\"highseat: set NEXT_PUBLIC_HIGHSEAT_SITE, or pass `site` to <Highseat />\")\n return null\n }\n\n return createElement(ReactHighseat, { ...props, site, src })\n}\n\n/** A Next.js rewrite: the subset of its fields this package writes and reads. */\ninterface Rewrite {\n source: string\n destination: string\n}\n\n/** The phases Next.js applies rewrites in, when `rewrites()` returns an object. */\ninterface RewritePhases {\n beforeFiles?: Rewrite[]\n afterFiles?: Rewrite[]\n fallback?: Rewrite[]\n}\n\n/** The part of a Next.js config `withHighseat` reads. */\ninterface NextConfigWithRewrites {\n rewrites?: () => Promise<Rewrite[] | RewritePhases>\n env?: Record<string, string | undefined>\n}\n\n/** A Next.js config with the proxy's rewrite added, or its own rewrites when there is no path. */\nexport type WithHighseatConfig<Config> = Config & {\n rewrites: () => Promise<Rewrite[] | RewritePhases>\n}\n\nexport interface WithHighseatOptions {\n /**\n * The site's proxy path from Installation settings. Defaults to the `HIGHSEAT_PATH`\n * environment variable, the one `createProxy()` reads, so rotating the path is an\n * environment change and a redeploy.\n */\n path?: string\n /**\n * Where the app's proxy route handler lives. Defaults to `/api/highseat`, the route of\n * `app/api/highseat/route.ts`.\n */\n route?: string\n}\n\nconst DEFAULT_ROUTE = \"/api/highseat\"\n\n// A dev session can ask for the rewrites more than once, so the warning is printed once.\nlet hasWarnedNoPath = false\n\n/**\n * Adds a rewrite from the site's proxy path to the app's proxy route handler, ahead of every\n * other route. The rewrite stays inside the app, so the handler still sees the visitor's IP, and\n * the route file never moves when the path rotates.\n *\n * The proxy is optional. Without a path, `next dev` and `next build` warn once and leave the\n * rewrite out, and `<Highseat />` loads the tracker from Highseat's host. Nothing here throws,\n * so `next typegen` runs without the variable too. With a path, it also sets `HIGHSEAT_PATH` in\n * the config's `env`, so Next.js writes the build's value into the bundled code of `<Highseat />`\n * and `createProxy()`. In the App Router, the script and the rewrite then agree, whatever the\n * server's environment.\n *\n * ```ts\n * // next.config.ts\n * import { withHighseat } from \"highseat/next\"\n * export default withHighseat({ reactStrictMode: true })\n * ```\n */\nexport function withHighseat<Config extends object>(\n config: Config,\n options: WithHighseatOptions = {},\n): WithHighseatConfig<Config> {\n const path = findProxyPath(options.path)\n\n // Typed loosely so any Next.js version's config fits; only `rewrites` and `env` are read.\n const { rewrites: ownRewrites, env } = config as NextConfigWithRewrites\n\n async function passRewritesThrough(): Promise<Rewrite[] | RewritePhases> {\n if (!hasWarnedNoPath) {\n hasWarnedNoPath = true\n console.warn(\n \"highseat/next: no HIGHSEAT_PATH, so this build has no proxy rewrite and the tracker loads from Highseat's host. For first-party delivery, set it to the path from the site's Installation settings, or pass `path`, for the build as well as at runtime: a path set only at runtime points the tracker at a route this build does not have.\",\n )\n }\n return (await ownRewrites?.()) ?? []\n }\n\n if (!path) return { ...config, rewrites: passRewritesThrough }\n\n const rewrite = { source: path, destination: options.route ?? DEFAULT_ROUTE }\n\n async function rewrites(): Promise<RewritePhases> {\n const existing = (await ownRewrites?.()) ?? []\n\n // A plain array is Next.js shorthand for the `afterFiles` phase.\n if (Array.isArray(existing)) return { beforeFiles: [rewrite], afterFiles: existing }\n return { ...existing, beforeFiles: [rewrite, ...(existing.beforeFiles ?? [])] }\n }\n\n return { ...config, env: { ...env, HIGHSEAT_PATH: path }, rewrites }\n}\n"],"mappings":";;;;;;;;;;;;;AAuBA,SAAgB,SAAS,OAA2C;CAClE,MAAM,OAAO,MAAM,QAAQ,aAAa;CACxC,MAAM,MAAM,MAAM,OAAO,cAAc;CAEvC,IAAI,CAAC,MAAM;EACT,QAAQ,KAAK,yEAAyE;EACtF,OAAO;CACT;CAEA,OAAO,cAAcA,YAAe;EAAE,GAAG;EAAO;EAAM;CAAI,CAAC;AAC7D;AAwCA,MAAM,gBAAgB;AAGtB,IAAI,kBAAkB;;;;;;;;;;;;;;;;;;;AAoBtB,SAAgB,aACd,QACA,UAA+B,CAAC,GACJ;CAC5B,MAAM,OAAO,cAAc,QAAQ,IAAI;CAGvC,MAAM,EAAE,UAAU,aAAa,QAAQ;CAEvC,eAAe,sBAA0D;EACvE,IAAI,CAAC,iBAAiB;GACpB,kBAAkB;GAClB,QAAQ,KACN,6UACF;EACF;EACA,OAAQ,MAAM,cAAc,KAAM,CAAC;CACrC;CAEA,IAAI,CAAC,MAAM,OAAO;EAAE,GAAG;EAAQ,UAAU;CAAoB;CAE7D,MAAM,UAAU;EAAE,QAAQ;EAAM,aAAa,QAAQ,SAAS;CAAc;CAE5E,eAAe,WAAmC;EAChD,MAAM,WAAY,MAAM,cAAc,KAAM,CAAC;EAG7C,IAAI,MAAM,QAAQ,QAAQ,GAAG,OAAO;GAAE,aAAa,CAAC,OAAO;GAAG,YAAY;EAAS;EACnF,OAAO;GAAE,GAAG;GAAU,aAAa,CAAC,SAAS,GAAI,SAAS,eAAe,CAAC,CAAE;EAAE;CAChF;CAEA,OAAO;EAAE,GAAG;EAAQ,KAAK;GAAE,GAAG;GAAK,eAAe;EAAK;EAAG;CAAS;AACrE"}
1
+ {"version":3,"file":"next.js","names":["ReactHighseat"],"sources":["../src/next.ts"],"sourcesContent":["import { createElement, type ReactElement } from \"react\"\nimport { findNextSite, findProxyPath } from \"./env\"\nimport { Highseat as ReactHighseat, type HighseatProps as ReactHighseatProps } from \"./react\"\n\nexport { createProxy } from \"./proxy\"\nexport { Embed, useBeforeSend, useIdentify } from \"./react\"\nexport type { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions } from \"./proxy\"\nexport type { EmbedBackground, EmbedProps, EmbedTheme, EmbedWidget } from \"./react\"\n\n/**\n * The props of `<Highseat />` in a server layout. `beforeSend` is left out: a server component\n * cannot pass a function to the browser, so the hook is set with {@link useBeforeSend} in a client\n * component instead.\n */\nexport interface HighseatProps extends Partial<Omit<ReactHighseatProps, \"beforeSend\">> {}\n\n/**\n * Loads the Highseat tracker in a Next.js app. Render it once, in the root layout. The site id\n * comes from `NEXT_PUBLIC_HIGHSEAT_SITE`, and the script loads from the site's proxy path in\n * `HIGHSEAT_PATH` when one is set. `HIGHSEAT_PATH` is a server variable, so in the Pages Router,\n * where the layout also renders in the browser, pass `src` instead. A prop wins over its variable.\n * `userId` takes an id the server already knows. For a session read in the browser, call\n * `useIdentify` in a client component instead, and set a `beforeSend` hook with `useBeforeSend`.\n */\nexport function Highseat(props: HighseatProps): ReactElement | null {\n const site = props.site ?? findNextSite()\n const src = props.src ?? findProxyPath()\n\n if (!site) {\n console.warn(\"highseat: set NEXT_PUBLIC_HIGHSEAT_SITE, or pass `site` to <Highseat />\")\n return null\n }\n\n return createElement(ReactHighseat, { ...props, site, src })\n}\n\n/** A Next.js rewrite: the subset of its fields this package writes and reads. */\ninterface Rewrite {\n source: string\n destination: string\n}\n\n/** The phases Next.js applies rewrites in, when `rewrites()` returns an object. */\ninterface RewritePhases {\n beforeFiles?: Rewrite[]\n afterFiles?: Rewrite[]\n fallback?: Rewrite[]\n}\n\n/** The part of a Next.js config `withHighseat` reads. */\ninterface NextConfigWithRewrites {\n rewrites?: () => Promise<Rewrite[] | RewritePhases>\n env?: Record<string, string | undefined>\n}\n\n/** A Next.js config with the proxy's rewrite added, or its own rewrites when there is no path. */\nexport type WithHighseatConfig<Config> = Config & {\n rewrites: () => Promise<Rewrite[] | RewritePhases>\n}\n\nexport interface WithHighseatOptions {\n /**\n * The site's proxy path from Installation settings. Defaults to the `HIGHSEAT_PATH`\n * environment variable, the one `createProxy()` reads, so rotating the path is an\n * environment change and a redeploy.\n */\n path?: string\n /**\n * Where the app's proxy route handler lives. Defaults to `/api/highseat`, the route of\n * `app/api/highseat/route.ts`.\n */\n route?: string\n}\n\nconst DEFAULT_ROUTE = \"/api/highseat\"\n\n// A dev session can ask for the rewrites more than once, so the warning is printed once.\nlet hasWarnedNoPath = false\n\n/**\n * Adds a rewrite from the site's proxy path to the app's proxy route handler, ahead of every\n * other route. The rewrite stays inside the app, so the handler still sees the visitor's IP, and\n * the route file never moves when the path rotates.\n *\n * The proxy is optional. Without a path, `next dev` and `next build` warn once and leave the\n * rewrite out, and `<Highseat />` loads the tracker from Highseat's host. Nothing here throws,\n * so `next typegen` runs without the variable too. With a path, it also sets `HIGHSEAT_PATH` in\n * the config's `env`, so Next.js writes the build's value into the bundled code of `<Highseat />`\n * and `createProxy()`. In the App Router, the script and the rewrite then agree, whatever the\n * server's environment.\n *\n * ```ts\n * // next.config.ts\n * import { withHighseat } from \"highseat/next\"\n * export default withHighseat({ reactStrictMode: true })\n * ```\n */\nexport function withHighseat<Config extends object>(\n config: Config,\n options: WithHighseatOptions = {},\n): WithHighseatConfig<Config> {\n const path = findProxyPath(options.path)\n\n // Typed loosely so any Next.js version's config fits; only `rewrites` and `env` are read.\n const { rewrites: ownRewrites, env } = config as NextConfigWithRewrites\n\n async function passRewritesThrough(): Promise<Rewrite[] | RewritePhases> {\n if (!hasWarnedNoPath) {\n hasWarnedNoPath = true\n console.warn(\n \"highseat/next: no HIGHSEAT_PATH, so this build has no proxy rewrite and the tracker loads from Highseat's host. For first-party delivery, set it to the path from the site's Installation settings, or pass `path`, for the build as well as at runtime: a path set only at runtime points the tracker at a route this build does not have.\",\n )\n }\n return (await ownRewrites?.()) ?? []\n }\n\n if (!path) return { ...config, rewrites: passRewritesThrough }\n\n const rewrite = { source: path, destination: options.route ?? DEFAULT_ROUTE }\n\n async function rewrites(): Promise<RewritePhases> {\n const existing = (await ownRewrites?.()) ?? []\n\n // A plain array is Next.js shorthand for the `afterFiles` phase.\n if (Array.isArray(existing)) return { beforeFiles: [rewrite], afterFiles: existing }\n return { ...existing, beforeFiles: [rewrite, ...(existing.beforeFiles ?? [])] }\n }\n\n return { ...config, env: { ...env, HIGHSEAT_PATH: path }, rewrites }\n}\n"],"mappings":";;;;;;;;;;;;;AAwBA,SAAgB,SAAS,OAA2C;CAClE,MAAM,OAAO,MAAM,QAAQ,aAAa;CACxC,MAAM,MAAM,MAAM,OAAO,cAAc;CAEvC,IAAI,CAAC,MAAM;EACT,QAAQ,KAAK,yEAAyE;EACtF,OAAO;CACT;CAEA,OAAO,cAAcA,YAAe;EAAE,GAAG;EAAO;EAAM;CAAI,CAAC;AAC7D;AAwCA,MAAM,gBAAgB;AAGtB,IAAI,kBAAkB;;;;;;;;;;;;;;;;;;;AAoBtB,SAAgB,aACd,QACA,UAA+B,CAAC,GACJ;CAC5B,MAAM,OAAO,cAAc,QAAQ,IAAI;CAGvC,MAAM,EAAE,UAAU,aAAa,QAAQ;CAEvC,eAAe,sBAA0D;EACvE,IAAI,CAAC,iBAAiB;GACpB,kBAAkB;GAClB,QAAQ,KACN,6UACF;EACF;EACA,OAAQ,MAAM,cAAc,KAAM,CAAC;CACrC;CAEA,IAAI,CAAC,MAAM,OAAO;EAAE,GAAG;EAAQ,UAAU;CAAoB;CAE7D,MAAM,UAAU;EAAE,QAAQ;EAAM,aAAa,QAAQ,SAAS;CAAc;CAE5E,eAAe,WAAmC;EAChD,MAAM,WAAY,MAAM,cAAc,KAAM,CAAC;EAG7C,IAAI,MAAM,QAAQ,QAAQ,GAAG,OAAO;GAAE,aAAa,CAAC,OAAO;GAAG,YAAY;EAAS;EACnF,OAAO;GAAE,GAAG;GAAU,aAAa,CAAC,SAAS,GAAI,SAAS,eAAe,CAAC,CAAE;EAAE;CAChF;CAEA,OAAO;EAAE,GAAG;EAAQ,KAAK;GAAE,GAAG;GAAK,eAAe;EAAK;EAAG;CAAS;AACrE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "highseat",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
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",
package/proxy.d.ts CHANGED
@@ -10,9 +10,9 @@ export interface ProxyOptions {
10
10
  */
11
11
  path?: string;
12
12
  /**
13
- * The site secret, `hs_secret_…`, or an older one that starts `hs_proxy_` or `ana_proxy_`.
14
- * Defaults to the `HIGHSEAT_SECRET` environment variable. Without one the proxy is off: the
15
- * proxy path answers 503, or every request goes to `fallback`.
13
+ * The site secret, `hs_secret_…`, from the site's Installation settings. Defaults to the
14
+ * `HIGHSEAT_SECRET` environment variable. Without one the proxy is off: the proxy path answers
15
+ * 503, or every request goes to `fallback`.
16
16
  */
17
17
  secret?: string;
18
18
  /**
package/proxy.js.map CHANGED
@@ -1 +1 @@
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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"}
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_…`, from the site's Installation settings. Defaults to the\n * `HIGHSEAT_SECRET` environment variable. Without one the proxy is off: the proxy path answers\n * 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/react.d.ts CHANGED
@@ -1,13 +1,7 @@
1
1
  import { BeforeSend, InjectOptions, UserId } from "./index.js";
2
+ import { EmbedBackground, EmbedTheme, EmbedWidget } from "./embed.js";
3
+ import { CSSProperties, IframeHTMLAttributes, ReactElement } from "react";
2
4
  //#region src/react.d.ts
3
- export interface HighseatProps extends InjectOptions {
4
- /**
5
- * The signed-in user's id from your own system, never an email or other personal data. Pass
6
- * `null` once the visitor is signed out, and `undefined` while the session is still loading.
7
- * See {@link useIdentify}.
8
- */
9
- userId?: UserId;
10
- }
11
5
  /**
12
6
  * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`
13
7
  * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and
@@ -39,6 +33,14 @@ export declare function useIdentify(userId: UserId): void;
39
33
  * ```
40
34
  */
41
35
  export declare function useBeforeSend(beforeSend: BeforeSend | undefined): void;
36
+ export interface HighseatProps extends InjectOptions {
37
+ /**
38
+ * The signed-in user's id from your own system, never an email or other personal data. Pass
39
+ * `null` once the visitor is signed out, and `undefined` while the session is still loading.
40
+ * See {@link useIdentify}.
41
+ */
42
+ userId?: UserId;
43
+ }
42
44
  /**
43
45
  * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
44
46
  * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
@@ -46,5 +48,53 @@ export declare function useBeforeSend(beforeSend: BeforeSend | undefined): void;
46
48
  * {@link useIdentify} does, and with `beforeSend` it sets the hook, as {@link useBeforeSend} does.
47
49
  */
48
50
  export declare function Highseat({ site, src, outbound, downloads, hashRouting, beforeSend, debug, userId }: HighseatProps): null;
51
+ /**
52
+ * The props of `<Embed />`. Attributes it does not name, such as `id` or `loading`, go to the
53
+ * frame, while `className` and `style` go to the box around the frame and the credit link.
54
+ */
55
+ export interface EmbedProps extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, "children" | "className" | "height" | "src" | "srcDoc" | "style" | "title" | "width"> {
56
+ /** The token of a share link or a widget, the last part of its embed code's `/share/` URL. */
57
+ token: string;
58
+ /** The frame's accessible name, which a screen reader announces. */
59
+ title: string;
60
+ /**
61
+ * The embed's theme. Leave it out for `system`, which follows the reader's own setting. Pass
62
+ * the page's theme when the page has its own switch, or the frame can disagree with the page.
63
+ * Pass `null` while that theme is still unknown after mount: the slot stays empty until then,
64
+ * so the frame loads once.
65
+ */
66
+ theme?: EmbedTheme | null;
67
+ /**
68
+ * The embed's background. `transparent`, the default for a widget, lets your page's background
69
+ * show through. `default`, the default for a whole board, keeps the board's own.
70
+ */
71
+ background?: EmbedBackground;
72
+ /** The kind of widget the token shows, which sets the height held before the frame loads. */
73
+ widget?: EmbedWidget;
74
+ /** The height in pixels to hold until the embed reports its own. */
75
+ height?: number;
76
+ /** Whether to show the "Analytics by Highseat" link under the frame. It is yours to remove. */
77
+ credit?: boolean;
78
+ /** A class for the credit link. It replaces the link's default spacing and size. */
79
+ creditClassName?: string;
80
+ /** A class for the box around the frame and the credit link. */
81
+ className?: string;
82
+ /** A style for the box around the frame and the credit link. */
83
+ style?: CSSProperties;
84
+ }
85
+ /**
86
+ * Shows a Highseat share link or widget in a frame that fits its content, with a followed credit
87
+ * link under it. Needs no script tag: the component listens for the embed's height itself.
88
+ *
89
+ * The frame renders only after mount, so the page's theme can come from a client-only source such
90
+ * as `next-themes` with no hydration mismatch. Until then, an empty box holds the frame's height,
91
+ * and the credit link is already in the server's HTML.
92
+ *
93
+ * ```tsx
94
+ * <Embed token="YOUR_WIDGET_TOKEN" title="Visitors" widget="chart" theme="dark" />
95
+ * ```
96
+ */
97
+ export declare function Embed({ token, title, theme, background, widget, height, credit, creditClassName, className, style, ...frameProps }: EmbedProps): ReactElement;
49
98
  //#endregion
99
+ export type { EmbedBackground, EmbedTheme, EmbedWidget };
50
100
  //# sourceMappingURL=react.d.ts.map
package/react.js CHANGED
@@ -1,8 +1,18 @@
1
1
  "use client";
2
2
  import { inject, setBeforeSend } from "./index.js";
3
+ import { EMBED_FALLBACK_HEIGHT, WIDGET_FALLBACK_HEIGHTS, findEmbedHeight, formatEmbedSrc, getFrameColorScheme } from "./embed.js";
3
4
  import { syncIdentity } from "./identity.js";
4
- import { useEffect, useRef } from "react";
5
+ import { createElement, useEffect, useRef, useState, useSyncExternalStore } from "react";
5
6
  //#region src/react.ts
7
+ const CREDIT_STYLE = {
8
+ display: "block",
9
+ width: "fit-content",
10
+ marginTop: 8,
11
+ fontSize: 12
12
+ };
13
+ function subscribeToNothing() {
14
+ return () => {};
15
+ }
6
16
  /**
7
17
  * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`
8
18
  * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and
@@ -75,7 +85,64 @@ function Highseat({ site, src, outbound, downloads, hashRouting, beforeSend, deb
75
85
  useIdentify(userId);
76
86
  return null;
77
87
  }
88
+ /**
89
+ * Shows a Highseat share link or widget in a frame that fits its content, with a followed credit
90
+ * link under it. Needs no script tag: the component listens for the embed's height itself.
91
+ *
92
+ * The frame renders only after mount, so the page's theme can come from a client-only source such
93
+ * as `next-themes` with no hydration mismatch. Until then, an empty box holds the frame's height,
94
+ * and the credit link is already in the server's HTML.
95
+ *
96
+ * ```tsx
97
+ * <Embed token="YOUR_WIDGET_TOKEN" title="Visitors" widget="chart" theme="dark" />
98
+ * ```
99
+ */
100
+ function Embed({ token, title, theme, background, widget, height, credit = true, creditClassName, className, style, ...frameProps }) {
101
+ const isMounted = useSyncExternalStore(subscribeToNothing, () => true, () => false);
102
+ const [frame, setFrame] = useState(null);
103
+ const [reportedHeight, setReportedHeight] = useState();
104
+ useEffect(() => {
105
+ if (!frame) return;
106
+ function onMessage(event) {
107
+ const nextHeight = findEmbedHeight(event, frame?.contentWindow);
108
+ if (nextHeight !== void 0) setReportedHeight(nextHeight);
109
+ }
110
+ window.addEventListener("message", onMessage);
111
+ return () => window.removeEventListener("message", onMessage);
112
+ }, [frame]);
113
+ const look = {
114
+ theme: theme ?? "system",
115
+ background: background ?? (widget ? "transparent" : "default")
116
+ };
117
+ const fallbackHeight = widget ? WIDGET_FALLBACK_HEIGHTS[widget] : EMBED_FALLBACK_HEIGHT;
118
+ const frameHeight = reportedHeight ?? height ?? fallbackHeight;
119
+ const src = formatEmbedSrc(token, look);
120
+ return createElement("div", {
121
+ className,
122
+ style
123
+ }, isMounted && theme !== null ? createElement("iframe", {
124
+ loading: "lazy",
125
+ ...frameProps,
126
+ key: src,
127
+ ref: setFrame,
128
+ src,
129
+ title,
130
+ style: {
131
+ display: "block",
132
+ width: "100%",
133
+ height: frameHeight,
134
+ border: 0,
135
+ ...(look.background === "transparent" || widget) && { colorScheme: getFrameColorScheme(look.theme) }
136
+ }
137
+ }) : createElement("div", { style: { height: frameHeight } }), credit && createElement("a", {
138
+ href: "https://highseat.com/?ref=embed",
139
+ target: "_blank",
140
+ rel: "noopener",
141
+ className: creditClassName,
142
+ style: creditClassName ? void 0 : CREDIT_STYLE
143
+ }, "Analytics by Highseat"));
144
+ }
78
145
  //#endregion
79
- export { Highseat, useBeforeSend, useIdentify };
146
+ export { Embed, Highseat, useBeforeSend, useIdentify };
80
147
 
81
148
  //# sourceMappingURL=react.js.map
package/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { syncIdentity } from \"./identity\"\nimport { inject, setBeforeSend, type BeforeSend, type InjectOptions, type UserId } from \"./index\"\n\nexport interface HighseatProps extends InjectOptions {\n /**\n * The signed-in user's id from your own system, never an email or other personal data. Pass\n * `null` once the visitor is signed out, and `undefined` while the session is still loading.\n * See {@link useIdentify}.\n */\n userId?: UserId\n}\n\n/**\n * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`\n * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and\n * `undefined`, a session still loading, changes nothing. Works in any client component, with or\n * without `<Highseat />` on the page, and before the tracker has loaded.\n *\n * Tell loading apart from signed out: `useIdentify(session?.user.id)` passes `undefined` after\n * sign-out too, so it never clears the id.\n *\n * ```tsx\n * const { data, isPending } = useSession()\n * useIdentify(isPending ? undefined : (data?.user.id ?? null))\n * ```\n */\nexport function useIdentify(userId: UserId): void {\n // The id this component identified last. A ref survives Strict Mode's second effect run, so\n // the same id is never sent twice.\n const identified = useRef<UserId>(undefined)\n\n useEffect(() => {\n identified.current = syncIdentity(identified.current, userId)\n }, [userId])\n}\n\n/**\n * Sets the {@link BeforeSend} hook while the component is mounted, and clears it on unmount.\n * `undefined` leaves the current hook alone. Use it in a client component when the tracker loads\n * from a server component, such as `<Highseat />` in a Next.js App Router layout, where a\n * function cannot be passed as a prop. Effects run before the tracker's script loads, so the\n * first page view already passes through the hook.\n *\n * ```tsx\n * \"use client\"\n * export function Redact() {\n * useBeforeSend(event => (event.url.includes(\"/admin/\") ? null : event))\n * return null\n * }\n * ```\n */\nexport function useBeforeSend(beforeSend: BeforeSend | undefined): void {\n useEffect(() => {\n if (!beforeSend) return\n setBeforeSend(beforeSend)\n return () => {\n // Another component may have set its own hook since.\n if (window.highseat?.beforeSend === beforeSend) setBeforeSend(undefined)\n }\n }, [beforeSend])\n}\n\n/**\n * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after\n * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker\n * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as\n * {@link useIdentify} does, and with `beforeSend` it sets the hook, as {@link useBeforeSend} does.\n */\nexport function Highseat({\n site,\n src,\n outbound,\n downloads,\n hashRouting,\n beforeSend,\n debug,\n userId,\n}: HighseatProps): null {\n // Declared first, so its effect sets the hook before the next one adds the script tag.\n useBeforeSend(beforeSend)\n\n useEffect(() => {\n inject({ site, src, outbound, downloads, hashRouting, debug })\n }, [site, src, outbound, downloads, hashRouting, debug])\n\n useIdentify(userId)\n\n return null\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,YAAY,QAAsB;CAGhD,MAAM,aAAa,OAAe,KAAA,CAAS;CAE3C,gBAAgB;EACd,WAAW,UAAU,aAAa,WAAW,SAAS,MAAM;CAC9D,GAAG,CAAC,MAAM,CAAC;AACb;;;;;;;;;;;;;;;;AAiBA,SAAgB,cAAc,YAA0C;CACtE,gBAAgB;EACd,IAAI,CAAC,YAAY;EACjB,cAAc,UAAU;EACxB,aAAa;GAEX,IAAI,OAAO,UAAU,eAAe,YAAY,cAAc,KAAA,CAAS;EACzE;CACF,GAAG,CAAC,UAAU,CAAC;AACjB;;;;;;;AAQA,SAAgB,SAAS,EACvB,MACA,KACA,UACA,WACA,aACA,YACA,OACA,UACsB;CAEtB,cAAc,UAAU;CAExB,gBAAgB;EACd,OAAO;GAAE;GAAM;GAAK;GAAU;GAAW;GAAa;EAAM,CAAC;CAC/D,GAAG;EAAC;EAAM;EAAK;EAAU;EAAW;EAAa;CAAK,CAAC;CAEvD,YAAY,MAAM;CAElB,OAAO;AACT"}
1
+ {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["\"use client\"\n\nimport {\n createElement,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type IframeHTMLAttributes,\n type ReactElement,\n} from \"react\"\nimport {\n EMBED_CREDIT_TEXT,\n EMBED_CREDIT_URL,\n EMBED_FALLBACK_HEIGHT,\n findEmbedHeight,\n formatEmbedSrc,\n getFrameColorScheme,\n WIDGET_FALLBACK_HEIGHTS,\n type EmbedBackground,\n type EmbedTheme,\n type EmbedWidget,\n} from \"./embed\"\nimport { syncIdentity } from \"./identity\"\nimport { inject, setBeforeSend, type BeforeSend, type InjectOptions, type UserId } from \"./index\"\n\nexport type { EmbedBackground, EmbedTheme, EmbedWidget } from \"./embed\"\n\nconst CREDIT_STYLE: CSSProperties = {\n display: \"block\",\n width: \"fit-content\",\n marginTop: 8,\n fontSize: 12,\n}\n\nfunction subscribeToNothing() {\n return () => {}\n}\n\n/**\n * Keeps the visitor's user id in step with a session. Each new value is passed to `identify`\n * once: an id identifies the visitor, `null` clears the id at sign-out or session expiry, and\n * `undefined`, a session still loading, changes nothing. Works in any client component, with or\n * without `<Highseat />` on the page, and before the tracker has loaded.\n *\n * Tell loading apart from signed out: `useIdentify(session?.user.id)` passes `undefined` after\n * sign-out too, so it never clears the id.\n *\n * ```tsx\n * const { data, isPending } = useSession()\n * useIdentify(isPending ? undefined : (data?.user.id ?? null))\n * ```\n */\nexport function useIdentify(userId: UserId): void {\n // The id this component identified last. A ref survives Strict Mode's second effect run, so\n // the same id is never sent twice.\n const identified = useRef<UserId>(undefined)\n\n useEffect(() => {\n identified.current = syncIdentity(identified.current, userId)\n }, [userId])\n}\n\n/**\n * Sets the {@link BeforeSend} hook while the component is mounted, and clears it on unmount.\n * `undefined` leaves the current hook alone. Use it in a client component when the tracker loads\n * from a server component, such as `<Highseat />` in a Next.js App Router layout, where a\n * function cannot be passed as a prop. Effects run before the tracker's script loads, so the\n * first page view already passes through the hook.\n *\n * ```tsx\n * \"use client\"\n * export function Redact() {\n * useBeforeSend(event => (event.url.includes(\"/admin/\") ? null : event))\n * return null\n * }\n * ```\n */\nexport function useBeforeSend(beforeSend: BeforeSend | undefined): void {\n useEffect(() => {\n if (!beforeSend) return\n setBeforeSend(beforeSend)\n return () => {\n // Another component may have set its own hook since.\n if (window.highseat?.beforeSend === beforeSend) setBeforeSend(undefined)\n }\n }, [beforeSend])\n}\n\nexport interface HighseatProps extends InjectOptions {\n /**\n * The signed-in user's id from your own system, never an email or other personal data. Pass\n * `null` once the visitor is signed out, and `undefined` while the session is still loading.\n * See {@link useIdentify}.\n */\n userId?: UserId\n}\n\n/**\n * Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after\n * the page mounts and renders nothing; mounting it again adds no second tag, and the tracker\n * counts client-side navigation on its own. With `userId`, it also identifies the visitor, as\n * {@link useIdentify} does, and with `beforeSend` it sets the hook, as {@link useBeforeSend} does.\n */\nexport function Highseat({\n site,\n src,\n outbound,\n downloads,\n hashRouting,\n beforeSend,\n debug,\n userId,\n}: HighseatProps): null {\n // Declared first, so its effect sets the hook before the next one adds the script tag.\n useBeforeSend(beforeSend)\n\n useEffect(() => {\n inject({ site, src, outbound, downloads, hashRouting, debug })\n }, [site, src, outbound, downloads, hashRouting, debug])\n\n useIdentify(userId)\n\n return null\n}\n\n/**\n * The props of `<Embed />`. Attributes it does not name, such as `id` or `loading`, go to the\n * frame, while `className` and `style` go to the box around the frame and the credit link.\n */\nexport interface EmbedProps extends Omit<\n IframeHTMLAttributes<HTMLIFrameElement>,\n \"children\" | \"className\" | \"height\" | \"src\" | \"srcDoc\" | \"style\" | \"title\" | \"width\"\n> {\n /** The token of a share link or a widget, the last part of its embed code's `/share/` URL. */\n token: string\n /** The frame's accessible name, which a screen reader announces. */\n title: string\n /**\n * The embed's theme. Leave it out for `system`, which follows the reader's own setting. Pass\n * the page's theme when the page has its own switch, or the frame can disagree with the page.\n * Pass `null` while that theme is still unknown after mount: the slot stays empty until then,\n * so the frame loads once.\n */\n theme?: EmbedTheme | null\n /**\n * The embed's background. `transparent`, the default for a widget, lets your page's background\n * show through. `default`, the default for a whole board, keeps the board's own.\n */\n background?: EmbedBackground\n /** The kind of widget the token shows, which sets the height held before the frame loads. */\n widget?: EmbedWidget\n /** The height in pixels to hold until the embed reports its own. */\n height?: number\n /** Whether to show the \"Analytics by Highseat\" link under the frame. It is yours to remove. */\n credit?: boolean\n /** A class for the credit link. It replaces the link's default spacing and size. */\n creditClassName?: string\n /** A class for the box around the frame and the credit link. */\n className?: string\n /** A style for the box around the frame and the credit link. */\n style?: CSSProperties\n}\n\n/**\n * Shows a Highseat share link or widget in a frame that fits its content, with a followed credit\n * link under it. Needs no script tag: the component listens for the embed's height itself.\n *\n * The frame renders only after mount, so the page's theme can come from a client-only source such\n * as `next-themes` with no hydration mismatch. Until then, an empty box holds the frame's height,\n * and the credit link is already in the server's HTML.\n *\n * ```tsx\n * <Embed token=\"YOUR_WIDGET_TOKEN\" title=\"Visitors\" widget=\"chart\" theme=\"dark\" />\n * ```\n */\nexport function Embed({\n token,\n title,\n theme,\n background,\n widget,\n height,\n credit = true,\n creditClassName,\n className,\n style,\n ...frameProps\n}: EmbedProps): ReactElement {\n // React reads the last snapshot on the server and during hydration, so both render the slot.\n const isMounted = useSyncExternalStore(\n subscribeToNothing,\n () => true,\n () => false,\n )\n const [frame, setFrame] = useState<HTMLIFrameElement | null>(null)\n const [reportedHeight, setReportedHeight] = useState<number>()\n\n // The listener starts as the frame enters the page, before its page can load and post, so the\n // first height is heard and no `highseat:embed-request` is needed.\n useEffect(() => {\n if (!frame) return\n\n function onMessage(event: MessageEvent) {\n const nextHeight = findEmbedHeight(event, frame?.contentWindow)\n if (nextHeight !== undefined) setReportedHeight(nextHeight)\n }\n\n window.addEventListener(\"message\", onMessage)\n return () => window.removeEventListener(\"message\", onMessage)\n }, [frame])\n\n const look = {\n theme: theme ?? \"system\",\n background: background ?? (widget ? \"transparent\" : \"default\"),\n } as const\n const fallbackHeight = widget ? WIDGET_FALLBACK_HEIGHTS[widget] : EMBED_FALLBACK_HEIGHT\n const frameHeight = reportedHeight ?? height ?? fallbackHeight\n const src = formatEmbedSrc(token, look)\n\n return createElement(\n \"div\",\n { className, style },\n isMounted && theme !== null\n ? createElement(\"iframe\", {\n loading: \"lazy\",\n ...frameProps,\n // A new element per URL: changing `src` on a live frame would add a history entry, so\n // the host page's back button would step through the reader's theme changes.\n key: src,\n ref: setFrame,\n src,\n title,\n style: {\n display: \"block\",\n width: \"100%\",\n height: frameHeight,\n border: 0,\n // A widget's page is see-through around its card whatever the background.\n ...((look.background === \"transparent\" || widget) && {\n colorScheme: getFrameColorScheme(look.theme),\n }),\n },\n })\n : createElement(\"div\", { style: { height: frameHeight } }),\n credit &&\n createElement(\n \"a\",\n {\n href: EMBED_CREDIT_URL,\n target: \"_blank\",\n rel: \"noopener\",\n className: creditClassName,\n style: creditClassName ? undefined : CREDIT_STYLE,\n },\n EMBED_CREDIT_TEXT,\n ),\n )\n}\n"],"mappings":";;;;;;AA6BA,MAAM,eAA8B;CAClC,SAAS;CACT,OAAO;CACP,WAAW;CACX,UAAU;AACZ;AAEA,SAAS,qBAAqB;CAC5B,aAAa,CAAC;AAChB;;;;;;;;;;;;;;;AAgBA,SAAgB,YAAY,QAAsB;CAGhD,MAAM,aAAa,OAAe,KAAA,CAAS;CAE3C,gBAAgB;EACd,WAAW,UAAU,aAAa,WAAW,SAAS,MAAM;CAC9D,GAAG,CAAC,MAAM,CAAC;AACb;;;;;;;;;;;;;;;;AAiBA,SAAgB,cAAc,YAA0C;CACtE,gBAAgB;EACd,IAAI,CAAC,YAAY;EACjB,cAAc,UAAU;EACxB,aAAa;GAEX,IAAI,OAAO,UAAU,eAAe,YAAY,cAAc,KAAA,CAAS;EACzE;CACF,GAAG,CAAC,UAAU,CAAC;AACjB;;;;;;;AAiBA,SAAgB,SAAS,EACvB,MACA,KACA,UACA,WACA,aACA,YACA,OACA,UACsB;CAEtB,cAAc,UAAU;CAExB,gBAAgB;EACd,OAAO;GAAE;GAAM;GAAK;GAAU;GAAW;GAAa;EAAM,CAAC;CAC/D,GAAG;EAAC;EAAM;EAAK;EAAU;EAAW;EAAa;CAAK,CAAC;CAEvD,YAAY,MAAM;CAElB,OAAO;AACT;;;;;;;;;;;;;AAoDA,SAAgB,MAAM,EACpB,OACA,OACA,OACA,YACA,QACA,QACA,SAAS,MACT,iBACA,WACA,OACA,GAAG,cACwB;CAE3B,MAAM,YAAY,qBAChB,0BACM,YACA,KACR;CACA,MAAM,CAAC,OAAO,YAAY,SAAmC,IAAI;CACjE,MAAM,CAAC,gBAAgB,qBAAqB,SAAiB;CAI7D,gBAAgB;EACd,IAAI,CAAC,OAAO;EAEZ,SAAS,UAAU,OAAqB;GACtC,MAAM,aAAa,gBAAgB,OAAO,OAAO,aAAa;GAC9D,IAAI,eAAe,KAAA,GAAW,kBAAkB,UAAU;EAC5D;EAEA,OAAO,iBAAiB,WAAW,SAAS;EAC5C,aAAa,OAAO,oBAAoB,WAAW,SAAS;CAC9D,GAAG,CAAC,KAAK,CAAC;CAEV,MAAM,OAAO;EACX,OAAO,SAAS;EAChB,YAAY,eAAe,SAAS,gBAAgB;CACtD;CACA,MAAM,iBAAiB,SAAS,wBAAwB,UAAU;CAClE,MAAM,cAAc,kBAAkB,UAAU;CAChD,MAAM,MAAM,eAAe,OAAO,IAAI;CAEtC,OAAO,cACL,OACA;EAAE;EAAW;CAAM,GACnB,aAAa,UAAU,OACnB,cAAc,UAAU;EACtB,SAAS;EACT,GAAG;EAGH,KAAK;EACL,KAAK;EACL;EACA;EACA,OAAO;GACL,SAAS;GACT,OAAO;GACP,QAAQ;GACR,QAAQ;GAER,IAAK,KAAK,eAAe,iBAAiB,WAAW,EACnD,aAAa,oBAAoB,KAAK,KAAK,EAC7C;EACF;CACF,CAAC,IACD,cAAc,OAAO,EAAE,OAAO,EAAE,QAAQ,YAAY,EAAE,CAAC,GAC3D,UACE,cACE,KACA;EACE,MAAA;EACA,QAAQ;EACR,KAAK;EACL,WAAW;EACX,OAAO,kBAAkB,KAAA,IAAY;CACvC,GAAA,uBAEF,CACJ;AACF"}