@faststats/web 0.13.0 → 0.13.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.
@@ -0,0 +1,16 @@
1
+ import { o as getStorageItem, s as setStorageItem, t as createId } from "./session-manager-VM5RZj9H.js";
2
+ //#region src/utils/identifiers.ts
3
+ const key = (siteKey) => `faststats_anon_id_${siteKey}`;
4
+ /** The visitor's persisted anonymous ID, or "" when it cannot be stored. */
5
+ function getAnonymousId(siteKey, cookieless = false) {
6
+ if (cookieless) return "";
7
+ const existing = getStorageItem("localStorage", key(siteKey));
8
+ if (existing) return existing;
9
+ const id = createId();
10
+ return setStorageItem("localStorage", key(siteKey), id) ? id : "";
11
+ }
12
+ function resetAnonymousId(siteKey, cookieless = false) {
13
+ return !cookieless && setStorageItem("localStorage", key(siteKey), null) ? getAnonymousId(siteKey) : "";
14
+ }
15
+ //#endregion
16
+ export { resetAnonymousId as n, getAnonymousId as t };
@@ -0,0 +1,177 @@
1
+ //#region src/extensions.ts
2
+ /** Runs a built-in tracker with the client's settings and page lifecycle. */
3
+ function trackerExtension(name, create, hook) {
4
+ return {
5
+ name,
6
+ setup(context) {
7
+ const { siteKey, baseUrl, debug, trackHash, sdkName, sdkVersion } = context;
8
+ const tracker = create({
9
+ siteKey,
10
+ baseUrl,
11
+ debug,
12
+ trackHash,
13
+ cookieless: context.isCookieless(),
14
+ sdkName,
15
+ sdkVersion
16
+ });
17
+ context.on("pageHide", ({ persisted, terminal }) => tracker.onPageHidden(persisted, terminal));
18
+ context.on("consentChange", ({ cookieless }) => tracker.setCookielessMode(cookieless));
19
+ hook?.(tracker, context);
20
+ tracker.start();
21
+ return ({ discard }) => tracker.stop(discard);
22
+ }
23
+ };
24
+ }
25
+ var ExtensionHost = class {
26
+ context;
27
+ log;
28
+ handlers = /* @__PURE__ */ new Map();
29
+ cleanups = [];
30
+ stopped = false;
31
+ constructor(extensions, context, log) {
32
+ this.context = context;
33
+ this.log = log;
34
+ for (const extension of extensions) this.setup(extension);
35
+ }
36
+ emit(hook, event) {
37
+ if (this.stopped) return;
38
+ for (const handler of this.handlers.get(hook) ?? []) try {
39
+ handler(event)?.catch((error) => this.log(`${hook} failed`, error));
40
+ } catch (error) {
41
+ this.log(`${hook} failed`, error);
42
+ }
43
+ }
44
+ stop(discard = false) {
45
+ if (this.stopped) return;
46
+ this.stopped = true;
47
+ for (const cleanup of this.cleanups.reverse()) try {
48
+ cleanup({ discard });
49
+ } catch (error) {
50
+ this.log("cleanup failed", error);
51
+ }
52
+ this.cleanups.length = 0;
53
+ this.handlers.clear();
54
+ }
55
+ async setup(extension) {
56
+ if (this.stopped) return;
57
+ const subscriptions = [];
58
+ try {
59
+ const cleanup = await extension.setup({
60
+ ...this.context,
61
+ on: (hook, handler) => {
62
+ if (this.stopped) return () => {};
63
+ const handlers = this.handlers.get(hook) ?? /* @__PURE__ */ new Set();
64
+ this.handlers.set(hook, handlers);
65
+ handlers.add(handler);
66
+ const off = () => handlers.delete(handler);
67
+ subscriptions.push(off);
68
+ return off;
69
+ }
70
+ });
71
+ if (this.stopped) cleanup?.({ discard: true });
72
+ else if (cleanup) this.cleanups.push(cleanup);
73
+ } catch (error) {
74
+ for (const off of subscriptions) off();
75
+ this.log(`${extension.name} setup failed`, error);
76
+ }
77
+ }
78
+ };
79
+ //#endregion
80
+ //#region src/utils/api-urls.ts
81
+ function resolveBaseUrl(baseUrl) {
82
+ return baseUrl?.replace(/\/+$/, "") || "https://metrics.faststats.dev";
83
+ }
84
+ const URLS = {
85
+ events: "/v1/web",
86
+ identify: "/v1/identify",
87
+ replay: "/v1/replay",
88
+ vitals: "/v1/vitals"
89
+ };
90
+ function sanitizeUrl(url, includeHash = false) {
91
+ try {
92
+ const parsed = new URL(url, location.href);
93
+ return `${parsed.origin}${parsed.pathname}${includeHash ? parsed.hash : ""}`;
94
+ } catch {
95
+ return url.split(includeHash ? "?" : /[?#]/, 1)[0] ?? "";
96
+ }
97
+ }
98
+ const UTM_KEYS = [
99
+ "utm_source",
100
+ "utm_medium",
101
+ "utm_campaign",
102
+ "utm_term",
103
+ "utm_content"
104
+ ];
105
+ function getReferrer() {
106
+ const raw = document.referrer;
107
+ if (!raw) return null;
108
+ try {
109
+ if (new URL(raw, location.href).origin === location.origin) return null;
110
+ } catch {}
111
+ return sanitizeUrl(raw);
112
+ }
113
+ /** Visitor environment sent with every event; unavailable values are omitted. */
114
+ function getClientContext() {
115
+ let timezone;
116
+ try {
117
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
118
+ } catch {}
119
+ const { width, height } = globalThis.screen ?? {};
120
+ return {
121
+ timezone,
122
+ language: globalThis.navigator?.language,
123
+ screen: width && height ? `${width}x${height}` : void 0
124
+ };
125
+ }
126
+ function getPageContext(includeHash = false) {
127
+ const hash = includeHash ? location.hash : "";
128
+ const ctx = {
129
+ page: `${location.pathname}${hash}`,
130
+ url: sanitizeUrl(location.href, includeHash),
131
+ referrer: getReferrer(),
132
+ title: document.title
133
+ };
134
+ if (!location.search) return ctx;
135
+ const sp = new URLSearchParams(location.search);
136
+ for (const key of UTM_KEYS) {
137
+ const value = sp.get(key);
138
+ if (value) ctx[key] = value;
139
+ }
140
+ return ctx;
141
+ }
142
+ //#endregion
143
+ //#region src/utils/send-data.ts
144
+ async function sendData(options) {
145
+ const { url, data, contentType = "application/json", debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, settleRejected } = options;
146
+ const log = (...details) => {
147
+ if (debug) console.log(debugPrefix, ...details);
148
+ };
149
+ if (useBeacon) try {
150
+ const body = data instanceof Blob ? data : new Blob([data], { type: contentType });
151
+ if (navigator.sendBeacon(url, body)) {
152
+ log("Sent via beacon");
153
+ return true;
154
+ }
155
+ } catch {}
156
+ const post = (keepalive) => fetch(url, {
157
+ method: "POST",
158
+ body: data,
159
+ headers: { "Content-Type": contentType },
160
+ keepalive,
161
+ credentials: "omit"
162
+ });
163
+ try {
164
+ const useKeepalive = keepalive && new Blob([data]).size <= 61440;
165
+ const { ok, status } = await post(useKeepalive).catch((error) => {
166
+ if (!useKeepalive) throw error;
167
+ return post(false);
168
+ });
169
+ log(ok ? "Sent via fetch" : status);
170
+ return ok || !!settleRejected && status < 500 && status !== 429;
171
+ } catch {
172
+ log("Failed to send");
173
+ return false;
174
+ }
175
+ }
176
+ //#endregion
177
+ export { resolveBaseUrl as a, trackerExtension as c, getPageContext as i, URLS as n, sanitizeUrl as o, getClientContext as r, ExtensionHost as s, sendData as t };
@@ -0,0 +1,116 @@
1
+ //#region src/utils/storage.ts
2
+ function getStorageItem(name, key) {
3
+ try {
4
+ return globalThis[name]?.getItem(key) ?? null;
5
+ } catch {
6
+ return null;
7
+ }
8
+ }
9
+ /** Stores `value`, or removes the item for null. False when storage fails. */
10
+ function setStorageItem(name, key, value) {
11
+ try {
12
+ const storage = globalThis[name];
13
+ if (!storage) return false;
14
+ if (value === null) storage.removeItem(key);
15
+ else storage.setItem(key, value);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+ //#endregion
22
+ //#region src/utils/session-manager.ts
23
+ const IDLE_MS = 18e5;
24
+ const MAX_AGE_MS = 864e5;
25
+ const SESSION_KEY = "faststats_session_";
26
+ const WINDOW_KEY = "faststats_window_id_";
27
+ const memory = /* @__PURE__ */ new Map();
28
+ const listeners = /* @__PURE__ */ new Map();
29
+ const NO_STORAGE = {};
30
+ const slot = (siteKey, cookieless) => `${siteKey}:${cookieless ? "memory" : "persistent"}`;
31
+ function storageScope() {
32
+ try {
33
+ return globalThis.localStorage ?? NO_STORAGE;
34
+ } catch {
35
+ return NO_STORAGE;
36
+ }
37
+ }
38
+ function createId() {
39
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
40
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
41
+ }
42
+ function load(siteKey, cookieless) {
43
+ if (!cookieless) try {
44
+ const session = JSON.parse(getStorageItem("localStorage", `${SESSION_KEY}${siteKey}`) ?? "null");
45
+ if (typeof session?.id === "string" && session.id && Number.isFinite(session.start) && Number.isFinite(session.activity)) return session;
46
+ } catch {}
47
+ const entry = memory.get(slot(siteKey, cookieless));
48
+ return entry?.scope === storageScope() ? entry.session : null;
49
+ }
50
+ function save(siteKey, cookieless, session) {
51
+ const json = JSON.stringify(session);
52
+ if (cookieless || !setStorageItem("localStorage", SESSION_KEY + siteKey, json)) memory.set(slot(siteKey, cookieless), {
53
+ scope: storageScope(),
54
+ session
55
+ });
56
+ }
57
+ function toContext(siteKey, cookieless, { id, start }) {
58
+ let windowId = cookieless ? id : getStorageItem("sessionStorage", WINDOW_KEY + siteKey);
59
+ if (!windowId) {
60
+ windowId = createId();
61
+ if (!setStorageItem("sessionStorage", WINDOW_KEY + siteKey, windowId)) windowId = id;
62
+ }
63
+ return {
64
+ sessionId: id,
65
+ windowId,
66
+ sessionStart: start
67
+ };
68
+ }
69
+ function emit(siteKey, cookieless, prev, next) {
70
+ for (const listener of listeners.get(slot(siteKey, cookieless)) ?? []) listener(prev, next);
71
+ }
72
+ function resolveSession(siteKey, cookieless, touch) {
73
+ const now = Date.now();
74
+ const existing = load(siteKey, cookieless);
75
+ const expired = !!existing && (now - existing.activity >= IDLE_MS || now - existing.start >= MAX_AGE_MS);
76
+ const session = existing && !expired ? existing : {
77
+ id: createId(),
78
+ start: now,
79
+ activity: now
80
+ };
81
+ if (touch) session.activity = now;
82
+ if (touch || session !== existing) save(siteKey, cookieless, session);
83
+ const context = toContext(siteKey, cookieless, session);
84
+ if (existing && expired) emit(siteKey, cookieless, toContext(siteKey, cookieless, existing), context);
85
+ return context;
86
+ }
87
+ /** The current session, replacing an expired one without counting activity. */
88
+ function getSessionContext(siteKey, cookieless = false) {
89
+ return resolveSession(siteKey, cookieless, false);
90
+ }
91
+ /** Counts visitor activity in the current session, which it returns. */
92
+ function touchActivity(siteKey, cookieless = false) {
93
+ return resolveSession(siteKey, cookieless, true);
94
+ }
95
+ function resetSession(siteKey, cookieless = false) {
96
+ const prev = getSessionContext(siteKey, cookieless);
97
+ memory.delete(slot(siteKey, cookieless));
98
+ if (!cookieless) {
99
+ setStorageItem("localStorage", SESSION_KEY + siteKey, null);
100
+ setStorageItem("sessionStorage", WINDOW_KEY + siteKey, null);
101
+ }
102
+ const next = getSessionContext(siteKey, cookieless);
103
+ if (prev.sessionId !== next.sessionId) emit(siteKey, cookieless, prev, next);
104
+ return next;
105
+ }
106
+ function onSessionRotated(siteKey, cookieless, listener) {
107
+ const key = slot(siteKey, cookieless);
108
+ const set = listeners.get(key) ?? /* @__PURE__ */ new Set();
109
+ listeners.set(key, set.add(listener));
110
+ return () => {
111
+ set.delete(listener);
112
+ if (!set.size) listeners.delete(key);
113
+ };
114
+ }
115
+ //#endregion
116
+ export { touchActivity as a, resetSession as i, getSessionContext as n, getStorageItem as o, onSessionRotated as r, setStorageItem as s, createId as t };
package/dist/error.d.ts CHANGED
@@ -39,6 +39,8 @@ export default class ErrorTracker {
39
39
  private onError;
40
40
  private onRejection;
41
41
  private enqueue;
42
+ /** Returns true when the error was already queued. */
43
+ private add;
42
44
  private flush;
43
45
  }
44
46
  //#endregion
package/dist/error.js CHANGED
@@ -1,30 +1,14 @@
1
- import { a as resolveBaseUrl, i as getPageContext, n as URLS, t as sendData } from "./chunks/send-data-C76HbZ8d.js";
2
- import { n as getSessionContext } from "./chunks/session-manager-DUpF3mi3.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
1
+ import { a as resolveBaseUrl, c as trackerExtension, i as getPageContext, n as URLS, t as sendData } from "./chunks/send-data-B7P3f5PT.js";
2
+ import { n as getSessionContext } from "./chunks/session-manager-VM5RZj9H.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-3X8XI1pD.js";
4
4
  //#region src/error.ts
5
5
  const SDK_NAME = "@faststats/web";
6
- const SDK_VERSION = "0.13.0";
6
+ const SDK_VERSION = "0.13.1";
7
7
  function errorTracking(options = {}) {
8
- return {
9
- name: "error-tracking",
10
- setup(context) {
11
- const tracker = new ErrorTracker({
12
- ...options,
13
- siteKey: context.siteKey,
14
- baseUrl: context.baseUrl,
15
- debug: context.debug,
16
- trackHash: context.trackHash,
17
- cookieless: context.isCookieless(),
18
- sdkName: context.sdkName,
19
- sdkVersion: context.sdkVersion
20
- });
21
- context.on("pageHide", ({ persisted }) => tracker.onPageHidden(persisted));
22
- context.on("error", ({ error }) => tracker.captureError(error));
23
- context.on("consentChange", ({ cookieless }) => tracker.setCookielessMode(cookieless));
24
- tracker.start();
25
- return ({ discard }) => tracker.stop(discard);
26
- }
27
- };
8
+ return trackerExtension("error-tracking", (context) => new ErrorTracker({
9
+ ...options,
10
+ ...context
11
+ }), (tracker, context) => context.on("error", ({ error }) => tracker.captureError(error)));
28
12
  }
29
13
  const EXTENSION_URL = /(?:^|\()(chrome|moz|ms-browser|safari-web)-extension:\/\//;
30
14
  function toEntry(kind, source, handled, fallback) {
@@ -103,23 +87,34 @@ var ErrorTracker = class {
103
87
  const buildId = globalThis.__SOURCEMAPS_BUILD__?.buildId;
104
88
  const userId = getAnonymousId(this.options.siteKey, this.cookieless);
105
89
  const ctx = getSessionContext(this.options.siteKey, this.cookieless);
106
- this.queue.push({
90
+ const context = {
91
+ token: this.options.siteKey,
92
+ ...userId ? { userId } : {},
93
+ sessionId: ctx.sessionId,
94
+ windowId: ctx.windowId,
95
+ ...typeof buildId === "string" && buildId.trim() ? { buildId } : {},
96
+ sdkName: this.options.sdkName ?? SDK_NAME,
97
+ sdkVersion: this.options.sdkVersion ?? SDK_VERSION,
98
+ event: "error",
99
+ ...getPageContext(this.options.trackHash ?? false),
100
+ properties: {}
101
+ };
102
+ const key = JSON.stringify([context, error]);
103
+ if (this.add({
104
+ key,
105
+ context,
107
106
  error,
108
- context: {
109
- token: this.options.siteKey,
110
- ...userId ? { userId } : {},
111
- sessionId: ctx.sessionId,
112
- windowId: ctx.windowId,
113
- ...typeof buildId === "string" && buildId.trim() ? { buildId } : {},
114
- sdkName: this.options.sdkName ?? SDK_NAME,
115
- sdkVersion: this.options.sdkVersion ?? SDK_VERSION,
116
- event: "error",
117
- ...getPageContext(this.options.trackHash ?? false),
118
- properties: {}
119
- }
120
- });
107
+ count: 1
108
+ })) return;
121
109
  if (this.queue.length >= this.maxQueueSize) this.flush();
122
110
  }
111
+ /** Returns true when the error was already queued. */
112
+ add(item) {
113
+ const queued = this.queue.find(({ key }) => key === item.key);
114
+ if (queued) queued.count += item.count;
115
+ else this.queue.push(item);
116
+ return !!queued;
117
+ }
123
118
  async flush() {
124
119
  if (this.flushing || !this.queue.length) return;
125
120
  this.flushing = true;
@@ -129,42 +124,35 @@ var ErrorTracker = class {
129
124
  const groups = /* @__PURE__ */ new Map();
130
125
  for (const item of batch) {
131
126
  const key = JSON.stringify(item.context);
132
- const group = groups.get(key) ?? {
133
- context: item.context,
134
- items: []
135
- };
136
- group.items.push(item);
137
- groups.set(key, group);
127
+ const group = groups.get(key);
128
+ if (group) group.push(item);
129
+ else groups.set(key, [item]);
138
130
  }
139
131
  let failed = [];
140
132
  try {
141
- failed = (await Promise.all([...groups.values()].map(async (group) => {
142
- const errors = /* @__PURE__ */ new Map();
143
- for (const { error } of group.items) {
144
- const key = JSON.stringify(error);
145
- const existing = errors.get(key);
146
- if (existing) existing.count++;
147
- else errors.set(key, {
148
- ...error,
149
- count: 1
150
- });
151
- }
133
+ failed = (await Promise.all([...groups.values()].map(async (items) => {
152
134
  return await sendData({
153
135
  url: this.endpoint,
154
136
  data: JSON.stringify({
155
- ...group.context,
156
- errors: [...errors.values()]
137
+ ...items[0]?.context,
138
+ errors: items.map(({ error, count }) => ({
139
+ ...error,
140
+ count
141
+ }))
157
142
  }),
158
143
  debug: this.options.debug,
159
144
  debugPrefix: "[ErrorTracker]",
160
145
  settleRejected: true
161
- }) ? [] : group.items;
146
+ }) ? [] : items;
162
147
  }))).flat();
163
148
  } finally {
164
149
  this.flushing = false;
165
150
  }
166
151
  if (generation !== this.generation) return;
167
- this.queue = [...failed, ...this.queue].slice(-this.maxQueueSize);
152
+ const captured = this.queue;
153
+ this.queue = [...failed];
154
+ for (const item of captured) this.add(item);
155
+ this.queue = this.queue.slice(-this.maxQueueSize);
168
156
  if (!this.started && !failed.length) this.flush();
169
157
  }
170
158
  };
package/dist/flags.js CHANGED
@@ -1,5 +1,5 @@
1
- import { n as getSessionContext } from "./chunks/session-manager-DUpF3mi3.js";
2
- import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
1
+ import { n as getSessionContext } from "./chunks/session-manager-VM5RZj9H.js";
2
+ import { t as getAnonymousId } from "./chunks/identifiers-3X8XI1pD.js";
3
3
  //#region src/flags.ts
4
4
  const DEFAULT_ENDPOINT = "https://flags.faststats.dev";
5
5
  const DEFAULT_TIMEOUT_MS = 3e3;
package/dist/index.d.ts CHANGED
@@ -66,13 +66,10 @@ export declare class WebAnalytics {
66
66
  private readonly onPageHide;
67
67
  private readonly onPageShow;
68
68
  private readonly onResume;
69
+ private readonly onBlur;
69
70
  private readonly onActivity;
70
71
  private isIdle;
71
72
  private leavePage;
72
- /**
73
- * Reports the visit's running totals: as a `page_ping` while it may still
74
- * continue, and as its single `page_leave` once it has ended.
75
- */
76
73
  private checkpoint;
77
74
  private pauseVisit;
78
75
  private resumeVisit;