@faststats/web 0.10.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,21 +3,20 @@
3
3
  > [!IMPORTANT]
4
4
  > This repository is in early development and not intended for production use.
5
5
 
6
- The base SDK only handles analytics, identity, sessions, navigation, and consent.
7
- Error tracking, Web Vitals, and session replay are independent extensions, so
8
- applications only download the products they use.
6
+ The base SDK handles analytics, identity, sessions, navigation, outbound link
7
+ clicks, and consent.
8
+ Error tracking, Web Vitals, session replay, and feature flags are independent
9
+ extensions, so applications only download the products they use.
9
10
 
10
11
  ```ts
11
12
  import { init, track } from "@faststats/web";
12
13
  import { errorTracking } from "@faststats/web/error";
13
- import { outboundLinks } from "@faststats/web/outbound-links";
14
14
  import { sessionReplay } from "@faststats/web/replay";
15
15
  import { webVitals } from "@faststats/web/web-vitals";
16
16
 
17
17
  init({
18
18
  siteKey: "site_123",
19
19
  extensions: [
20
- outboundLinks(),
21
20
  errorTracking(),
22
21
  webVitals(),
23
22
  sessionReplay({ recordConsole: true }),
@@ -78,6 +77,55 @@ since the last delivered checkpoint. The collector must accept successive events
78
77
  for the same visitor and URL; deploy the removal of its five-second debounce
79
78
  before releasing this SDK.
80
79
 
80
+ ### Feature flags
81
+
82
+ `featureFlags()` loads the project's flags for the visitor the client tracks and
83
+ resolves them synchronously from memory:
84
+
85
+ ```ts
86
+ import { init } from "@faststats/web";
87
+ import { featureFlags } from "@faststats/web/flags";
88
+
89
+ const flags = featureFlags({ flags: ["new-checkout", "max-uploads"] });
90
+ init({ siteKey: "site_123", extensions: [flags] });
91
+
92
+ await flags.ready();
93
+ if (flags.isEnabled("new-checkout")) showNewCheckout();
94
+ const maxUploads = flags.get("max-uploads", 10);
95
+ ```
96
+
97
+ Every loaded flag is recorded as an exposure, whether or not the app reads it,
98
+ so list the keys the app uses in `flags`. Without it every flag in the project
99
+ is loaded.
100
+
101
+ Flags are evaluated for the anonymous visitor ID, and the session ID links each
102
+ evaluation to analytics. `identify()` re-evaluates them for that user, so
103
+ rollouts follow the user across devices, and `logout()` goes back to a new
104
+ anonymous visitor. When the user is known at startup, pass `userId` to skip the
105
+ anonymous load. A cookieless visitor has no lasting ID, so rollouts hold for the
106
+ session only. Flags are not loaded while consent is `"denied"`.
107
+
108
+ `get()` returns the default passed at the call site while flags are loading, and
109
+ when the flag does not exist, is archived, has a different type, or the service
110
+ cannot be reached within `timeoutMs`. `details(key)` adds the variant and reason
111
+ (`rule_match`, `target_match`, `fallthrough`, `disabled`,
112
+ `prerequisite_failed`).
113
+
114
+ ```ts
115
+ flags.subscribe(() => render(flags.status)); // "idle" | "loading" | "ready" | "error"
116
+ await flags.setContext({ attributes: { plan: "pro" } });
117
+ await flags.reload();
118
+ ```
119
+
120
+ | Option | |
121
+ | ------------ | -------------------------------------------------------- |
122
+ | `flags` | The flag keys to load. |
123
+ | `userId` | Your ID for a signed-in user, when known at startup. |
124
+ | `attributes` | Anything targeting rules should match on. |
125
+ | `endpoint` | Base URL of the flags service, when self-run. |
126
+ | `timeoutMs` | Aborts a slower load. Defaults to `3000`. |
127
+ | `fetch` | A `fetch` implementation to use instead of the global. |
128
+
81
129
  ### Network recording in session replay
82
130
 
83
131
  Network recording is disabled by default. Enable it with
@@ -123,7 +171,7 @@ export function diagnostics(): AnalyticsExtension {
123
171
  name: "diagnostics",
124
172
  setup(context) {
125
173
  const off = context.on("pageChange", ({ url }) => {
126
- console.debug("page changed", url, context.getSession());
174
+ console.debug("page changed", url);
127
175
  });
128
176
 
129
177
  return () => off();
@@ -1,10 +1,3 @@
1
- //#region src/utils/session-manager.d.ts
2
- type SessionContext = {
3
- sessionId: string;
4
- windowId: string;
5
- sessionStart: number;
6
- };
7
- //#endregion
8
1
  //#region src/extensions.d.ts
9
2
  type ExtensionHooks = {
10
3
  pageChange: {
@@ -20,6 +13,10 @@ type ExtensionHooks = {
20
13
  consentChange: {
21
14
  cookieless: boolean;
22
15
  };
16
+ /** `identify()` set a user, or `logout()` cleared one. */
17
+ identityChange: {
18
+ userId: string | undefined;
19
+ };
23
20
  error: {
24
21
  error: Error;
25
22
  };
@@ -34,11 +31,7 @@ interface AnalyticsExtensionContext {
34
31
  readonly sdkName?: string;
35
32
  readonly sdkVersion?: string;
36
33
  isCookieless(): boolean;
37
- getAnonymousId(): string;
38
- getSession(): SessionContext;
39
- capture(event: string, properties?: Record<string, unknown>): void;
40
34
  on<K extends ExtensionHook>(hook: K, handler: ExtensionHandler<K>): () => void;
41
- log(message: string, ...details: unknown[]): void;
42
35
  }
43
36
  interface AnalyticsExtension {
44
37
  readonly name: string;
@@ -1,4 +1,4 @@
1
- import { c as removeStorageItem, l as setStorageItem, n as createId, s as getStorageItem } from "./send-data-DAVUdfNm.js";
1
+ import { c as setStorageItem, o as getStorageItem, s as removeStorageItem, t as createId } from "./session-manager-CFQEWbgo.js";
2
2
  //#region src/utils/identifiers.ts
3
3
  const ANONYMOUS_ID_KEY = "faststats_anon_id_";
4
4
  function getAnonymousId(siteKey, cookieless = false) {
@@ -0,0 +1,84 @@
1
+ //#region src/utils/api-urls.ts
2
+ function resolveBaseUrl(baseUrl) {
3
+ return baseUrl?.replace(/\/+$/, "") || "https://metrics.faststats.dev";
4
+ }
5
+ const URLS = {
6
+ events: "/v1/web",
7
+ identify: "/v1/identify",
8
+ replay: "/v1/replay",
9
+ vitals: "/v1/vitals"
10
+ };
11
+ function sanitizeUrl(url, includeHash = false) {
12
+ try {
13
+ const parsed = new URL(url, location.href);
14
+ return `${parsed.origin}${parsed.pathname}${includeHash ? parsed.hash : ""}`;
15
+ } catch {
16
+ return url.split(includeHash ? "?" : /[?#]/, 1)[0] ?? "";
17
+ }
18
+ }
19
+ const UTM_KEYS = [
20
+ "utm_source",
21
+ "utm_medium",
22
+ "utm_campaign",
23
+ "utm_term",
24
+ "utm_content"
25
+ ];
26
+ function getReferrer() {
27
+ const raw = document.referrer;
28
+ if (!raw) return null;
29
+ try {
30
+ const pageOrigin = new URL(location.href).origin;
31
+ if (new URL(raw, location.href).origin === pageOrigin) return null;
32
+ } catch {}
33
+ return sanitizeUrl(raw);
34
+ }
35
+ function getPageContext(includeHash = false) {
36
+ const hash = includeHash ? location.hash : "";
37
+ const ctx = {
38
+ page: `${location.pathname}${hash}`,
39
+ url: sanitizeUrl(location.href, includeHash),
40
+ referrer: getReferrer(),
41
+ title: document.title || ""
42
+ };
43
+ if (!location.search) return ctx;
44
+ const sp = new URLSearchParams(location.search);
45
+ for (const key of UTM_KEYS) {
46
+ const value = sp.get(key);
47
+ if (value) ctx[key] = value;
48
+ }
49
+ return ctx;
50
+ }
51
+ //#endregion
52
+ //#region src/utils/send-data.ts
53
+ async function sendData(options) {
54
+ const { url, data, contentType = "application/json", debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, settleRejected } = options;
55
+ if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") try {
56
+ const beaconBody = data instanceof Blob || typeof Blob === "undefined" ? data : new Blob([data], { type: contentType });
57
+ if (globalThis.navigator.sendBeacon(url, beaconBody)) {
58
+ if (debug) console.log(`${debugPrefix} Sent via beacon`);
59
+ return true;
60
+ }
61
+ } catch {}
62
+ const size = typeof data === "string" ? data.length : data instanceof Blob ? data.size : data.byteLength;
63
+ const post = (keepalive) => fetch(url, {
64
+ method: "POST",
65
+ body: data,
66
+ headers: { "Content-Type": contentType },
67
+ keepalive,
68
+ credentials: "omit"
69
+ });
70
+ try {
71
+ const useKeepalive = keepalive && size <= 61440;
72
+ const { ok, status } = await post(useKeepalive).catch((error) => {
73
+ if (!useKeepalive) throw error;
74
+ return post(false);
75
+ });
76
+ if (debug) console.log(debugPrefix, ok ? "Sent via fetch" : status);
77
+ return ok || !!settleRejected && status < 500 && status !== 429;
78
+ } catch {
79
+ if (debug) console.log(debugPrefix, "Failed to send");
80
+ return false;
81
+ }
82
+ }
83
+ //#endregion
84
+ export { sanitizeUrl as a, resolveBaseUrl as i, URLS as n, getPageContext as r, sendData as t };
@@ -31,6 +31,7 @@ function removeStorageItem(name, key) {
31
31
  const IDLE_MS = 18e5;
32
32
  const MAX_AGE_MS = 864e5;
33
33
  const SESSION_KEY = "faststats_session_";
34
+ const WINDOW_KEY = "faststats_window_id_";
34
35
  const NO_STORAGE = {};
35
36
  const memorySessions = /* @__PURE__ */ new Map();
36
37
  const rotationListeners = /* @__PURE__ */ new Map();
@@ -64,7 +65,7 @@ function readSession(siteKey) {
64
65
  if (!raw) return memory;
65
66
  try {
66
67
  const session = JSON.parse(raw);
67
- return typeof session?.id === "string" && session.id && Number.isFinite(session.start) && Number.isFinite(session.activity) ? session : memory;
68
+ if (typeof session?.id === "string" && session.id && Number.isFinite(session.start) && Number.isFinite(session.activity)) return session;
68
69
  } catch {}
69
70
  return memory;
70
71
  }
@@ -104,7 +105,7 @@ function resolveSession(siteKey, cookieless, touch) {
104
105
  }
105
106
  function windowId(siteKey, cookieless, sessionId) {
106
107
  if (cookieless) return sessionId;
107
- const key = `faststats_window_id_${siteKey}`;
108
+ const key = `${WINDOW_KEY}${siteKey}`;
108
109
  const existing = getStorageItem("sessionStorage", key);
109
110
  if (existing) return existing;
110
111
  const id = createId();
@@ -128,7 +129,7 @@ function resetSession(siteKey, cookieless = false) {
128
129
  memorySessions.delete(memoryKey(siteKey, cookieless));
129
130
  if (!cookieless) {
130
131
  removeStorageItem("localStorage", `${SESSION_KEY}${siteKey}`);
131
- removeStorageItem("sessionStorage", `faststats_window_id_${siteKey}`);
132
+ removeStorageItem("sessionStorage", `${WINDOW_KEY}${siteKey}`);
132
133
  }
133
134
  const next = getSessionContext(siteKey, cookieless);
134
135
  if (previous.sessionId !== next.sessionId) emitRotation(siteKey, cookieless, previous, next);
@@ -148,35 +149,4 @@ function onSessionRotated(siteKey, cookieless, listener) {
148
149
  };
149
150
  }
150
151
  //#endregion
151
- //#region src/utils/send-data.ts
152
- async function sendData(options) {
153
- const { url, data, contentType = "application/json", headers = {}, debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, signal, settleRejected } = options;
154
- if (signal?.aborted) return false;
155
- if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") try {
156
- const beaconBody = data instanceof Blob || typeof Blob === "undefined" ? data : new Blob([data], { type: contentType });
157
- if (globalThis.navigator.sendBeacon(url, beaconBody)) {
158
- if (debug) console.log(`${debugPrefix} Sent via beacon`);
159
- return true;
160
- }
161
- } catch {}
162
- try {
163
- const { ok, status } = await fetch(url, {
164
- method: "POST",
165
- body: data,
166
- headers: {
167
- "Content-Type": contentType,
168
- ...headers
169
- },
170
- keepalive,
171
- credentials: "omit",
172
- signal
173
- });
174
- if (debug) console.log(debugPrefix, ok ? "Sent via fetch" : status);
175
- return ok || !!settleRejected && status < 500 && status !== 429;
176
- } catch {
177
- if (debug) console.log(debugPrefix, "Failed to send");
178
- return false;
179
- }
180
- }
181
- //#endregion
182
- export { resetSession as a, removeStorageItem as c, onSessionRotated as i, setStorageItem as l, createId as n, touchActivity as o, getSessionContext as r, getStorageItem as s, sendData as t };
152
+ export { touchActivity as a, setStorageItem as c, resetSession as i, getSessionContext as n, getStorageItem as o, onSessionRotated as r, removeStorageItem as s, createId as t };
package/dist/error.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
1
+ import { t as AnalyticsExtension } from "./chunks/extensions-BfubKIzk.js";
2
2
  //#region src/error.d.ts
3
3
  export type ErrorEntry = {
4
4
  error: string;
@@ -17,7 +17,7 @@ export interface ErrorTrackingOptions {
17
17
  sdkName?: string;
18
18
  sdkVersion?: string;
19
19
  }
20
- export type ErrorTrackingExtensionOptions = Omit<ErrorTrackingOptions, "siteKey" | "baseUrl" | "debug" | "trackHash" | "cookieless" | "sdkName" | "sdkVersion">;
20
+ export type ErrorTrackingExtensionOptions = Pick<ErrorTrackingOptions, "flushInterval" | "maxQueueSize">;
21
21
  export declare function errorTracking(options?: ErrorTrackingExtensionOptions): AnalyticsExtension;
22
22
  export default class ErrorTracker {
23
23
  private readonly options;
@@ -26,11 +26,14 @@ export default class ErrorTracker {
26
26
  private timer;
27
27
  private started;
28
28
  private flushing;
29
+ private generation;
29
30
  private cookieless;
31
+ private readonly maxQueueSize;
30
32
  constructor(options: ErrorTrackingOptions);
31
33
  setCookielessMode(cookieless: boolean): void;
32
34
  start(): void;
33
35
  stop(discard?: boolean): void;
36
+ private discard;
34
37
  onPageHidden(persisted?: boolean): void;
35
38
  captureError(error: Error): void;
36
39
  private onError;
package/dist/error.js CHANGED
@@ -1,9 +1,9 @@
1
- import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { r as getSessionContext, t as sendData } from "./chunks/send-data-DAVUdfNm.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-BgGtAQdp.js";
1
+ import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as sendData } from "./chunks/send-data-DYZZjeM-.js";
2
+ import { n as getSessionContext } from "./chunks/session-manager-CFQEWbgo.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.js";
4
4
  //#region src/error.ts
5
5
  const SDK_NAME = "@faststats/web";
6
- const SDK_VERSION = "0.10.3";
6
+ const SDK_VERSION = "0.12.0";
7
7
  function errorTracking(options = {}) {
8
8
  return {
9
9
  name: "error-tracking",
@@ -51,14 +51,17 @@ var ErrorTracker = class {
51
51
  timer = null;
52
52
  started = false;
53
53
  flushing = false;
54
+ generation = 0;
54
55
  cookieless;
56
+ maxQueueSize;
55
57
  constructor(options) {
56
58
  this.options = options;
57
- this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.events}`;
59
+ this.maxQueueSize = options.maxQueueSize ?? 50;
60
+ this.endpoint = `${resolveBaseUrl(options.baseUrl)}${URLS.events}`;
58
61
  this.cookieless = options.cookieless ?? false;
