highseat 0.0.2 → 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +296 -9
- package/index.d.ts +168 -9
- package/index.js +95 -12
- package/index.js.map +1 -0
- package/next.d.ts +58 -0
- package/next.js +79 -0
- package/next.js.map +1 -0
- package/package.json +52 -10
- package/proxy.d.ts +57 -0
- package/proxy.js +1 -0
- package/proxy2.js +216 -0
- package/proxy2.js.map +1 -0
- package/react.d.ts +11 -0
- package/react.js +31 -0
- package/react.js.map +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Piotr Kulpinski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,15 +1,302 @@
|
|
|
1
|
-
#
|
|
1
|
+
# highseat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Highseat analytics for JavaScript: typed tracking, a first-party proxy and framework components.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **Pre-release.** The `alpha` versions can change shape between releases.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
import { snippet } from "highseat"
|
|
7
|
+
## Install
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
```bash
|
|
10
|
+
npm install highseat@alpha
|
|
12
11
|
```
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
## Entry points
|
|
14
|
+
|
|
15
|
+
| Import | Contents | Needs |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `highseat` | `track()`, `identify()`, `getAttributionId()` and `inject()` | Nothing |
|
|
18
|
+
| `highseat/react` | `<Highseat />` | React |
|
|
19
|
+
| `highseat/next` | `<Highseat />`, `createProxy()` and `withHighseat()` for Next.js | Next.js |
|
|
20
|
+
| `highseat/proxy` | `createProxy()`, a first-party proxy for any runtime with `fetch` | Nothing |
|
|
21
|
+
|
|
22
|
+
Each entry point loads on its own and imports no framework it does not name, so `highseat/proxy` runs in a Cloudflare Worker without React installed.
|
|
23
|
+
|
|
24
|
+
The package loads the tracker from Highseat, or from your site's proxy path, rather than bundling it. A call made before the tracker has loaded waits for it, and no call ever throws: on a server, with the tracker blocked, or with tracking off, it does nothing.
|
|
25
|
+
|
|
26
|
+
## Next.js
|
|
27
|
+
|
|
28
|
+
Set the site id from your site's Installation settings, and the proxy path if the site has one:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
NEXT_PUBLIC_HIGHSEAT_SITE=site_…
|
|
32
|
+
HIGHSEAT_PATH=/3f9a0c1e2b
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then render `<Highseat />` once, in the root layout:
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// app/layout.tsx
|
|
39
|
+
import { Highseat } from "highseat/next"
|
|
40
|
+
|
|
41
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
42
|
+
return (
|
|
43
|
+
<html lang="en">
|
|
44
|
+
<body>
|
|
45
|
+
{children}
|
|
46
|
+
<Highseat />
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The tracker counts each client-side navigation as one page view on its own.
|
|
54
|
+
|
|
55
|
+
## React
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { Highseat } from "highseat/react"
|
|
59
|
+
|
|
60
|
+
<Highseat site="site_…" />
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`src` takes the site's proxy path (`/3f9a0c1e2b`) for first-party delivery. `outbound={false}` and `downloads={false}` turn off the automatic link and download events, and `hashRouting` counts `#/route` changes as page views.
|
|
64
|
+
|
|
65
|
+
Without React, `inject({ site })` from `highseat` adds the same script tag.
|
|
66
|
+
|
|
67
|
+
## Events
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { identify, track } from "highseat"
|
|
71
|
+
|
|
72
|
+
track("signup")
|
|
73
|
+
track("purchase", { revenue: { amount: 4999, currency: "USD" }, order_id: order.id })
|
|
74
|
+
identify(user.id)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`revenue.amount` is an integer in the currency's smallest unit: 4999 is $49.99. With a payment provider connected, send `purchase` without `revenue`: the provider's webhook records the money, and revenue sent from the page as well would count the sale twice. Send `revenue` only for a checkout no provider reports, with an `order_id`, so a provider sale added later for the same order is not counted again. Event names are stored exactly as written, so `Purchase` and `purchase` are two events.
|
|
78
|
+
|
|
79
|
+
A few names are built in, with typed properties: `signup`, `login`, `purchase`, `subscription_started`, `trial_started`, `file_download`, `outbound_link_click` and `404`. Declare your own, and make a misspelt name a type error, by augmenting `Register` in any file your compiler sees:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
declare module "highseat" {
|
|
83
|
+
interface Register {
|
|
84
|
+
events: {
|
|
85
|
+
signup: { plan: "free" | "pro" }
|
|
86
|
+
invite_sent: { role: "admin" | "member" }
|
|
87
|
+
}
|
|
88
|
+
strict: true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Without `strict: true`, any other name still compiles as a custom event. The declarations are types only and add nothing to your bundle.
|
|
94
|
+
|
|
95
|
+
## Revenue attribution
|
|
96
|
+
|
|
97
|
+
`getAttributionId()` resolves to the id that joins a payment to the visit. Pass it to your checkout as `attribution_id` metadata. It resolves to `undefined` if the tracker has not loaded within three seconds, so a checkout that awaits it never hangs.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const attributionId = await getAttributionId()
|
|
101
|
+
await createCheckout({ metadata: { attribution_id: attributionId } })
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## First-party proxy
|
|
105
|
+
|
|
106
|
+
The proxy serves the tracker and forwards its events from your own domain, on the site's own path, so a filter list that blocks Highseat's host does not block your site. A `GET` on the path serves the script and a `POST` forwards events. The proxy sends the visitor's IP under your site secret, which a rewrite or a redirect rule cannot do: without it, every visitor would get your server's country.
|
|
107
|
+
|
|
108
|
+
Set two environment variables from your site's Installation settings:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
HIGHSEAT_PATH=/3f9a0c1e2b # the site's proxy path
|
|
112
|
+
HIGHSEAT_SECRET=hs_secret_... # the site secret
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then load the tracker from the path. In Next.js, `<Highseat />` reads `HIGHSEAT_PATH` itself. Elsewhere, pass `src="/3f9a0c1e2b"` to `<Highseat />` or `inject()`, or use the script tag from Installation settings.
|
|
116
|
+
|
|
117
|
+
To rotate the path, rotate it in Installation settings, update `HIGHSEAT_PATH` and the snippet, and redeploy. On a host whose route is a file or a config entry (Netlify, Vercel without Next.js, Astro), move that route too.
|
|
118
|
+
|
|
119
|
+
### Options
|
|
120
|
+
|
|
121
|
+
`createProxy(options)` returns a `(request: Request) => Promise<Response>` handler.
|
|
122
|
+
|
|
123
|
+
| Option | Default | |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| `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`. |
|
|
126
|
+
| `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. |
|
|
127
|
+
| `secret` | `HIGHSEAT_SECRET` | The site secret. An older secret that starts `hs_proxy_` or `ana_proxy_` works too. `createProxy()` throws without one. |
|
|
128
|
+
| `upstream` | `https://cdn.highseat.com` | The Highseat ingest origin. |
|
|
129
|
+
| `fallback` | None | Answers every other path, for a handler that sees every request on the site. |
|
|
130
|
+
|
|
131
|
+
### Next.js
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// app/api/highseat/route.ts
|
|
135
|
+
import { createProxy } from "highseat/next"
|
|
136
|
+
|
|
137
|
+
const proxy = createProxy({ clientIp: "vercel" })
|
|
138
|
+
|
|
139
|
+
export { proxy as GET, proxy as POST }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// next.config.ts
|
|
144
|
+
import { withHighseat } from "highseat/next"
|
|
145
|
+
|
|
146
|
+
export default withHighseat({
|
|
147
|
+
// Your own config.
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Next.js loads `next.config.ts` with `require`, and the package is ES modules only, so the config needs Node 20.19 or 22.12 and later, the versions that `require` an ES module without a flag.
|
|
152
|
+
|
|
153
|
+
`withHighseat()` rewrites `HIGHSEAT_PATH` to the route handler inside the app, so the visitor's IP survives and the route file never moves. Pass `{ route: "/stats" }` for a handler elsewhere. Without `HIGHSEAT_PATH`, `next build` fails, while `next dev` warns once and runs without the proxy. Off Vercel, the outermost proxy that sets the header decides the preset: `"cloudflare"` behind a proxied Cloudflare record, even with nginx or Traefik in between, and `"nginx"` behind nginx alone.
|
|
154
|
+
|
|
155
|
+
### Cloudflare Worker
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { createProxy } from "highseat/proxy"
|
|
159
|
+
|
|
160
|
+
interface Env {
|
|
161
|
+
HIGHSEAT_PATH: string
|
|
162
|
+
HIGHSEAT_SECRET: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export default {
|
|
166
|
+
fetch(request: Request, env: Env) {
|
|
167
|
+
const proxy = createProxy({
|
|
168
|
+
path: env.HIGHSEAT_PATH,
|
|
169
|
+
secret: env.HIGHSEAT_SECRET,
|
|
170
|
+
clientIp: "cloudflare",
|
|
171
|
+
fallback: fetch,
|
|
172
|
+
})
|
|
173
|
+
return proxy(request)
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Route the Worker on `yoursite.com/*`: `fallback: fetch` passes every other request on to your origin. Set the secret with `wrangler secret put HIGHSEAT_SECRET`.
|
|
179
|
+
|
|
180
|
+
### Cloudflare Pages
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// functions/[path].ts
|
|
184
|
+
import { createProxy } from "highseat/proxy"
|
|
185
|
+
|
|
186
|
+
interface Env {
|
|
187
|
+
HIGHSEAT_PATH: string
|
|
188
|
+
HIGHSEAT_SECRET: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const onRequest: PagesFunction<Env> = ({ request, env, next }) => {
|
|
192
|
+
const proxy = createProxy({
|
|
193
|
+
path: env.HIGHSEAT_PATH,
|
|
194
|
+
secret: env.HIGHSEAT_SECRET,
|
|
195
|
+
clientIp: "cloudflare",
|
|
196
|
+
fallback: next,
|
|
197
|
+
})
|
|
198
|
+
return proxy(request)
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The function sees each one-segment path, and `next` serves your site for every path but the proxy's. Add both variables in the Pages project's settings.
|
|
203
|
+
|
|
204
|
+
### Netlify
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
// netlify/functions/highseat.ts
|
|
208
|
+
import { createProxy } from "highseat/proxy"
|
|
209
|
+
|
|
210
|
+
export const config = { path: "/3f9a0c1e2b" }
|
|
211
|
+
|
|
212
|
+
export default createProxy({ clientIp: "netlify" })
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Netlify reads `config.path` from the source, so it cannot come from an environment variable. Set only `HIGHSEAT_SECRET`.
|
|
216
|
+
|
|
217
|
+
### Vercel without Next.js
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// api/highseat.ts
|
|
221
|
+
import { createProxy } from "highseat/proxy"
|
|
222
|
+
|
|
223
|
+
const proxy = createProxy({ clientIp: "vercel" })
|
|
224
|
+
|
|
225
|
+
export { proxy as GET, proxy as POST }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
// vercel.json
|
|
230
|
+
{ "rewrites": [{ "source": "/3f9a0c1e2b", "destination": "/api/highseat" }] }
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The rewrite carries the path, so set only `HIGHSEAT_SECRET`.
|
|
234
|
+
|
|
235
|
+
### Astro
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// src/pages/3f9a0c1e2b.ts
|
|
239
|
+
import type { APIRoute } from "astro"
|
|
240
|
+
import { createProxy } from "highseat/proxy"
|
|
241
|
+
|
|
242
|
+
export const prerender = false
|
|
243
|
+
|
|
244
|
+
export const ALL: APIRoute = ({ request, clientAddress }) =>
|
|
245
|
+
createProxy({ clientIp: () => clientAddress })(request)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Needs a server adapter. The file name carries the path, so set only `HIGHSEAT_SECRET`. On the Cloudflare adapter, pass `secret: locals.runtime.env.HIGHSEAT_SECRET`.
|
|
249
|
+
|
|
250
|
+
### Hono
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { Hono } from "hono"
|
|
254
|
+
import { createProxy } from "highseat/proxy"
|
|
255
|
+
|
|
256
|
+
const proxy = createProxy({ clientIp: "nginx" })
|
|
257
|
+
const app = new Hono()
|
|
258
|
+
|
|
259
|
+
app.all(process.env.HIGHSEAT_PATH!, context => proxy(context.req.raw))
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The outermost proxy that sets the header decides the preset: `"cloudflare"` behind a proxied Cloudflare record, `"nginx"` behind nginx alone, or a function for a server facing visitors directly.
|
|
263
|
+
|
|
264
|
+
### nginx
|
|
265
|
+
|
|
266
|
+
For a host without functions, nginx forwards the same two headers. It reads no environment variables, so the secret is written into the config: keep the file out of version control.
|
|
267
|
+
|
|
268
|
+
```nginx
|
|
269
|
+
# A GET on the path loads the script; a POST goes to the events location below.
|
|
270
|
+
location = /3f9a0c1e2b {
|
|
271
|
+
if ($request_method = POST) {
|
|
272
|
+
rewrite ^ /3f9a0c1e2b/events last;
|
|
273
|
+
}
|
|
274
|
+
proxy_pass https://cdn.highseat.com/script.js;
|
|
275
|
+
include highseat-proxy.conf;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
location = /3f9a0c1e2b/events {
|
|
279
|
+
internal;
|
|
280
|
+
proxy_pass https://cdn.highseat.com/api/event;
|
|
281
|
+
include highseat-proxy.conf;
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```nginx
|
|
286
|
+
# highseat-proxy.conf
|
|
287
|
+
proxy_set_header Host cdn.highseat.com;
|
|
288
|
+
proxy_ssl_server_name on;
|
|
289
|
+
proxy_set_header X-Hs-Secret "hs_secret_...";
|
|
290
|
+
# Behind Cloudflare or another edge, use $http_cf_connecting_ip instead.
|
|
291
|
+
proxy_set_header X-Hs-Client-Ip $remote_addr;
|
|
292
|
+
# An empty value keeps the visitor's own header from reaching Highseat.
|
|
293
|
+
proxy_set_header Cookie "";
|
|
294
|
+
proxy_set_header Authorization "";
|
|
295
|
+
proxy_set_header Referer "";
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
A host with no functions and no server of its own, such as GitHub Pages or S3, loads the script from Highseat's host instead.
|
|
299
|
+
|
|
300
|
+
## Support
|
|
301
|
+
|
|
302
|
+
Report a problem to [support@highseat.com](mailto:support@highseat.com). The source on GitHub, [highseat/highseat-js](https://github.com/highseat/highseat-js), is a read-only mirror and takes no issues or pull requests.
|
package/index.d.ts
CHANGED
|
@@ -1,9 +1,168 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The browser client. Every call goes to the hosted tracker on `window.highseat`; before the
|
|
4
|
+
* tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:
|
|
5
|
+
* on a server, with the tracker blocked, or with tracking off, a call does nothing.
|
|
6
|
+
*/
|
|
7
|
+
/** A value an event property can hold. A `null` or `undefined` value is left out of the event. */
|
|
8
|
+
export type PropertyValue = string | number | boolean | null | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Money attached to an event. `amount` is an integer in the currency's smallest unit, so
|
|
11
|
+
* `{ amount: 4999, currency: "USD" }` is $49.99 and `{ amount: 5000, currency: "JPY" }` is ¥5000.
|
|
12
|
+
*/
|
|
13
|
+
export interface Revenue {
|
|
14
|
+
/** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */
|
|
15
|
+
amount: number;
|
|
16
|
+
/** An ISO 4217 code, such as `USD` or `EUR`. */
|
|
17
|
+
currency: string;
|
|
18
|
+
}
|
|
19
|
+
/** The properties of an event with no declared shape: any string keys, plus optional revenue. */
|
|
20
|
+
export interface Properties {
|
|
21
|
+
/** Money the event brought in. */
|
|
22
|
+
revenue?: Revenue;
|
|
23
|
+
[key: string]: PropertyValue | Revenue;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The events Highseat knows by name. A site extends it, or overrides an entry, through
|
|
27
|
+
* {@link Register}. Type-only: it adds nothing to the bundle.
|
|
28
|
+
*/
|
|
29
|
+
export interface BuiltInEvents {
|
|
30
|
+
/** A visitor created an account. */
|
|
31
|
+
signup: Properties;
|
|
32
|
+
/** A visitor signed in. */
|
|
33
|
+
login: Properties;
|
|
34
|
+
/**
|
|
35
|
+
* A sale. With a payment provider connected, leave `revenue` out: the provider's webhook
|
|
36
|
+
* records the money, and revenue sent here as well would count the sale twice. Send `revenue`
|
|
37
|
+
* only for a checkout no provider reports, and then with an `order_id`, so a provider sale
|
|
38
|
+
* added later for the same order is not counted again.
|
|
39
|
+
*/
|
|
40
|
+
purchase: Properties;
|
|
41
|
+
/** A paid subscription began. */
|
|
42
|
+
subscription_started: Properties;
|
|
43
|
+
/** A free trial began. */
|
|
44
|
+
trial_started: Properties;
|
|
45
|
+
/** A file download. The tracker records links to files on its own. */
|
|
46
|
+
file_download: {
|
|
47
|
+
url: string;
|
|
48
|
+
};
|
|
49
|
+
/** A click on a link to another site. The tracker records these on its own. */
|
|
50
|
+
outbound_link_click: {
|
|
51
|
+
url: string;
|
|
52
|
+
};
|
|
53
|
+
/** A visit to a missing page. Call it from a single-page app's not-found route. */
|
|
54
|
+
"404": Properties;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Declares a site's own events. Augment it from any file the compiler sees:
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* declare module "highseat" {
|
|
61
|
+
* interface Register {
|
|
62
|
+
* events: {
|
|
63
|
+
* signup: { plan: "free" | "pro" }
|
|
64
|
+
* }
|
|
65
|
+
* strict: true
|
|
66
|
+
* }
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`
|
|
71
|
+
* accept only built-in and registered names, so a misspelt name is a type error.
|
|
72
|
+
*/
|
|
73
|
+
export interface Register {}
|
|
74
|
+
/**
|
|
75
|
+
* A registered event's properties as `track` accepts them. A shape with no properties takes none,
|
|
76
|
+
* and a property whose type an event cannot carry becomes `never`, so a call passing it fails
|
|
77
|
+
* instead of sending `"[object Object]"`. The shape is not rejected at registration, where a
|
|
78
|
+
* failed check would silently drop every registered event at once.
|
|
79
|
+
*/
|
|
80
|
+
type CheckedProperties<Shape> = [keyof Shape] extends [never] ? Record<string, never> : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never; };
|
|
81
|
+
type RegisteredEvents = Register extends {
|
|
82
|
+
events: infer Registered;
|
|
83
|
+
} ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]>; } : {};
|
|
84
|
+
type KnownEvents = Omit<BuiltInEvents, keyof RegisteredEvents> & RegisteredEvents;
|
|
85
|
+
/** Every event name `track` knows, built-in or registered. */
|
|
86
|
+
export type EventName = Extract<keyof KnownEvents, string>;
|
|
87
|
+
/**
|
|
88
|
+
* The names `track` accepts: known names, and any other string unless the site is strict.
|
|
89
|
+
* `string & {}` keeps the known names in the editor's suggestions, which a plain `string` in the
|
|
90
|
+
* union would absorb.
|
|
91
|
+
*/
|
|
92
|
+
export type TrackName = Register extends {
|
|
93
|
+
strict: true;
|
|
94
|
+
} ? EventName : EventName | (string & {});
|
|
95
|
+
/**
|
|
96
|
+
* The arguments after an event's name. A known name takes its declared properties, required
|
|
97
|
+
* when any of them is; any other name takes {@link Properties}. Wrapped in tuples so a union of
|
|
98
|
+
* names is checked as a whole rather than name by name.
|
|
99
|
+
*/
|
|
100
|
+
export type TrackArgs<Name extends string> = [Name] extends [EventName] ? {} extends KnownEvents[Name] ? [properties?: KnownEvents[Name]] : [properties: KnownEvents[Name]] : [properties?: Properties];
|
|
101
|
+
/** The tracker's API on `window.highseat`, or the queue that stands in for it until it loads. */
|
|
102
|
+
export interface HighseatGlobal {
|
|
103
|
+
/** Records an event. */
|
|
104
|
+
track?: (name: string, properties?: Properties) => void;
|
|
105
|
+
/** Sets or clears the visitor's user id. */
|
|
106
|
+
identify?: (id: string | number | null | undefined) => void;
|
|
107
|
+
/** The id that joins a payment to this tab's visit. */
|
|
108
|
+
getAttributionId?: () => string | undefined;
|
|
109
|
+
/** Calls made before the tracker loaded, drained in order when it starts. */
|
|
110
|
+
q?: [string, unknown[]][];
|
|
111
|
+
}
|
|
112
|
+
/** How long {@link getAttributionId} waits for the tracker. */
|
|
113
|
+
export interface AttributionOptions {
|
|
114
|
+
/** Milliseconds to wait for the tracker to load. Defaults to 3000. */
|
|
115
|
+
timeout?: number;
|
|
116
|
+
}
|
|
117
|
+
/** How the tracker is loaded. Each option maps to an attribute on its script tag. */
|
|
118
|
+
export interface InjectOptions {
|
|
119
|
+
/** The site id, `site_…`, from the site's Installation settings. */
|
|
120
|
+
site: string;
|
|
121
|
+
/**
|
|
122
|
+
* Where the script loads from: the site's proxy path (`/3f9a0c1e2b`) for first-party
|
|
123
|
+
* delivery, or a full URL. Defaults to Highseat's own host. The tracker posts its events back
|
|
124
|
+
* to the same place.
|
|
125
|
+
*/
|
|
126
|
+
src?: string;
|
|
127
|
+
/** Records clicks on links to other sites as `outbound_link_click`. Defaults to `true`. */
|
|
128
|
+
outbound?: boolean;
|
|
129
|
+
/** Records clicks on links to files as `file_download`. Defaults to `true`. */
|
|
130
|
+
downloads?: boolean;
|
|
131
|
+
/** Counts a change of `#/route` as a page view, for an app that routes by hash. */
|
|
132
|
+
hashRouting?: boolean;
|
|
133
|
+
}
|
|
134
|
+
declare global {
|
|
135
|
+
interface Window {
|
|
136
|
+
highseat?: HighseatGlobal;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Where the tracker loads from when the site has no proxy path. */
|
|
140
|
+
export declare const SCRIPT_URL = "https://cdn.highseat.com/script.js";
|
|
141
|
+
/**
|
|
142
|
+
* Records an event. A known name autocompletes and checks its properties; any other name is a
|
|
143
|
+
* custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so
|
|
144
|
+
* `Purchase` and `purchase` are two events.
|
|
145
|
+
*/
|
|
146
|
+
export declare function track<Name extends TrackName>(name: Name, ...args: TrackArgs<Name>): void;
|
|
147
|
+
/**
|
|
148
|
+
* Links the visitor to a user id from your own system, on this event and every later one, across
|
|
149
|
+
* visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so
|
|
150
|
+
* `identify(user?.id)` before a session loads does not sign the visitor out.
|
|
151
|
+
*/
|
|
152
|
+
export declare function identify(id: string | number | null | undefined): void;
|
|
153
|
+
/**
|
|
154
|
+
* The id that joins a payment to this tab's visit. Pass it to the checkout as metadata
|
|
155
|
+
* (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.
|
|
156
|
+
* Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has
|
|
157
|
+
* no tracker script or its script fails to load (an ad blocker stopping it), after the timeout
|
|
158
|
+
* when the script is still loading, and on a server or with tracking off.
|
|
159
|
+
*/
|
|
160
|
+
export declare function getAttributionId(options?: AttributionOptions): Promise<string | undefined>;
|
|
161
|
+
/**
|
|
162
|
+
* Adds the tracker's script tag to the page, once. A second call for the same site, such as a
|
|
163
|
+
* component mounting again, finds the tag and does nothing. Does nothing on a server.
|
|
164
|
+
*/
|
|
165
|
+
export declare function inject(options: InjectOptions): void;
|
|
166
|
+
//#endregion
|
|
167
|
+
export {};
|
|
168
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -1,15 +1,98 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/** Where the tracker loads from when the site has no proxy path. */
|
|
3
|
+
const SCRIPT_URL = "https://cdn.highseat.com/script.js";
|
|
4
|
+
const LIVE = Symbol.for("highseat");
|
|
5
|
+
const ATTRIBUTION_TIMEOUT = 3e3;
|
|
6
|
+
const ATTRIBUTION_POLL_INTERVAL = 50;
|
|
7
|
+
const injectedScripts = /* @__PURE__ */ new WeakSet();
|
|
8
|
+
const failedScripts = /* @__PURE__ */ new WeakSet();
|
|
9
|
+
function findGlobal() {
|
|
10
|
+
if (typeof window === "undefined") return;
|
|
11
|
+
return window.highseat ??= {};
|
|
12
|
+
}
|
|
13
|
+
function isLive(api) {
|
|
14
|
+
return !!api && api[LIVE] === true;
|
|
15
|
+
}
|
|
16
|
+
function markFailed(event) {
|
|
17
|
+
failedScripts.add(event.currentTarget);
|
|
18
|
+
}
|
|
19
|
+
function findTrackerScripts() {
|
|
20
|
+
return Array.from(document.scripts).filter((script) => injectedScripts.has(script) || script.dataset.site?.startsWith("site_"));
|
|
21
|
+
}
|
|
22
|
+
function call(method, args) {
|
|
23
|
+
const api = findGlobal();
|
|
24
|
+
if (!api) return;
|
|
25
|
+
const handler = api[method];
|
|
26
|
+
if (typeof handler !== "function") {
|
|
27
|
+
api.q ??= [];
|
|
28
|
+
api.q.push([method, args]);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
handler(...args);
|
|
33
|
+
} catch {}
|
|
5
34
|
}
|
|
6
|
-
|
|
7
35
|
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
36
|
+
* Records an event. A known name autocompletes and checks its properties; any other name is a
|
|
37
|
+
* custom event, unless {@link Register} sets `strict`. Names are stored exactly as written, so
|
|
38
|
+
* `Purchase` and `purchase` are two events.
|
|
39
|
+
*/
|
|
40
|
+
function track(name, ...args) {
|
|
41
|
+
const [properties] = args;
|
|
42
|
+
call("track", properties === void 0 ? [name] : [name, properties]);
|
|
15
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Links the visitor to a user id from your own system, on this event and every later one, across
|
|
46
|
+
* visits. `null` clears it at sign-out. An empty or `undefined` id is ignored, so
|
|
47
|
+
* `identify(user?.id)` before a session loads does not sign the visitor out.
|
|
48
|
+
*/
|
|
49
|
+
function identify(id) {
|
|
50
|
+
call("identify", [id]);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The id that joins a payment to this tab's visit. Pass it to the checkout as metadata
|
|
54
|
+
* (`attribution_id`), and the payment provider's webhook credits the sale to the visit's source.
|
|
55
|
+
* Resolves to `undefined`, so a checkout that awaits it never hangs: at once when the page has
|
|
56
|
+
* no tracker script or its script fails to load (an ad blocker stopping it), after the timeout
|
|
57
|
+
* when the script is still loading, and on a server or with tracking off.
|
|
58
|
+
*/
|
|
59
|
+
async function getAttributionId(options = {}) {
|
|
60
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
61
|
+
const deadline = Date.now() + (options.timeout ?? ATTRIBUTION_TIMEOUT);
|
|
62
|
+
let api = window.highseat;
|
|
63
|
+
while (!isLive(api)) {
|
|
64
|
+
if (Date.now() >= deadline) return;
|
|
65
|
+
const scripts = findTrackerScripts();
|
|
66
|
+
for (const script of scripts) script.addEventListener("error", markFailed);
|
|
67
|
+
if (scripts.every((script) => failedScripts.has(script))) return;
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, ATTRIBUTION_POLL_INTERVAL));
|
|
69
|
+
api = window.highseat;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return api.getAttributionId?.();
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Adds the tracker's script tag to the page, once. A second call for the same site, such as a
|
|
79
|
+
* component mounting again, finds the tag and does nothing. Does nothing on a server.
|
|
80
|
+
*/
|
|
81
|
+
function inject(options) {
|
|
82
|
+
if (typeof document === "undefined") return;
|
|
83
|
+
const { site, src = SCRIPT_URL, outbound, downloads, hashRouting } = options;
|
|
84
|
+
if (Array.from(document.scripts).some((script) => script.dataset.site === site)) return;
|
|
85
|
+
const script = document.createElement("script");
|
|
86
|
+
script.addEventListener("error", markFailed);
|
|
87
|
+
injectedScripts.add(script);
|
|
88
|
+
script.src = src;
|
|
89
|
+
script.dataset.site = site;
|
|
90
|
+
if (outbound === false) script.dataset.outbound = "false";
|
|
91
|
+
if (downloads === false) script.dataset.downloads = "false";
|
|
92
|
+
if (hashRouting) script.dataset.hashRouting = "";
|
|
93
|
+
document.head.append(script);
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { SCRIPT_URL, getAttributionId, identify, inject, track };
|
|
97
|
+
|
|
98
|
+
//# sourceMappingURL=index.js.map
|
package/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * The browser client. Every call goes to the hosted tracker on `window.highseat`; before the\n * tracker has loaded, calls wait in a queue the tracker drains on startup. Nothing here throws:\n * on a server, with the tracker blocked, or with tracking off, a call does nothing.\n */\n\n/** A value an event property can hold. A `null` or `undefined` value is left out of the event. */\nexport type PropertyValue = string | number | boolean | null | undefined\n\n/**\n * Money attached to an event. `amount` is an integer in the currency's smallest unit, so\n * `{ amount: 4999, currency: \"USD\" }` is $49.99 and `{ amount: 5000, currency: \"JPY\" }` is ¥5000.\n */\nexport interface Revenue {\n /** An integer in minor units: 4999 for $49.99. An amount with decimals is rejected. */\n amount: number\n /** An ISO 4217 code, such as `USD` or `EUR`. */\n currency: string\n}\n\n/** The properties of an event with no declared shape: any string keys, plus optional revenue. */\nexport interface Properties {\n /** Money the event brought in. */\n revenue?: Revenue\n [key: string]: PropertyValue | Revenue\n}\n\n/**\n * The events Highseat knows by name. A site extends it, or overrides an entry, through\n * {@link Register}. Type-only: it adds nothing to the bundle.\n */\nexport interface BuiltInEvents {\n /** A visitor created an account. */\n signup: Properties\n /** A visitor signed in. */\n login: Properties\n /**\n * A sale. With a payment provider connected, leave `revenue` out: the provider's webhook\n * records the money, and revenue sent here as well would count the sale twice. Send `revenue`\n * only for a checkout no provider reports, and then with an `order_id`, so a provider sale\n * added later for the same order is not counted again.\n */\n purchase: Properties\n /** A paid subscription began. */\n subscription_started: Properties\n /** A free trial began. */\n trial_started: Properties\n /** A file download. The tracker records links to files on its own. */\n file_download: { url: string }\n /** A click on a link to another site. The tracker records these on its own. */\n outbound_link_click: { url: string }\n /** A visit to a missing page. Call it from a single-page app's not-found route. */\n \"404\": Properties\n}\n\n/**\n * Declares a site's own events. Augment it from any file the compiler sees:\n *\n * ```ts\n * declare module \"highseat\" {\n * interface Register {\n * events: {\n * signup: { plan: \"free\" | \"pro\" }\n * }\n * strict: true\n * }\n * }\n * ```\n *\n * `events` adds names, or replaces a built-in one's properties. `strict: true` makes `track`\n * accept only built-in and registered names, so a misspelt name is a type error.\n */\nexport interface Register {}\n\n/**\n * A registered event's properties as `track` accepts them. A shape with no properties takes none,\n * and a property whose type an event cannot carry becomes `never`, so a call passing it fails\n * instead of sending `\"[object Object]\"`. The shape is not rejected at registration, where a\n * failed check would silently drop every registered event at once.\n */\ntype CheckedProperties<Shape> = [keyof Shape] extends [never]\n ? Record<string, never>\n : { [Key in keyof Shape]: Shape[Key] extends PropertyValue | Revenue ? Shape[Key] : never }\n\ntype RegisteredEvents = Register extends { events: infer Registered }\n ? { [Name in keyof Registered]: CheckedProperties<Registered[Name]> }\n : {}\n\ntype KnownEvents = Omit<BuiltInEvents, keyof RegisteredEvents> & RegisteredEvents\n\n/** Every event name `track` knows, built-in or registered. */\nexport type EventName = Extract<keyof KnownEvents, string>\n\n/**\n * The names `track` accepts: known names, and any other string unless the site is strict.\n * `string & {}` keeps the known names in the editor's suggestions, which a plain `string` in the\n * union would absorb.\n */\nexport type TrackName = Register extends { strict: true } ? EventName : EventName | (string & {})\n\n/**\n * The arguments after an event's name. A known name takes its declared properties, required\n * when any of them is; any other name takes {@link Properties}. Wrapped in tuples so a union of\n * names is checked as a whole rather than name by name.\n */\nexport type TrackArgs<Name extends string> = [Name] extends [EventName]\n ? {} extends KnownEvents[Name]\n ? [properties?: KnownEvents[Name]]\n : [properties: KnownEvents[Name]]\n : [properties?: Properties]\n\n/** 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: string | number | null | undefined) => 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}\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}\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 ATTRIBUTION_TIMEOUT = 3000\nconst ATTRIBUTION_POLL_INTERVAL = 50\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\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// Matched on the `site_` prefix as the tracker matches itself: another tool's snippet can carry a\n// `data-site` too.\nfunction findTrackerScripts() {\n return Array.from(document.scripts).filter(\n script => injectedScripts.has(script) || script.dataset.site?.startsWith(\"site_\"),\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 return\n }\n\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: string | number | null | undefined): 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 ?? ATTRIBUTION_TIMEOUT)\n let api = window.highseat\n\n while (!isLive(api)) {\n if (Date.now() >= deadline) return\n\n const scripts = findTrackerScripts()\n for (const script of scripts) script.addEventListener(\"error\", markFailed)\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, ATTRIBUTION_POLL_INTERVAL))\n api = window.highseat\n }\n\n try {\n return api.getAttributionId?.()\n } catch {\n return\n }\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 does nothing. 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 } = options\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":";;AA0JA,MAAa,aAAa;AAI1B,MAAM,OAAO,OAAO,IAAI,UAAU;AAElC,MAAM,sBAAsB;AAC5B,MAAM,4BAA4B;AAKlC,MAAM,kCAAkB,IAAI,QAA2B;AACvD,MAAM,gCAAgB,IAAI,QAA2B;AAErD,SAAS,aAAyC;CAChD,IAAI,OAAO,WAAW,aAAa;CACnC,OAAQ,OAAO,aAAa,CAAC;AAC/B;AAEA,SAAS,OAAO,KAAwD;CACtE,OAAO,CAAC,CAAC,OAAQ,IAAgC,UAAU;AAC7D;AAGA,SAAS,WAAW,OAAc;CAChC,cAAc,IAAI,MAAM,aAAkC;AAC5D;AAIA,SAAS,qBAAqB;CAC5B,OAAO,MAAM,KAAK,SAAS,OAAO,CAAC,CAAC,QAClC,WAAU,gBAAgB,IAAI,MAAM,KAAK,OAAO,QAAQ,MAAM,WAAW,OAAO,CAClF;AACF;AAEA,SAAS,KAAK,QAA8B,MAAiB;CAC3D,MAAM,MAAM,WAAW;CACvB,IAAI,CAAC,KAAK;CAEV,MAAM,UAAU,IAAI;CACpB,IAAI,OAAO,YAAY,YAAY;EACjC,IAAI,MAAM,CAAC;EACX,IAAI,EAAE,KAAK,CAAC,QAAQ,IAAI,CAAC;EACzB;CACF;CAEA,IAAI;EACF,QAAQ,GAAG,IAAI;CACjB,QAAQ,CAAC;AACX;;;;;;AAOA,SAAgB,MAA8B,MAAY,GAAG,MAA6B;CACxF,MAAM,CAAC,cAAc;CACrB,KAAK,SAAS,eAAe,KAAA,IAAY,CAAC,IAAI,IAAI,CAAC,MAAM,UAAU,CAAC;AACtE;;;;;;AAOA,SAAgB,SAAS,IAA8C;CACrE,KAAK,YAAY,CAAC,EAAE,CAAC;AACvB;;;;;;;;AASA,eAAsB,iBACpB,UAA8B,CAAC,GACF;CAC7B,IAAI,OAAO,WAAW,eAAe,OAAO,aAAa,aAAa;CAEtE,MAAM,WAAW,KAAK,IAAI,KAAK,QAAQ,WAAW;CAClD,IAAI,MAAM,OAAO;CAEjB,OAAO,CAAC,OAAO,GAAG,GAAG;EACnB,IAAI,KAAK,IAAI,KAAK,UAAU;EAE5B,MAAM,UAAU,mBAAmB;EACnC,KAAK,MAAM,UAAU,SAAS,OAAO,iBAAiB,SAAS,UAAU;EAEzE,IAAI,QAAQ,OAAM,WAAU,cAAc,IAAI,MAAM,CAAC,GAAG;EAExD,MAAM,IAAI,SAAQ,YAAW,WAAW,SAAS,yBAAyB,CAAC;EAC3E,MAAM,OAAO;CACf;CAEA,IAAI;EACF,OAAO,IAAI,mBAAmB;CAChC,QAAQ;EACN;CACF;AACF;;;;;AAMA,SAAgB,OAAO,SAA8B;CACnD,IAAI,OAAO,aAAa,aAAa;CAErC,MAAM,EAAE,MAAM,MAAM,YAAY,UAAU,WAAW,gBAAgB;CACrE,IAAI,MAAM,KAAK,SAAS,OAAO,CAAC,CAAC,MAAK,WAAU,OAAO,QAAQ,SAAS,IAAI,GAAG;CAE/E,MAAM,SAAS,SAAS,cAAc,QAAQ;CAC9C,OAAO,iBAAiB,SAAS,UAAU;CAC3C,gBAAgB,IAAI,MAAM;CAC1B,OAAO,MAAM;CACb,OAAO,QAAQ,OAAO;CACtB,IAAI,aAAa,OAAO,OAAO,QAAQ,WAAW;CAClD,IAAI,cAAc,OAAO,OAAO,QAAQ,YAAY;CACpD,IAAI,aAAa,OAAO,QAAQ,cAAc;CAC9C,SAAS,KAAK,OAAO,MAAM;AAC7B"}
|
package/next.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { InjectOptions } from "./index.js";
|
|
2
|
+
import { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions, createProxy } from "./proxy.js";
|
|
3
|
+
import { ReactElement } from "react";
|
|
4
|
+
//#region src/next.d.ts
|
|
5
|
+
export interface HighseatProps extends Partial<InjectOptions> {}
|
|
6
|
+
/**
|
|
7
|
+
* Loads the Highseat tracker in a Next.js app. Render it once, in the root layout. The site id
|
|
8
|
+
* comes from `NEXT_PUBLIC_HIGHSEAT_SITE`, and the script loads from the site's proxy path in
|
|
9
|
+
* `HIGHSEAT_PATH` when one is set. `HIGHSEAT_PATH` is a server variable, so in the Pages Router,
|
|
10
|
+
* where the layout also renders in the browser, pass `src` instead. A prop wins over its variable.
|
|
11
|
+
*/
|
|
12
|
+
export declare function Highseat(props: HighseatProps): ReactElement | null;
|
|
13
|
+
/** A Next.js rewrite: the subset of its fields this package writes and reads. */
|
|
14
|
+
interface Rewrite {
|
|
15
|
+
source: string;
|
|
16
|
+
destination: string;
|
|
17
|
+
}
|
|
18
|
+
/** The phases Next.js applies rewrites in, when `rewrites()` returns an object. */
|
|
19
|
+
interface RewritePhases {
|
|
20
|
+
beforeFiles?: Rewrite[];
|
|
21
|
+
afterFiles?: Rewrite[];
|
|
22
|
+
fallback?: Rewrite[];
|
|
23
|
+
}
|
|
24
|
+
/** A Next.js config with the proxy's rewrite added, or its own rewrites when there is no path. */
|
|
25
|
+
export type WithHighseatConfig<Config> = Config & {
|
|
26
|
+
rewrites: () => Promise<Rewrite[] | RewritePhases>;
|
|
27
|
+
};
|
|
28
|
+
export interface WithHighseatOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The site's proxy path from Installation settings. Defaults to the `HIGHSEAT_PATH`
|
|
31
|
+
* environment variable, the one `createProxy()` reads, so rotating the path is an
|
|
32
|
+
* environment change and a redeploy.
|
|
33
|
+
*/
|
|
34
|
+
path?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Where the app's proxy route handler lives. Defaults to `/api/highseat`, the route of
|
|
37
|
+
* `app/api/highseat/route.ts`.
|
|
38
|
+
*/
|
|
39
|
+
route?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Adds a rewrite from the site's proxy path to the app's proxy route handler, ahead of every
|
|
43
|
+
* other route. The rewrite stays inside the app, so the handler still sees the visitor's IP, and
|
|
44
|
+
* the route file never moves when the path rotates.
|
|
45
|
+
*
|
|
46
|
+
* Without a proxy path, `next build` fails, while `next dev` warns once and runs without the
|
|
47
|
+
* rewrite.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* // next.config.ts
|
|
51
|
+
* import { withHighseat } from "highseat/next"
|
|
52
|
+
* export default withHighseat({ reactStrictMode: true })
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export declare function withHighseat<Config extends object>(config: Config, options?: WithHighseatOptions): WithHighseatConfig<Config>;
|
|
56
|
+
//#endregion
|
|
57
|
+
export { type ClientIp, type ClientIpPreset, type ProxyHandler, type ProxyOptions, createProxy };
|
|
58
|
+
//# sourceMappingURL=next.d.ts.map
|
package/next.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Highseat as Highseat$1 } from "./react.js";
|
|
2
|
+
import { i as isProduction, n as findNextSite, r as findProxyPath, t as createProxy } from "./proxy2.js";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
//#region src/next.ts
|
|
5
|
+
/**
|
|
6
|
+
* Loads the Highseat tracker in a Next.js app. Render it once, in the root layout. The site id
|
|
7
|
+
* comes from `NEXT_PUBLIC_HIGHSEAT_SITE`, and the script loads from the site's proxy path in
|
|
8
|
+
* `HIGHSEAT_PATH` when one is set. `HIGHSEAT_PATH` is a server variable, so in the Pages Router,
|
|
9
|
+
* where the layout also renders in the browser, pass `src` instead. A prop wins over its variable.
|
|
10
|
+
*/
|
|
11
|
+
function Highseat(props) {
|
|
12
|
+
const site = props.site ?? findNextSite();
|
|
13
|
+
const src = props.src ?? findProxyPath();
|
|
14
|
+
if (!site) {
|
|
15
|
+
console.warn("highseat: set NEXT_PUBLIC_HIGHSEAT_SITE, or pass `site` to <Highseat />");
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return createElement(Highseat$1, {
|
|
19
|
+
...props,
|
|
20
|
+
site,
|
|
21
|
+
src
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const DEFAULT_ROUTE = "/api/highseat";
|
|
25
|
+
const NO_PATH_MESSAGE = "highseat/next: no proxy path. Set HIGHSEAT_PATH to the path from the site's Installation settings, or pass `path`.";
|
|
26
|
+
let hasWarnedNoPath = false;
|
|
27
|
+
/**
|
|
28
|
+
* Adds a rewrite from the site's proxy path to the app's proxy route handler, ahead of every
|
|
29
|
+
* other route. The rewrite stays inside the app, so the handler still sees the visitor's IP, and
|
|
30
|
+
* the route file never moves when the path rotates.
|
|
31
|
+
*
|
|
32
|
+
* Without a proxy path, `next build` fails, while `next dev` warns once and runs without the
|
|
33
|
+
* rewrite.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* // next.config.ts
|
|
37
|
+
* import { withHighseat } from "highseat/next"
|
|
38
|
+
* export default withHighseat({ reactStrictMode: true })
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
function withHighseat(config, options = {}) {
|
|
42
|
+
const path = findProxyPath(options.path);
|
|
43
|
+
const { rewrites: ownRewrites } = config;
|
|
44
|
+
async function passRewritesThrough() {
|
|
45
|
+
if (isProduction()) throw new Error(NO_PATH_MESSAGE);
|
|
46
|
+
if (!hasWarnedNoPath) {
|
|
47
|
+
hasWarnedNoPath = true;
|
|
48
|
+
console.warn(`${NO_PATH_MESSAGE} The proxy is off until then.`);
|
|
49
|
+
}
|
|
50
|
+
return await ownRewrites?.() ?? [];
|
|
51
|
+
}
|
|
52
|
+
if (!path) return {
|
|
53
|
+
...config,
|
|
54
|
+
rewrites: passRewritesThrough
|
|
55
|
+
};
|
|
56
|
+
const rewrite = {
|
|
57
|
+
source: path,
|
|
58
|
+
destination: options.route ?? DEFAULT_ROUTE
|
|
59
|
+
};
|
|
60
|
+
async function rewrites() {
|
|
61
|
+
const existing = await ownRewrites?.() ?? [];
|
|
62
|
+
if (Array.isArray(existing)) return {
|
|
63
|
+
beforeFiles: [rewrite],
|
|
64
|
+
afterFiles: existing
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
...existing,
|
|
68
|
+
beforeFiles: [rewrite, ...existing.beforeFiles ?? []]
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
...config,
|
|
73
|
+
rewrites
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
export { Highseat, createProxy, withHighseat };
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=next.js.map
|
package/next.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next.js","names":["ReactHighseat"],"sources":["../src/next.ts"],"sourcesContent":["import { createElement, type ReactElement } from \"react\"\nimport { findNextSite, findProxyPath, isProduction } from \"./env\"\nimport type { InjectOptions } from \"./index\"\nimport { Highseat as ReactHighseat } from \"./react\"\n\nexport { createProxy } from \"./proxy\"\nexport type { ClientIp, ClientIpPreset, ProxyHandler, ProxyOptions } from \"./proxy\"\n\nexport interface HighseatProps extends Partial<InjectOptions> {}\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 */\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}\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\nconst NO_PATH_MESSAGE =\n \"highseat/next: no proxy path. Set HIGHSEAT_PATH to the path from the site's Installation settings, or pass `path`.\"\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 * Without a proxy path, `next build` fails, while `next dev` warns once and runs without the\n * rewrite.\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` is read.\n const { rewrites: ownRewrites } = config as NextConfigWithRewrites\n\n // Next.js hands the build phase only to a config written as a function, so a missing path is\n // judged where the rewrites are read: `next build` reads them in production mode, `next dev`\n // in development, and `next start` serves the ones the build wrote without asking again.\n async function passRewritesThrough(): Promise<Rewrite[] | RewritePhases> {\n if (isProduction()) throw new Error(NO_PATH_MESSAGE)\n\n if (!hasWarnedNoPath) {\n hasWarnedNoPath = true\n console.warn(`${NO_PATH_MESSAGE} The proxy is off until then.`)\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, rewrites }\n}\n"],"mappings":";;;;;;;;;;AAgBA,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;AAuCA,MAAM,gBAAgB;AAEtB,MAAM,kBACJ;AAGF,IAAI,kBAAkB;;;;;;;;;;;;;;;AAgBtB,SAAgB,aACd,QACA,UAA+B,CAAC,GACJ;CAC5B,MAAM,OAAO,cAAc,QAAQ,IAAI;CAGvC,MAAM,EAAE,UAAU,gBAAgB;CAKlC,eAAe,sBAA0D;EACvE,IAAI,aAAa,GAAG,MAAM,IAAI,MAAM,eAAe;EAEnD,IAAI,CAAC,iBAAiB;GACpB,kBAAkB;GAClB,QAAQ,KAAK,GAAG,gBAAgB,8BAA8B;EAChE;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;CAAS;AAC/B"}
|
package/package.json
CHANGED
|
@@ -1,22 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "highseat",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Highseat:
|
|
5
|
-
"keywords": [
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Highseat analytics for JavaScript: typed tracking, a first-party proxy and framework components.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"analytics",
|
|
7
|
+
"cookieless",
|
|
8
|
+
"highseat",
|
|
9
|
+
"privacy",
|
|
10
|
+
"web-analytics"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/highseat/highseat-js#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"email": "support@highseat.com"
|
|
15
|
+
},
|
|
6
16
|
"license": "MIT",
|
|
7
17
|
"author": "Piotr Kulpinski",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/highseat/highseat-js.git",
|
|
21
|
+
"directory": "packages/highseat"
|
|
22
|
+
},
|
|
8
23
|
"type": "module",
|
|
9
|
-
"
|
|
10
|
-
"types": "index.d.ts",
|
|
24
|
+
"sideEffects": false,
|
|
11
25
|
"exports": {
|
|
12
26
|
".": {
|
|
13
27
|
"types": "./index.d.ts",
|
|
14
28
|
"default": "./index.js"
|
|
29
|
+
},
|
|
30
|
+
"./react": {
|
|
31
|
+
"types": "./react.d.ts",
|
|
32
|
+
"default": "./react.js"
|
|
33
|
+
},
|
|
34
|
+
"./next": {
|
|
35
|
+
"types": "./next.d.ts",
|
|
36
|
+
"default": "./next.js"
|
|
37
|
+
},
|
|
38
|
+
"./proxy": {
|
|
39
|
+
"types": "./proxy.d.ts",
|
|
40
|
+
"default": "./proxy.js"
|
|
15
41
|
}
|
|
16
42
|
},
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"next": ">=14",
|
|
48
|
+
"react": ">=18"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"next": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"react": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
60
|
+
},
|
|
61
|
+
"main": "./index.js",
|
|
62
|
+
"module": "./index.js",
|
|
63
|
+
"types": "./index.d.ts"
|
|
22
64
|
}
|
package/proxy.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/proxy.d.ts
|
|
2
|
+
/** A host whose own header carries the visitor's IP. */
|
|
3
|
+
export type ClientIpPreset = "vercel" | "cloudflare" | "netlify" | "nginx";
|
|
4
|
+
/**
|
|
5
|
+
* Where the proxy reads the visitor's IP: a host preset, or a function for any other setup. A
|
|
6
|
+
* function returns the IP, or nothing when the request carries none.
|
|
7
|
+
*/
|
|
8
|
+
export type ClientIp = ClientIpPreset | ((request: Request) => string | null | undefined);
|
|
9
|
+
export interface ProxyOptions {
|
|
10
|
+
/**
|
|
11
|
+
* The site's proxy path from Installation settings, such as `/3f9a0c1e2b`, with or without its
|
|
12
|
+
* leading slash. A request on any
|
|
13
|
+
* other path is answered 404, or handed to `fallback`. Defaults to the `HIGHSEAT_PATH`
|
|
14
|
+
* environment variable. Leave both unset on a host whose router already sends only the proxy
|
|
15
|
+
* path here, such as a file named after the path.
|
|
16
|
+
*/
|
|
17
|
+
path?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The site secret, `hs_secret_…`, or an older one that starts `hs_proxy_` or `ana_proxy_`.
|
|
20
|
+
* Defaults to the `HIGHSEAT_SECRET` environment variable.
|
|
21
|
+
*/
|
|
22
|
+
secret?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Where the visitor's IP comes from: `vercel`, `cloudflare`, `netlify`, `nginx` (the
|
|
25
|
+
* `X-Real-IP` header your nginx sets), or a function. A visitor can write any header the host
|
|
26
|
+
* does not overwrite, so there is no default.
|
|
27
|
+
*
|
|
28
|
+
* The outermost proxy that sets the header decides the preset. Behind a proxied Cloudflare
|
|
29
|
+
* record, use `cloudflare` on any origin, not only a Worker or a Pages Function: a Next.js,
|
|
30
|
+
* TanStack Start or Node server behind Cloudflare, with or without Traefik or nginx in between,
|
|
31
|
+
* sees a Cloudflare edge address in `X-Real-IP`, and the visitor's IP in `cf-connecting-ip`.
|
|
32
|
+
*/
|
|
33
|
+
clientIp: ClientIp;
|
|
34
|
+
/** The ingest origin the proxy forwards to. Defaults to `https://cdn.highseat.com`. */
|
|
35
|
+
upstream?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Answers a request on any path other than `path`, for a handler that sees every request on
|
|
38
|
+
* the site: `fetch` in a Cloudflare Worker routed on the whole site, `next` in a Pages
|
|
39
|
+
* Function. Requires `path`.
|
|
40
|
+
*/
|
|
41
|
+
fallback?: (request: Request) => Response | Promise<Response>;
|
|
42
|
+
}
|
|
43
|
+
/** A fetch handler, the shape every supported runtime and framework route accepts. */
|
|
44
|
+
export type ProxyHandler = (request: Request) => Promise<Response>;
|
|
45
|
+
/**
|
|
46
|
+
* Creates the first-party proxy handler for a site. A GET serves the tracker script and a POST
|
|
47
|
+
* forwards its events, both to the ingest host, with the site secret and the visitor's IP. The
|
|
48
|
+
* returned function takes a `Request` and returns a `Response`, so it mounts as a Next.js route
|
|
49
|
+
* handler, a Cloudflare Worker, a Pages Function, a Netlify or Vercel function, an Astro
|
|
50
|
+
* endpoint or a Hono route.
|
|
51
|
+
*
|
|
52
|
+
* Throws when the secret is missing. Without one, ingest reads every visitor as the proxy's own
|
|
53
|
+
* server and drops it as datacenter traffic, so the proxy fails at startup instead.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createProxy(options: ProxyOptions): ProxyHandler;
|
|
56
|
+
//#endregion
|
|
57
|
+
//# sourceMappingURL=proxy.d.ts.map
|
package/proxy.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as createProxy } from "./proxy2.js";
|
package/proxy2.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
//#region src/env.ts
|
|
2
|
+
/**
|
|
3
|
+
* The variables the package reads from a customer's environment. Each is named in full, never
|
|
4
|
+
* looked up by a computed key: Next.js inlines a `NEXT_PUBLIC_` variable into browser code only
|
|
5
|
+
* where the source spells out `process.env.NEXT_PUBLIC_…`. The server variables return
|
|
6
|
+
* `undefined` where the runtime has no `process`: a Cloudflare Worker without `nodejs_compat`,
|
|
7
|
+
* whose options carry the values instead.
|
|
8
|
+
*/
|
|
9
|
+
/** The site id for `<Highseat />` in Next.js. Public, so it reaches the browser. */
|
|
10
|
+
function findNextSite() {
|
|
11
|
+
return process.env.NEXT_PUBLIC_HIGHSEAT_SITE;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The site's proxy path: `option` when given, else `HIGHSEAT_PATH`, a server variable shared by
|
|
15
|
+
* `<Highseat />`, `withHighseat()` and the proxy handler. A value written without its leading
|
|
16
|
+
* slash gets one, as Installation settings shows it (`/3f9a0c1e2b`). An empty value counts as
|
|
17
|
+
* unset.
|
|
18
|
+
*/
|
|
19
|
+
function findProxyPath(option) {
|
|
20
|
+
const path = option ?? (typeof process === "undefined" ? void 0 : process.env.HIGHSEAT_PATH);
|
|
21
|
+
if (!path) return;
|
|
22
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
23
|
+
}
|
|
24
|
+
/** The site secret for the proxy handler. */
|
|
25
|
+
function findSecret() {
|
|
26
|
+
if (typeof process === "undefined") return;
|
|
27
|
+
return process.env.HIGHSEAT_SECRET;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether this runs inside `next build`, which loads every route without serving one. Set in the
|
|
31
|
+
* build's route workers only: `next.config.ts` receives the phase as an argument instead.
|
|
32
|
+
*/
|
|
33
|
+
function isNextBuild() {
|
|
34
|
+
if (typeof process === "undefined") return false;
|
|
35
|
+
return process.env.NEXT_PHASE === "phase-production-build";
|
|
36
|
+
}
|
|
37
|
+
/** Whether Node runs in production mode, as `next build` and `next start` do. */
|
|
38
|
+
function isProduction() {
|
|
39
|
+
if (typeof process === "undefined") return false;
|
|
40
|
+
return process.env.NODE_ENV === "production";
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/proxy.ts
|
|
44
|
+
const DEFAULT_UPSTREAM = "https://cdn.highseat.com";
|
|
45
|
+
const SECRET_PREFIXES = [
|
|
46
|
+
"hs_secret_",
|
|
47
|
+
"hs_proxy_",
|
|
48
|
+
"ana_proxy_"
|
|
49
|
+
];
|
|
50
|
+
const CLIENT_IP_HEADERS = {
|
|
51
|
+
vercel: "x-real-ip",
|
|
52
|
+
cloudflare: "cf-connecting-ip",
|
|
53
|
+
netlify: "x-nf-client-connection-ip",
|
|
54
|
+
nginx: "x-real-ip"
|
|
55
|
+
};
|
|
56
|
+
const SCRIPT_REQUEST_HEADERS = ["user-agent", "if-none-match"];
|
|
57
|
+
const EVENT_REQUEST_HEADERS = [
|
|
58
|
+
"content-type",
|
|
59
|
+
"user-agent",
|
|
60
|
+
"origin"
|
|
61
|
+
];
|
|
62
|
+
const RESPONSE_HEADERS = [
|
|
63
|
+
"content-type",
|
|
64
|
+
"etag",
|
|
65
|
+
"cache-control",
|
|
66
|
+
"vary",
|
|
67
|
+
"retry-after",
|
|
68
|
+
"x-hs-ip",
|
|
69
|
+
"x-hs-ip-source"
|
|
70
|
+
];
|
|
71
|
+
const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
|
|
72
|
+
101,
|
|
73
|
+
204,
|
|
74
|
+
205,
|
|
75
|
+
304
|
|
76
|
+
]);
|
|
77
|
+
const UPSTREAM_TIMEOUT_MS = 1e4;
|
|
78
|
+
const MAX_EVENT_BYTES = 524288;
|
|
79
|
+
function getClientIpReader(clientIp) {
|
|
80
|
+
if (typeof clientIp === "function") return {
|
|
81
|
+
read: (request) => clientIp(request)?.trim() ?? "",
|
|
82
|
+
source: "the clientIp function"
|
|
83
|
+
};
|
|
84
|
+
const header = CLIENT_IP_HEADERS[clientIp];
|
|
85
|
+
if (!header) throw new Error(`highseat/proxy: unknown clientIp preset "${String(clientIp)}".`);
|
|
86
|
+
return {
|
|
87
|
+
read: (request) => request.headers.get(header)?.trim() ?? "",
|
|
88
|
+
source: `the "${clientIp}" preset's ${header} header`
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function copyHeaders(from, to, names) {
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
const value = from.get(name);
|
|
94
|
+
if (value !== null) to.set(name, value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function answerError(status, error) {
|
|
98
|
+
return Response.json({ error }, {
|
|
99
|
+
status,
|
|
100
|
+
headers: { "cache-control": "no-store" }
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function getSecret(option) {
|
|
104
|
+
const secret = option ?? findSecret();
|
|
105
|
+
if (!secret) throw new Error("highseat/proxy: no site secret. Set HIGHSEAT_SECRET to the secret from the site's Installation settings, or pass `secret`.");
|
|
106
|
+
if (!SECRET_PREFIXES.some((prefix) => secret.startsWith(prefix))) throw new Error(`highseat/proxy: not a site secret. Copy it from the site's Installation settings; it starts with "${SECRET_PREFIXES[0]}".`);
|
|
107
|
+
return secret;
|
|
108
|
+
}
|
|
109
|
+
/** The request body, or `undefined` once it passes `MAX_EVENT_BYTES`. */
|
|
110
|
+
async function readEventBody(request) {
|
|
111
|
+
if (Number(request.headers.get("content-length")) > MAX_EVENT_BYTES) return;
|
|
112
|
+
if (!request.body) return /* @__PURE__ */ new Uint8Array();
|
|
113
|
+
const reader = request.body.getReader();
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let size = 0;
|
|
116
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
117
|
+
size += chunk.value.byteLength;
|
|
118
|
+
if (size > MAX_EVENT_BYTES) {
|
|
119
|
+
reader.cancel();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
chunks.push(chunk.value);
|
|
123
|
+
}
|
|
124
|
+
const body = new Uint8Array(size);
|
|
125
|
+
let offset = 0;
|
|
126
|
+
for (const chunk of chunks) {
|
|
127
|
+
body.set(chunk, offset);
|
|
128
|
+
offset += chunk.byteLength;
|
|
129
|
+
}
|
|
130
|
+
return body;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Creates the first-party proxy handler for a site. A GET serves the tracker script and a POST
|
|
134
|
+
* forwards its events, both to the ingest host, with the site secret and the visitor's IP. The
|
|
135
|
+
* returned function takes a `Request` and returns a `Response`, so it mounts as a Next.js route
|
|
136
|
+
* handler, a Cloudflare Worker, a Pages Function, a Netlify or Vercel function, an Astro
|
|
137
|
+
* endpoint or a Hono route.
|
|
138
|
+
*
|
|
139
|
+
* Throws when the secret is missing. Without one, ingest reads every visitor as the proxy's own
|
|
140
|
+
* server and drops it as datacenter traffic, so the proxy fails at startup instead.
|
|
141
|
+
*/
|
|
142
|
+
function createProxy(options) {
|
|
143
|
+
const path = findProxyPath(options.path);
|
|
144
|
+
let secret;
|
|
145
|
+
try {
|
|
146
|
+
secret = getSecret(options.secret);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!isNextBuild()) throw error;
|
|
149
|
+
return () => Promise.reject(error);
|
|
150
|
+
}
|
|
151
|
+
const upstream = new URL(options.upstream ?? DEFAULT_UPSTREAM).origin;
|
|
152
|
+
const { fallback } = options;
|
|
153
|
+
if (fallback && path === void 0) throw new Error("highseat/proxy: `fallback` needs a path. Set HIGHSEAT_PATH, or pass `path`.");
|
|
154
|
+
const clientIpReader = getClientIpReader(options.clientIp);
|
|
155
|
+
let hasWarnedNoIp = false;
|
|
156
|
+
function readClientIp(request) {
|
|
157
|
+
const ip = clientIpReader.read(request);
|
|
158
|
+
if (!ip && !hasWarnedNoIp) {
|
|
159
|
+
hasWarnedNoIp = true;
|
|
160
|
+
console.warn(`highseat/proxy: no visitor IP in ${clientIpReader.source}, so Highseat refuses every event. Set clientIp to the outermost proxy in front of this server.`);
|
|
161
|
+
}
|
|
162
|
+
return ip;
|
|
163
|
+
}
|
|
164
|
+
function buildUpstreamHeaders(request, names) {
|
|
165
|
+
const headers = new Headers({
|
|
166
|
+
"accept-encoding": "identity",
|
|
167
|
+
"x-hs-secret": secret,
|
|
168
|
+
"x-hs-client-ip": readClientIp(request)
|
|
169
|
+
});
|
|
170
|
+
copyHeaders(request.headers, headers, names);
|
|
171
|
+
return headers;
|
|
172
|
+
}
|
|
173
|
+
async function forward(url, init) {
|
|
174
|
+
let response;
|
|
175
|
+
try {
|
|
176
|
+
response = await fetch(url, {
|
|
177
|
+
...init,
|
|
178
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
|
|
179
|
+
});
|
|
180
|
+
} catch {
|
|
181
|
+
return answerError(502, "Highseat is unreachable from this proxy.");
|
|
182
|
+
}
|
|
183
|
+
const headers = new Headers();
|
|
184
|
+
copyHeaders(response.headers, headers, RESPONSE_HEADERS);
|
|
185
|
+
if (init.method === "POST") headers.set("cache-control", "no-store");
|
|
186
|
+
const body = NULL_BODY_STATUSES.has(response.status) ? null : response.body;
|
|
187
|
+
return new Response(body, {
|
|
188
|
+
status: response.status,
|
|
189
|
+
headers
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return async function proxy(request) {
|
|
193
|
+
if (path !== void 0 && new URL(request.url).pathname !== path) return fallback ? fallback(request) : new Response(null, { status: 404 });
|
|
194
|
+
if (request.method === "GET" || request.method === "HEAD") return forward(`${upstream}/script.js`, {
|
|
195
|
+
method: request.method,
|
|
196
|
+
headers: buildUpstreamHeaders(request, SCRIPT_REQUEST_HEADERS)
|
|
197
|
+
});
|
|
198
|
+
if (request.method === "POST") {
|
|
199
|
+
const body = await readEventBody(request);
|
|
200
|
+
if (!body) return answerError(413, "The event batch is too large.");
|
|
201
|
+
return forward(`${upstream}/api/event`, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: buildUpstreamHeaders(request, EVENT_REQUEST_HEADERS),
|
|
204
|
+
body
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return new Response(null, {
|
|
208
|
+
status: 405,
|
|
209
|
+
headers: { allow: "GET, HEAD, POST" }
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
export { isProduction as i, findNextSite as n, findProxyPath as r, createProxy as t };
|
|
215
|
+
|
|
216
|
+
//# sourceMappingURL=proxy2.js.map
|
package/proxy2.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proxy2.js","names":[],"sources":["../src/env.ts","../src/proxy.ts"],"sourcesContent":["/**\n * The variables the package reads from a customer's environment. Each is named in full, never\n * looked up by a computed key: Next.js inlines a `NEXT_PUBLIC_` variable into browser code only\n * where the source spells out `process.env.NEXT_PUBLIC_…`. The server variables return\n * `undefined` where the runtime has no `process`: a Cloudflare Worker without `nodejs_compat`,\n * whose options carry the values instead.\n */\n\n/** The site id for `<Highseat />` in Next.js. Public, so it reaches the browser. */\nexport function findNextSite(): string | undefined {\n return process.env.NEXT_PUBLIC_HIGHSEAT_SITE\n}\n\n/**\n * The site's proxy path: `option` when given, else `HIGHSEAT_PATH`, a server variable shared by\n * `<Highseat />`, `withHighseat()` and the proxy handler. A value written without its leading\n * slash gets one, as Installation settings shows it (`/3f9a0c1e2b`). An empty value counts as\n * unset.\n */\nexport function findProxyPath(option?: string): string | undefined {\n const path = option ?? (typeof process === \"undefined\" ? undefined : process.env.HIGHSEAT_PATH)\n if (!path) return\n return path.startsWith(\"/\") ? path : `/${path}`\n}\n\n/** The site secret for the proxy handler. */\nexport function findSecret(): string | undefined {\n if (typeof process === \"undefined\") return\n return process.env.HIGHSEAT_SECRET\n}\n\n/**\n * Whether this runs inside `next build`, which loads every route without serving one. Set in the\n * build's route workers only: `next.config.ts` receives the phase as an argument instead.\n */\nexport function isNextBuild(): boolean {\n if (typeof process === \"undefined\") return false\n return process.env.NEXT_PHASE === \"phase-production-build\"\n}\n\n/** Whether Node runs in production mode, as `next build` and `next start` do. */\nexport function isProduction(): boolean {\n if (typeof process === \"undefined\") return false\n return process.env.NODE_ENV === \"production\"\n}\n","import { findProxyPath, findSecret, isNextBuild } from \"./env\"\n\n/** A host whose own header carries the visitor's IP. */\nexport type ClientIpPreset = \"vercel\" | \"cloudflare\" | \"netlify\" | \"nginx\"\n\n/**\n * Where the proxy reads the visitor's IP: a host preset, or a function for any other setup. A\n * function returns the IP, or nothing when the request carries none.\n */\nexport type ClientIp = ClientIpPreset | ((request: Request) => string | null | undefined)\n\nexport interface ProxyOptions {\n /**\n * The site's proxy path from Installation settings, such as `/3f9a0c1e2b`, with or without its\n * leading slash. A request on any\n * other path is answered 404, or handed to `fallback`. Defaults to the `HIGHSEAT_PATH`\n * environment variable. Leave both unset on a host whose router already sends only the proxy\n * path here, such as a file named after the path.\n */\n path?: string\n /**\n * The site secret, `hs_secret_…`, or an older one that starts `hs_proxy_` or `ana_proxy_`.\n * Defaults to the `HIGHSEAT_SECRET` environment variable.\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. Requires `path`.\n */\n fallback?: (request: Request) => Response | Promise<Response>\n}\n\n/** A fetch handler, the shape every supported runtime and framework route accepts. */\nexport type ProxyHandler = (request: Request) => Promise<Response>\n\nconst DEFAULT_UPSTREAM = \"https://cdn.highseat.com\"\n// The current prefix first. Ingest still accepts the secrets minted under the older two, so a\n// site keeps its secret when it moves to this handler.\nconst SECRET_PREFIXES = [\"hs_secret_\", \"hs_proxy_\", \"ana_proxy_\"]\n\nconst CLIENT_IP_HEADERS: Record<ClientIpPreset, string> = {\n vercel: \"x-real-ip\",\n // Read from the inbound request: Cloudflare overwrites it on the Worker's own subrequest.\n cloudflare: \"cf-connecting-ip\",\n netlify: \"x-nf-client-connection-ip\",\n nginx: \"x-real-ip\",\n}\n\n// The visitor headers each direction forwards. Copied by name, so a cookie, an `Authorization`\n// header or a visitor's own `x-hs-*` header never reaches ingest.\nconst SCRIPT_REQUEST_HEADERS = [\"user-agent\", \"if-none-match\"]\nconst EVENT_REQUEST_HEADERS = [\"content-type\", \"user-agent\", \"origin\"]\n\n// The upstream headers the response keeps. Rebuilt by name because Node, Bun and Deno hand back\n// a decompressed body under the original `content-encoding` and `content-length`, which would\n// describe bytes this handler no longer has. `x-hs-ip` and `x-hs-ip-source` show which IP\n// ingest resolved, for `curl -i` against the proxy path.\nconst RESPONSE_HEADERS = [\n \"content-type\",\n \"etag\",\n \"cache-control\",\n \"vary\",\n \"retry-after\",\n \"x-hs-ip\",\n \"x-hs-ip-source\",\n]\n\n// Statuses whose response must not carry a body, which the Response constructor enforces.\nconst NULL_BODY_STATUSES = new Set([101, 204, 205, 304])\n\nconst UPSTREAM_TIMEOUT_MS = 10_000\n\n// Ingest refuses a larger batch, so a bigger body is answered here without buffering the rest.\nconst MAX_EVENT_BYTES = 512 * 1024\n\ninterface ClientIpReader {\n read: (request: Request) => string\n /** Where `read` looks, for the warning about an empty IP. */\n source: string\n}\n\nfunction getClientIpReader(clientIp: ClientIp): ClientIpReader {\n if (typeof clientIp === \"function\") {\n return { read: request => clientIp(request)?.trim() ?? \"\", source: \"the clientIp function\" }\n }\n\n const header = CLIENT_IP_HEADERS[clientIp]\n if (!header) throw new Error(`highseat/proxy: unknown clientIp preset \"${String(clientIp)}\".`)\n return {\n read: request => request.headers.get(header)?.trim() ?? \"\",\n source: `the \"${clientIp}\" preset's ${header} header`,\n }\n}\n\nfunction copyHeaders(from: Headers, to: Headers, names: string[]) {\n for (const name of names) {\n const value = from.get(name)\n if (value !== null) to.set(name, value)\n }\n}\n\nfunction answerError(status: number, error: string) {\n return Response.json({ error }, { status, headers: { \"cache-control\": \"no-store\" } })\n}\n\nfunction getSecret(option: string | undefined): string {\n const secret = option ?? findSecret()\n\n if (!secret) {\n throw new Error(\n \"highseat/proxy: no site secret. Set HIGHSEAT_SECRET to the secret from the site's Installation settings, or pass `secret`.\",\n )\n }\n if (!SECRET_PREFIXES.some(prefix => secret.startsWith(prefix))) {\n throw new Error(\n `highseat/proxy: not a site secret. Copy it from the site's Installation settings; it starts with \"${SECRET_PREFIXES[0]}\".`,\n )\n }\n return secret\n}\n\n/** The 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 * Throws when the secret is missing. Without one, ingest reads every visitor as the proxy's own\n * server and drops it as datacenter traffic, so the proxy fails at startup instead.\n */\nexport function createProxy(options: ProxyOptions): ProxyHandler {\n const path = findProxyPath(options.path)\n let secret: string\n\n try {\n secret = getSecret(options.secret)\n } catch (error) {\n // Next.js loads every route while it builds, often without the runtime secrets. There the\n // error waits for the first request 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 const { fallback } = options\n\n if (fallback && path === undefined) {\n throw new Error(\"highseat/proxy: `fallback` needs a path. Set HIGHSEAT_PATH, or pass `path`.\")\n }\n\n const clientIpReader = getClientIpReader(options.clientIp)\n let hasWarnedNoIp = false\n\n // Ingest answers 400 to every event without a visitor IP and says nothing about the preset,\n // so the first request without one names the source it read.\n function readClientIp(request: Request) {\n const ip = clientIpReader.read(request)\n\n if (!ip && !hasWarnedNoIp) {\n hasWarnedNoIp = true\n console.warn(\n `highseat/proxy: no visitor IP in ${clientIpReader.source}, so Highseat refuses every event. Set clientIp to the outermost proxy in front of this server.`,\n )\n }\n return ip\n }\n\n function buildUpstreamHeaders(request: Request, names: string[]) {\n const headers = new Headers({\n // The response never copies `content-encoding`, so ingest is asked for a plain body.\n \"accept-encoding\": \"identity\",\n \"x-hs-secret\": secret,\n \"x-hs-client-ip\": readClientIp(request),\n })\n copyHeaders(request.headers, headers, names)\n return headers\n }\n\n async function forward(url: string, init: RequestInit) {\n let response: Response\n\n try {\n response = await fetch(url, { ...init, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) })\n } catch {\n return answerError(502, \"Highseat is unreachable from this proxy.\")\n }\n\n const headers = new Headers()\n copyHeaders(response.headers, headers, RESPONSE_HEADERS)\n if (init.method === \"POST\") headers.set(\"cache-control\", \"no-store\")\n\n const body = NULL_BODY_STATUSES.has(response.status) ? null : response.body\n return new Response(body, { status: response.status, headers })\n }\n\n return async function proxy(request) {\n if (path !== undefined && new URL(request.url).pathname !== path) {\n return fallback ? fallback(request) : new Response(null, { status: 404 })\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
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { InjectOptions } from "./index.js";
|
|
2
|
+
//#region src/react.d.ts
|
|
3
|
+
export interface HighseatProps extends InjectOptions {}
|
|
4
|
+
/**
|
|
5
|
+
* Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
|
|
6
|
+
* the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
|
|
7
|
+
* counts client-side navigation on its own.
|
|
8
|
+
*/
|
|
9
|
+
export declare function Highseat({ site, src, outbound, downloads, hashRouting }: HighseatProps): null;
|
|
10
|
+
//#endregion
|
|
11
|
+
//# sourceMappingURL=react.d.ts.map
|
package/react.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { inject } from "./index.js";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
//#region src/react.ts
|
|
5
|
+
/**
|
|
6
|
+
* Loads the Highseat tracker. Render it once, in the root layout. It adds the script tag after
|
|
7
|
+
* the page mounts and renders nothing; mounting it again adds no second tag, and the tracker
|
|
8
|
+
* counts client-side navigation on its own.
|
|
9
|
+
*/
|
|
10
|
+
function Highseat({ site, src, outbound, downloads, hashRouting }) {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
inject({
|
|
13
|
+
site,
|
|
14
|
+
src,
|
|
15
|
+
outbound,
|
|
16
|
+
downloads,
|
|
17
|
+
hashRouting
|
|
18
|
+
});
|
|
19
|
+
}, [
|
|
20
|
+
site,
|
|
21
|
+
src,
|
|
22
|
+
outbound,
|
|
23
|
+
downloads,
|
|
24
|
+
hashRouting
|
|
25
|
+
]);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { Highseat };
|
|
30
|
+
|
|
31
|
+
//# sourceMappingURL=react.js.map
|
package/react.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["\"use client\"\n\nimport { useEffect } from \"react\"\nimport { inject, type InjectOptions } from \"./index\"\n\nexport interface HighseatProps extends InjectOptions {}\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.\n */\nexport function Highseat({ site, src, outbound, downloads, hashRouting }: HighseatProps): null {\n useEffect(() => {\n inject({ site, src, outbound, downloads, hashRouting })\n }, [site, src, outbound, downloads, hashRouting])\n\n return null\n}\n"],"mappings":";;;;;;;;;AAYA,SAAgB,SAAS,EAAE,MAAM,KAAK,UAAU,WAAW,eAAoC;CAC7F,gBAAgB;EACd,OAAO;GAAE;GAAM;GAAK;GAAU;GAAW;EAAY,CAAC;CACxD,GAAG;EAAC;EAAM;EAAK;EAAU;EAAW;CAAW,CAAC;CAEhD,OAAO;AACT"}
|