@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 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;
@@ -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
+ }