59
62
  }
60
63
  setCookielessMode(cookieless) {
61
- if (this.cookieless !== cookieless) this.queue = [];
64
+ if (this.cookieless !== cookieless) this.discard();
62
65
  this.cookieless = cookieless;
63
66
  }
64
67
  start() {
@@ -69,15 +72,19 @@ var ErrorTracker = class {
69
72
  this.timer = setInterval(() => void this.flush(), this.options.flushInterval ?? 5e3);
70
73
  }
71
74
  stop(discard = false) {
72
- if (!this.started || typeof window === "undefined") return;
75
+ if (!this.started) return;
73
76
  this.started = false;
74
77
  window.removeEventListener("error", this.onError);
75
78
  window.removeEventListener("unhandledrejection", this.onRejection);
76
79
  if (this.timer) clearInterval(this.timer);
77
80
  this.timer = null;
78
- if (discard) this.queue = [];
81
+ if (discard) this.discard();
79
82
  else this.flush();
80
83
  }
84
+ discard() {
85
+ this.generation++;
86
+ this.queue = [];
87
+ }
81
88
  onPageHidden(persisted = false) {
82
89
  if (!persisted) this.flush();
83
90
  }
@@ -111,11 +118,12 @@ var ErrorTracker = class {
111
118
  properties: {}
112
119
  }
113
120
  });
