highseat 0.5.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 +87 -3
- package/embed.d.ts +15 -0
- package/embed.js +49 -0
- package/embed.js.map +1 -0
- package/index.d.ts +21 -13
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/ingest.d.ts +1 -1
- package/ingest.js +3 -7
- package/ingest.js.map +1 -1
- package/mcp.d.ts +61 -0
- package/mcp.js +160 -0
- package/mcp.js.map +1 -0
- package/next.d.ts +3 -2
- package/next.js +2 -2
- package/next.js.map +1 -1
- package/package.json +9 -1
- package/proxy.d.ts +3 -3
- package/proxy.js.map +1 -1
- package/react.d.ts +58 -8
- package/react.js +69 -2
- package/react.js.map +1 -1
- package/server.d.ts +44 -4
- package/server.js.map +1 -1
package/mcp.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { track } from "./server.js";
|
|
2
|
+
//#region src/mcp.ts
|
|
3
|
+
const CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo";
|
|
4
|
+
const ERROR_CODE = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
5
|
+
const ERROR_CODE_PREFIX = /^([A-Z][A-Z0-9_]{1,63}):/;
|
|
6
|
+
const MAX_PARSED_LENGTH = 262144;
|
|
7
|
+
const wrapped = /* @__PURE__ */ new WeakSet();
|
|
8
|
+
const callClients = /* @__PURE__ */ new WeakMap();
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function toClient(info) {
|
|
13
|
+
if (!info?.name) return;
|
|
14
|
+
return {
|
|
15
|
+
client: info.name,
|
|
16
|
+
...info.version ? { client_version: info.version } : {},
|
|
17
|
+
client_source: "client_info"
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The client behind a call. A client of the 2026-07-28 protocol names itself on every request. A
|
|
22
|
+
* 2025-era client names itself only in its `initialize` handshake, which a stateful server
|
|
23
|
+
* remembers and a stateless one never sees again, so its User-Agent's product name stands in.
|
|
24
|
+
*/
|
|
25
|
+
function readClient(context, server) {
|
|
26
|
+
const envelope = context.mcpReq.envelope;
|
|
27
|
+
const declared = isRecord(envelope) ? envelope[CLIENT_INFO_KEY] : void 0;
|
|
28
|
+
const client = toClient(isRecord(declared) ? declared : void 0) ?? toClient(server?.server.getClientVersion());
|
|
29
|
+
if (client) return client;
|
|
30
|
+
const product = context.http?.req?.headers.get("user-agent")?.split(/[\s/]/)[0];
|
|
31
|
+
return product ? {
|
|
32
|
+
client: product,
|
|
33
|
+
client_source: "user_agent"
|
|
34
|
+
} : {
|
|
35
|
+
client: "unknown",
|
|
36
|
+
client_source: "none"
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* How many items a result holds: the length of a list, or of the one list a result object holds,
|
|
41
|
+
* such as `data` in a page of rows. A result with no list, or with several, has no count.
|
|
42
|
+
*/
|
|
43
|
+
function countItems(data) {
|
|
44
|
+
if (Array.isArray(data)) return data.length;
|
|
45
|
+
if (!isRecord(data)) return;
|
|
46
|
+
const lists = Object.values(data).filter(Array.isArray);
|
|
47
|
+
return lists.length === 1 ? lists[0]?.length : void 0;
|
|
48
|
+
}
|
|
49
|
+
/** The structured data a result carries, or the JSON of its one text block. */
|
|
50
|
+
function readData(result) {
|
|
51
|
+
if (result.structuredContent !== void 0) return result.structuredContent;
|
|
52
|
+
const [block, ...rest] = result.content;
|
|
53
|
+
if (rest.length > 0 || block?.type !== "text" || block.text.length > MAX_PARSED_LENGTH) return;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(block.text);
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** The error code a failed result starts its text with, such as `NOT_FOUND: …`. */
|
|
61
|
+
function readErrorCode(result) {
|
|
62
|
+
const block = result.content.find((content) => content.type === "text");
|
|
63
|
+
return block?.type === "text" ? block.text.match(ERROR_CODE_PREFIX)?.[1] : void 0;
|
|
64
|
+
}
|
|
65
|
+
function readOutcome(result, isReadOnly) {
|
|
66
|
+
if (result.isError) return { status: readErrorCode(result) ?? "TOOL_ERROR" };
|
|
67
|
+
const results = isReadOnly ? countItems(readData(result)) : void 0;
|
|
68
|
+
return {
|
|
69
|
+
status: "ok",
|
|
70
|
+
...results === void 0 ? {} : { results }
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** The code a thrown error carries, such as an `ORPCError`'s, or a generic one. */
|
|
74
|
+
function readThrownCode(error) {
|
|
75
|
+
const code = isRecord(error) ? error.code : void 0;
|
|
76
|
+
return typeof code === "string" && ERROR_CODE.test(code) ? code : "INTERNAL_SERVER_ERROR";
|
|
77
|
+
}
|
|
78
|
+
function isCallToolResult(value) {
|
|
79
|
+
return isRecord(value) && Array.isArray(value.content);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Records every tool call on an MCP server built with the MCP TypeScript SDK v2. Call it once,
|
|
83
|
+
* right after creating the server and before registering any tool: it wraps `registerTool`, so a
|
|
84
|
+
* tool registered earlier would go unrecorded, and it throws when the server already has tools.
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* const server = new McpServer({ name: "Acme", version: "1.0.0" })
|
|
88
|
+
* trackToolCalls(server, { server: "public" })
|
|
89
|
+
* server.registerTool("search", config, handler)
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* Each call sends `mcp_tool_call` with the tool, the client, the status, the time it took and,
|
|
93
|
+
* for a read-only tool whose result is a list, how many items it returned. The tool's arguments,
|
|
94
|
+
* the caller's IP and its user agent are never sent. A call still waiting for input from the
|
|
95
|
+
* client is recorded once it ends. Failures to send never reach the tool: they go to the
|
|
96
|
+
* tracker's `onError`.
|
|
97
|
+
*/
|
|
98
|
+
function trackToolCalls(server, options = {}) {
|
|
99
|
+
if (wrapped.has(server)) return;
|
|
100
|
+
const registered = server._registeredTools;
|
|
101
|
+
if (registered && Object.keys(registered).length > 0) throw new Error("highseat/mcp: call trackToolCalls() before registering any tool, or those tools go unrecorded.");
|
|
102
|
+
wrapped.add(server);
|
|
103
|
+
const { tracker = { track }, server: label, hostname, waitUntil } = options;
|
|
104
|
+
const register = server.registerTool.bind(server);
|
|
105
|
+
function send(tool, client, outcome, startedAt) {
|
|
106
|
+
tracker.track("mcp_tool_call", {
|
|
107
|
+
tool,
|
|
108
|
+
...label ? { server: label } : {},
|
|
109
|
+
...client,
|
|
110
|
+
...outcome,
|
|
111
|
+
duration_ms: Math.round(performance.now() - startedAt)
|
|
112
|
+
}, {
|
|
113
|
+
...hostname ? { hostname } : {},
|
|
114
|
+
...waitUntil ? { waitUntil } : {}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function registerTracked(name, config, handler) {
|
|
118
|
+
const isReadOnly = config.annotations?.readOnlyHint === true;
|
|
119
|
+
return register(name, config, async (...args) => {
|
|
120
|
+
const context = args.at(-1);
|
|
121
|
+
const client = readClient(context, server);
|
|
122
|
+
callClients.set(context, client);
|
|
123
|
+
const startedAt = performance.now();
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
result = await handler(...args);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
send(name, client, { status: readThrownCode(error) }, startedAt);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
if (isCallToolResult(result)) send(name, client, readOutcome(result, isReadOnly), startedAt);
|
|
132
|
+
return result;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
server.registerTool = registerTracked;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Tags a link a tool returns, so the visit it brings reads as the assistant that showed it:
|
|
139
|
+
* `utm_source` names the client and `utm_medium` is `mcp`, which Highseat reads as the AI
|
|
140
|
+
* channel. A link that already has a `utm_source` is returned as it is. Pass the tool handler's
|
|
141
|
+
* context.
|
|
142
|
+
*
|
|
143
|
+
* ```ts
|
|
144
|
+
* server.registerTool("get_product", config, async ({ slug }, context) => ({
|
|
145
|
+
* content: [{ type: "text", text: mcpLink(`https://acme.com/products/${slug}`, context) }],
|
|
146
|
+
* }))
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
function mcpLink(url, context) {
|
|
150
|
+
const link = new URL(url);
|
|
151
|
+
if (link.searchParams.has("utm_source")) return url;
|
|
152
|
+
const { client, client_source } = callClients.get(context) ?? readClient(context);
|
|
153
|
+
link.searchParams.set("utm_source", client_source === "none" ? "mcp" : client);
|
|
154
|
+
link.searchParams.set("utm_medium", "mcp");
|
|
155
|
+
return link.href;
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
export { mcpLink, trackToolCalls };
|
|
159
|
+
|
|
160
|
+
//# sourceMappingURL=mcp.js.map
|
package/mcp.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp.js","names":[],"sources":["../src/mcp.ts"],"sourcesContent":["import type {\n CallToolResult,\n Implementation,\n McpServer,\n ServerContext,\n ToolAnnotations,\n} from \"@modelcontextprotocol/server\"\nimport { type ServerBuiltInEvents, type ServerEventOptions, track, type WaitUntil } from \"./server\"\n\n/**\n * MCP tool calls, recorded from your own MCP server built on the MCP TypeScript SDK v2: one\n * `mcp_tool_call` event per call, with the tool, the client, the outcome and the time it took.\n * The tool's arguments, the caller's IP and its user agent are never sent: assistant directories\n * forbid collecting conversation data, and the caller is the assistant's server, not a person.\n */\n\ntype ToolCall = ServerBuiltInEvents[\"mcp_tool_call\"]\n\n/** What the event records about the client behind a call. */\ntype ToolClient = Pick<ToolCall, \"client\" | \"client_version\" | \"client_source\">\n\n/** What tool calls are sent with: a tracker from `createTracker()`, or any `track()` like its. */\nexport interface ToolCallTracker {\n track: (name: \"mcp_tool_call\", properties: ToolCall, options?: ServerEventOptions) => void\n}\n\nexport interface TrackToolCallsOptions {\n /** The tracker events are sent with. Defaults to the one `track()` from `highseat/server` uses. */\n tracker?: ToolCallTracker\n /** A label for this MCP server, such as `public` or `admin`, when the site runs more than one. */\n server?: string\n /** The site hostname every call is filed under. Defaults to the tracker's `hostname`. */\n hostname?: string\n /**\n * Keeps a serverless function alive until each event is sent: `after` from `next/server`, or\n * `ctx.waitUntil` in a Cloudflare Worker, bound to its object.\n */\n waitUntil?: WaitUntil\n}\n\n/** The parts of a tool's registration the wrapper reads. */\ninterface ToolConfig {\n annotations?: ToolAnnotations\n}\n\ntype ToolHandler = (...args: unknown[]) => unknown\n\ntype RegisterTool = (name: string, config: ToolConfig, handler: ToolHandler) => unknown\n\n/** How a call ended, as the event records it. */\ninterface ToolOutcome {\n status: string\n results?: number\n}\n\n// The `_meta` key a client of the 2026-07-28 protocol names itself under on every request. The\n// SDK exports it as `CLIENT_INFO_META_KEY`; it is written out so this module loads no SDK code.\nconst CLIENT_INFO_KEY = \"io.modelcontextprotocol/clientInfo\"\n\n// An error code as the agent sees it, such as `NOT_FOUND` or `RATE_LIMITED`.\nconst ERROR_CODE = /^[A-Z][A-Z0-9_]{1,63}$/\nconst ERROR_CODE_PREFIX = /^([A-Z][A-Z0-9_]{1,63}):/\n\n// A text result is parsed for its count only below this length, so a large page is never parsed\n// a second time.\nconst MAX_PARSED_LENGTH = 256 * 1024\n\nconst wrapped = new WeakSet<McpServer>()\n\n// The client each call was recorded under, so `mcpLink()` tags a link with the same name.\nconst callClients = new WeakMap<ServerContext, ToolClient>()\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return !!value && typeof value === \"object\" && !Array.isArray(value)\n}\n\nfunction toClient(info: Implementation | undefined): ToolClient | undefined {\n if (!info?.name) return\n return {\n client: info.name,\n ...(info.version ? { client_version: info.version } : {}),\n client_source: \"client_info\",\n }\n}\n\n/**\n * The client behind a call. A client of the 2026-07-28 protocol names itself on every request. A\n * 2025-era client names itself only in its `initialize` handshake, which a stateful server\n * remembers and a stateless one never sees again, so its User-Agent's product name stands in.\n */\nfunction readClient(context: ServerContext, server?: McpServer): ToolClient {\n const envelope: unknown = context.mcpReq.envelope\n const declared = isRecord(envelope) ? envelope[CLIENT_INFO_KEY] : undefined\n\n const client =\n toClient(isRecord(declared) ? (declared as Implementation) : undefined) ??\n toClient(server?.server.getClientVersion())\n if (client) return client\n\n const product = context.http?.req?.headers.get(\"user-agent\")?.split(/[\\s/]/)[0]\n return product\n ? { client: product, client_source: \"user_agent\" }\n : { client: \"unknown\", client_source: \"none\" }\n}\n\n/**\n * How many items a result holds: the length of a list, or of the one list a result object holds,\n * such as `data` in a page of rows. A result with no list, or with several, has no count.\n */\nfunction countItems(data: unknown): number | undefined {\n if (Array.isArray(data)) return data.length\n if (!isRecord(data)) return\n\n const lists = Object.values(data).filter(Array.isArray)\n return lists.length === 1 ? lists[0]?.length : undefined\n}\n\n/** The structured data a result carries, or the JSON of its one text block. */\nfunction readData(result: CallToolResult): unknown {\n if (result.structuredContent !== undefined) return result.structuredContent\n\n const [block, ...rest] = result.content\n if (rest.length > 0 || block?.type !== \"text\" || block.text.length > MAX_PARSED_LENGTH) return\n\n try {\n return JSON.parse(block.text) as unknown\n } catch {\n return\n }\n}\n\n/** The error code a failed result starts its text with, such as `NOT_FOUND: …`. */\nfunction readErrorCode(result: CallToolResult) {\n const block = result.content.find(content => content.type === \"text\")\n return block?.type === \"text\" ? block.text.match(ERROR_CODE_PREFIX)?.[1] : undefined\n}\n\nfunction readOutcome(result: CallToolResult, isReadOnly: boolean): ToolOutcome {\n if (result.isError) return { status: readErrorCode(result) ?? \"TOOL_ERROR\" }\n\n const results = isReadOnly ? countItems(readData(result)) : undefined\n return { status: \"ok\", ...(results === undefined ? {} : { results }) }\n}\n\n/** The code a thrown error carries, such as an `ORPCError`'s, or a generic one. */\nfunction readThrownCode(error: unknown) {\n const code = isRecord(error) ? error.code : undefined\n return typeof code === \"string\" && ERROR_CODE.test(code) ? code : \"INTERNAL_SERVER_ERROR\"\n}\n\nfunction isCallToolResult(value: unknown): value is CallToolResult {\n return isRecord(value) && Array.isArray(value.content)\n}\n\n/**\n * Records every tool call on an MCP server built with the MCP TypeScript SDK v2. Call it once,\n * right after creating the server and before registering any tool: it wraps `registerTool`, so a\n * tool registered earlier would go unrecorded, and it throws when the server already has tools.\n *\n * ```ts\n * const server = new McpServer({ name: \"Acme\", version: \"1.0.0\" })\n * trackToolCalls(server, { server: \"public\" })\n * server.registerTool(\"search\", config, handler)\n * ```\n *\n * Each call sends `mcp_tool_call` with the tool, the client, the status, the time it took and,\n * for a read-only tool whose result is a list, how many items it returned. The tool's arguments,\n * the caller's IP and its user agent are never sent. A call still waiting for input from the\n * client is recorded once it ends. Failures to send never reach the tool: they go to the\n * tracker's `onError`.\n */\nexport function trackToolCalls(server: McpServer, options: TrackToolCallsOptions = {}): void {\n if (wrapped.has(server)) return\n\n // The SDK keeps its tools in a private record. Reading it is the only way to tell that some\n // were registered before this call, and a version without it skips the check.\n const registered = (server as unknown as { _registeredTools?: Record<string, unknown> })\n ._registeredTools\n if (registered && Object.keys(registered).length > 0) {\n throw new Error(\n \"highseat/mcp: call trackToolCalls() before registering any tool, or those tools go unrecorded.\",\n )\n }\n\n wrapped.add(server)\n\n const { tracker = { track }, server: label, hostname, waitUntil } = options\n const register = server.registerTool.bind(server) as RegisterTool\n\n function send(tool: string, client: ToolClient, outcome: ToolOutcome, startedAt: number) {\n tracker.track(\n \"mcp_tool_call\",\n {\n tool,\n ...(label ? { server: label } : {}),\n ...client,\n ...outcome,\n duration_ms: Math.round(performance.now() - startedAt),\n },\n { ...(hostname ? { hostname } : {}), ...(waitUntil ? { waitUntil } : {}) },\n )\n }\n\n function registerTracked(name: string, config: ToolConfig, handler: ToolHandler) {\n const isReadOnly = config.annotations?.readOnlyHint === true\n\n return register(name, config, async (...args: unknown[]) => {\n // The context is the handler's last argument: a tool with an input schema gets its\n // arguments first.\n const context = args.at(-1) as ServerContext\n const client = readClient(context, server)\n callClients.set(context, client)\n const startedAt = performance.now()\n\n let result: unknown\n try {\n result = await handler(...args)\n } catch (error) {\n send(name, client, { status: readThrownCode(error) }, startedAt)\n throw error\n }\n\n if (isCallToolResult(result)) send(name, client, readOutcome(result, isReadOnly), startedAt)\n return result\n })\n }\n\n server.registerTool = registerTracked as McpServer[\"registerTool\"]\n}\n\n/**\n * Tags a link a tool returns, so the visit it brings reads as the assistant that showed it:\n * `utm_source` names the client and `utm_medium` is `mcp`, which Highseat reads as the AI\n * channel. A link that already has a `utm_source` is returned as it is. Pass the tool handler's\n * context.\n *\n * ```ts\n * server.registerTool(\"get_product\", config, async ({ slug }, context) => ({\n * content: [{ type: \"text\", text: mcpLink(`https://acme.com/products/${slug}`, context) }],\n * }))\n * ```\n */\nexport function mcpLink(url: string, context: ServerContext): string {\n const link = new URL(url)\n if (link.searchParams.has(\"utm_source\")) return url\n\n const { client, client_source } = callClients.get(context) ?? readClient(context)\n link.searchParams.set(\"utm_source\", client_source === \"none\" ? \"mcp\" : client)\n link.searchParams.set(\"utm_medium\", \"mcp\")\n return link.href\n}\n"],"mappings":";;AAyDA,MAAM,kBAAkB;AAGxB,MAAM,aAAa;AACnB,MAAM,oBAAoB;AAI1B,MAAM,oBAAoB;AAE1B,MAAM,0BAAU,IAAI,QAAmB;AAGvC,MAAM,8BAAc,IAAI,QAAmC;AAE3D,SAAS,SAAS,OAAkD;CAClE,OAAO,CAAC,CAAC,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK;AACrE;AAEA,SAAS,SAAS,MAA0D;CAC1E,IAAI,CAAC,MAAM,MAAM;CACjB,OAAO;EACL,QAAQ,KAAK;EACb,GAAI,KAAK,UAAU,EAAE,gBAAgB,KAAK,QAAQ,IAAI,CAAC;EACvD,eAAe;CACjB;AACF;;;;;;AAOA,SAAS,WAAW,SAAwB,QAAgC;CAC1E,MAAM,WAAoB,QAAQ,OAAO;CACzC,MAAM,WAAW,SAAS,QAAQ,IAAI,SAAS,mBAAmB,KAAA;CAElE,MAAM,SACJ,SAAS,SAAS,QAAQ,IAAK,WAA8B,KAAA,CAAS,KACtE,SAAS,QAAQ,OAAO,iBAAiB,CAAC;CAC5C,IAAI,QAAQ,OAAO;CAEnB,MAAM,UAAU,QAAQ,MAAM,KAAK,QAAQ,IAAI,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,CAAC;CAC7E,OAAO,UACH;EAAE,QAAQ;EAAS,eAAe;CAAa,IAC/C;EAAE,QAAQ;EAAW,eAAe;CAAO;AACjD;;;;;AAMA,SAAS,WAAW,MAAmC;CACrD,IAAI,MAAM,QAAQ,IAAI,GAAG,OAAO,KAAK;CACrC,IAAI,CAAC,SAAS,IAAI,GAAG;CAErB,MAAM,QAAQ,OAAO,OAAO,IAAI,CAAC,CAAC,OAAO,MAAM,OAAO;CACtD,OAAO,MAAM,WAAW,IAAI,MAAM,EAAE,EAAE,SAAS,KAAA;AACjD;;AAGA,SAAS,SAAS,QAAiC;CACjD,IAAI,OAAO,sBAAsB,KAAA,GAAW,OAAO,OAAO;CAE1D,MAAM,CAAC,OAAO,GAAG,QAAQ,OAAO;CAChC,IAAI,KAAK,SAAS,KAAK,OAAO,SAAS,UAAU,MAAM,KAAK,SAAS,mBAAmB;CAExF,IAAI;EACF,OAAO,KAAK,MAAM,MAAM,IAAI;CAC9B,QAAQ;EACN;CACF;AACF;;AAGA,SAAS,cAAc,QAAwB;CAC7C,MAAM,QAAQ,OAAO,QAAQ,MAAK,YAAW,QAAQ,SAAS,MAAM;CACpE,OAAO,OAAO,SAAS,SAAS,MAAM,KAAK,MAAM,iBAAiB,CAAC,GAAG,KAAK,KAAA;AAC7E;AAEA,SAAS,YAAY,QAAwB,YAAkC;CAC7E,IAAI,OAAO,SAAS,OAAO,EAAE,QAAQ,cAAc,MAAM,KAAK,aAAa;CAE3E,MAAM,UAAU,aAAa,WAAW,SAAS,MAAM,CAAC,IAAI,KAAA;CAC5D,OAAO;EAAE,QAAQ;EAAM,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;CAAG;AACvE;;AAGA,SAAS,eAAe,OAAgB;CACtC,MAAM,OAAO,SAAS,KAAK,IAAI,MAAM,OAAO,KAAA;CAC5C,OAAO,OAAO,SAAS,YAAY,WAAW,KAAK,IAAI,IAAI,OAAO;AACpE;AAEA,SAAS,iBAAiB,OAAyC;CACjE,OAAO,SAAS,KAAK,KAAK,MAAM,QAAQ,MAAM,OAAO;AACvD;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,eAAe,QAAmB,UAAiC,CAAC,GAAS;CAC3F,IAAI,QAAQ,IAAI,MAAM,GAAG;CAIzB,MAAM,aAAc,OACjB;CACH,IAAI,cAAc,OAAO,KAAK,UAAU,CAAC,CAAC,SAAS,GACjD,MAAM,IAAI,MACR,gGACF;CAGF,QAAQ,IAAI,MAAM;CAElB,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,QAAQ,OAAO,UAAU,cAAc;CACpE,MAAM,WAAW,OAAO,aAAa,KAAK,MAAM;CAEhD,SAAS,KAAK,MAAc,QAAoB,SAAsB,WAAmB;EACvF,QAAQ,MACN,iBACA;GACE;GACA,GAAI,QAAQ,EAAE,QAAQ,MAAM,IAAI,CAAC;GACjC,GAAG;GACH,GAAG;GACH,aAAa,KAAK,MAAM,YAAY,IAAI,IAAI,SAAS;EACvD,GACA;GAAE,GAAI,WAAW,EAAE,SAAS,IAAI,CAAC;GAAI,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;EAAG,CAC3E;CACF;CAEA,SAAS,gBAAgB,MAAc,QAAoB,SAAsB;EAC/E,MAAM,aAAa,OAAO,aAAa,iBAAiB;EAExD,OAAO,SAAS,MAAM,QAAQ,OAAO,GAAG,SAAoB;GAG1D,MAAM,UAAU,KAAK,GAAG,EAAE;GAC1B,MAAM,SAAS,WAAW,SAAS,MAAM;GACzC,YAAY,IAAI,SAAS,MAAM;GAC/B,MAAM,YAAY,YAAY,IAAI;GAElC,IAAI;GACJ,IAAI;IACF,SAAS,MAAM,QAAQ,GAAG,IAAI;GAChC,SAAS,OAAO;IACd,KAAK,MAAM,QAAQ,EAAE,QAAQ,eAAe,KAAK,EAAE,GAAG,SAAS;IAC/D,MAAM;GACR;GAEA,IAAI,iBAAiB,MAAM,GAAG,KAAK,MAAM,QAAQ,YAAY,QAAQ,UAAU,GAAG,SAAS;GAC3F,OAAO;EACT,CAAC;CACH;CAEA,OAAO,eAAe;AACxB;;;;;;;;;;;;;AAcA,SAAgB,QAAQ,KAAa,SAAgC;CACnE,MAAM,OAAO,IAAI,IAAI,GAAG;CACxB,IAAI,KAAK,aAAa,IAAI,YAAY,GAAG,OAAO;CAEhD,MAAM,EAAE,QAAQ,kBAAkB,YAAY,IAAI,OAAO,KAAK,WAAW,OAAO;CAChF,KAAK,aAAa,IAAI,cAAc,kBAAkB,SAAS,QAAQ,MAAM;CAC7E,KAAK,aAAa,IAAI,cAAc,KAAK;CACzC,OAAO,KAAK;AACd"}
|
package/next.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { HighseatProps as HighseatProps$1, useBeforeSend, useIdentify } from "./react.js";
|
|
2
1
|
import { ClientIp, ClientIpPreset } from "./ingest.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":";;;;;;;;;;;;;
|
|
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.
|
|
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",
|
|
@@ -42,16 +42,24 @@
|
|
|
42
42
|
"./server": {
|
|
43
43
|
"types": "./server.d.ts",
|
|
44
44
|
"default": "./server.js"
|
|
45
|
+
},
|
|
46
|
+
"./mcp": {
|
|
47
|
+
"types": "./mcp.d.ts",
|
|
48
|
+
"default": "./mcp.js"
|
|
45
49
|
}
|
|
46
50
|
},
|
|
47
51
|
"publishConfig": {
|
|
48
52
|
"access": "public"
|
|
49
53
|
},
|
|
50
54
|
"peerDependencies": {
|
|
55
|
+
"@modelcontextprotocol/server": ">=2",
|
|
51
56
|
"next": ">=14",
|
|
52
57
|
"react": ">=18"
|
|
53
58
|
},
|
|
54
59
|
"peerDependenciesMeta": {
|
|
60
|
+
"@modelcontextprotocol/server": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
55
63
|
"next": {
|
|
56
64
|
"optional": true
|
|
57
65
|
},
|
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_…`,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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":";;;AA+CA,MAAM,yBAAyB,CAAC,cAAc,eAAe;AAC7D,MAAM,wBAAwB;CAAC;CAAgB;CAAc;AAAQ;AAMrE,MAAM,mBAAmB;CACvB;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AAGA,MAAM,qCAAqB,IAAI,IAAI;CAAC;CAAK;CAAK;CAAK;AAAG,CAAC;AAEvD,MAAM,sBAAsB;AAG5B,MAAM,kBAAkB;AAExB,SAAS,YAAY,MAAe,IAAa,OAAiB;CAChE,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,QAAQ,KAAK,IAAI,IAAI;EAC3B,IAAI,UAAU,MAAM,GAAG,IAAI,MAAM,KAAK;CACxC;AACF;AAEA,SAAS,YAAY,QAAgB,OAAe;CAClD,OAAO,SAAS,KAAK,EAAE,MAAM,GAAG;EAAE;EAAQ,SAAS,EAAE,iBAAiB,WAAW;CAAE,CAAC;AACtF;AAIA,SAAS,kBAAkB;CACzB,OAAO,YAAY,KAAK,sEAAsE;AAChG;AAIA,IAAI,mBAAmB;;;;;AAMvB,SAAS,oBACP,SACA,MACA,UACc;CACd,MAAM,QAAQ,QAAQ,KAAK,OAAO;CAGlC,IAAI,CAAC,oBAAoB,CAAC,YAAY,GAAG;EACvC,mBAAmB;EAEnB,QAAQ,KAAK,mBAAmB,MAAM,oCADtB,WAAW,qCAAqC,6BACkB,EAAE;CACtF;CAEA,OAAO,eAAe,MAAM,SAAS;EACnC,IAAI,UAAU,OAAO,SAAS,OAAO;EACrC,IAAI,SAAS,KAAA,KAAa,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC,aAAa,MAAM,OAAO,gBAAgB;EACzF,OAAO,YAAY,KAAK,kCAAkC,MAAM,EAAE;CACpE;AACF;;AAGA,eAAe,cAAc,SAAgE;CAC3F,IAAI,OAAO,QAAQ,QAAQ,IAAI,gBAAgB,CAAC,IAAI,iBAAiB;CACrE,IAAI,CAAC,QAAQ,MAAM,uBAAO,IAAI,WAAW;CAEzC,MAAM,SAAS,QAAQ,KAAK,UAAU;CACtC,MAAM,SAAuB,CAAC;CAC9B,IAAI,OAAO;CAEX,KAAK,IAAI,QAAQ,MAAM,OAAO,KAAK,GAAG,CAAC,MAAM,MAAM,QAAQ,MAAM,OAAO,KAAK,GAAG;EAC9E,QAAQ,MAAM,MAAM;EACpB,IAAI,OAAO,iBAAiB;GAC1B,OAAY,OAAO;GACnB;EACF;EACA,OAAO,KAAK,MAAM,KAAK;CACzB;CAEA,MAAM,OAAO,IAAI,WAAW,IAAI;CAChC,IAAI,SAAS;CACb,KAAK,MAAM,SAAS,QAAQ;EAC1B,KAAK,IAAI,OAAO,MAAM;EACtB,UAAU,MAAM;CAClB;CACA,OAAO;AACT;;;;;;;;;;;;AAaA,SAAgB,YAAY,SAAqC;CAC/D,MAAM,OAAO,cAAc,QAAQ,IAAI;CACvC,MAAM,EAAE,aAAa;CACrB,IAAI;CAEJ,IAAI;EACF,cAAc,gBAAgB,QAAQ,QAAQ,gBAAgB;CAChE,SAAS,OAAO;EAGd,IAAI,CAAC,YAAY,GAAG,MAAM;EAC1B,aAAa,QAAQ,OAAO,KAAK;CACnC;CAEA,MAAM,WAAW,IAAI,IAAI,QAAQ,YAAA,0BAA4B,CAAC,CAAC;CAI/D,MAAM,UAAU,CACd,GAAI,YAAY,SAAS,KAAA,IAAY,CAAC,eAAe,IAAI,CAAC,GAC1D,GAAI,cAAc,CAAC,IAAI,CAAC,iBAAiB,CAC3C;CACA,IAAI,CAAC,eAAe,QAAQ,QAAQ,OAAO,oBAAoB,SAAS,MAAM,QAAQ;CAEtF,MAAM,SAAS;CAEf,MAAM,iBAAiB,kBAAkB,QAAQ,UAAU,gBAAgB;CAC3E,IAAI,gBAAgB;CAIpB,SAAS,aAAa,SAAkB;EACtC,MAAM,KAAK,eAAe,KAAK,OAAO;EAEtC,IAAI,CAAC,MAAM,CAAC,eAAe;GACzB,gBAAgB;GAChB,QAAQ,KACN,oCAAoC,eAAe,OAAO,gGAC5D;EACF;EACA,OAAO;CACT;CAEA,SAAS,qBAAqB,SAAkB,OAAiB;EAC/D,MAAM,UAAU,IAAI,QAAQ;GAE1B,mBAAmB;GACnB,eAAe;GACf,kBAAkB,aAAa,OAAO;EACxC,CAAC;EACD,YAAY,QAAQ,SAAS,SAAS,KAAK;EAC3C,OAAO;CACT;CAEA,eAAe,QAAQ,KAAa,MAAmB;EACrD,IAAI;EAEJ,IAAI;GACF,WAAW,MAAM,MAAM,KAAK;IAAE,GAAG;IAAM,QAAQ,YAAY,QAAQ,mBAAmB;GAAE,CAAC;EAC3F,QAAQ;GACN,OAAO,YAAY,KAAK,0CAA0C;EACpE;EAEA,MAAM,UAAU,IAAI,QAAQ;EAC5B,YAAY,SAAS,SAAS,SAAS,gBAAgB;EACvD,IAAI,KAAK,WAAW,QAAQ,QAAQ,IAAI,iBAAiB,UAAU;EAEnE,MAAM,OAAO,mBAAmB,IAAI,SAAS,MAAM,IAAI,OAAO,SAAS;EACvE,OAAO,IAAI,SAAS,MAAM;GAAE,QAAQ,SAAS;GAAQ;EAAQ,CAAC;CAChE;CAEA,OAAO,eAAe,MAAM,SAAS;EACnC,IAAI,SAAS,KAAA,KAAa,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC,aAAa,MAC1D,OAAO,WAAW,SAAS,OAAO,IAAI,gBAAgB;EAGxD,IAAI,QAAQ,WAAW,SAAS,QAAQ,WAAW,QACjD,OAAO,QAAQ,GAAG,SAAS,aAAa;GACtC,QAAQ,QAAQ;GAChB,SAAS,qBAAqB,SAAS,sBAAsB;EAC/D,CAAC;EAGH,IAAI,QAAQ,WAAW,QAAQ;GAC7B,MAAM,OAAO,MAAM,cAAc,OAAO;GACxC,IAAI,CAAC,MAAM,OAAO,YAAY,KAAK,+BAA+B;GAElE,OAAO,QAAQ,GAAG,SAAS,aAAa;IACtC,QAAQ;IACR,SAAS,qBAAqB,SAAS,qBAAqB;IAC5D;GACF,CAAC;EACH;EAEA,OAAO,IAAI,SAAS,MAAM;GAAE,QAAQ;GAAK,SAAS,EAAE,OAAO,kBAAkB;EAAE,CAAC;CAClF;AACF"}
|
|
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"}
|