@hostess/browser 0.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/index.cjs +417 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.js +411 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Horizon Web Labs
|
|
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
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @hostess/browser
|
|
2
|
+
|
|
3
|
+
Framework-agnostic browser core for Hostess **Audience Analytics** and **Speed
|
|
4
|
+
Insights**. Page views and Core Web Vitals from any site behind a Hostess URL —
|
|
5
|
+
cookieless, no consent banner, ~1.7 KB gzipped for analytics.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @hostess/browser
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { inject, injectSpeedInsights } from "@hostess/browser";
|
|
13
|
+
|
|
14
|
+
inject(); // page views
|
|
15
|
+
injectSpeedInsights(); // LCP, CLS, INP, FCP, TTFB
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This is the shared core that framework adapters build on — `@hostess/nextjs`
|
|
19
|
+
re-exports `<Analytics />` and `<SpeedInsights />` on top of it. Use it directly
|
|
20
|
+
in any SPA, or (later) via a `<script>` tag on static sites.
|
|
21
|
+
|
|
22
|
+
## API
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
inject(opts?); // one pv beacon per page view (load + SPA navigations)
|
|
26
|
+
injectSpeedInsights(opts?); // wv beacons from the web-vitals attribution build
|
|
27
|
+
track(name, props?); // custom events — phase 2, currently a no-op stub
|
|
28
|
+
|
|
29
|
+
type RouteInfo = { route: string; path: string };
|
|
30
|
+
type RouteProvider = {
|
|
31
|
+
current(): RouteInfo;
|
|
32
|
+
onChange(cb: (info: RouteInfo, nav: "spa" | "back-forward") => void): () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// opts: { routeProvider?: RouteProvider; debug?: boolean }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Both entry points are **idempotent** and safe to call during SSR (they no-op
|
|
39
|
+
without a DOM). The only framework-specific concern is route-template
|
|
40
|
+
reconstruction, expressed through `RouteProvider`; the default provider uses
|
|
41
|
+
`location.pathname` as both `route` and `path` and tracks SPA navigations via
|
|
42
|
+
`history`/`popstate`.
|
|
43
|
+
|
|
44
|
+
## What it sends
|
|
45
|
+
|
|
46
|
+
Same-origin `POST /_hostess/rum` — a JSON array of beacons (≤ 20, ≤ 8 KB per
|
|
47
|
+
request) via `navigator.sendBeacon`, falling back to `fetch(keepalive)`. Flushed
|
|
48
|
+
when the page is hidden (where CLS and INP finalize) and at queue thresholds.
|
|
49
|
+
|
|
50
|
+
Each beacon (schema v1):
|
|
51
|
+
|
|
52
|
+
| Field | Meaning |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `v` | schema version (`1`) |
|
|
55
|
+
| `k` | `pv` (page view) or `wv` (web vital) |
|
|
56
|
+
| `route` / `path` | route template and concrete path (query stripped) |
|
|
57
|
+
| `ref` | referrer **origin only**, `""` for same-origin/direct |
|
|
58
|
+
| `utm` | the five `utm_*` params only |
|
|
59
|
+
| `nav` | `load` / `spa` / `back-forward` |
|
|
60
|
+
| `dc` | coarse device class (`desktop` / `mobile` / `tablet`) |
|
|
61
|
+
| `sdk` | `browser@<version>` (adapters override) |
|
|
62
|
+
|
|
63
|
+
`wv` beacons add `m` (metric name), `val` (ms; CLS unitless), and `rating`.
|
|
64
|
+
Everything else — visitor identity, country, browser/OS, hostname — is derived
|
|
65
|
+
**server-side** and never sent by the client.
|
|
66
|
+
|
|
67
|
+
## Privacy
|
|
68
|
+
|
|
69
|
+
Hard lines enforced in code and asserted in tests:
|
|
70
|
+
|
|
71
|
+
- **No cookies, no `localStorage`, no fingerprinting.**
|
|
72
|
+
- **No query strings** and no raw URLs with parameter values — only the five
|
|
73
|
+
`utm_*` keys cross the wire, and referrers are reduced to their origin.
|
|
74
|
+
- **No user identifiers.** Visitor uniqueness is derived server-side from a
|
|
75
|
+
daily-rotating salt (the Plausible model).
|
|
76
|
+
|
|
77
|
+
`NODE_ENV=development` sends **nothing**. `debug: true` logs the would-be
|
|
78
|
+
payloads to the console (in any environment).
|
|
79
|
+
|
|
80
|
+
## Failure posture
|
|
81
|
+
|
|
82
|
+
Beacons are fire-and-forget. If the ingest endpoint is missing or disabled
|
|
83
|
+
(`insights.browser: disabled` → 404), the queue detects it on the first
|
|
84
|
+
observable request and **stops for the rest of the page's lifetime** — silent
|
|
85
|
+
and free after the first failure.
|
|
86
|
+
|
|
87
|
+
## Bundle
|
|
88
|
+
|
|
89
|
+
Tree-shakeable, `sideEffects: false`, ESM + CJS via bunchee. Importing only
|
|
90
|
+
`inject` pulls ~1.7 KB gzipped; `injectSpeedInsights` adds the `web-vitals`
|
|
91
|
+
attribution build (~6 KB gzipped total).
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pnpm install
|
|
97
|
+
pnpm --filter @hostess/browser build
|
|
98
|
+
pnpm --filter @hostess/browser test
|
|
99
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
var attribution = require('web-vitals/attribution');
|
|
4
|
+
|
|
5
|
+
/** True only in a real browser (guards against SSR / Node import). */ function hasDom() {
|
|
6
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
7
|
+
}
|
|
8
|
+
/** True at the moment the page is hidden — the correctness-critical flush point. */ function isHidden() {
|
|
9
|
+
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `NODE_ENV=development` disables all beacons (bundlers inline this constant;
|
|
13
|
+
* the `typeof` guard keeps it safe when `process` is absent in the browser).
|
|
14
|
+
*/ function isDevelopment() {
|
|
15
|
+
try {
|
|
16
|
+
return typeof process !== "undefined" && !!process.env && process.env.NODE_ENV === "development";
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Same-origin ingest path (howl-cloud/hostess#32). Never cross-origin, so no
|
|
23
|
+
// CORS ever applies.
|
|
24
|
+
const ENDPOINT = "/_hostess/rum";
|
|
25
|
+
// Transport hard caps from schema v1: a request carries at most 20 beacons and
|
|
26
|
+
// 8 KB. A flush is split into as many batches as needed to respect both.
|
|
27
|
+
const MAX_BEACONS = 20;
|
|
28
|
+
const MAX_BYTES = 8 * 1024;
|
|
29
|
+
const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
|
30
|
+
function byteLength(s) {
|
|
31
|
+
return encoder ? encoder.encode(s).length : s.length;
|
|
32
|
+
}
|
|
33
|
+
/** Greedily pack beacons into batches that respect both the count and byte caps. */ function splitBatches(beacons) {
|
|
34
|
+
const batches = [];
|
|
35
|
+
let current = [];
|
|
36
|
+
for (const beacon of beacons){
|
|
37
|
+
const candidate = current.length ? [
|
|
38
|
+
...current,
|
|
39
|
+
beacon
|
|
40
|
+
] : [
|
|
41
|
+
beacon
|
|
42
|
+
];
|
|
43
|
+
const tooMany = candidate.length > MAX_BEACONS;
|
|
44
|
+
const tooBig = byteLength(JSON.stringify(candidate)) > MAX_BYTES;
|
|
45
|
+
if (current.length && (tooMany || tooBig)) {
|
|
46
|
+
batches.push(current);
|
|
47
|
+
current = [
|
|
48
|
+
beacon
|
|
49
|
+
]; // a single beacon over 8 KB is unsplittable; send alone
|
|
50
|
+
} else {
|
|
51
|
+
current = candidate;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (current.length) batches.push(current);
|
|
55
|
+
return batches;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Page-lifetime beacon queue. One instance per page (a window singleton shared
|
|
59
|
+
* by `inject` and `injectSpeedInsights`), so their beacons batch together.
|
|
60
|
+
*
|
|
61
|
+
* Flush triggers: the queue reaches `MAX_BEACONS`, the page transitions to
|
|
62
|
+
* hidden, or a beacon is enqueued while the page is already hidden. That last
|
|
63
|
+
* rule is what makes the CLS/INP path correct without depending on listener
|
|
64
|
+
* order: `web-vitals` finalizes those metrics inside its own
|
|
65
|
+
* `visibilitychange` handler, and whichever order the handlers run, a beacon
|
|
66
|
+
* pushed while hidden flushes immediately.
|
|
67
|
+
*
|
|
68
|
+
* Failure posture (fire-and-forget, back-off-and-stop): a disabled endpoint
|
|
69
|
+
* returns 404, but `navigator.sendBeacon`'s boolean only reports whether the UA
|
|
70
|
+
* queued the request, never the HTTP status. So the queue *probes* with an
|
|
71
|
+
* observable `fetch(keepalive)` until the first success; a 404 or network
|
|
72
|
+
* error there stops the queue for the rest of the page's life (silent and free
|
|
73
|
+
* after the first failure). Once the endpoint is confirmed live, it prefers
|
|
74
|
+
* `sendBeacon` — the only transport that reliably survives page unload.
|
|
75
|
+
*/ class BeaconQueue {
|
|
76
|
+
push(beacon) {
|
|
77
|
+
if (this.stopped) return;
|
|
78
|
+
this.ensureListeners();
|
|
79
|
+
this.buffer.push(beacon);
|
|
80
|
+
if (this.buffer.length >= MAX_BEACONS || isHidden()) this.flush();
|
|
81
|
+
}
|
|
82
|
+
flush() {
|
|
83
|
+
if (this.stopped || this.buffer.length === 0) return;
|
|
84
|
+
const batches = splitBatches(this.buffer);
|
|
85
|
+
this.buffer = [];
|
|
86
|
+
for (const batch of batches)this.send(batch);
|
|
87
|
+
}
|
|
88
|
+
/** Halt all sending for the page lifetime and drop anything buffered. */ stop() {
|
|
89
|
+
this.stopped = true;
|
|
90
|
+
this.buffer = [];
|
|
91
|
+
}
|
|
92
|
+
ensureListeners() {
|
|
93
|
+
if (this.listening || typeof document === "undefined") return;
|
|
94
|
+
this.listening = true;
|
|
95
|
+
document.addEventListener("visibilitychange", ()=>{
|
|
96
|
+
if (isHidden()) this.flush();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
send(batch) {
|
|
100
|
+
const body = JSON.stringify(batch);
|
|
101
|
+
// Fast path once the endpoint is confirmed live: sendBeacon survives unload.
|
|
102
|
+
if (this.verified && this.trySendBeacon(body)) return;
|
|
103
|
+
// Observable path: confirms the endpoint and detects a disabled one.
|
|
104
|
+
if (typeof fetch === "function") {
|
|
105
|
+
fetch(ENDPOINT, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
body,
|
|
108
|
+
keepalive: true,
|
|
109
|
+
credentials: "omit",
|
|
110
|
+
// text/plain mirrors sendBeacon and keeps this a simple same-origin
|
|
111
|
+
// request; the ingest parses the JSON body regardless of content-type.
|
|
112
|
+
headers: {
|
|
113
|
+
"Content-Type": "text/plain;charset=UTF-8"
|
|
114
|
+
}
|
|
115
|
+
}).then((res)=>{
|
|
116
|
+
if (res.ok) this.verified = true;
|
|
117
|
+
else this.stop();
|
|
118
|
+
}).catch(()=>this.stop());
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// No fetch available: last-resort sendBeacon (cannot observe the result).
|
|
122
|
+
if (!this.trySendBeacon(body)) this.stop();
|
|
123
|
+
}
|
|
124
|
+
trySendBeacon(body) {
|
|
125
|
+
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
|
126
|
+
if (!nav || typeof nav.sendBeacon !== "function") return false;
|
|
127
|
+
try {
|
|
128
|
+
return nav.sendBeacon(ENDPOINT, body);
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
constructor(){
|
|
134
|
+
this.buffer = [];
|
|
135
|
+
this.stopped = false;
|
|
136
|
+
this.verified = false;
|
|
137
|
+
this.listening = false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const KEY = "__hostess_rum__";
|
|
142
|
+
function state() {
|
|
143
|
+
const w = window;
|
|
144
|
+
return w[KEY] ??= {};
|
|
145
|
+
}
|
|
146
|
+
function getQueue() {
|
|
147
|
+
const st = state();
|
|
148
|
+
return st.queue ??= new BeaconQueue();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Strip the query string and hash, leaving only the path. */ function stripQuery(pathOrUrl) {
|
|
152
|
+
let end = pathOrUrl.length;
|
|
153
|
+
const q = pathOrUrl.indexOf("?");
|
|
154
|
+
const h = pathOrUrl.indexOf("#");
|
|
155
|
+
if (q >= 0) end = Math.min(end, q);
|
|
156
|
+
if (h >= 0) end = Math.min(end, h);
|
|
157
|
+
return pathOrUrl.slice(0, end);
|
|
158
|
+
}
|
|
159
|
+
// history.pushState / replaceState do not emit an event. Patch them once (per
|
|
160
|
+
// page) to dispatch one, so the default provider can observe SPA navigations
|
|
161
|
+
// without every provider stacking its own monkey-patch. popstate covers
|
|
162
|
+
// back/forward natively.
|
|
163
|
+
const LOCATION_CHANGE_EVENT = "hostess:locationchange";
|
|
164
|
+
const HISTORY_PATCH_FLAG = "__hostess_history_patched__";
|
|
165
|
+
function ensureHistoryEvents() {
|
|
166
|
+
const w = window;
|
|
167
|
+
if (w[HISTORY_PATCH_FLAG]) return;
|
|
168
|
+
w[HISTORY_PATCH_FLAG] = true;
|
|
169
|
+
for (const method of [
|
|
170
|
+
"pushState",
|
|
171
|
+
"replaceState"
|
|
172
|
+
]){
|
|
173
|
+
const original = history[method];
|
|
174
|
+
history[method] = function(...args) {
|
|
175
|
+
const result = original.apply(this, args);
|
|
176
|
+
window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT));
|
|
177
|
+
return result;
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Default provider with no framework knowledge: `location.pathname` is used as
|
|
183
|
+
* both the route template and the concrete path — correct for non-parameterized
|
|
184
|
+
* sites, and the ingest's route-hygiene caps absorb the rest. It also emits
|
|
185
|
+
* navigation changes (pushState/replaceState → "spa", popstate →
|
|
186
|
+
* "back-forward") so `inject()` is useful standalone in a bare SPA; adapters
|
|
187
|
+
* that supply real templates replace this entirely.
|
|
188
|
+
*/ function defaultRouteProvider() {
|
|
189
|
+
const read = ()=>{
|
|
190
|
+
const path = typeof location !== "undefined" ? location.pathname : "/";
|
|
191
|
+
return {
|
|
192
|
+
route: path,
|
|
193
|
+
path
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
current: read,
|
|
198
|
+
onChange (cb) {
|
|
199
|
+
if (typeof window === "undefined") return ()=>{};
|
|
200
|
+
ensureHistoryEvents();
|
|
201
|
+
let last = location.pathname;
|
|
202
|
+
const emit = (nav)=>()=>{
|
|
203
|
+
const path = location.pathname;
|
|
204
|
+
if (path === last) return; // ignore hash-only / query-only changes
|
|
205
|
+
last = path;
|
|
206
|
+
cb({
|
|
207
|
+
route: path,
|
|
208
|
+
path
|
|
209
|
+
}, nav);
|
|
210
|
+
};
|
|
211
|
+
const onSpa = emit("spa");
|
|
212
|
+
const onPop = emit("back-forward");
|
|
213
|
+
window.addEventListener(LOCATION_CHANGE_EVENT, onSpa);
|
|
214
|
+
window.addEventListener("popstate", onPop);
|
|
215
|
+
return ()=>{
|
|
216
|
+
window.removeEventListener(LOCATION_CHANGE_EVENT, onSpa);
|
|
217
|
+
window.removeEventListener("popstate", onPop);
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Reduce a referrer URL to its origin only — never the path or query. Returns
|
|
225
|
+
* "" for direct visits, same-origin navigations, and non-http(s) schemes, so a
|
|
226
|
+
* beacon can never leak where on another site the visitor came from.
|
|
227
|
+
*/ function referrerOrigin(ref) {
|
|
228
|
+
const raw = (typeof document !== "undefined" ? document.referrer : "");
|
|
229
|
+
if (!raw) return "";
|
|
230
|
+
let url;
|
|
231
|
+
try {
|
|
232
|
+
url = new URL(raw);
|
|
233
|
+
} catch {
|
|
234
|
+
return "";
|
|
235
|
+
}
|
|
236
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
|
237
|
+
const here = typeof location !== "undefined" ? location.origin : "";
|
|
238
|
+
if (url.origin === here) return "";
|
|
239
|
+
return url.origin;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const UTM_KEYS = [
|
|
243
|
+
"source",
|
|
244
|
+
"medium",
|
|
245
|
+
"campaign",
|
|
246
|
+
"term",
|
|
247
|
+
"content"
|
|
248
|
+
];
|
|
249
|
+
// Matches the ingest's per-value cap; over-length values are truncated rather
|
|
250
|
+
// than dropped so a long campaign name still attributes.
|
|
251
|
+
const MAX_VALUE_LENGTH = 64;
|
|
252
|
+
/**
|
|
253
|
+
* Extract the five `utm_*` parameters from a query string. Every other query
|
|
254
|
+
* parameter is ignored and never sent — this is the only place a raw query
|
|
255
|
+
* value crosses into a beacon.
|
|
256
|
+
*/ function extractUtm(search) {
|
|
257
|
+
const qs = (typeof location !== "undefined" ? location.search : "");
|
|
258
|
+
const utm = {};
|
|
259
|
+
if (!qs) return utm;
|
|
260
|
+
let params;
|
|
261
|
+
try {
|
|
262
|
+
params = new URLSearchParams(qs);
|
|
263
|
+
} catch {
|
|
264
|
+
return utm;
|
|
265
|
+
}
|
|
266
|
+
for (const key of UTM_KEYS){
|
|
267
|
+
const value = params.get(`utm_${key}`);
|
|
268
|
+
if (value != null && value !== "") {
|
|
269
|
+
utm[key] = value.slice(0, MAX_VALUE_LENGTH);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return utm;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Coarse device class from UA Client Hints with a UA-string fallback. Only
|
|
277
|
+
* three buckets — no model, no fingerprinting.
|
|
278
|
+
*
|
|
279
|
+
* Tablet is decided from the UA string first because UA-CH low-entropy hints
|
|
280
|
+
* have no tablet signal (an iPad reports `mobile: false`, indistinguishable
|
|
281
|
+
* from a desktop). Everything else prefers the `mobile` hint, falling back to
|
|
282
|
+
* UA-string keywords.
|
|
283
|
+
*/ function deviceClass() {
|
|
284
|
+
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
|
285
|
+
const ua = nav?.userAgent ?? "";
|
|
286
|
+
if (/\bipad\b/i.test(ua) || /\btablet\b/i.test(ua) || /android/i.test(ua) && !/mobile/i.test(ua)) {
|
|
287
|
+
return "tablet";
|
|
288
|
+
}
|
|
289
|
+
const uaData = nav?.userAgentData;
|
|
290
|
+
if (uaData && typeof uaData.mobile === "boolean") {
|
|
291
|
+
return uaData.mobile ? "mobile" : "desktop";
|
|
292
|
+
}
|
|
293
|
+
if (/mobi|iphone|ipod|android/i.test(ua)) return "mobile";
|
|
294
|
+
return "desktop";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The single gate every beacon passes through.
|
|
299
|
+
*
|
|
300
|
+
* - `debug` logs the would-be payload (works even in development, so you can
|
|
301
|
+
* verify what *would* be sent).
|
|
302
|
+
* - `NODE_ENV=development` is silent: it returns before the queue is ever
|
|
303
|
+
* created, guaranteeing no network and no listeners in development.
|
|
304
|
+
*/ function report(beacon, debug) {
|
|
305
|
+
if (debug) {
|
|
306
|
+
try {
|
|
307
|
+
console.log("[hostess/browser]", beacon);
|
|
308
|
+
} catch {
|
|
309
|
+
// console unavailable — never let logging break the host page.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (isDevelopment()) return;
|
|
313
|
+
getQueue().push(beacon);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Bumped in lockstep with package.json on release. Surfaces in the `sdk`
|
|
317
|
+
// beacon field as `browser@<version>` (framework adapters override the prefix).
|
|
318
|
+
const SDK_VERSION = "0.1.0";
|
|
319
|
+
|
|
320
|
+
function defaultSdk$1() {
|
|
321
|
+
return `browser@${SDK_VERSION}`;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Start collecting page views: one `pv` beacon on initial load, then one per
|
|
325
|
+
* SPA navigation reported by the route provider. Idempotent — a second call is
|
|
326
|
+
* a no-op. Safe to call during SSR (no-ops without a DOM).
|
|
327
|
+
*/ function inject(opts = {}) {
|
|
328
|
+
if (!hasDom()) return;
|
|
329
|
+
const st = state();
|
|
330
|
+
if (st.pv) return;
|
|
331
|
+
st.pv = true;
|
|
332
|
+
const provider = opts.routeProvider ?? defaultRouteProvider();
|
|
333
|
+
const debug = opts.debug ?? false;
|
|
334
|
+
const sdk = opts.sdk ?? defaultSdk$1();
|
|
335
|
+
emitPageview(provider.current(), "load", debug, sdk);
|
|
336
|
+
provider.onChange((info, nav)=>emitPageview(info, nav, debug, sdk));
|
|
337
|
+
}
|
|
338
|
+
function emitPageview(info, nav, debug, sdk) {
|
|
339
|
+
const beacon = {
|
|
340
|
+
v: 1,
|
|
341
|
+
k: "pv",
|
|
342
|
+
route: info.route,
|
|
343
|
+
path: stripQuery(info.path),
|
|
344
|
+
ref: referrerOrigin(),
|
|
345
|
+
utm: extractUtm(),
|
|
346
|
+
nav,
|
|
347
|
+
dc: deviceClass(),
|
|
348
|
+
sdk
|
|
349
|
+
};
|
|
350
|
+
report(beacon, debug);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function defaultSdk() {
|
|
354
|
+
return `browser@${SDK_VERSION}`;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Start collecting Core Web Vitals (LCP, CLS, INP, FCP, TTFB) via the
|
|
358
|
+
* `web-vitals` attribution build. Each metric reports once, finalized at
|
|
359
|
+
* page-hide for the field-only values (CLS, INP) — the queue's hidden-flush
|
|
360
|
+
* path carries those out. No FID (deprecated). Idempotent; no-ops during SSR.
|
|
361
|
+
*/ function injectSpeedInsights(opts = {}) {
|
|
362
|
+
if (!hasDom()) return;
|
|
363
|
+
const st = state();
|
|
364
|
+
if (st.wv) return;
|
|
365
|
+
st.wv = true;
|
|
366
|
+
const provider = opts.routeProvider ?? defaultRouteProvider();
|
|
367
|
+
const debug = opts.debug ?? false;
|
|
368
|
+
const sdk = opts.sdk ?? defaultSdk();
|
|
369
|
+
const handler = (metric)=>emitVital(metric, provider, debug, sdk);
|
|
370
|
+
attribution.onLCP(handler);
|
|
371
|
+
attribution.onCLS(handler);
|
|
372
|
+
attribution.onINP(handler);
|
|
373
|
+
attribution.onFCP(handler);
|
|
374
|
+
attribution.onTTFB(handler);
|
|
375
|
+
}
|
|
376
|
+
function emitVital(metric, provider, debug, sdk) {
|
|
377
|
+
const info = provider.current();
|
|
378
|
+
const beacon = {
|
|
379
|
+
v: 1,
|
|
380
|
+
k: "wv",
|
|
381
|
+
route: info.route,
|
|
382
|
+
path: stripQuery(info.path),
|
|
383
|
+
ref: referrerOrigin(),
|
|
384
|
+
utm: extractUtm(),
|
|
385
|
+
nav: navFromMetric(metric.navigationType),
|
|
386
|
+
dc: deviceClass(),
|
|
387
|
+
sdk,
|
|
388
|
+
m: metric.name,
|
|
389
|
+
val: metricValue(metric),
|
|
390
|
+
rating: metric.rating
|
|
391
|
+
};
|
|
392
|
+
report(beacon, debug);
|
|
393
|
+
}
|
|
394
|
+
// web-vitals does not track SPA soft navigations, so a vital is only ever
|
|
395
|
+
// "load" or "back-forward" (bfcache restores).
|
|
396
|
+
function navFromMetric(navigationType) {
|
|
397
|
+
return navigationType === "back-forward" || navigationType === "back-forward-cache" ? "back-forward" : "load";
|
|
398
|
+
}
|
|
399
|
+
// CLS is unitless and small — keep four decimals. The millisecond metrics are
|
|
400
|
+
// rounded to whole ms.
|
|
401
|
+
function metricValue(metric) {
|
|
402
|
+
return metric.name === "CLS" ? Math.round(metric.value * 10000) / 10000 : Math.round(metric.value);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Custom events — phase 2 (howl-cloud/hostess-js#3). Exported now as a typed
|
|
407
|
+
* no-op so adapters can re-export it today and the real implementation lands
|
|
408
|
+
* without a breaking API change. Records nothing yet.
|
|
409
|
+
*/ function track(_name, _props) {
|
|
410
|
+
// Intentionally empty until custom-event ingest exists.
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
exports.SDK_VERSION = SDK_VERSION;
|
|
414
|
+
exports.defaultRouteProvider = defaultRouteProvider;
|
|
415
|
+
exports.inject = inject;
|
|
416
|
+
exports.injectSpeedInsights = injectSpeedInsights;
|
|
417
|
+
exports.track = track;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
type DeviceClass = "desktop" | "mobile" | "tablet";
|
|
2
|
+
type NavType = "load" | "spa" | "back-forward";
|
|
3
|
+
type BeaconKind = "pv" | "wv";
|
|
4
|
+
type WebVitalName = "LCP" | "CLS" | "INP" | "FCP" | "TTFB";
|
|
5
|
+
type Rating = "good" | "needs-improvement" | "poor";
|
|
6
|
+
/** Template + concrete path for a page view. Query string is always stripped. */
|
|
7
|
+
interface RouteInfo {
|
|
8
|
+
/** Route template, e.g. `/blog/[slug]`. Equals `path` when no adapter. */
|
|
9
|
+
route: string;
|
|
10
|
+
/** Concrete path, e.g. `/blog/hello-world`. Query string stripped. */
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The single framework-specific seam. Adapters (e.g. `@hostess/nextjs`)
|
|
15
|
+
* implement this to supply real route templates and navigation events; the
|
|
16
|
+
* default provider uses `location.pathname` for both `route` and `path`.
|
|
17
|
+
*/
|
|
18
|
+
interface RouteProvider {
|
|
19
|
+
current(): RouteInfo;
|
|
20
|
+
/** Subscribe to client-side navigations. Returns an unsubscribe function. */
|
|
21
|
+
onChange(cb: (info: RouteInfo, nav: "spa" | "back-forward") => void): () => void;
|
|
22
|
+
}
|
|
23
|
+
/** The five UTM parameters, extracted client-side. Only present keys are set. */
|
|
24
|
+
interface Utm {
|
|
25
|
+
source?: string;
|
|
26
|
+
medium?: string;
|
|
27
|
+
campaign?: string;
|
|
28
|
+
term?: string;
|
|
29
|
+
content?: string;
|
|
30
|
+
}
|
|
31
|
+
interface BaseBeacon {
|
|
32
|
+
/** Schema version. */
|
|
33
|
+
v: 1;
|
|
34
|
+
/** Beacon kind: pageview or web vital. */
|
|
35
|
+
k: BeaconKind;
|
|
36
|
+
route: string;
|
|
37
|
+
path: string;
|
|
38
|
+
/** Referrer origin only, "" for same-origin/direct. */
|
|
39
|
+
ref: string;
|
|
40
|
+
utm: Utm;
|
|
41
|
+
nav: NavType;
|
|
42
|
+
/** Coarse device class (UA-CH with UA fallback). */
|
|
43
|
+
dc: DeviceClass;
|
|
44
|
+
/** `<adapter>@<version>`, e.g. `browser@0.1.0`. */
|
|
45
|
+
sdk: string;
|
|
46
|
+
}
|
|
47
|
+
interface PageviewBeacon extends BaseBeacon {
|
|
48
|
+
k: "pv";
|
|
49
|
+
}
|
|
50
|
+
interface WebVitalBeacon extends BaseBeacon {
|
|
51
|
+
k: "wv";
|
|
52
|
+
m: WebVitalName;
|
|
53
|
+
/** Value in ms; CLS is unitless. */
|
|
54
|
+
val: number;
|
|
55
|
+
rating: Rating;
|
|
56
|
+
}
|
|
57
|
+
type Beacon = PageviewBeacon | WebVitalBeacon;
|
|
58
|
+
interface InjectOptions {
|
|
59
|
+
/** Adapter-supplied route source. Defaults to a `location.pathname` provider. */
|
|
60
|
+
routeProvider?: RouteProvider;
|
|
61
|
+
/** Log would-be payloads to the console; still sends unless in development. */
|
|
62
|
+
debug?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Override the `sdk` field. Framework adapters pass their own identifier
|
|
65
|
+
* (e.g. `nextjs@0.2.0`); defaults to `browser@<version>`. Not part of the
|
|
66
|
+
* documented public surface — reserved for adapters.
|
|
67
|
+
*/
|
|
68
|
+
sdk?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Start collecting page views: one `pv` beacon on initial load, then one per
|
|
73
|
+
* SPA navigation reported by the route provider. Idempotent — a second call is
|
|
74
|
+
* a no-op. Safe to call during SSR (no-ops without a DOM).
|
|
75
|
+
*/
|
|
76
|
+
declare function inject(opts?: InjectOptions): void;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Start collecting Core Web Vitals (LCP, CLS, INP, FCP, TTFB) via the
|
|
80
|
+
* `web-vitals` attribution build. Each metric reports once, finalized at
|
|
81
|
+
* page-hide for the field-only values (CLS, INP) — the queue's hidden-flush
|
|
82
|
+
* path carries those out. No FID (deprecated). Idempotent; no-ops during SSR.
|
|
83
|
+
*/
|
|
84
|
+
declare function injectSpeedInsights(opts?: InjectOptions): void;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Custom events — phase 2 (howl-cloud/hostess-js#3). Exported now as a typed
|
|
88
|
+
* no-op so adapters can re-export it today and the real implementation lands
|
|
89
|
+
* without a breaking API change. Records nothing yet.
|
|
90
|
+
*/
|
|
91
|
+
declare function track(_name: string, _props?: Record<string, string | number | boolean>): void;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Default provider with no framework knowledge: `location.pathname` is used as
|
|
95
|
+
* both the route template and the concrete path — correct for non-parameterized
|
|
96
|
+
* sites, and the ingest's route-hygiene caps absorb the rest. It also emits
|
|
97
|
+
* navigation changes (pushState/replaceState → "spa", popstate →
|
|
98
|
+
* "back-forward") so `inject()` is useful standalone in a bare SPA; adapters
|
|
99
|
+
* that supply real templates replace this entirely.
|
|
100
|
+
*/
|
|
101
|
+
declare function defaultRouteProvider(): RouteProvider;
|
|
102
|
+
|
|
103
|
+
declare const SDK_VERSION = "0.1.0";
|
|
104
|
+
|
|
105
|
+
export { SDK_VERSION, defaultRouteProvider, inject, injectSpeedInsights, track };
|
|
106
|
+
export type { Beacon, BeaconKind, DeviceClass, InjectOptions, NavType, PageviewBeacon, Rating, RouteInfo, RouteProvider, Utm, WebVitalBeacon, WebVitalName };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { onLCP, onCLS, onINP, onFCP, onTTFB } from 'web-vitals/attribution';
|
|
2
|
+
|
|
3
|
+
/** True only in a real browser (guards against SSR / Node import). */ function hasDom() {
|
|
4
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
5
|
+
}
|
|
6
|
+
/** True at the moment the page is hidden — the correctness-critical flush point. */ function isHidden() {
|
|
7
|
+
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `NODE_ENV=development` disables all beacons (bundlers inline this constant;
|
|
11
|
+
* the `typeof` guard keeps it safe when `process` is absent in the browser).
|
|
12
|
+
*/ function isDevelopment() {
|
|
13
|
+
try {
|
|
14
|
+
return typeof process !== "undefined" && !!process.env && process.env.NODE_ENV === "development";
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Same-origin ingest path (howl-cloud/hostess#32). Never cross-origin, so no
|
|
21
|
+
// CORS ever applies.
|
|
22
|
+
const ENDPOINT = "/_hostess/rum";
|
|
23
|
+
// Transport hard caps from schema v1: a request carries at most 20 beacons and
|
|
24
|
+
// 8 KB. A flush is split into as many batches as needed to respect both.
|
|
25
|
+
const MAX_BEACONS = 20;
|
|
26
|
+
const MAX_BYTES = 8 * 1024;
|
|
27
|
+
const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
|
28
|
+
function byteLength(s) {
|
|
29
|
+
return encoder ? encoder.encode(s).length : s.length;
|
|
30
|
+
}
|
|
31
|
+
/** Greedily pack beacons into batches that respect both the count and byte caps. */ function splitBatches(beacons) {
|
|
32
|
+
const batches = [];
|
|
33
|
+
let current = [];
|
|
34
|
+
for (const beacon of beacons){
|
|
35
|
+
const candidate = current.length ? [
|
|
36
|
+
...current,
|
|
37
|
+
beacon
|
|
38
|
+
] : [
|
|
39
|
+
beacon
|
|
40
|
+
];
|
|
41
|
+
const tooMany = candidate.length > MAX_BEACONS;
|
|
42
|
+
const tooBig = byteLength(JSON.stringify(candidate)) > MAX_BYTES;
|
|
43
|
+
if (current.length && (tooMany || tooBig)) {
|
|
44
|
+
batches.push(current);
|
|
45
|
+
current = [
|
|
46
|
+
beacon
|
|
47
|
+
]; // a single beacon over 8 KB is unsplittable; send alone
|
|
48
|
+
} else {
|
|
49
|
+
current = candidate;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (current.length) batches.push(current);
|
|
53
|
+
return batches;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Page-lifetime beacon queue. One instance per page (a window singleton shared
|
|
57
|
+
* by `inject` and `injectSpeedInsights`), so their beacons batch together.
|
|
58
|
+
*
|
|
59
|
+
* Flush triggers: the queue reaches `MAX_BEACONS`, the page transitions to
|
|
60
|
+
* hidden, or a beacon is enqueued while the page is already hidden. That last
|
|
61
|
+
* rule is what makes the CLS/INP path correct without depending on listener
|
|
62
|
+
* order: `web-vitals` finalizes those metrics inside its own
|
|
63
|
+
* `visibilitychange` handler, and whichever order the handlers run, a beacon
|
|
64
|
+
* pushed while hidden flushes immediately.
|
|
65
|
+
*
|
|
66
|
+
* Failure posture (fire-and-forget, back-off-and-stop): a disabled endpoint
|
|
67
|
+
* returns 404, but `navigator.sendBeacon`'s boolean only reports whether the UA
|
|
68
|
+
* queued the request, never the HTTP status. So the queue *probes* with an
|
|
69
|
+
* observable `fetch(keepalive)` until the first success; a 404 or network
|
|
70
|
+
* error there stops the queue for the rest of the page's life (silent and free
|
|
71
|
+
* after the first failure). Once the endpoint is confirmed live, it prefers
|
|
72
|
+
* `sendBeacon` — the only transport that reliably survives page unload.
|
|
73
|
+
*/ class BeaconQueue {
|
|
74
|
+
push(beacon) {
|
|
75
|
+
if (this.stopped) return;
|
|
76
|
+
this.ensureListeners();
|
|
77
|
+
this.buffer.push(beacon);
|
|
78
|
+
if (this.buffer.length >= MAX_BEACONS || isHidden()) this.flush();
|
|
79
|
+
}
|
|
80
|
+
flush() {
|
|
81
|
+
if (this.stopped || this.buffer.length === 0) return;
|
|
82
|
+
const batches = splitBatches(this.buffer);
|
|
83
|
+
this.buffer = [];
|
|
84
|
+
for (const batch of batches)this.send(batch);
|
|
85
|
+
}
|
|
86
|
+
/** Halt all sending for the page lifetime and drop anything buffered. */ stop() {
|
|
87
|
+
this.stopped = true;
|
|
88
|
+
this.buffer = [];
|
|
89
|
+
}
|
|
90
|
+
ensureListeners() {
|
|
91
|
+
if (this.listening || typeof document === "undefined") return;
|
|
92
|
+
this.listening = true;
|
|
93
|
+
document.addEventListener("visibilitychange", ()=>{
|
|
94
|
+
if (isHidden()) this.flush();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
send(batch) {
|
|
98
|
+
const body = JSON.stringify(batch);
|
|
99
|
+
// Fast path once the endpoint is confirmed live: sendBeacon survives unload.
|
|
100
|
+
if (this.verified && this.trySendBeacon(body)) return;
|
|
101
|
+
// Observable path: confirms the endpoint and detects a disabled one.
|
|
102
|
+
if (typeof fetch === "function") {
|
|
103
|
+
fetch(ENDPOINT, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body,
|
|
106
|
+
keepalive: true,
|
|
107
|
+
credentials: "omit",
|
|
108
|
+
// text/plain mirrors sendBeacon and keeps this a simple same-origin
|
|
109
|
+
// request; the ingest parses the JSON body regardless of content-type.
|
|
110
|
+
headers: {
|
|
111
|
+
"Content-Type": "text/plain;charset=UTF-8"
|
|
112
|
+
}
|
|
113
|
+
}).then((res)=>{
|
|
114
|
+
if (res.ok) this.verified = true;
|
|
115
|
+
else this.stop();
|
|
116
|
+
}).catch(()=>this.stop());
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// No fetch available: last-resort sendBeacon (cannot observe the result).
|
|
120
|
+
if (!this.trySendBeacon(body)) this.stop();
|
|
121
|
+
}
|
|
122
|
+
trySendBeacon(body) {
|
|
123
|
+
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
|
124
|
+
if (!nav || typeof nav.sendBeacon !== "function") return false;
|
|
125
|
+
try {
|
|
126
|
+
return nav.sendBeacon(ENDPOINT, body);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
constructor(){
|
|
132
|
+
this.buffer = [];
|
|
133
|
+
this.stopped = false;
|
|
134
|
+
this.verified = false;
|
|
135
|
+
this.listening = false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const KEY = "__hostess_rum__";
|
|
140
|
+
function state() {
|
|
141
|
+
const w = window;
|
|
142
|
+
return w[KEY] ??= {};
|
|
143
|
+
}
|
|
144
|
+
function getQueue() {
|
|
145
|
+
const st = state();
|
|
146
|
+
return st.queue ??= new BeaconQueue();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Strip the query string and hash, leaving only the path. */ function stripQuery(pathOrUrl) {
|
|
150
|
+
let end = pathOrUrl.length;
|
|
151
|
+
const q = pathOrUrl.indexOf("?");
|
|
152
|
+
const h = pathOrUrl.indexOf("#");
|
|
153
|
+
if (q >= 0) end = Math.min(end, q);
|
|
154
|
+
if (h >= 0) end = Math.min(end, h);
|
|
155
|
+
return pathOrUrl.slice(0, end);
|
|
156
|
+
}
|
|
157
|
+
// history.pushState / replaceState do not emit an event. Patch them once (per
|
|
158
|
+
// page) to dispatch one, so the default provider can observe SPA navigations
|
|
159
|
+
// without every provider stacking its own monkey-patch. popstate covers
|
|
160
|
+
// back/forward natively.
|
|
161
|
+
const LOCATION_CHANGE_EVENT = "hostess:locationchange";
|
|
162
|
+
const HISTORY_PATCH_FLAG = "__hostess_history_patched__";
|
|
163
|
+
function ensureHistoryEvents() {
|
|
164
|
+
const w = window;
|
|
165
|
+
if (w[HISTORY_PATCH_FLAG]) return;
|
|
166
|
+
w[HISTORY_PATCH_FLAG] = true;
|
|
167
|
+
for (const method of [
|
|
168
|
+
"pushState",
|
|
169
|
+
"replaceState"
|
|
170
|
+
]){
|
|
171
|
+
const original = history[method];
|
|
172
|
+
history[method] = function(...args) {
|
|
173
|
+
const result = original.apply(this, args);
|
|
174
|
+
window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT));
|
|
175
|
+
return result;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Default provider with no framework knowledge: `location.pathname` is used as
|
|
181
|
+
* both the route template and the concrete path — correct for non-parameterized
|
|
182
|
+
* sites, and the ingest's route-hygiene caps absorb the rest. It also emits
|
|
183
|
+
* navigation changes (pushState/replaceState → "spa", popstate →
|
|
184
|
+
* "back-forward") so `inject()` is useful standalone in a bare SPA; adapters
|
|
185
|
+
* that supply real templates replace this entirely.
|
|
186
|
+
*/ function defaultRouteProvider() {
|
|
187
|
+
const read = ()=>{
|
|
188
|
+
const path = typeof location !== "undefined" ? location.pathname : "/";
|
|
189
|
+
return {
|
|
190
|
+
route: path,
|
|
191
|
+
path
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
return {
|
|
195
|
+
current: read,
|
|
196
|
+
onChange (cb) {
|
|
197
|
+
if (typeof window === "undefined") return ()=>{};
|
|
198
|
+
ensureHistoryEvents();
|
|
199
|
+
let last = location.pathname;
|
|
200
|
+
const emit = (nav)=>()=>{
|
|
201
|
+
const path = location.pathname;
|
|
202
|
+
if (path === last) return; // ignore hash-only / query-only changes
|
|
203
|
+
last = path;
|
|
204
|
+
cb({
|
|
205
|
+
route: path,
|
|
206
|
+
path
|
|
207
|
+
}, nav);
|
|
208
|
+
};
|
|
209
|
+
const onSpa = emit("spa");
|
|
210
|
+
const onPop = emit("back-forward");
|
|
211
|
+
window.addEventListener(LOCATION_CHANGE_EVENT, onSpa);
|
|
212
|
+
window.addEventListener("popstate", onPop);
|
|
213
|
+
return ()=>{
|
|
214
|
+
window.removeEventListener(LOCATION_CHANGE_EVENT, onSpa);
|
|
215
|
+
window.removeEventListener("popstate", onPop);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Reduce a referrer URL to its origin only — never the path or query. Returns
|
|
223
|
+
* "" for direct visits, same-origin navigations, and non-http(s) schemes, so a
|
|
224
|
+
* beacon can never leak where on another site the visitor came from.
|
|
225
|
+
*/ function referrerOrigin(ref) {
|
|
226
|
+
const raw = (typeof document !== "undefined" ? document.referrer : "");
|
|
227
|
+
if (!raw) return "";
|
|
228
|
+
let url;
|
|
229
|
+
try {
|
|
230
|
+
url = new URL(raw);
|
|
231
|
+
} catch {
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
|
235
|
+
const here = typeof location !== "undefined" ? location.origin : "";
|
|
236
|
+
if (url.origin === here) return "";
|
|
237
|
+
return url.origin;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const UTM_KEYS = [
|
|
241
|
+
"source",
|
|
242
|
+
"medium",
|
|
243
|
+
"campaign",
|
|
244
|
+
"term",
|
|
245
|
+
"content"
|
|
246
|
+
];
|
|
247
|
+
// Matches the ingest's per-value cap; over-length values are truncated rather
|
|
248
|
+
// than dropped so a long campaign name still attributes.
|
|
249
|
+
const MAX_VALUE_LENGTH = 64;
|
|
250
|
+
/**
|
|
251
|
+
* Extract the five `utm_*` parameters from a query string. Every other query
|
|
252
|
+
* parameter is ignored and never sent — this is the only place a raw query
|
|
253
|
+
* value crosses into a beacon.
|
|
254
|
+
*/ function extractUtm(search) {
|
|
255
|
+
const qs = (typeof location !== "undefined" ? location.search : "");
|
|
256
|
+
const utm = {};
|
|
257
|
+
if (!qs) return utm;
|
|
258
|
+
let params;
|
|
259
|
+
try {
|
|
260
|
+
params = new URLSearchParams(qs);
|
|
261
|
+
} catch {
|
|
262
|
+
return utm;
|
|
263
|
+
}
|
|
264
|
+
for (const key of UTM_KEYS){
|
|
265
|
+
const value = params.get(`utm_${key}`);
|
|
266
|
+
if (value != null && value !== "") {
|
|
267
|
+
utm[key] = value.slice(0, MAX_VALUE_LENGTH);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return utm;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Coarse device class from UA Client Hints with a UA-string fallback. Only
|
|
275
|
+
* three buckets — no model, no fingerprinting.
|
|
276
|
+
*
|
|
277
|
+
* Tablet is decided from the UA string first because UA-CH low-entropy hints
|
|
278
|
+
* have no tablet signal (an iPad reports `mobile: false`, indistinguishable
|
|
279
|
+
* from a desktop). Everything else prefers the `mobile` hint, falling back to
|
|
280
|
+
* UA-string keywords.
|
|
281
|
+
*/ function deviceClass() {
|
|
282
|
+
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
|
283
|
+
const ua = nav?.userAgent ?? "";
|
|
284
|
+
if (/\bipad\b/i.test(ua) || /\btablet\b/i.test(ua) || /android/i.test(ua) && !/mobile/i.test(ua)) {
|
|
285
|
+
return "tablet";
|
|
286
|
+
}
|
|
287
|
+
const uaData = nav?.userAgentData;
|
|
288
|
+
if (uaData && typeof uaData.mobile === "boolean") {
|
|
289
|
+
return uaData.mobile ? "mobile" : "desktop";
|
|
290
|
+
}
|
|
291
|
+
if (/mobi|iphone|ipod|android/i.test(ua)) return "mobile";
|
|
292
|
+
return "desktop";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The single gate every beacon passes through.
|
|
297
|
+
*
|
|
298
|
+
* - `debug` logs the would-be payload (works even in development, so you can
|
|
299
|
+
* verify what *would* be sent).
|
|
300
|
+
* - `NODE_ENV=development` is silent: it returns before the queue is ever
|
|
301
|
+
* created, guaranteeing no network and no listeners in development.
|
|
302
|
+
*/ function report(beacon, debug) {
|
|
303
|
+
if (debug) {
|
|
304
|
+
try {
|
|
305
|
+
console.log("[hostess/browser]", beacon);
|
|
306
|
+
} catch {
|
|
307
|
+
// console unavailable — never let logging break the host page.
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (isDevelopment()) return;
|
|
311
|
+
getQueue().push(beacon);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Bumped in lockstep with package.json on release. Surfaces in the `sdk`
|
|
315
|
+
// beacon field as `browser@<version>` (framework adapters override the prefix).
|
|
316
|
+
const SDK_VERSION = "0.1.0";
|
|
317
|
+
|
|
318
|
+
function defaultSdk$1() {
|
|
319
|
+
return `browser@${SDK_VERSION}`;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Start collecting page views: one `pv` beacon on initial load, then one per
|
|
323
|
+
* SPA navigation reported by the route provider. Idempotent — a second call is
|
|
324
|
+
* a no-op. Safe to call during SSR (no-ops without a DOM).
|
|
325
|
+
*/ function inject(opts = {}) {
|
|
326
|
+
if (!hasDom()) return;
|
|
327
|
+
const st = state();
|
|
328
|
+
if (st.pv) return;
|
|
329
|
+
st.pv = true;
|
|
330
|
+
const provider = opts.routeProvider ?? defaultRouteProvider();
|
|
331
|
+
const debug = opts.debug ?? false;
|
|
332
|
+
const sdk = opts.sdk ?? defaultSdk$1();
|
|
333
|
+
emitPageview(provider.current(), "load", debug, sdk);
|
|
334
|
+
provider.onChange((info, nav)=>emitPageview(info, nav, debug, sdk));
|
|
335
|
+
}
|
|
336
|
+
function emitPageview(info, nav, debug, sdk) {
|
|
337
|
+
const beacon = {
|
|
338
|
+
v: 1,
|
|
339
|
+
k: "pv",
|
|
340
|
+
route: info.route,
|
|
341
|
+
path: stripQuery(info.path),
|
|
342
|
+
ref: referrerOrigin(),
|
|
343
|
+
utm: extractUtm(),
|
|
344
|
+
nav,
|
|
345
|
+
dc: deviceClass(),
|
|
346
|
+
sdk
|
|
347
|
+
};
|
|
348
|
+
report(beacon, debug);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function defaultSdk() {
|
|
352
|
+
return `browser@${SDK_VERSION}`;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Start collecting Core Web Vitals (LCP, CLS, INP, FCP, TTFB) via the
|
|
356
|
+
* `web-vitals` attribution build. Each metric reports once, finalized at
|
|
357
|
+
* page-hide for the field-only values (CLS, INP) — the queue's hidden-flush
|
|
358
|
+
* path carries those out. No FID (deprecated). Idempotent; no-ops during SSR.
|
|
359
|
+
*/ function injectSpeedInsights(opts = {}) {
|
|
360
|
+
if (!hasDom()) return;
|
|
361
|
+
const st = state();
|
|
362
|
+
if (st.wv) return;
|
|
363
|
+
st.wv = true;
|
|
364
|
+
const provider = opts.routeProvider ?? defaultRouteProvider();
|
|
365
|
+
const debug = opts.debug ?? false;
|
|
366
|
+
const sdk = opts.sdk ?? defaultSdk();
|
|
367
|
+
const handler = (metric)=>emitVital(metric, provider, debug, sdk);
|
|
368
|
+
onLCP(handler);
|
|
369
|
+
onCLS(handler);
|
|
370
|
+
onINP(handler);
|
|
371
|
+
onFCP(handler);
|
|
372
|
+
onTTFB(handler);
|
|
373
|
+
}
|
|
374
|
+
function emitVital(metric, provider, debug, sdk) {
|
|
375
|
+
const info = provider.current();
|
|
376
|
+
const beacon = {
|
|
377
|
+
v: 1,
|
|
378
|
+
k: "wv",
|
|
379
|
+
route: info.route,
|
|
380
|
+
path: stripQuery(info.path),
|
|
381
|
+
ref: referrerOrigin(),
|
|
382
|
+
utm: extractUtm(),
|
|
383
|
+
nav: navFromMetric(metric.navigationType),
|
|
384
|
+
dc: deviceClass(),
|
|
385
|
+
sdk,
|
|
386
|
+
m: metric.name,
|
|
387
|
+
val: metricValue(metric),
|
|
388
|
+
rating: metric.rating
|
|
389
|
+
};
|
|
390
|
+
report(beacon, debug);
|
|
391
|
+
}
|
|
392
|
+
// web-vitals does not track SPA soft navigations, so a vital is only ever
|
|
393
|
+
// "load" or "back-forward" (bfcache restores).
|
|
394
|
+
function navFromMetric(navigationType) {
|
|
395
|
+
return navigationType === "back-forward" || navigationType === "back-forward-cache" ? "back-forward" : "load";
|
|
396
|
+
}
|
|
397
|
+
// CLS is unitless and small — keep four decimals. The millisecond metrics are
|
|
398
|
+
// rounded to whole ms.
|
|
399
|
+
function metricValue(metric) {
|
|
400
|
+
return metric.name === "CLS" ? Math.round(metric.value * 10000) / 10000 : Math.round(metric.value);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Custom events — phase 2 (howl-cloud/hostess-js#3). Exported now as a typed
|
|
405
|
+
* no-op so adapters can re-export it today and the real implementation lands
|
|
406
|
+
* without a breaking API change. Records nothing yet.
|
|
407
|
+
*/ function track(_name, _props) {
|
|
408
|
+
// Intentionally empty until custom-event ingest exists.
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export { SDK_VERSION, defaultRouteProvider, inject, injectSpeedInsights, track };
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hostess/browser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic browser core for Hostess Audience Analytics and Speed Insights.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/howl-cloud/hostess-js",
|
|
9
|
+
"directory": "packages/browser"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://hostess.sh",
|
|
12
|
+
"bugs": "https://github.com/howl-cloud/hostess-js/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"require": "./dist/index.cjs"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"web-vitals": "^5.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^20.0.0",
|
|
37
|
+
"bunchee": "^6.11.0",
|
|
38
|
+
"jsdom": "^25.0.0",
|
|
39
|
+
"typescript": "^5.6.0",
|
|
40
|
+
"vitest": "^2.1.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bunchee",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "vitest run"
|
|
46
|
+
}
|
|
47
|
+
}
|