114
- if (this.queue.length >= (this.options.maxQueueSize ?? 50)) this.flush();
121
+ if (this.queue.length >= this.maxQueueSize) this.flush();
115
122
  }
116
123
  async flush() {
117
124
  if (this.flushing || !this.queue.length) return;
118
125
  this.flushing = true;
126
+ const generation = this.generation;
119
127
  const batch = this.queue;
120
128
  this.queue = [];
121
129
  const groups = /* @__PURE__ */ new Map();
@@ -128,29 +136,36 @@ var ErrorTracker = class {
128
136
  group.items.push(item);
129
137
  groups.set(key, group);
130
138
  }
131
- const failed = await Promise.all([...groups.values()].map(async (group) => {
132
- const errors = /* @__PURE__ */ new Map();
133
- for (const { error } of group.items) {
134
- const key = JSON.stringify(error);
135
- const existing = errors.get(key);
136
- if (existing) existing.count++;
137
- else errors.set(key, {
138
- ...error,
139
- count: 1
140
- });
141
- }
142
- return await sendData({
143
- url: this.endpoint,
144
- data: JSON.stringify({
145
- ...group.context,
146
- errors: [...errors.values()]
147
- }),
148
- debug: this.options.debug,
149
- debugPrefix: "[ErrorTracker]"
150
- }) ? [] : group.items;
151
- }));
152
- this.queue.unshift(...failed.flat());
153
- this.flushing = false;
139
+ let failed = [];
140
+ 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
+ }
152
+ return await sendData({
153
+ url: this.endpoint,
154
+ data: JSON.stringify({
155
+ ...group.context,
156
+ errors: [...errors.values()]
157
+ }),
158
+ debug: this.options.debug,
159
+ debugPrefix: "[ErrorTracker]",
160
+ settleRejected: true
161
+ }) ? [] : group.items;
162
+ }))).flat();
163
+ } finally {
164
+ this.flushing = false;
165
+ }
166
+ if (generation !== this.generation) return;
167
+ this.queue = [...failed, ...this.queue].slice(-this.maxQueueSize);
168
+ if (!this.started && !failed.length) this.flush();
154
169
  }
155
170
  };
156
171
  //#endregion
@@ -0,0 +1,79 @@
1
+ import { n as AnalyticsExtensionContext, r as ExtensionCleanup, t as AnalyticsExtension } from "./chunks/extensions-BfubKIzk.js";
2
+ //#region src/flags.d.ts
3
+ export interface FeatureFlagsOptions {
4
+ /**
5
+ * Flag keys this app reads. Every loaded flag is recorded as an exposure,
6
+ * so list them to keep flags the app never shows out of analytics.
7
+ * Loads every flag when omitted.
8
+ */
9
+ flags?: string[];
10
+ /** Your own ID for a signed-in user. Rollouts follow it across devices. */
11
+ userId?: string;
12
+ /** Anything targeting rules should match on. */
13
+ attributes?: Record<string, unknown>;
14
+ /** Base URL of the feature flags service. */
15
+ endpoint?: string;
16
+ /** Aborts a load that takes longer, so flags serve their defaults. */
17
+ timeoutMs?: number;
18
+ fetch?: typeof fetch;
19
+ }
20
+ export type FeatureFlagReason = "disabled" | "prerequisite_failed" | "target_match" | "rule_match" | "fallthrough";
21
+ export interface FeatureFlagResult {
22
+ value: unknown;
23
+ variant: string;
24
+ reason: FeatureFlagReason;
25
+ }
26
+ export interface FeatureFlagsContext {
27
+ /** Pass `null` to go back to the anonymous visitor. */
28
+ userId?: string | null;
29
+ /** Replaces the attributes set before. */
30
+ attributes?: Record<string, unknown>;
31
+ }
32
+ /**
33
+ * `idle` until the client starts tracking, `loading` during the first load
34
+ * for a visitor, then `ready`, or `error` when that load failed.
35
+ */
36
+ export type FeatureFlagsStatus = "idle" | "loading" | "ready" | "error";
37
+ /**
38
+ * Loads flags from the FastStats flags service for the visitor the analytics
39
+ * client tracks and resolves them synchronously from memory.
40
+ */
41
+ export declare class FeatureFlags implements AnalyticsExtension {
42
+ private readonly options;
43
+ readonly name = "feature-flags";
44
+ private readonly listeners;
45
+ private context;
46
+ private results;
47
+ private currentStatus;
48
+ private latestLoad;
49
+ private subjectKey;
50
+ private loading;
51
+ private userId;
52
+ private attributes;
53
+ constructor(options?: FeatureFlagsOptions);
54
+ setup(context: AnalyticsExtensionContext): ExtensionCleanup;
55
+ get status(): FeatureFlagsStatus;
56
+ /**
57
+ * The flag's value, or `defaultValue` while flags are loading, when the flag
58
+ * does not exist or is archived, or when its type differs from the default's.
59
+ */
60
+ get(key: string, defaultValue: boolean): boolean;
61
+ get(key: string, defaultValue: string): string;
62
+ get(key: string, defaultValue: number): number;
63
+ get<T>(key: string, defaultValue: T): T;
64
+ isEnabled(key: string): boolean;
65
+ details(key: string): FeatureFlagResult | undefined;
66
+ /** Changes who flags are evaluated for and loads them again. */
67
+ setContext(context: FeatureFlagsContext): Promise<void>;
68
+ /** Resolves once the load in flight settles. Never rejects. */
69
+ ready(): Promise<void>;
70
+ /** Loads the flags again. Never rejects; a failed load sets `status`. */
71
+ reload(): Promise<void>;
72
+ /** Calls `listener` whenever the flags or `status` change. */
73
+ subscribe(listener: () => void): () => void;
74
+ private subject;
75
+ private evaluate;
76
+ private update;
77
+ }
78
+ export declare function featureFlags(options?: FeatureFlagsOptions): FeatureFlags;
79
+ //#endregion
package/dist/flags.js ADDED
@@ -0,0 +1,145 @@
1
+ import { n as getSessionContext } from "./chunks/session-manager-CFQEWbgo.js";
2
+ import { t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.js";
3
+ //#region src/flags.ts
4
+ const DEFAULT_ENDPOINT = "https://flags.faststats.dev";
5
+ const DEFAULT_TIMEOUT_MS = 3e3;
6
+ /**
7
+ * Loads flags from the FastStats flags service for the visitor the analytics
8
+ * client tracks and resolves them synchronously from memory.
9
+ */
10
+ var FeatureFlags = class {
11
+ options;
12
+ name = "feature-flags";
13
+ listeners = /* @__PURE__ */ new Set();
14
+ context = null;
15
+ results = {};
16
+ currentStatus = "idle";
17
+ latestLoad = 0;
18
+ subjectKey = "";
19
+ loading = null;
20
+ userId;
21
+ attributes;
22
+ constructor(options = {}) {
23
+ this.options = options;
24
+ this.userId = options.userId;
25
+ this.attributes = options.attributes;
26
+ }
27
+ setup(context) {
28
+ this.context = context;
29
+ context.on("identityChange", ({ userId }) => {
30
+ this.userId = userId;
31
+ this.reload();
32
+ });
33
+ context.on("consentChange", () => void this.reload());
34
+ this.reload();
35
+ return () => {
36
+ if (this.context !== context) return;
37
+ this.context = null;
38
+ this.latestLoad++;
39
+ this.loading = null;
40
+ this.subjectKey = "";
41
+ this.update({}, "idle");
42
+ };
43
+ }
44
+ get status() {
45
+ return this.currentStatus;
46
+ }
47
+ get(key, defaultValue) {
48
+ const result = this.details(key);
49
+ if (!result) return defaultValue;
50
+ const type = typeof defaultValue;
51
+ if ((type === "boolean" || type === "string" || type === "number") && typeof result.value !== type) return defaultValue;
52
+ return result.value;
53
+ }
54
+ isEnabled(key) {
55
+ return this.get(key, false);
56
+ }
57
+ details(key) {
58
+ return Object.hasOwn(this.results, key) ? this.results[key] : void 0;
59
+ }
60
+ /** Changes who flags are evaluated for and loads them again. */
61
+ setContext(context) {
62
+ if (context.userId !== void 0) this.userId = context.userId?.trim() || void 0;
63
+ if (context.attributes) this.attributes = context.attributes;
64
+ return this.reload();
65
+ }
66
+ /** Resolves once the load in flight settles. Never rejects. */
67
+ ready() {
68
+ return this.loading ?? Promise.resolve();
69
+ }
70
+ /** Loads the flags again. Never rejects; a failed load sets `status`. */
71
+ reload() {
72
+ const context = this.context;
73
+ if (!context) return Promise.resolve();
74
+ const load = ++this.latestLoad;
75
+ const subject = this.subject(context);
76
+ if (subject.key !== this.subjectKey) {
77
+ this.subjectKey = subject.key;
78
+ this.update({}, "loading");
79
+ }
80
+ const loading = this.evaluate(context, subject.body).then((results) => {
81
+ if (load === this.latestLoad) this.update(results, "ready");
82
+ }, (error) => {
83
+ if (context.debug) console.log("[FastStats] flags failed", error);
84
+ if (load !== this.latestLoad) return;
85
+ if (this.currentStatus === "loading") this.update({}, "error");
86
+ }).finally(() => {
87
+ if (this.loading === loading) this.loading = null;
88
+ });
89
+ this.loading = loading;
90
+ return loading;
91
+ }
92
+ /** Calls `listener` whenever the flags or `status` change. */
93
+ subscribe(listener) {
94
+ this.listeners.add(listener);
95
+ return () => {
96
+ this.listeners.delete(listener);
97
+ };
98
+ }
99
+ subject(context) {
100
+ const cookieless = context.isCookieless();
101
+ const { sessionId } = getSessionContext(context.siteKey, cookieless);
102
+ const identifier = getAnonymousId(context.siteKey, cookieless) || sessionId;
103
+ return {
104
+ key: JSON.stringify([identifier, this.userId]),
105
+ body: {
106
+ identifier,
107
+ userId: this.userId,
108
+ sessionId,
109
+ attributes: this.attributes ?? {},
110
+ flags: this.options.flags && [...new Set(this.options.flags)]
111
+ }
112
+ };
113
+ }
114
+ async evaluate(context, body) {
115
+ const endpoint = (this.options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
116
+ const response = await (this.options.fetch ?? fetch)(`${endpoint}/v1/evaluate`, {
117
+ method: "POST",
118
+ headers: {
119
+ "content-type": "application/json",
120
+ authorization: `Bearer ${context.siteKey}`
121
+ },
122
+ body: JSON.stringify(body),
123
+ signal: AbortSignal.timeout(this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
124
+ cache: "no-store"
125
+ });
126
+ if (!response.ok) throw new Error(`Evaluation failed with status ${response.status}`);
127
+ const data = await response.json();
128
+ if (typeof data?.flags !== "object" || data.flags === null) throw new Error("Unexpected response from the flags service");
129
+ return data.flags;
130
+ }
131
+ update(results, status) {
132
+ this.results = results;
133
+ this.currentStatus = status;
134
+ for (const listener of [...this.listeners]) try {
135
+ listener();
136
+ } catch (error) {
137
+ if (this.context?.debug) console.log("[FastStats] flags listener failed", error);
138
+ }
139
+ }
140
+ };
141
+ function featureFlags(options = {}) {
142
+ return new FeatureFlags(options);
143
+ }
144
+ //#endregion
145
+ export { FeatureFlags, featureFlags };