@intlayer/analytics 9.4.0-canary.0 → 9.4.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.
Files changed (36) hide show
  1. package/README.md +3 -0
  2. package/dist/cjs/core/AnalyticsClient.cjs +25 -6
  3. package/dist/cjs/core/AnalyticsClient.cjs.map +1 -1
  4. package/dist/cjs/core/publicToken.cjs +104 -0
  5. package/dist/cjs/core/publicToken.cjs.map +1 -0
  6. package/dist/cjs/core/transport.cjs +8 -2
  7. package/dist/cjs/core/transport.cjs.map +1 -1
  8. package/dist/cjs/index.cjs +4 -0
  9. package/dist/cjs/isEnabled.cjs +4 -2
  10. package/dist/cjs/isEnabled.cjs.map +1 -1
  11. package/dist/cjs/utils/isBot.cjs +89 -0
  12. package/dist/cjs/utils/isBot.cjs.map +1 -0
  13. package/dist/esm/core/AnalyticsClient.mjs +25 -6
  14. package/dist/esm/core/AnalyticsClient.mjs.map +1 -1
  15. package/dist/esm/core/publicToken.mjs +103 -0
  16. package/dist/esm/core/publicToken.mjs.map +1 -0
  17. package/dist/esm/core/transport.mjs +8 -2
  18. package/dist/esm/core/transport.mjs.map +1 -1
  19. package/dist/esm/index.mjs +2 -1
  20. package/dist/esm/isEnabled.mjs +4 -2
  21. package/dist/esm/isEnabled.mjs.map +1 -1
  22. package/dist/esm/utils/isBot.mjs +86 -0
  23. package/dist/esm/utils/isBot.mjs.map +1 -0
  24. package/dist/types/core/AnalyticsClient.d.ts.map +1 -1
  25. package/dist/types/core/publicToken.d.ts +48 -0
  26. package/dist/types/core/publicToken.d.ts.map +1 -0
  27. package/dist/types/core/transport.d.ts +5 -1
  28. package/dist/types/core/transport.d.ts.map +1 -1
  29. package/dist/types/core/types.d.ts +12 -1
  30. package/dist/types/core/types.d.ts.map +1 -1
  31. package/dist/types/index.d.ts +2 -1
  32. package/dist/types/isEnabled.d.ts +2 -1
  33. package/dist/types/isEnabled.d.ts.map +1 -1
  34. package/dist/types/utils/isBot.d.ts +54 -0
  35. package/dist/types/utils/isBot.d.ts.map +1 -0
  36. package/package.json +8 -3
package/README.md CHANGED
@@ -214,6 +214,7 @@ Explore our comprehensive documentation to get started with Intlayer and learn h
214
214
  <li><a href="https://intlayer.org/doc/environment/vite-and-vue" rel=''>Vite + Vue</a></li>
215
215
  <li><a href="https://intlayer.org/doc/environment/nuxt-and-vue" rel=''>Vite + Nuxt</a></li>
216
216
  <li><a href="https://intlayer.org/doc/environment/vite-and-solid" rel=''>Vite + Solid</a></li>
217
+ <li><a href="https://intlayer.org/doc/environment/solid-start" rel=''>Solid Start</a></li>
217
218
  <li><a href="https://intlayer.org/doc/environment/angular" rel=''>Angular</a></li>
218
219
  <li>
219
220
  <a href="https://intlayer.org/doc/environment/express" rel=''>Backend</a>
@@ -223,7 +224,9 @@ Explore our comprehensive documentation to get started with Intlayer and learn h
223
224
  <li><a href="https://intlayer.org/doc/environment/fastify" rel=''>Fastify</a></li>
224
225
  <li><a href="https://intlayer.org/doc/environment/adonisjs" rel=''>AdonisJS</a></li>
225
226
  <li><a href="https://intlayer.org/doc/environment/hono" rel=''>Hono</a></li>
227
+ <li><a href="https://intlayer.org/doc/environment/elysia" rel=''>Elysia</a></li>
226
228
  </ul>
229
+
227
230
  </li>
228
231
  </ul>
229
232
  </details>
@@ -1,7 +1,9 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ const require_utils_isBot = require('../utils/isBot.cjs');
2
3
  const require_utils_idle = require('../utils/idle.cjs');
3
4
  const require_core_abTesting = require('./abTesting.cjs');
4
5
  const require_core_eventBuffer = require('./eventBuffer.cjs');
6
+ const require_core_publicToken = require('./publicToken.cjs');
5
7
  const require_core_sampling = require('./sampling.cjs');
6
8
  const require_core_session = require('./session.cjs');
7
9
  const require_core_transport = require('./transport.cjs');
@@ -48,22 +50,33 @@ const referrerHost = () => {
48
50
  */
49
51
  const createAnalyticsClient = (config) => {
50
52
  const sessionId = require_core_session.getSessionId();
51
- const sampledIn = require_core_sampling.isSampledIn(sessionId, config.sampleRate);
53
+ const isBotSession = require_utils_isBot.isBotEnvironment();
54
+ const sampledIn = !isBotSession && require_core_sampling.isSampledIn(sessionId, config.sampleRate);
52
55
  const buffer = require_core_eventBuffer.createEventBuffer({ max: config.maxBufferSize });
53
56
  const endpoint = `${config.backendURL.replace(/\/$/, "")}/api/analytics/events`;
57
+ const publicToken = require_core_publicToken.createPublicTokenManager({
58
+ backendURL: config.backendURL,
59
+ clientId: config.clientId
60
+ });
54
61
  let ambientLocale = "";
55
62
  let flushTimer = null;
56
63
  let started = false;
57
64
  let detachListeners = null;
65
+ let lastPageViewUrl = null;
58
66
  let seenExposureKeys = /* @__PURE__ */ new Set();
59
67
  const flush = (useBeacon = false) => {
60
68
  const events = buffer.drain();
69
+ const token = publicToken.getToken();
61
70
  for (let start = 0; start < events.length; start += 200) require_core_transport.sendEvents(endpoint, {
62
- clientId: config.clientId,
71
+ token,
72
+ clientId: token ? void 0 : config.clientId,
63
73
  sessionId,
64
74
  sdkVersion: SDK_VERSION,
65
75
  events: events.slice(start, start + 200)
66
- }, { useBeacon });
76
+ }, {
77
+ useBeacon,
78
+ token
79
+ });
67
80
  };
68
81
  /** Pushes an event and triggers an early flush if the buffer is full. */
69
82
  const enqueue = (event) => {
@@ -74,16 +87,20 @@ const createAnalyticsClient = (config) => {
74
87
  };
75
88
  const trackPageView = (event = {}) => {
76
89
  if (!sampledIn) return;
90
+ const url = event.url ?? currentUrl();
91
+ const reason = event.reason ?? "initial";
92
+ if (reason === "route_change" && url === lastPageViewUrl) return;
93
+ lastPageViewUrl = url;
77
94
  seenExposureKeys = /* @__PURE__ */ new Set();
78
95
  enqueue({
79
96
  type: "page_view",
80
97
  t: Date.now(),
81
- url: event.url ?? currentUrl(),
98
+ url,
82
99
  locale: event.locale ?? ambientLocale,
83
100
  ref: event.ref ?? referrerHost(),
84
101
  vw: event.vw ?? (typeof window !== "undefined" ? window.innerWidth : void 0),
85
102
  vh: event.vh ?? (typeof window !== "undefined" ? window.innerHeight : void 0),
86
- reason: event.reason ?? "initial"
103
+ reason
87
104
  });
88
105
  };
89
106
  const trackContentExposure = (event) => {
@@ -107,6 +124,7 @@ const createAnalyticsClient = (config) => {
107
124
  });
108
125
  };
109
126
  const trackConversion = (event) => {
127
+ if (isBotSession) return;
110
128
  enqueue({
111
129
  ...event,
112
130
  type: "conversion",
@@ -144,8 +162,9 @@ const createAnalyticsClient = (config) => {
144
162
  };
145
163
  };
146
164
  const start = () => {
147
- if (started) return;
165
+ if (started || isBotSession) return;
148
166
  started = true;
167
+ publicToken.prime();
149
168
  flushTimer = setInterval(flush, config.flushInterval);
150
169
  flushTimer?.unref?.();
151
170
  require_utils_idle.onIdle(attachListeners);
@@ -1 +1 @@
1
- {"version":3,"file":"AnalyticsClient.cjs","names":["getSessionId","isSampledIn","createEventBuffer","assignVariant"],"sources":["../../../src/core/AnalyticsClient.ts"],"sourcesContent":["import { onIdle } from '../utils/idle';\nimport { assignVariant } from './abTesting';\nimport { createEventBuffer, type EventBuffer } from './eventBuffer';\nimport { isSampledIn } from './sampling';\nimport { getSessionId } from './session';\nimport { sendEvents } from './transport';\nimport type {\n AnalyticsClientConfig,\n AnalyticsEvent,\n ContentExposureEvent,\n ConversionEvent,\n PageViewEvent,\n TrackableEvent,\n} from './types';\n\n/**\n * Caller-supplied fields for a page view. Everything is optional — the client\n * fills `url`, `locale`, viewport, and referrer from the environment when omitted.\n */\nexport type PageViewInput = {\n /** Explicit pathname / screen name (native runtimes have no `location`). */\n url?: string;\n /** Explicit locale; falls back to the client's ambient locale. */\n locale?: string;\n /** Referrer host override. */\n ref?: string;\n /** Viewport width override. */\n vw?: number;\n /** Viewport height override. */\n vh?: number;\n /** Why the view was recorded. */\n reason?: PageViewEvent['reason'];\n};\n\n/** Default flush cadence — one network request per 20 seconds. */\nexport const DEFAULT_FLUSH_INTERVAL = 20_000;\n/** Default cap on distinct buffered events before an early flush. */\nexport const DEFAULT_MAX_BUFFER_SIZE = 500;\n/** Default sampling — keep every session. */\nexport const DEFAULT_SAMPLE_RATE = 1;\n/**\n * Maximum events per network request. `sendBeacon` and `fetch(keepalive)`\n * reject bodies over ~64KB, so large batches are split into multiple sends.\n */\nexport const MAX_EVENTS_PER_SEND = 200;\n/** Identifies the producing SDK in the ingest envelope. */\nconst SDK_VERSION = 'intlayer-analytics/1';\n\n/**\n * The public surface of the analytics client singleton. Framework bindings hold\n * a reference to this and forward provider/node/conversion signals into it.\n */\nexport type AnalyticsClient = {\n /** Sets the ambient locale stamped onto subsequent events. */\n setLocale: (locale: string) => void;\n /** Records a page/locale view (provider level). */\n trackPageView: (event?: PageViewInput) => void;\n /** Records a content exposure (node level). Sampled + deduped per page view. */\n trackContentExposure: (\n event: Omit<TrackableEvent<ContentExposureEvent>, 'type'>\n ) => void;\n /** Records a conversion tied to an experiment variant. */\n trackConversion: (\n event: Omit<TrackableEvent<ConversionEvent>, 'type'>\n ) => void;\n /** Deterministically resolves this session's variant for an experiment. */\n getVariant: (\n experimentKey: string,\n variants: string[],\n weights?: number[]\n ) => string;\n /** Sends the buffered batch now. */\n flush: (useBeacon?: boolean) => void;\n /** Starts the flush loop and lifecycle listeners (idempotent). */\n start: () => void;\n /** Stops timers/listeners and flushes any remaining events. */\n stop: () => void;\n};\n\n/**\n * Reads the current page pathname. Search params are intentionally excluded to\n * avoid capturing anything sensitive.\n */\nconst currentUrl = (): string => {\n if (typeof window === 'undefined' || !window.location) return '';\n return window.location.pathname;\n};\n\n/**\n * Reduces the referrer to its host — never the full URL — for privacy.\n */\nconst referrerHost = (): string | undefined => {\n if (typeof document === 'undefined' || !document.referrer) return undefined;\n try {\n return new URL(document.referrer).host;\n } catch {\n return undefined;\n }\n};\n\n/**\n * Creates an analytics client. Prefer {@link initAnalyticsClient} in application\n * code — it manages a shared singleton so there is only ever one flush timer.\n *\n * @param config - Resolved client configuration.\n * @returns A started-on-demand {@link AnalyticsClient}.\n */\nexport const createAnalyticsClient = (\n config: AnalyticsClientConfig\n): AnalyticsClient => {\n const sessionId = getSessionId();\n const sampledIn = isSampledIn(sessionId, config.sampleRate);\n const buffer: EventBuffer = createEventBuffer({ max: config.maxBufferSize });\n const endpoint = `${config.backendURL.replace(/\\/$/, '')}/api/analytics/events`;\n\n let ambientLocale = '';\n let flushTimer: ReturnType<typeof setInterval> | null = null;\n let started = false;\n let detachListeners: (() => void) | null = null;\n\n // Exposure keys already recorded for the current page view. React (and other\n // frameworks) resolve content nodes on every re-render; without this gate a\n // component re-rendering 60×/s would count 60 exposures per second. The set\n // is cleared on each page view so the recorded semantic is \"shown at least\n // once during this page view\".\n let seenExposureKeys = new Set<string>();\n\n const flush = (useBeacon = false): void => {\n const events = buffer.drain();\n\n // Split into bounded chunks so no single request exceeds transport limits.\n for (let start = 0; start < events.length; start += MAX_EVENTS_PER_SEND) {\n sendEvents(\n endpoint,\n {\n clientId: config.clientId,\n sessionId,\n sdkVersion: SDK_VERSION,\n events: events.slice(start, start + MAX_EVENTS_PER_SEND),\n },\n { useBeacon }\n );\n }\n };\n\n /** Pushes an event and triggers an early flush if the buffer is full. */\n const enqueue = (event: AnalyticsEvent): void => {\n const size = buffer.push(event);\n if (size >= config.maxBufferSize) flush();\n };\n\n const setLocale = (locale: string): void => {\n ambientLocale = locale;\n };\n\n const trackPageView: AnalyticsClient['trackPageView'] = (event = {}) => {\n if (!sampledIn) return;\n\n // A new page view starts a fresh exposure window.\n seenExposureKeys = new Set<string>();\n\n enqueue({\n type: 'page_view',\n t: Date.now(),\n url: event.url ?? currentUrl(),\n locale: event.locale ?? ambientLocale,\n ref: event.ref ?? referrerHost(),\n vw:\n event.vw ??\n (typeof window !== 'undefined' ? window.innerWidth : undefined),\n vh:\n event.vh ??\n (typeof window !== 'undefined' ? window.innerHeight : undefined),\n reason: event.reason ?? 'initial',\n });\n };\n\n const trackContentExposure: AnalyticsClient['trackContentExposure'] = (\n event\n ) => {\n if (!sampledIn) return;\n\n const locale = event.locale ?? ambientLocale;\n // Joined with \\x01 (never present in content keys) so parts cannot collide.\n const exposureKey = [\n event.dictionaryKey,\n event.keyPath,\n locale,\n event.experimentKey ?? '',\n event.variant ?? '',\n ].join('\u0001');\n\n // Deduplicate within the current page view (see `seenExposureKeys`).\n if (seenExposureKeys.has(exposureKey)) return;\n seenExposureKeys.add(exposureKey);\n\n enqueue({\n ...event,\n type: 'content_exposure',\n t: Date.now(),\n url: currentUrl(),\n locale,\n });\n };\n\n const trackConversion: AnalyticsClient['trackConversion'] = (event) => {\n // Conversions are never sampled out — they are rare and decisive for A/B.\n enqueue({\n ...event,\n type: 'conversion',\n t: Date.now(),\n url: currentUrl(),\n locale: event.locale ?? ambientLocale,\n });\n // Flush conversions promptly so they are not lost on immediate navigation.\n flush();\n };\n\n const getVariant: AnalyticsClient['getVariant'] = (\n experimentKey,\n variants,\n weights\n ) => assignVariant(sessionId, experimentKey, variants, weights);\n\n /** Attaches lifecycle + SPA-navigation listeners without blocking render. */\n const attachListeners = (): void => {\n if (typeof window === 'undefined') return;\n\n const onHidden = (): void => {\n if (document.visibilityState === 'hidden') flush(true);\n };\n const onPageHide = (): void => flush(true);\n const onPopState = (): void => trackPageView({ reason: 'route_change' });\n\n document.addEventListener('visibilitychange', onHidden);\n window.addEventListener('pagehide', onPageHide, { capture: true });\n window.addEventListener('popstate', onPopState);\n\n // Patch history.pushState so client-side route changes emit a page view.\n // `replaceState` is intentionally left untouched: routers use it for\n // same-page URL sync (scroll, params), which is not a navigation.\n const history = window.history;\n const originalPush = history.pushState;\n\n const patchedPushState = function patchedPush(\n this: History,\n ...args: Parameters<History['pushState']>\n ) {\n const result = originalPush.apply(this, args);\n trackPageView({ reason: 'route_change' });\n return result;\n };\n history.pushState = patchedPushState;\n\n detachListeners = (): void => {\n document.removeEventListener('visibilitychange', onHidden);\n window.removeEventListener('pagehide', onPageHide, { capture: true });\n window.removeEventListener('popstate', onPopState);\n // Only restore if nothing else re-patched pushState after us; restoring\n // blindly would clobber another library's wrapper.\n if (history.pushState === patchedPushState) {\n history.pushState = originalPush;\n }\n };\n };\n\n const start = (): void => {\n if (started) return;\n started = true;\n\n flushTimer = setInterval(flush, config.flushInterval);\n // Some runtimes (Node during SSR) keep the process alive on timers.\n (flushTimer as { unref?: () => void })?.unref?.();\n\n onIdle(attachListeners);\n };\n\n const stop = (): void => {\n if (!started) return;\n started = false;\n\n if (flushTimer) {\n clearInterval(flushTimer);\n flushTimer = null;\n }\n detachListeners?.();\n detachListeners = null;\n flush(true);\n };\n\n return {\n setLocale,\n trackPageView,\n trackContentExposure,\n trackConversion,\n getVariant,\n flush,\n start,\n stop,\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"AnalyticsClient.cjs","names":["getSessionId","isBotEnvironment","isSampledIn","createEventBuffer","createPublicTokenManager","assignVariant"],"sources":["../../../src/core/AnalyticsClient.ts"],"sourcesContent":["import { onIdle } from '../utils/idle';\nimport { isBotEnvironment } from '../utils/isBot';\nimport { assignVariant } from './abTesting';\nimport { createEventBuffer, type EventBuffer } from './eventBuffer';\nimport { createPublicTokenManager } from './publicToken';\nimport { isSampledIn } from './sampling';\nimport { getSessionId } from './session';\nimport { sendEvents } from './transport';\nimport type {\n AnalyticsClientConfig,\n AnalyticsEvent,\n ContentExposureEvent,\n ConversionEvent,\n PageViewEvent,\n TrackableEvent,\n} from './types';\n\n/**\n * Caller-supplied fields for a page view. Everything is optional — the client\n * fills `url`, `locale`, viewport, and referrer from the environment when omitted.\n */\nexport type PageViewInput = {\n /** Explicit pathname / screen name (native runtimes have no `location`). */\n url?: string;\n /** Explicit locale; falls back to the client's ambient locale. */\n locale?: string;\n /** Referrer host override. */\n ref?: string;\n /** Viewport width override. */\n vw?: number;\n /** Viewport height override. */\n vh?: number;\n /** Why the view was recorded. */\n reason?: PageViewEvent['reason'];\n};\n\n/** Default flush cadence — one network request per 20 seconds. */\nexport const DEFAULT_FLUSH_INTERVAL = 20_000;\n/** Default cap on distinct buffered events before an early flush. */\nexport const DEFAULT_MAX_BUFFER_SIZE = 500;\n/** Default sampling — keep every session. */\nexport const DEFAULT_SAMPLE_RATE = 1;\n/**\n * Maximum events per network request. `sendBeacon` and `fetch(keepalive)`\n * reject bodies over ~64KB, so large batches are split into multiple sends.\n */\nexport const MAX_EVENTS_PER_SEND = 200;\n/** Identifies the producing SDK in the ingest envelope. */\nconst SDK_VERSION = 'intlayer-analytics/1';\n\n/**\n * The public surface of the analytics client singleton. Framework bindings hold\n * a reference to this and forward provider/node/conversion signals into it.\n */\nexport type AnalyticsClient = {\n /** Sets the ambient locale stamped onto subsequent events. */\n setLocale: (locale: string) => void;\n /** Records a page/locale view (provider level). */\n trackPageView: (event?: PageViewInput) => void;\n /** Records a content exposure (node level). Sampled + deduped per page view. */\n trackContentExposure: (\n event: Omit<TrackableEvent<ContentExposureEvent>, 'type'>\n ) => void;\n /** Records a conversion tied to an experiment variant. */\n trackConversion: (\n event: Omit<TrackableEvent<ConversionEvent>, 'type'>\n ) => void;\n /** Deterministically resolves this session's variant for an experiment. */\n getVariant: (\n experimentKey: string,\n variants: string[],\n weights?: number[]\n ) => string;\n /** Sends the buffered batch now. */\n flush: (useBeacon?: boolean) => void;\n /** Starts the flush loop and lifecycle listeners (idempotent). */\n start: () => void;\n /** Stops timers/listeners and flushes any remaining events. */\n stop: () => void;\n};\n\n/**\n * Reads the current page pathname. Search params are intentionally excluded to\n * avoid capturing anything sensitive.\n */\nconst currentUrl = (): string => {\n if (typeof window === 'undefined' || !window.location) return '';\n return window.location.pathname;\n};\n\n/**\n * Reduces the referrer to its host — never the full URL — for privacy.\n */\nconst referrerHost = (): string | undefined => {\n if (typeof document === 'undefined' || !document.referrer) return undefined;\n try {\n return new URL(document.referrer).host;\n } catch {\n return undefined;\n }\n};\n\n/**\n * Creates an analytics client. Prefer {@link initAnalyticsClient} in application\n * code — it manages a shared singleton so there is only ever one flush timer.\n *\n * @param config - Resolved client configuration.\n * @returns A started-on-demand {@link AnalyticsClient}.\n */\nexport const createAnalyticsClient = (\n config: AnalyticsClientConfig\n): AnalyticsClient => {\n const sessionId = getSessionId();\n // Crawlers, link-preview fetchers and headless runtimes execute JavaScript,\n // so they reach this point like any visitor. Detected once at creation: the\n // client then buffers nothing, sends nothing and never starts its timers.\n const isBotSession = isBotEnvironment();\n const sampledIn = !isBotSession && isSampledIn(sessionId, config.sampleRate);\n const buffer: EventBuffer = createEventBuffer({ max: config.maxBufferSize });\n const endpoint = `${config.backendURL.replace(/\\/$/, '')}/api/analytics/events`;\n const publicToken = createPublicTokenManager({\n backendURL: config.backendURL,\n clientId: config.clientId,\n });\n\n let ambientLocale = '';\n let flushTimer: ReturnType<typeof setInterval> | null = null;\n let started = false;\n let detachListeners: (() => void) | null = null;\n\n // Path of the most recent page view, so an automatic route-change signal for\n // a page already recorded is ignored. Routers call `pushState` for\n // query-only and hash-only updates (filters, tabs, modals, scroll sync),\n // each of which would otherwise land as another view of the same page.\n let lastPageViewUrl: string | null = null;\n\n // Exposure keys already recorded for the current page view. React (and other\n // frameworks) resolve content nodes on every re-render; without this gate a\n // component re-rendering 60×/s would count 60 exposures per second. The set\n // is cleared on each page view so the recorded semantic is \"shown at least\n // once during this page view\".\n let seenExposureKeys = new Set<string>();\n\n const flush = (useBeacon = false): void => {\n const events = buffer.drain();\n\n // Read synchronously: the flush-on-hide path runs during `pagehide` and\n // cannot await the exchange. Until it resolves, the public `clientId` is\n // sent instead so the first batch of a visit is still attributed.\n const token = publicToken.getToken();\n\n // Split into bounded chunks so no single request exceeds transport limits.\n for (let start = 0; start < events.length; start += MAX_EVENTS_PER_SEND) {\n sendEvents(\n endpoint,\n {\n token,\n clientId: token ? undefined : config.clientId,\n sessionId,\n sdkVersion: SDK_VERSION,\n events: events.slice(start, start + MAX_EVENTS_PER_SEND),\n },\n { useBeacon, token }\n );\n }\n };\n\n /** Pushes an event and triggers an early flush if the buffer is full. */\n const enqueue = (event: AnalyticsEvent): void => {\n const size = buffer.push(event);\n if (size >= config.maxBufferSize) flush();\n };\n\n const setLocale = (locale: string): void => {\n ambientLocale = locale;\n };\n\n const trackPageView: AnalyticsClient['trackPageView'] = (event = {}) => {\n if (!sampledIn) return;\n\n const url = event.url ?? currentUrl();\n const reason = event.reason ?? 'initial';\n\n // Only the automatic navigation signal is deduplicated: a provider asking\n // for an `initial` or `locale_change` view is reporting something the\n // pathname alone cannot express.\n if (reason === 'route_change' && url === lastPageViewUrl) return;\n lastPageViewUrl = url;\n\n // A new page view starts a fresh exposure window.\n seenExposureKeys = new Set<string>();\n\n enqueue({\n type: 'page_view',\n t: Date.now(),\n url,\n locale: event.locale ?? ambientLocale,\n ref: event.ref ?? referrerHost(),\n vw:\n event.vw ??\n (typeof window !== 'undefined' ? window.innerWidth : undefined),\n vh:\n event.vh ??\n (typeof window !== 'undefined' ? window.innerHeight : undefined),\n reason,\n });\n };\n\n const trackContentExposure: AnalyticsClient['trackContentExposure'] = (\n event\n ) => {\n if (!sampledIn) return;\n\n const locale = event.locale ?? ambientLocale;\n // Joined with \\x01 (never present in content keys) so parts cannot collide.\n const exposureKey = [\n event.dictionaryKey,\n event.keyPath,\n locale,\n event.experimentKey ?? '',\n event.variant ?? '',\n ].join('\u0001');\n\n // Deduplicate within the current page view (see `seenExposureKeys`).\n if (seenExposureKeys.has(exposureKey)) return;\n seenExposureKeys.add(exposureKey);\n\n enqueue({\n ...event,\n type: 'content_exposure',\n t: Date.now(),\n url: currentUrl(),\n locale,\n });\n };\n\n const trackConversion: AnalyticsClient['trackConversion'] = (event) => {\n // Conversions are never sampled out — they are rare and decisive for A/B —\n // but bot traffic is still excluded, or it would skew every variant rate.\n if (isBotSession) return;\n\n enqueue({\n ...event,\n type: 'conversion',\n t: Date.now(),\n url: currentUrl(),\n locale: event.locale ?? ambientLocale,\n });\n // Flush conversions promptly so they are not lost on immediate navigation.\n flush();\n };\n\n const getVariant: AnalyticsClient['getVariant'] = (\n experimentKey,\n variants,\n weights\n ) => assignVariant(sessionId, experimentKey, variants, weights);\n\n /** Attaches lifecycle + SPA-navigation listeners without blocking render. */\n const attachListeners = (): void => {\n if (typeof window === 'undefined') return;\n\n const onHidden = (): void => {\n if (document.visibilityState === 'hidden') flush(true);\n };\n const onPageHide = (): void => flush(true);\n const onPopState = (): void => trackPageView({ reason: 'route_change' });\n\n document.addEventListener('visibilitychange', onHidden);\n window.addEventListener('pagehide', onPageHide, { capture: true });\n window.addEventListener('popstate', onPopState);\n\n // Patch history.pushState so client-side route changes emit a page view.\n // `replaceState` is intentionally left untouched: routers use it for\n // same-page URL sync (scroll, params), which is not a navigation.\n const history = window.history;\n const originalPush = history.pushState;\n\n const patchedPushState = function patchedPush(\n this: History,\n ...args: Parameters<History['pushState']>\n ) {\n const result = originalPush.apply(this, args);\n trackPageView({ reason: 'route_change' });\n return result;\n };\n history.pushState = patchedPushState;\n\n detachListeners = (): void => {\n document.removeEventListener('visibilitychange', onHidden);\n window.removeEventListener('pagehide', onPageHide, { capture: true });\n window.removeEventListener('popstate', onPopState);\n // Only restore if nothing else re-patched pushState after us; restoring\n // blindly would clobber another library's wrapper.\n if (history.pushState === patchedPushState) {\n history.pushState = originalPush;\n }\n };\n };\n\n const start = (): void => {\n if (started || isBotSession) return;\n started = true;\n\n // Exchanged ahead of the first flush so a token is already cached by the\n // time a batch — including one sent during `pagehide` — goes out.\n publicToken.prime();\n\n flushTimer = setInterval(flush, config.flushInterval);\n // Some runtimes (Node during SSR) keep the process alive on timers.\n (flushTimer as { unref?: () => void })?.unref?.();\n\n onIdle(attachListeners);\n };\n\n const stop = (): void => {\n if (!started) return;\n started = false;\n\n if (flushTimer) {\n clearInterval(flushTimer);\n flushTimer = null;\n }\n detachListeners?.();\n detachListeners = null;\n flush(true);\n };\n\n return {\n setLocale,\n trackPageView,\n trackContentExposure,\n trackConversion,\n getVariant,\n flush,\n start,\n stop,\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,104 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ //#region src/core/publicToken.ts
3
+ /**
4
+ * Short-lived ingest token used to authorise analytics batches.
5
+ *
6
+ * The SDK runs in the browser, where no confidential credential can be kept, so
7
+ * it exchanges the project's **public** `clientId` for a token scoped to
8
+ * analytics ingestion and nothing else. The backend authorises the exchange by
9
+ * the request's `Origin`, which the page cannot forge.
10
+ *
11
+ * The token is read synchronously at flush time — the flush-on-hide path runs
12
+ * during `pagehide` and cannot await anything — so it is fetched ahead of time
13
+ * and cached, both in memory and in `sessionStorage` so a multi-page visit
14
+ * exchanges once per tab rather than once per navigation.
15
+ */
16
+ /** `sessionStorage` key holding the cached token for this tab. */
17
+ const TOKEN_STORAGE_KEY = "__intlayer_public_token__";
18
+ /**
19
+ * Refresh margin: a token within this window of expiry is treated as expired,
20
+ * so a batch is never sent with a token that dies in flight.
21
+ */
22
+ const EXPIRY_MARGIN_MS = 6e4;
23
+ /** Reads the cached token for this tab, ignoring an unusable cache. */
24
+ const readStoredToken = () => {
25
+ try {
26
+ const raw = window.sessionStorage?.getItem(TOKEN_STORAGE_KEY);
27
+ if (!raw) return null;
28
+ const parsed = JSON.parse(raw);
29
+ if (typeof parsed?.token !== "string") return null;
30
+ if (typeof parsed?.expiresAt !== "number") return null;
31
+ return parsed;
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+ /** Persists the token for this tab. Best-effort: storage may be unavailable. */
37
+ const writeStoredToken = (cached) => {
38
+ try {
39
+ window.sessionStorage?.setItem(TOKEN_STORAGE_KEY, JSON.stringify(cached));
40
+ } catch {}
41
+ };
42
+ /**
43
+ * Creates the ingest token manager for a project.
44
+ *
45
+ * Every failure path is silent: analytics must never break, slow, or log noise
46
+ * into the host application. Without a token the client falls back to sending
47
+ * its public `clientId`, which the backend still accepts.
48
+ *
49
+ * @param options - Backend URL and the public project key.
50
+ * @returns A manager exposing a synchronous {@link PublicTokenManager.getToken}.
51
+ */
52
+ const createPublicTokenManager = ({ backendURL, clientId }) => {
53
+ const endpoint = `${backendURL.replace(/\/$/, "")}/api/public/token`;
54
+ let cached = null;
55
+ let pendingExchange = null;
56
+ const isUsable = (candidate) => candidate !== null && candidate.expiresAt - EXPIRY_MARGIN_MS > Date.now();
57
+ const getToken = () => {
58
+ if (isUsable(cached)) return cached.token;
59
+ if (typeof window !== "undefined") {
60
+ const stored = readStoredToken();
61
+ if (isUsable(stored)) {
62
+ cached = stored;
63
+ return stored.token;
64
+ }
65
+ }
66
+ };
67
+ const exchange = async () => {
68
+ try {
69
+ const response = await fetch(endpoint, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({ clientId }),
73
+ credentials: "omit",
74
+ mode: "cors"
75
+ });
76
+ if (!response.ok) return;
77
+ const payload = await response.json();
78
+ const token = payload?.data?.token;
79
+ const expiresIn = payload?.data?.expiresIn ?? 0;
80
+ if (!token || expiresIn <= 0) return;
81
+ cached = {
82
+ token,
83
+ expiresAt: Date.now() + expiresIn * 1e3
84
+ };
85
+ if (typeof window !== "undefined") writeStoredToken(cached);
86
+ } catch {} finally {
87
+ pendingExchange = null;
88
+ }
89
+ };
90
+ const prime = () => {
91
+ if (!clientId || typeof fetch !== "function") return;
92
+ if (getToken()) return;
93
+ if (pendingExchange) return;
94
+ pendingExchange = exchange();
95
+ };
96
+ return {
97
+ getToken,
98
+ prime
99
+ };
100
+ };
101
+
102
+ //#endregion
103
+ exports.createPublicTokenManager = createPublicTokenManager;
104
+ //# sourceMappingURL=publicToken.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publicToken.cjs","names":[],"sources":["../../../src/core/publicToken.ts"],"sourcesContent":["/**\n * Short-lived ingest token used to authorise analytics batches.\n *\n * The SDK runs in the browser, where no confidential credential can be kept, so\n * it exchanges the project's **public** `clientId` for a token scoped to\n * analytics ingestion and nothing else. The backend authorises the exchange by\n * the request's `Origin`, which the page cannot forge.\n *\n * The token is read synchronously at flush time — the flush-on-hide path runs\n * during `pagehide` and cannot await anything — so it is fetched ahead of time\n * and cached, both in memory and in `sessionStorage` so a multi-page visit\n * exchanges once per tab rather than once per navigation.\n */\n\n/** `sessionStorage` key holding the cached token for this tab. */\nconst TOKEN_STORAGE_KEY = '__intlayer_public_token__';\n\n/**\n * Refresh margin: a token within this window of expiry is treated as expired,\n * so a batch is never sent with a token that dies in flight.\n */\nconst EXPIRY_MARGIN_MS = 60 * 1000;\n\ntype CachedToken = {\n token: string;\n /** Epoch milliseconds. */\n expiresAt: number;\n};\n\n/** Reads the cached token for this tab, ignoring an unusable cache. */\nconst readStoredToken = (): CachedToken | null => {\n try {\n const raw = window.sessionStorage?.getItem(TOKEN_STORAGE_KEY);\n if (!raw) return null;\n\n const parsed = JSON.parse(raw) as CachedToken;\n\n if (typeof parsed?.token !== 'string') return null;\n if (typeof parsed?.expiresAt !== 'number') return null;\n\n return parsed;\n } catch {\n // Private mode, disabled storage, or corrupted entry — start fresh.\n return null;\n }\n};\n\n/** Persists the token for this tab. Best-effort: storage may be unavailable. */\nconst writeStoredToken = (cached: CachedToken): void => {\n try {\n window.sessionStorage?.setItem(TOKEN_STORAGE_KEY, JSON.stringify(cached));\n } catch {\n /* ignore — the in-memory cache still applies for this page */\n }\n};\n\n/** Options accepted by {@link createPublicTokenManager}. */\nexport type PublicTokenManagerOptions = {\n /** Backend base URL, without a trailing slash. */\n backendURL: string;\n /** The project's public key (`editor.clientId`). */\n clientId?: string;\n};\n\n/** Synchronous accessor + background refresher for the ingest token. */\nexport type PublicTokenManager = {\n /**\n * The currently valid token, or `undefined` when none has been obtained yet.\n * Never blocks and never triggers a request.\n */\n getToken: () => string | undefined;\n /**\n * Ensures a valid token is being fetched. Safe to call repeatedly:\n * concurrent calls share a single in-flight exchange.\n */\n prime: () => void;\n};\n\n/**\n * Creates the ingest token manager for a project.\n *\n * Every failure path is silent: analytics must never break, slow, or log noise\n * into the host application. Without a token the client falls back to sending\n * its public `clientId`, which the backend still accepts.\n *\n * @param options - Backend URL and the public project key.\n * @returns A manager exposing a synchronous {@link PublicTokenManager.getToken}.\n */\nexport const createPublicTokenManager = ({\n backendURL,\n clientId,\n}: PublicTokenManagerOptions): PublicTokenManager => {\n const endpoint = `${backendURL.replace(/\\/$/, '')}/api/public/token`;\n\n let cached: CachedToken | null = null;\n let pendingExchange: Promise<void> | null = null;\n\n const isUsable = (candidate: CachedToken | null): candidate is CachedToken =>\n candidate !== null && candidate.expiresAt - EXPIRY_MARGIN_MS > Date.now();\n\n const getToken = (): string | undefined => {\n if (isUsable(cached)) return cached.token;\n\n if (typeof window !== 'undefined') {\n const stored = readStoredToken();\n if (isUsable(stored)) {\n cached = stored;\n return stored.token;\n }\n }\n\n return undefined;\n };\n\n const exchange = async (): Promise<void> => {\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ clientId }),\n // The exchange is authorised by `Origin`, never by cookies.\n credentials: 'omit',\n mode: 'cors',\n });\n\n if (!response.ok) return;\n\n const payload = (await response.json()) as {\n data?: { token?: string | null; expiresIn?: number };\n };\n\n const token = payload?.data?.token;\n const expiresIn = payload?.data?.expiresIn ?? 0;\n\n // A `null` token means the key is unknown or the origin is not allowed.\n if (!token || expiresIn <= 0) return;\n\n cached = { token, expiresAt: Date.now() + expiresIn * 1000 };\n\n if (typeof window !== 'undefined') writeStoredToken(cached);\n } catch {\n /* swallow — best-effort, the `clientId` fallback still reports */\n } finally {\n pendingExchange = null;\n }\n };\n\n const prime = (): void => {\n if (!clientId || typeof fetch !== 'function') return;\n if (getToken()) return;\n if (pendingExchange) return;\n\n pendingExchange = exchange();\n };\n\n return { getToken, prime };\n};\n"],"mappings":";;;;;;;;;;;;;;;;AAeA,MAAM,oBAAoB;;;;;AAM1B,MAAM,mBAAmB;;AASzB,MAAM,wBAA4C;CAChD,IAAI;EACF,MAAM,MAAM,OAAO,gBAAgB,QAAQ,iBAAiB;EAC5D,IAAI,CAAC,KAAK,OAAO;EAEjB,MAAM,SAAS,KAAK,MAAM,GAAG;EAE7B,IAAI,OAAO,QAAQ,UAAU,UAAU,OAAO;EAC9C,IAAI,OAAO,QAAQ,cAAc,UAAU,OAAO;EAElD,OAAO;CACT,QAAQ;EAEN,OAAO;CACT;AACF;;AAGA,MAAM,oBAAoB,WAA8B;CACtD,IAAI;EACF,OAAO,gBAAgB,QAAQ,mBAAmB,KAAK,UAAU,MAAM,CAAC;CAC1E,QAAQ,CAER;AACF;;;;;;;;;;;AAkCA,MAAa,4BAA4B,EACvC,YACA,eACmD;CACnD,MAAM,WAAW,GAAG,WAAW,QAAQ,OAAO,EAAE,EAAE;CAElD,IAAI,SAA6B;CACjC,IAAI,kBAAwC;CAE5C,MAAM,YAAY,cAChB,cAAc,QAAQ,UAAU,YAAY,mBAAmB,KAAK,IAAI;CAE1E,MAAM,iBAAqC;EACzC,IAAI,SAAS,MAAM,GAAG,OAAO,OAAO;EAEpC,IAAI,OAAO,WAAW,aAAa;GACjC,MAAM,SAAS,gBAAgB;GAC/B,IAAI,SAAS,MAAM,GAAG;IACpB,SAAS;IACT,OAAO,OAAO;GAChB;EACF;CAGF;CAEA,MAAM,WAAW,YAA2B;EAC1C,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,UAAU;IACrC,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;IAC9C,MAAM,KAAK,UAAU,EAAE,SAAS,CAAC;IAEjC,aAAa;IACb,MAAM;GACR,CAAC;GAED,IAAI,CAAC,SAAS,IAAI;GAElB,MAAM,UAAW,MAAM,SAAS,KAAK;GAIrC,MAAM,QAAQ,SAAS,MAAM;GAC7B,MAAM,YAAY,SAAS,MAAM,aAAa;GAG9C,IAAI,CAAC,SAAS,aAAa,GAAG;GAE9B,SAAS;IAAE;IAAO,WAAW,KAAK,IAAI,IAAI,YAAY;GAAK;GAE3D,IAAI,OAAO,WAAW,aAAa,iBAAiB,MAAM;EAC5D,QAAQ,CAER,UAAU;GACR,kBAAkB;EACpB;CACF;CAEA,MAAM,cAAoB;EACxB,IAAI,CAAC,YAAY,OAAO,UAAU,YAAY;EAC9C,IAAI,SAAS,GAAG;EAChB,IAAI,iBAAiB;EAErB,kBAAkB,SAAS;CAC7B;CAEA,OAAO;EAAE;EAAU;CAAM;AAC3B"}
@@ -18,16 +18,22 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
18
18
  * @param body - The batched payload.
19
19
  * @param options - Transport options.
20
20
  * @param options.useBeacon - Prefer `sendBeacon` (used when the page is hiding).
21
+ * @param options.token - Ingest token, sent as a bearer on the `fetch` path.
22
+ * `sendBeacon` cannot set headers, which is why the token is also carried in
23
+ * the body.
21
24
  * @returns `true` when a send was dispatched, `false` otherwise.
22
25
  */
23
- const sendEvents = (endpoint, body, { useBeacon = false } = {}) => {
26
+ const sendEvents = (endpoint, body, { useBeacon = false, token } = {}) => {
24
27
  try {
25
28
  const payload = JSON.stringify(body);
26
29
  if (useBeacon && typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") return navigator.sendBeacon(endpoint, payload);
27
30
  if (typeof fetch === "function") {
28
31
  fetch(endpoint, {
29
32
  method: "POST",
30
- headers: { "Content-Type": "application/json" },
33
+ headers: {
34
+ "Content-Type": "application/json",
35
+ ...token ? { Authorization: `Bearer ${token}` } : {}
36
+ },
31
37
  body: payload,
32
38
  keepalive: true,
33
39
  credentials: "omit",
@@ -1 +1 @@
1
- {"version":3,"file":"transport.cjs","names":[],"sources":["../../../src/core/transport.ts"],"sourcesContent":["import type { AnalyticsIngestBody } from './types';\n\n/**\n * Sends a batch of events to the backend. Every path is non-blocking and\n * no-throw — analytics must never break, slow, or delay the host application.\n *\n * Strategy:\n * 1. `navigator.sendBeacon` for the flush-on-hide path — survives page unload\n * and never blocks navigation. The payload is sent as `text/plain` (a\n * CORS-safelisted content type): a JSON `Blob` would require a CORS\n * preflight that browsers handle inconsistently during unload, silently\n * dropping the beacon. The backend parses `text/plain` bodies as JSON.\n * 2. `fetch(..., { keepalive: true })` for interval flushes — survives short\n * navigations without holding the main thread.\n * 3. Plain `fetch` fallback for runtimes without `keepalive` (older RN/Lynx).\n *\n * @param endpoint - Fully-qualified ingestion URL.\n * @param body - The batched payload.\n * @param options - Transport options.\n * @param options.useBeacon - Prefer `sendBeacon` (used when the page is hiding).\n * @returns `true` when a send was dispatched, `false` otherwise.\n */\nexport const sendEvents = (\n endpoint: string,\n body: AnalyticsIngestBody,\n { useBeacon = false }: { useBeacon?: boolean } = {}\n): boolean => {\n try {\n const payload = JSON.stringify(body);\n\n if (\n useBeacon &&\n typeof navigator !== 'undefined' &&\n typeof navigator.sendBeacon === 'function'\n ) {\n // Note: unlike the fetch path below, sendBeacon always includes\n // credentials; the ingest endpoint ignores them.\n return navigator.sendBeacon(endpoint, payload);\n }\n\n if (typeof fetch === 'function') {\n // keepalive lets the request outlive a same-tab navigation.\n void fetch(endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: payload,\n keepalive: true,\n // Analytics is best-effort; never send credentials.\n credentials: 'omit',\n mode: 'cors',\n }).catch(() => {\n /* swallow — best-effort delivery */\n });\n return true;\n }\n\n return false;\n } catch {\n // JSON serialization failed — drop the batch silently.\n return false;\n }\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAsBA,MAAa,cACX,UACA,MACA,EAAE,YAAY,UAAmC,CAAC,MACtC;CACZ,IAAI;EACF,MAAM,UAAU,KAAK,UAAU,IAAI;EAEnC,IACE,aACA,OAAO,cAAc,eACrB,OAAO,UAAU,eAAe,YAIhC,OAAO,UAAU,WAAW,UAAU,OAAO;EAG/C,IAAI,OAAO,UAAU,YAAY;GAE/B,AAAK,MAAM,UAAU;IACnB,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;IAC9C,MAAM;IACN,WAAW;IAEX,aAAa;IACb,MAAM;GACR,CAAC,CAAC,CAAC,YAAY,CAEf,CAAC;GACD,OAAO;EACT;EAEA,OAAO;CACT,QAAQ;EAEN,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"transport.cjs","names":[],"sources":["../../../src/core/transport.ts"],"sourcesContent":["import type { AnalyticsIngestBody } from './types';\n\n/**\n * Sends a batch of events to the backend. Every path is non-blocking and\n * no-throw — analytics must never break, slow, or delay the host application.\n *\n * Strategy:\n * 1. `navigator.sendBeacon` for the flush-on-hide path — survives page unload\n * and never blocks navigation. The payload is sent as `text/plain` (a\n * CORS-safelisted content type): a JSON `Blob` would require a CORS\n * preflight that browsers handle inconsistently during unload, silently\n * dropping the beacon. The backend parses `text/plain` bodies as JSON.\n * 2. `fetch(..., { keepalive: true })` for interval flushes — survives short\n * navigations without holding the main thread.\n * 3. Plain `fetch` fallback for runtimes without `keepalive` (older RN/Lynx).\n *\n * @param endpoint - Fully-qualified ingestion URL.\n * @param body - The batched payload.\n * @param options - Transport options.\n * @param options.useBeacon - Prefer `sendBeacon` (used when the page is hiding).\n * @param options.token - Ingest token, sent as a bearer on the `fetch` path.\n * `sendBeacon` cannot set headers, which is why the token is also carried in\n * the body.\n * @returns `true` when a send was dispatched, `false` otherwise.\n */\nexport const sendEvents = (\n endpoint: string,\n body: AnalyticsIngestBody,\n { useBeacon = false, token }: { useBeacon?: boolean; token?: string } = {}\n): boolean => {\n try {\n const payload = JSON.stringify(body);\n\n if (\n useBeacon &&\n typeof navigator !== 'undefined' &&\n typeof navigator.sendBeacon === 'function'\n ) {\n // Note: unlike the fetch path below, sendBeacon always includes\n // credentials; the ingest endpoint ignores them.\n return navigator.sendBeacon(endpoint, payload);\n }\n\n if (typeof fetch === 'function') {\n // keepalive lets the request outlive a same-tab navigation.\n void fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...(token ? { Authorization: `Bearer ${token}` } : {}),\n },\n body: payload,\n keepalive: true,\n // Analytics is best-effort; never send credentials.\n credentials: 'omit',\n mode: 'cors',\n }).catch(() => {\n /* swallow — best-effort delivery */\n });\n return true;\n }\n\n return false;\n } catch {\n // JSON serialization failed — drop the batch silently.\n return false;\n }\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,MAAa,cACX,UACA,MACA,EAAE,YAAY,OAAO,UAAmD,CAAC,MAC7D;CACZ,IAAI;EACF,MAAM,UAAU,KAAK,UAAU,IAAI;EAEnC,IACE,aACA,OAAO,cAAc,eACrB,OAAO,UAAU,eAAe,YAIhC,OAAO,UAAU,WAAW,UAAU,OAAO;EAG/C,IAAI,OAAO,UAAU,YAAY;GAE/B,AAAK,MAAM,UAAU;IACnB,QAAQ;IACR,SAAS;KACP,gBAAgB;KAChB,GAAI,QAAQ,EAAE,eAAe,UAAU,QAAQ,IAAI,CAAC;IACtD;IACA,MAAM;IACN,WAAW;IAEX,aAAa;IACb,MAAM;GACR,CAAC,CAAC,CAAC,YAAY,CAEf,CAAC;GACD,OAAO;EACT;EAEA,OAAO;CACT,QAAQ;EAEN,OAAO;CACT;AACF"}
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ const require_utils_isBot = require('./utils/isBot.cjs');
2
3
  const require_core_abTesting = require('./core/abTesting.cjs');
3
4
  const require_core_eventBuffer = require('./core/eventBuffer.cjs');
4
5
  const require_core_sampling = require('./core/sampling.cjs');
@@ -9,6 +10,7 @@ const require_core_globalManager = require('./core/globalManager.cjs');
9
10
  const require_core_initAnalyticsClient = require('./core/initAnalyticsClient.cjs');
10
11
  const require_events_contentExposure = require('./events/contentExposure.cjs');
11
12
 
13
+ exports.BOT_USER_AGENT_PATTERN = require_utils_isBot.BOT_USER_AGENT_PATTERN;
12
14
  exports.DEFAULT_FLUSH_INTERVAL = require_core_AnalyticsClient.DEFAULT_FLUSH_INTERVAL;
13
15
  exports.DEFAULT_MAX_BUFFER_SIZE = require_core_AnalyticsClient.DEFAULT_MAX_BUFFER_SIZE;
14
16
  exports.DEFAULT_SAMPLE_RATE = require_core_AnalyticsClient.DEFAULT_SAMPLE_RATE;
@@ -22,6 +24,8 @@ exports.getGlobalAnalyticsClient = require_core_globalManager.getGlobalAnalytics
22
24
  exports.getSessionId = require_core_session.getSessionId;
23
25
  exports.incrementAnalyticsClientRefCount = require_core_globalManager.incrementAnalyticsClientRefCount;
24
26
  exports.initAnalyticsClient = require_core_initAnalyticsClient.initAnalyticsClient;
27
+ exports.isBotEnvironment = require_utils_isBot.isBotEnvironment;
28
+ exports.isBotUserAgent = require_utils_isBot.isBotUserAgent;
25
29
  exports.isSampledIn = require_core_sampling.isSampledIn;
26
30
  exports.sendEvents = require_core_transport.sendEvents;
27
31
  exports.serializeKeyPath = require_events_contentExposure.serializeKeyPath;
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ const require_utils_isBot = require('./utils/isBot.cjs');
2
3
  let _intlayer_config_built = require("@intlayer/config/built");
3
4
 
4
5
  //#region src/isEnabled.ts
@@ -21,9 +22,10 @@ const TREE_SHAKE_ANALYTICS = process.env.INTLAYER_ANALYTICS_ENABLED === "false";
21
22
  * - the user did not opt out (`analytics.enabled === true`, the default), and
22
23
  * - a project key is configured (`editor.clientId`), and
23
24
  * - we are on the client, and
24
- * - we are the top window (not embedded in the editor).
25
+ * - we are the top window (not embedded in the editor), and
26
+ * - the visitor is not a bot, crawler or automation runtime.
25
27
  */
26
- const isEnabled = !TREE_SHAKE_ANALYTICS && _intlayer_config_built.analytics?.enabled === true && Boolean(_intlayer_config_built.editor?.clientId) && typeof window !== "undefined" && window.self === window.top;
28
+ const isEnabled = !TREE_SHAKE_ANALYTICS && _intlayer_config_built.analytics?.enabled === true && Boolean(_intlayer_config_built.editor?.clientId) && typeof window !== "undefined" && window.self === window.top && !require_utils_isBot.isBotEnvironment();
27
29
 
28
30
  //#endregion
29
31
  exports.isEnabled = isEnabled;
@@ -1 +1 @@
1
- {"version":3,"file":"isEnabled.cjs","names":["analytics","editor"],"sources":["../../src/isEnabled.ts"],"sourcesContent":["import { analytics, editor } from '@intlayer/config/built';\n\n/**\n * Compile-time kill switch. Bundlers replace `process.env.INTLAYER_ANALYTICS_ENABLED`\n * (set to `'false'` by `@intlayer/config` unless `analytics.enabled` is `true`\n * — the default once this package is installed — AND an `editor.clientId` is\n * configured) so the whole analytics integration is dead-code-eliminated for\n * apps that opted out.\n */\nconst TREE_SHAKE_ANALYTICS = process.env.INTLAYER_ANALYTICS_ENABLED === 'false';\n\n/**\n * Runtime guard mirroring `@intlayer/editor`'s `isEnabled`, but inverted with\n * respect to the iframe: analytics runs for real end users on the **top**\n * window and stays off inside the editor/CMS preview iframe so editor sessions\n * are never counted as traffic.\n *\n * Enabled when:\n * - the bundler did not tree-shake analytics away, and\n * - the user did not opt out (`analytics.enabled === true`, the default), and\n * - a project key is configured (`editor.clientId`), and\n * - we are on the client, and\n * - we are the top window (not embedded in the editor).\n */\nexport const isEnabled =\n !TREE_SHAKE_ANALYTICS &&\n analytics?.enabled === true &&\n Boolean(editor?.clientId) &&\n typeof window !== 'undefined' &&\n window.self === window.top;\n"],"mappings":";;;;;;;;;;;AASA,MAAM,uBAAuB,QAAQ,IAAI,+BAA+B;;;;;;;;;;;;;;AAexE,MAAa,YACX,CAAC,wBACDA,kCAAW,YAAY,QACvB,QAAQC,+BAAQ,QAAQ,KACxB,OAAO,WAAW,eAClB,OAAO,SAAS,OAAO"}
1
+ {"version":3,"file":"isEnabled.cjs","names":["analytics","editor","isBotEnvironment"],"sources":["../../src/isEnabled.ts"],"sourcesContent":["import { analytics, editor } from '@intlayer/config/built';\nimport { isBotEnvironment } from './utils/isBot';\n\n/**\n * Compile-time kill switch. Bundlers replace `process.env.INTLAYER_ANALYTICS_ENABLED`\n * (set to `'false'` by `@intlayer/config` unless `analytics.enabled` is `true`\n * — the default once this package is installed — AND an `editor.clientId` is\n * configured) so the whole analytics integration is dead-code-eliminated for\n * apps that opted out.\n */\nconst TREE_SHAKE_ANALYTICS = process.env.INTLAYER_ANALYTICS_ENABLED === 'false';\n\n/**\n * Runtime guard mirroring `@intlayer/editor`'s `isEnabled`, but inverted with\n * respect to the iframe: analytics runs for real end users on the **top**\n * window and stays off inside the editor/CMS preview iframe so editor sessions\n * are never counted as traffic.\n *\n * Enabled when:\n * - the bundler did not tree-shake analytics away, and\n * - the user did not opt out (`analytics.enabled === true`, the default), and\n * - a project key is configured (`editor.clientId`), and\n * - we are on the client, and\n * - we are the top window (not embedded in the editor), and\n * - the visitor is not a bot, crawler or automation runtime.\n */\nexport const isEnabled =\n !TREE_SHAKE_ANALYTICS &&\n analytics?.enabled === true &&\n Boolean(editor?.clientId) &&\n typeof window !== 'undefined' &&\n window.self === window.top &&\n !isBotEnvironment();\n"],"mappings":";;;;;;;;;;;;AAUA,MAAM,uBAAuB,QAAQ,IAAI,+BAA+B;;;;;;;;;;;;;;;AAgBxE,MAAa,YACX,CAAC,wBACDA,kCAAW,YAAY,QACvB,QAAQC,+BAAQ,QAAQ,KACxB,OAAO,WAAW,eAClB,OAAO,SAAS,OAAO,OACvB,CAACC,qCAAiB"}
@@ -0,0 +1,89 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ //#region src/utils/isBot.ts
3
+ /**
4
+ * Bot / automation detection.
5
+ *
6
+ * Analytics must describe real people. Crawlers, link-preview fetchers, uptime
7
+ * monitors, AI agents and CI browsers (Lighthouse, Playwright, Puppeteer) all
8
+ * execute JavaScript nowadays, so they would otherwise be counted as visitors
9
+ * and inflate page views, content exposures and A/B conversion rates.
10
+ *
11
+ * Everything here is a pure string/environment check — no network, no storage.
12
+ */
13
+ /**
14
+ * User agents that look like a bot but belong to real user devices, checked
15
+ * before {@link BOT_USER_AGENT_PATTERN}. `CUBOT` is an Android phone brand
16
+ * whose model strings (`CUBOT_X19`) contain the `bot` token.
17
+ */
18
+ const HUMAN_USER_AGENT_EXCEPTION_PATTERN = /cubot/i;
19
+ /**
20
+ * Matches known non-human user agents. Grouped by family so the list stays
21
+ * reviewable:
22
+ * - generic tokens (`…bot`, `crawler`, `spider`, `scraper`)
23
+ * - HTTP clients used by scripts (curl, wget, requests, axios…)
24
+ * - headless / automation runtimes (Playwright, Puppeteer, Lighthouse…)
25
+ * - search-engine and SEO crawlers
26
+ * - social link-preview fetchers
27
+ * - AI / LLM crawlers and agents
28
+ */
29
+ const BOT_USER_AGENT_PATTERN = /bot\b|bot[/\-\s]|crawler|crawling|spider|scraper|slurp|archiver|feedfetcher|validator|curl\/|wget\/|python-requests|python-urllib|okhttp|axios\/|node-fetch|got \(|go-http-client|java\/|libwww|httpunit|http_request|apache-httpclient|headless|phantomjs|puppeteer|playwright|selenium|webdriver|cypress|lighthouse|pagespeed|gtmetrix|prerender|pingdom|uptime|statuscake|site24x7|bingpreview|yandex|baiduspider|sogou|exabot|semrush|ahrefs|mj12|dotbot|petalbot|applebot|amazonbot|bytespider|facebookexternalhit|meta-externalagent|embedly|outbrain|quora link preview|skypeuripreview|vkshare|w3c_validator|apis-google|mediapartners|adsbot|storebot-google|google-inspectiontool|google-read-aloud|google-extended|duplexweb-google|gptbot|oai-searchbot|chatgpt-user|perplexity|claudebot|claude-web|anthropic-ai|cohere-ai|ccbot|diffbot|imagesift|omgili|timpi|youbot/i;
30
+ /**
31
+ * Tells whether a user agent string identifies a bot, crawler or automation
32
+ * runtime rather than a human visitor.
33
+ *
34
+ * A missing or empty user agent is treated as a bot: every real browser sends
35
+ * one, so its absence means a script or a forged request.
36
+ *
37
+ * @param userAgent - The raw `User-Agent` string (header or `navigator.userAgent`).
38
+ * @returns `true` when the traffic must not be recorded.
39
+ *
40
+ * @example
41
+ * isBotUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1)'); // true
42
+ * isBotUserAgent('Mozilla/5.0 (Macintosh) Chrome/120'); // false
43
+ */
44
+ const isBotUserAgent = (userAgent) => {
45
+ if (!userAgent) return true;
46
+ if (HUMAN_USER_AGENT_EXCEPTION_PATTERN.test(userAgent)) return false;
47
+ return BOT_USER_AGENT_PATTERN.test(userAgent);
48
+ };
49
+ /**
50
+ * Globals injected by automation runtimes. Their mere presence means the page
51
+ * is driven by a script, whatever the user agent claims.
52
+ */
53
+ const AUTOMATION_GLOBAL_KEYS = [
54
+ "_phantom",
55
+ "__nightmare",
56
+ "callPhantom",
57
+ "__selenium_unwrapped",
58
+ "__webdriver_evaluate",
59
+ "__driver_evaluate",
60
+ "domAutomation",
61
+ "Cypress"
62
+ ];
63
+ /**
64
+ * Tells whether the current browser environment is automated, combining the
65
+ * user agent with automation fingerprints (`navigator.webdriver`, runtime
66
+ * globals) that headless browsers spoofing a normal user agent still expose.
67
+ *
68
+ * Always `false` outside a browser (SSR): there is nothing to collect there,
69
+ * and the server must not disable a client that has not run yet.
70
+ *
71
+ * @returns `true` when analytics must stay off for this environment.
72
+ *
73
+ * @example
74
+ * if (isBotEnvironment()) return; // never start the client for a crawler
75
+ */
76
+ const isBotEnvironment = () => {
77
+ if (typeof navigator === "undefined") return false;
78
+ if (navigator.webdriver === true) return true;
79
+ const automationGlobals = globalThis;
80
+ if (AUTOMATION_GLOBAL_KEYS.some((key) => key in automationGlobals)) return true;
81
+ if (!navigator.userAgent) return false;
82
+ return isBotUserAgent(navigator.userAgent);
83
+ };
84
+
85
+ //#endregion
86
+ exports.BOT_USER_AGENT_PATTERN = BOT_USER_AGENT_PATTERN;
87
+ exports.isBotEnvironment = isBotEnvironment;
88
+ exports.isBotUserAgent = isBotUserAgent;
89
+ //# sourceMappingURL=isBot.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isBot.cjs","names":[],"sources":["../../../src/utils/isBot.ts"],"sourcesContent":["/**\n * Bot / automation detection.\n *\n * Analytics must describe real people. Crawlers, link-preview fetchers, uptime\n * monitors, AI agents and CI browsers (Lighthouse, Playwright, Puppeteer) all\n * execute JavaScript nowadays, so they would otherwise be counted as visitors\n * and inflate page views, content exposures and A/B conversion rates.\n *\n * Everything here is a pure string/environment check — no network, no storage.\n */\n\n/**\n * User agents that look like a bot but belong to real user devices, checked\n * before {@link BOT_USER_AGENT_PATTERN}. `CUBOT` is an Android phone brand\n * whose model strings (`CUBOT_X19`) contain the `bot` token.\n */\nconst HUMAN_USER_AGENT_EXCEPTION_PATTERN = /cubot/i;\n\n/**\n * Matches known non-human user agents. Grouped by family so the list stays\n * reviewable:\n * - generic tokens (`…bot`, `crawler`, `spider`, `scraper`)\n * - HTTP clients used by scripts (curl, wget, requests, axios…)\n * - headless / automation runtimes (Playwright, Puppeteer, Lighthouse…)\n * - search-engine and SEO crawlers\n * - social link-preview fetchers\n * - AI / LLM crawlers and agents\n */\nexport const BOT_USER_AGENT_PATTERN =\n /bot\\b|bot[/\\-\\s]|crawler|crawling|spider|scraper|slurp|archiver|feedfetcher|validator|curl\\/|wget\\/|python-requests|python-urllib|okhttp|axios\\/|node-fetch|got \\(|go-http-client|java\\/|libwww|httpunit|http_request|apache-httpclient|headless|phantomjs|puppeteer|playwright|selenium|webdriver|cypress|lighthouse|pagespeed|gtmetrix|prerender|pingdom|uptime|statuscake|site24x7|bingpreview|yandex|baiduspider|sogou|exabot|semrush|ahrefs|mj12|dotbot|petalbot|applebot|amazonbot|bytespider|facebookexternalhit|meta-externalagent|embedly|outbrain|quora link preview|skypeuripreview|vkshare|w3c_validator|apis-google|mediapartners|adsbot|storebot-google|google-inspectiontool|google-read-aloud|google-extended|duplexweb-google|gptbot|oai-searchbot|chatgpt-user|perplexity|claudebot|claude-web|anthropic-ai|cohere-ai|ccbot|diffbot|imagesift|omgili|timpi|youbot/i;\n\n/**\n * Tells whether a user agent string identifies a bot, crawler or automation\n * runtime rather than a human visitor.\n *\n * A missing or empty user agent is treated as a bot: every real browser sends\n * one, so its absence means a script or a forged request.\n *\n * @param userAgent - The raw `User-Agent` string (header or `navigator.userAgent`).\n * @returns `true` when the traffic must not be recorded.\n *\n * @example\n * isBotUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1)'); // true\n * isBotUserAgent('Mozilla/5.0 (Macintosh) Chrome/120'); // false\n */\nexport const isBotUserAgent = (userAgent?: string | null): boolean => {\n if (!userAgent) return true;\n if (HUMAN_USER_AGENT_EXCEPTION_PATTERN.test(userAgent)) return false;\n\n return BOT_USER_AGENT_PATTERN.test(userAgent);\n};\n\n/**\n * Globals injected by automation runtimes. Their mere presence means the page\n * is driven by a script, whatever the user agent claims.\n */\nconst AUTOMATION_GLOBAL_KEYS = [\n '_phantom',\n '__nightmare',\n 'callPhantom',\n '__selenium_unwrapped',\n '__webdriver_evaluate',\n '__driver_evaluate',\n 'domAutomation',\n 'Cypress',\n] as const;\n\n/**\n * Tells whether the current browser environment is automated, combining the\n * user agent with automation fingerprints (`navigator.webdriver`, runtime\n * globals) that headless browsers spoofing a normal user agent still expose.\n *\n * Always `false` outside a browser (SSR): there is nothing to collect there,\n * and the server must not disable a client that has not run yet.\n *\n * @returns `true` when analytics must stay off for this environment.\n *\n * @example\n * if (isBotEnvironment()) return; // never start the client for a crawler\n */\nexport const isBotEnvironment = (): boolean => {\n if (typeof navigator === 'undefined') return false;\n\n if (navigator.webdriver === true) return true;\n\n const automationGlobals = globalThis as unknown as Record<string, unknown>;\n if (AUTOMATION_GLOBAL_KEYS.some((key) => key in automationGlobals)) {\n return true;\n }\n\n // Unlike a server reading the `User-Agent` header, an absent user agent here\n // means a non-browser runtime (React Native, Lynx) — a real app, not a bot.\n if (!navigator.userAgent) return false;\n\n return isBotUserAgent(navigator.userAgent);\n};\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,MAAM,qCAAqC;;;;;;;;;;;AAY3C,MAAa,yBACX;;;;;;;;;;;;;;;AAgBF,MAAa,kBAAkB,cAAuC;CACpE,IAAI,CAAC,WAAW,OAAO;CACvB,IAAI,mCAAmC,KAAK,SAAS,GAAG,OAAO;CAE/D,OAAO,uBAAuB,KAAK,SAAS;AAC9C;;;;;AAMA,MAAM,yBAAyB;CAC7B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;;;;;;;AAeA,MAAa,yBAAkC;CAC7C,IAAI,OAAO,cAAc,aAAa,OAAO;CAE7C,IAAI,UAAU,cAAc,MAAM,OAAO;CAEzC,MAAM,oBAAoB;CAC1B,IAAI,uBAAuB,MAAM,QAAQ,OAAO,iBAAiB,GAC/D,OAAO;CAKT,IAAI,CAAC,UAAU,WAAW,OAAO;CAEjC,OAAO,eAAe,UAAU,SAAS;AAC3C"}
@@ -1,6 +1,8 @@
1
+ import { isBotEnvironment } from "../utils/isBot.mjs";
1
2
  import { onIdle } from "../utils/idle.mjs";
2
3
  import { assignVariant } from "./abTesting.mjs";
3
4
  import { createEventBuffer } from "./eventBuffer.mjs";
5
+ import { createPublicTokenManager } from "./publicToken.mjs";
4
6
  import { isSampledIn } from "./sampling.mjs";
5
7
  import { getSessionId } from "./session.mjs";
6
8
  import { sendEvents } from "./transport.mjs";
@@ -47,22 +49,33 @@ const referrerHost = () => {
47
49
  */
48
50
  const createAnalyticsClient = (config) => {
49
51
  const sessionId = getSessionId();
50
- const sampledIn = isSampledIn(sessionId, config.sampleRate);
52
+ const isBotSession = isBotEnvironment();
53
+ const sampledIn = !isBotSession && isSampledIn(sessionId, config.sampleRate);
51
54
  const buffer = createEventBuffer({ max: config.maxBufferSize });
52
55
  const endpoint = `${config.backendURL.replace(/\/$/, "")}/api/analytics/events`;
56
+ const publicToken = createPublicTokenManager({
57
+ backendURL: config.backendURL,
58
+ clientId: config.clientId
59
+ });
53
60
  let ambientLocale = "";
54
61
  let flushTimer = null;
55
62
  let started = false;
56
63
  let detachListeners = null;
64
+ let lastPageViewUrl = null;
57
65
  let seenExposureKeys = /* @__PURE__ */ new Set();
58
66
  const flush = (useBeacon = false) => {
59
67
  const events = buffer.drain();
68
+ const token = publicToken.getToken();
60
69
  for (let start = 0; start < events.length; start += 200) sendEvents(endpoint, {
61
- clientId: config.clientId,
70
+ token,
71
+ clientId: token ? void 0 : config.clientId,
62
72
  sessionId,
63
73
  sdkVersion: SDK_VERSION,
64
74
  events: events.slice(start, start + 200)
65
- }, { useBeacon });
75
+ }, {
76
+ useBeacon,
77
+ token
78
+ });
66
79
  };
67
80
  /** Pushes an event and triggers an early flush if the buffer is full. */
68
81
  const enqueue = (event) => {
@@ -73,16 +86,20 @@ const createAnalyticsClient = (config) => {
73
86
  };
74
87
  const trackPageView = (event = {}) => {
75
88
  if (!sampledIn) return;
89
+ const url = event.url ?? currentUrl();
90
+ const reason = event.reason ?? "initial";
91
+ if (reason === "route_change" && url === lastPageViewUrl) return;
92
+ lastPageViewUrl = url;
76
93
  seenExposureKeys = /* @__PURE__ */ new Set();
77
94
  enqueue({
78
95
  type: "page_view",
79
96
  t: Date.now(),
80
- url: event.url ?? currentUrl(),
97
+ url,
81
98
  locale: event.locale ?? ambientLocale,
82
99
  ref: event.ref ?? referrerHost(),
83
100
  vw: event.vw ?? (typeof window !== "undefined" ? window.innerWidth : void 0),
84
101
  vh: event.vh ?? (typeof window !== "undefined" ? window.innerHeight : void 0),
85
- reason: event.reason ?? "initial"
102
+ reason
86
103
  });
87
104
  };
88
105
  const trackContentExposure = (event) => {
@@ -106,6 +123,7 @@ const createAnalyticsClient = (config) => {
106
123
  });
107
124
  };
108
125
  const trackConversion = (event) => {
126
+ if (isBotSession) return;
109
127
  enqueue({
110
128
  ...event,
111
129
  type: "conversion",
@@ -143,8 +161,9 @@ const createAnalyticsClient = (config) => {
143
161
  };
144
162
  };
145
163
  const start = () => {
146
- if (started) return;
164
+ if (started || isBotSession) return;
147
165
  started = true;
166
+ publicToken.prime();
148
167
  flushTimer = setInterval(flush, config.flushInterval);
149
168
  flushTimer?.unref?.();
150
169
  onIdle(attachListeners);
@@ -1 +1 @@
1
- {"version":3,"file":"AnalyticsClient.mjs","names":[],"sources":["../../../src/core/AnalyticsClient.ts"],"sourcesContent":["import { onIdle } from '../utils/idle';\nimport { assignVariant } from './abTesting';\nimport { createEventBuffer, type EventBuffer } from './eventBuffer';\nimport { isSampledIn } from './sampling';\nimport { getSessionId } from './session';\nimport { sendEvents } from './transport';\nimport type {\n AnalyticsClientConfig,\n AnalyticsEvent,\n ContentExposureEvent,\n ConversionEvent,\n PageViewEvent,\n TrackableEvent,\n} from './types';\n\n/**\n * Caller-supplied fields for a page view. Everything is optional — the client\n * fills `url`, `locale`, viewport, and referrer from the environment when omitted.\n */\nexport type PageViewInput = {\n /** Explicit pathname / screen name (native runtimes have no `location`). */\n url?: string;\n /** Explicit locale; falls back to the client's ambient locale. */\n locale?: string;\n /** Referrer host override. */\n ref?: string;\n /** Viewport width override. */\n vw?: number;\n /** Viewport height override. */\n vh?: number;\n /** Why the view was recorded. */\n reason?: PageViewEvent['reason'];\n};\n\n/** Default flush cadence — one network request per 20 seconds. */\nexport const DEFAULT_FLUSH_INTERVAL = 20_000;\n/** Default cap on distinct buffered events before an early flush. */\nexport const DEFAULT_MAX_BUFFER_SIZE = 500;\n/** Default sampling — keep every session. */\nexport const DEFAULT_SAMPLE_RATE = 1;\n/**\n * Maximum events per network request. `sendBeacon` and `fetch(keepalive)`\n * reject bodies over ~64KB, so large batches are split into multiple sends.\n */\nexport const MAX_EVENTS_PER_SEND = 200;\n/** Identifies the producing SDK in the ingest envelope. */\nconst SDK_VERSION = 'intlayer-analytics/1';\n\n/**\n * The public surface of the analytics client singleton. Framework bindings hold\n * a reference to this and forward provider/node/conversion signals into it.\n */\nexport type AnalyticsClient = {\n /** Sets the ambient locale stamped onto subsequent events. */\n setLocale: (locale: string) => void;\n /** Records a page/locale view (provider level). */\n trackPageView: (event?: PageViewInput) => void;\n /** Records a content exposure (node level). Sampled + deduped per page view. */\n trackContentExposure: (\n event: Omit<TrackableEvent<ContentExposureEvent>, 'type'>\n ) => void;\n /** Records a conversion tied to an experiment variant. */\n trackConversion: (\n event: Omit<TrackableEvent<ConversionEvent>, 'type'>\n ) => void;\n /** Deterministically resolves this session's variant for an experiment. */\n getVariant: (\n experimentKey: string,\n variants: string[],\n weights?: number[]\n ) => string;\n /** Sends the buffered batch now. */\n flush: (useBeacon?: boolean) => void;\n /** Starts the flush loop and lifecycle listeners (idempotent). */\n start: () => void;\n /** Stops timers/listeners and flushes any remaining events. */\n stop: () => void;\n};\n\n/**\n * Reads the current page pathname. Search params are intentionally excluded to\n * avoid capturing anything sensitive.\n */\nconst currentUrl = (): string => {\n if (typeof window === 'undefined' || !window.location) return '';\n return window.location.pathname;\n};\n\n/**\n * Reduces the referrer to its host — never the full URL — for privacy.\n */\nconst referrerHost = (): string | undefined => {\n if (typeof document === 'undefined' || !document.referrer) return undefined;\n try {\n return new URL(document.referrer).host;\n } catch {\n return undefined;\n }\n};\n\n/**\n * Creates an analytics client. Prefer {@link initAnalyticsClient} in application\n * code — it manages a shared singleton so there is only ever one flush timer.\n *\n * @param config - Resolved client configuration.\n * @returns A started-on-demand {@link AnalyticsClient}.\n */\nexport const createAnalyticsClient = (\n config: AnalyticsClientConfig\n): AnalyticsClient => {\n const sessionId = getSessionId();\n const sampledIn = isSampledIn(sessionId, config.sampleRate);\n const buffer: EventBuffer = createEventBuffer({ max: config.maxBufferSize });\n const endpoint = `${config.backendURL.replace(/\\/$/, '')}/api/analytics/events`;\n\n let ambientLocale = '';\n let flushTimer: ReturnType<typeof setInterval> | null = null;\n let started = false;\n let detachListeners: (() => void) | null = null;\n\n // Exposure keys already recorded for the current page view. React (and other\n // frameworks) resolve content nodes on every re-render; without this gate a\n // component re-rendering 60×/s would count 60 exposures per second. The set\n // is cleared on each page view so the recorded semantic is \"shown at least\n // once during this page view\".\n let seenExposureKeys = new Set<string>();\n\n const flush = (useBeacon = false): void => {\n const events = buffer.drain();\n\n // Split into bounded chunks so no single request exceeds transport limits.\n for (let start = 0; start < events.length; start += MAX_EVENTS_PER_SEND) {\n sendEvents(\n endpoint,\n {\n clientId: config.clientId,\n sessionId,\n sdkVersion: SDK_VERSION,\n events: events.slice(start, start + MAX_EVENTS_PER_SEND),\n },\n { useBeacon }\n );\n }\n };\n\n /** Pushes an event and triggers an early flush if the buffer is full. */\n const enqueue = (event: AnalyticsEvent): void => {\n const size = buffer.push(event);\n if (size >= config.maxBufferSize) flush();\n };\n\n const setLocale = (locale: string): void => {\n ambientLocale = locale;\n };\n\n const trackPageView: AnalyticsClient['trackPageView'] = (event = {}) => {\n if (!sampledIn) return;\n\n // A new page view starts a fresh exposure window.\n seenExposureKeys = new Set<string>();\n\n enqueue({\n type: 'page_view',\n t: Date.now(),\n url: event.url ?? currentUrl(),\n locale: event.locale ?? ambientLocale,\n ref: event.ref ?? referrerHost(),\n vw:\n event.vw ??\n (typeof window !== 'undefined' ? window.innerWidth : undefined),\n vh:\n event.vh ??\n (typeof window !== 'undefined' ? window.innerHeight : undefined),\n reason: event.reason ?? 'initial',\n });\n };\n\n const trackContentExposure: AnalyticsClient['trackContentExposure'] = (\n event\n ) => {\n if (!sampledIn) return;\n\n const locale = event.locale ?? ambientLocale;\n // Joined with \\x01 (never present in content keys) so parts cannot collide.\n const exposureKey = [\n event.dictionaryKey,\n event.keyPath,\n locale,\n event.experimentKey ?? '',\n event.variant ?? '',\n ].join('\u0001');\n\n // Deduplicate within the current page view (see `seenExposureKeys`).\n if (seenExposureKeys.has(exposureKey)) return;\n seenExposureKeys.add(exposureKey);\n\n enqueue({\n ...event,\n type: 'content_exposure',\n t: Date.now(),\n url: currentUrl(),\n locale,\n });\n };\n\n const trackConversion: AnalyticsClient['trackConversion'] = (event) => {\n // Conversions are never sampled out — they are rare and decisive for A/B.\n enqueue({\n ...event,\n type: 'conversion',\n t: Date.now(),\n url: currentUrl(),\n locale: event.locale ?? ambientLocale,\n });\n // Flush conversions promptly so they are not lost on immediate navigation.\n flush();\n };\n\n const getVariant: AnalyticsClient['getVariant'] = (\n experimentKey,\n variants,\n weights\n ) => assignVariant(sessionId, experimentKey, variants, weights);\n\n /** Attaches lifecycle + SPA-navigation listeners without blocking render. */\n const attachListeners = (): void => {\n if (typeof window === 'undefined') return;\n\n const onHidden = (): void => {\n if (document.visibilityState === 'hidden') flush(true);\n };\n const onPageHide = (): void => flush(true);\n const onPopState = (): void => trackPageView({ reason: 'route_change' });\n\n document.addEventListener('visibilitychange', onHidden);\n window.addEventListener('pagehide', onPageHide, { capture: true });\n window.addEventListener('popstate', onPopState);\n\n // Patch history.pushState so client-side route changes emit a page view.\n // `replaceState` is intentionally left untouched: routers use it for\n // same-page URL sync (scroll, params), which is not a navigation.\n const history = window.history;\n const originalPush = history.pushState;\n\n const patchedPushState = function patchedPush(\n this: History,\n ...args: Parameters<History['pushState']>\n ) {\n const result = originalPush.apply(this, args);\n trackPageView({ reason: 'route_change' });\n return result;\n };\n history.pushState = patchedPushState;\n\n detachListeners = (): void => {\n document.removeEventListener('visibilitychange', onHidden);\n window.removeEventListener('pagehide', onPageHide, { capture: true });\n window.removeEventListener('popstate', onPopState);\n // Only restore if nothing else re-patched pushState after us; restoring\n // blindly would clobber another library's wrapper.\n if (history.pushState === patchedPushState) {\n history.pushState = originalPush;\n }\n };\n };\n\n const start = (): void => {\n if (started) return;\n started = true;\n\n flushTimer = setInterval(flush, config.flushInterval);\n // Some runtimes (Node during SSR) keep the process alive on timers.\n (flushTimer as { unref?: () => void })?.unref?.();\n\n onIdle(attachListeners);\n };\n\n const stop = (): void => {\n if (!started) return;\n started = false;\n\n if (flushTimer) {\n clearInterval(flushTimer);\n flushTimer = null;\n }\n detachListeners?.();\n detachListeners = null;\n flush(true);\n };\n\n return {\n setLocale,\n trackPageView,\n trackContentExposure,\n trackConversion,\n getVariant,\n flush,\n start,\n stop,\n };\n};\n"],"mappings":";;;;;;;;;AAmCA,MAAa,yBAAyB;;AAEtC,MAAa,0BAA0B;;AAEvC,MAAa,sBAAsB;;;;;AAKnC,MAAa,sBAAsB;;AAEnC,MAAM,cAAc;;;;;AAqCpB,MAAM,mBAA2B;CAC/B,IAAI,OAAO,WAAW,eAAe,CAAC,OAAO,UAAU,OAAO;CAC9D,OAAO,OAAO,SAAS;AACzB;;;;AAKA,MAAM,qBAAyC;CAC7C,IAAI,OAAO,aAAa,eAAe,CAAC,SAAS,UAAU,OAAO;CAClE,IAAI;EACF,OAAO,IAAI,IAAI,SAAS,QAAQ,CAAC,CAAC;CACpC,QAAQ;EACN;CACF;AACF;;;;;;;;AASA,MAAa,yBACX,WACoB;CACpB,MAAM,YAAY,aAAa;CAC/B,MAAM,YAAY,YAAY,WAAW,OAAO,UAAU;CAC1D,MAAM,SAAsB,kBAAkB,EAAE,KAAK,OAAO,cAAc,CAAC;CAC3E,MAAM,WAAW,GAAG,OAAO,WAAW,QAAQ,OAAO,EAAE,EAAE;CAEzD,IAAI,gBAAgB;CACpB,IAAI,aAAoD;CACxD,IAAI,UAAU;CACd,IAAI,kBAAuC;CAO3C,IAAI,mCAAmB,IAAI,IAAY;CAEvC,MAAM,SAAS,YAAY,UAAgB;EACzC,MAAM,SAAS,OAAO,MAAM;EAG5B,KAAK,IAAI,QAAQ,GAAG,QAAQ,OAAO,QAAQ,cACzC,WACE,UACA;GACE,UAAU,OAAO;GACjB;GACA,YAAY;GACZ,QAAQ,OAAO,MAAM,OAAO,WAA2B;EACzD,GACA,EAAE,UAAU,CACd;CAEJ;;CAGA,MAAM,WAAW,UAAgC;EAE/C,IADa,OAAO,KAAK,KAClB,KAAK,OAAO,eAAe,MAAM;CAC1C;CAEA,MAAM,aAAa,WAAyB;EAC1C,gBAAgB;CAClB;CAEA,MAAM,iBAAmD,QAAQ,CAAC,MAAM;EACtE,IAAI,CAAC,WAAW;EAGhB,mCAAmB,IAAI,IAAY;EAEnC,QAAQ;GACN,MAAM;GACN,GAAG,KAAK,IAAI;GACZ,KAAK,MAAM,OAAO,WAAW;GAC7B,QAAQ,MAAM,UAAU;GACxB,KAAK,MAAM,OAAO,aAAa;GAC/B,IACE,MAAM,OACL,OAAO,WAAW,cAAc,OAAO,aAAa;GACvD,IACE,MAAM,OACL,OAAO,WAAW,cAAc,OAAO,cAAc;GACxD,QAAQ,MAAM,UAAU;EAC1B,CAAC;CACH;CAEA,MAAM,wBACJ,UACG;EACH,IAAI,CAAC,WAAW;EAEhB,MAAM,SAAS,MAAM,UAAU;EAE/B,MAAM,cAAc;GAClB,MAAM;GACN,MAAM;GACN;GACA,MAAM,iBAAiB;GACvB,MAAM,WAAW;EACnB,CAAC,CAAC,KAAK,GAAG;EAGV,IAAI,iBAAiB,IAAI,WAAW,GAAG;EACvC,iBAAiB,IAAI,WAAW;EAEhC,QAAQ;GACN,GAAG;GACH,MAAM;GACN,GAAG,KAAK,IAAI;GACZ,KAAK,WAAW;GAChB;EACF,CAAC;CACH;CAEA,MAAM,mBAAuD,UAAU;EAErE,QAAQ;GACN,GAAG;GACH,MAAM;GACN,GAAG,KAAK,IAAI;GACZ,KAAK,WAAW;GAChB,QAAQ,MAAM,UAAU;EAC1B,CAAC;EAED,MAAM;CACR;CAEA,MAAM,cACJ,eACA,UACA,YACG,cAAc,WAAW,eAAe,UAAU,OAAO;;CAG9D,MAAM,wBAA8B;EAClC,IAAI,OAAO,WAAW,aAAa;EAEnC,MAAM,iBAAuB;GAC3B,IAAI,SAAS,oBAAoB,UAAU,MAAM,IAAI;EACvD;EACA,MAAM,mBAAyB,MAAM,IAAI;EACzC,MAAM,mBAAyB,cAAc,EAAE,QAAQ,eAAe,CAAC;EAEvE,SAAS,iBAAiB,oBAAoB,QAAQ;EACtD,OAAO,iBAAiB,YAAY,YAAY,EAAE,SAAS,KAAK,CAAC;EACjE,OAAO,iBAAiB,YAAY,UAAU;EAK9C,MAAM,UAAU,OAAO;EACvB,MAAM,eAAe,QAAQ;EAE7B,MAAM,mBAAmB,SAAS,YAEhC,GAAG,MACH;GACA,MAAM,SAAS,aAAa,MAAM,MAAM,IAAI;GAC5C,cAAc,EAAE,QAAQ,eAAe,CAAC;GACxC,OAAO;EACT;EACA,QAAQ,YAAY;EAEpB,wBAA8B;GAC5B,SAAS,oBAAoB,oBAAoB,QAAQ;GACzD,OAAO,oBAAoB,YAAY,YAAY,EAAE,SAAS,KAAK,CAAC;GACpE,OAAO,oBAAoB,YAAY,UAAU;GAGjD,IAAI,QAAQ,cAAc,kBACxB,QAAQ,YAAY;EAExB;CACF;CAEA,MAAM,cAAoB;EACxB,IAAI,SAAS;EACb,UAAU;EAEV,aAAa,YAAY,OAAO,OAAO,aAAa;EAEpD,AAAC,YAAuC,QAAQ;EAEhD,OAAO,eAAe;CACxB;CAEA,MAAM,aAAmB;EACvB,IAAI,CAAC,SAAS;EACd,UAAU;EAEV,IAAI,YAAY;GACd,cAAc,UAAU;GACxB,aAAa;EACf;EACA,kBAAkB;EAClB,kBAAkB;EAClB,MAAM,IAAI;CACZ;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF;AACF"}
1
+ {"version":3,"file":"AnalyticsClient.mjs","names":[],"sources":["../../../src/core/AnalyticsClient.ts"],"sourcesContent":["import { onIdle } from '../utils/idle';\nimport { isBotEnvironment } from '../utils/isBot';\nimport { assignVariant } from './abTesting';\nimport { createEventBuffer, type EventBuffer } from './eventBuffer';\nimport { createPublicTokenManager } from './publicToken';\nimport { isSampledIn } from './sampling';\nimport { getSessionId } from './session';\nimport { sendEvents } from './transport';\nimport type {\n AnalyticsClientConfig,\n AnalyticsEvent,\n ContentExposureEvent,\n ConversionEvent,\n PageViewEvent,\n TrackableEvent,\n} from './types';\n\n/**\n * Caller-supplied fields for a page view. Everything is optional — the client\n * fills `url`, `locale`, viewport, and referrer from the environment when omitted.\n */\nexport type PageViewInput = {\n /** Explicit pathname / screen name (native runtimes have no `location`). */\n url?: string;\n /** Explicit locale; falls back to the client's ambient locale. */\n locale?: string;\n /** Referrer host override. */\n ref?: string;\n /** Viewport width override. */\n vw?: number;\n /** Viewport height override. */\n vh?: number;\n /** Why the view was recorded. */\n reason?: PageViewEvent['reason'];\n};\n\n/** Default flush cadence — one network request per 20 seconds. */\nexport const DEFAULT_FLUSH_INTERVAL = 20_000;\n/** Default cap on distinct buffered events before an early flush. */\nexport const DEFAULT_MAX_BUFFER_SIZE = 500;\n/** Default sampling — keep every session. */\nexport const DEFAULT_SAMPLE_RATE = 1;\n/**\n * Maximum events per network request. `sendBeacon` and `fetch(keepalive)`\n * reject bodies over ~64KB, so large batches are split into multiple sends.\n */\nexport const MAX_EVENTS_PER_SEND = 200;\n/** Identifies the producing SDK in the ingest envelope. */\nconst SDK_VERSION = 'intlayer-analytics/1';\n\n/**\n * The public surface of the analytics client singleton. Framework bindings hold\n * a reference to this and forward provider/node/conversion signals into it.\n */\nexport type AnalyticsClient = {\n /** Sets the ambient locale stamped onto subsequent events. */\n setLocale: (locale: string) => void;\n /** Records a page/locale view (provider level). */\n trackPageView: (event?: PageViewInput) => void;\n /** Records a content exposure (node level). Sampled + deduped per page view. */\n trackContentExposure: (\n event: Omit<TrackableEvent<ContentExposureEvent>, 'type'>\n ) => void;\n /** Records a conversion tied to an experiment variant. */\n trackConversion: (\n event: Omit<TrackableEvent<ConversionEvent>, 'type'>\n ) => void;\n /** Deterministically resolves this session's variant for an experiment. */\n getVariant: (\n experimentKey: string,\n variants: string[],\n weights?: number[]\n ) => string;\n /** Sends the buffered batch now. */\n flush: (useBeacon?: boolean) => void;\n /** Starts the flush loop and lifecycle listeners (idempotent). */\n start: () => void;\n /** Stops timers/listeners and flushes any remaining events. */\n stop: () => void;\n};\n\n/**\n * Reads the current page pathname. Search params are intentionally excluded to\n * avoid capturing anything sensitive.\n */\nconst currentUrl = (): string => {\n if (typeof window === 'undefined' || !window.location) return '';\n return window.location.pathname;\n};\n\n/**\n * Reduces the referrer to its host — never the full URL — for privacy.\n */\nconst referrerHost = (): string | undefined => {\n if (typeof document === 'undefined' || !document.referrer) return undefined;\n try {\n return new URL(document.referrer).host;\n } catch {\n return undefined;\n }\n};\n\n/**\n * Creates an analytics client. Prefer {@link initAnalyticsClient} in application\n * code — it manages a shared singleton so there is only ever one flush timer.\n *\n * @param config - Resolved client configuration.\n * @returns A started-on-demand {@link AnalyticsClient}.\n */\nexport const createAnalyticsClient = (\n config: AnalyticsClientConfig\n): AnalyticsClient => {\n const sessionId = getSessionId();\n // Crawlers, link-preview fetchers and headless runtimes execute JavaScript,\n // so they reach this point like any visitor. Detected once at creation: the\n // client then buffers nothing, sends nothing and never starts its timers.\n const isBotSession = isBotEnvironment();\n const sampledIn = !isBotSession && isSampledIn(sessionId, config.sampleRate);\n const buffer: EventBuffer = createEventBuffer({ max: config.maxBufferSize });\n const endpoint = `${config.backendURL.replace(/\\/$/, '')}/api/analytics/events`;\n const publicToken = createPublicTokenManager({\n backendURL: config.backendURL,\n clientId: config.clientId,\n });\n\n let ambientLocale = '';\n let flushTimer: ReturnType<typeof setInterval> | null = null;\n let started = false;\n let detachListeners: (() => void) | null = null;\n\n // Path of the most recent page view, so an automatic route-change signal for\n // a page already recorded is ignored. Routers call `pushState` for\n // query-only and hash-only updates (filters, tabs, modals, scroll sync),\n // each of which would otherwise land as another view of the same page.\n let lastPageViewUrl: string | null = null;\n\n // Exposure keys already recorded for the current page view. React (and other\n // frameworks) resolve content nodes on every re-render; without this gate a\n // component re-rendering 60×/s would count 60 exposures per second. The set\n // is cleared on each page view so the recorded semantic is \"shown at least\n // once during this page view\".\n let seenExposureKeys = new Set<string>();\n\n const flush = (useBeacon = false): void => {\n const events = buffer.drain();\n\n // Read synchronously: the flush-on-hide path runs during `pagehide` and\n // cannot await the exchange. Until it resolves, the public `clientId` is\n // sent instead so the first batch of a visit is still attributed.\n const token = publicToken.getToken();\n\n // Split into bounded chunks so no single request exceeds transport limits.\n for (let start = 0; start < events.length; start += MAX_EVENTS_PER_SEND) {\n sendEvents(\n endpoint,\n {\n token,\n clientId: token ? undefined : config.clientId,\n sessionId,\n sdkVersion: SDK_VERSION,\n events: events.slice(start, start + MAX_EVENTS_PER_SEND),\n },\n { useBeacon, token }\n );\n }\n };\n\n /** Pushes an event and triggers an early flush if the buffer is full. */\n const enqueue = (event: AnalyticsEvent): void => {\n const size = buffer.push(event);\n if (size >= config.maxBufferSize) flush();\n };\n\n const setLocale = (locale: string): void => {\n ambientLocale = locale;\n };\n\n const trackPageView: AnalyticsClient['trackPageView'] = (event = {}) => {\n if (!sampledIn) return;\n\n const url = event.url ?? currentUrl();\n const reason = event.reason ?? 'initial';\n\n // Only the automatic navigation signal is deduplicated: a provider asking\n // for an `initial` or `locale_change` view is reporting something the\n // pathname alone cannot express.\n if (reason === 'route_change' && url === lastPageViewUrl) return;\n lastPageViewUrl = url;\n\n // A new page view starts a fresh exposure window.\n seenExposureKeys = new Set<string>();\n\n enqueue({\n type: 'page_view',\n t: Date.now(),\n url,\n locale: event.locale ?? ambientLocale,\n ref: event.ref ?? referrerHost(),\n vw:\n event.vw ??\n (typeof window !== 'undefined' ? window.innerWidth : undefined),\n vh:\n event.vh ??\n (typeof window !== 'undefined' ? window.innerHeight : undefined),\n reason,\n });\n };\n\n const trackContentExposure: AnalyticsClient['trackContentExposure'] = (\n event\n ) => {\n if (!sampledIn) return;\n\n const locale = event.locale ?? ambientLocale;\n // Joined with \\x01 (never present in content keys) so parts cannot collide.\n const exposureKey = [\n event.dictionaryKey,\n event.keyPath,\n locale,\n event.experimentKey ?? '',\n event.variant ?? '',\n ].join('\u0001');\n\n // Deduplicate within the current page view (see `seenExposureKeys`).\n if (seenExposureKeys.has(exposureKey)) return;\n seenExposureKeys.add(exposureKey);\n\n enqueue({\n ...event,\n type: 'content_exposure',\n t: Date.now(),\n url: currentUrl(),\n locale,\n });\n };\n\n const trackConversion: AnalyticsClient['trackConversion'] = (event) => {\n // Conversions are never sampled out — they are rare and decisive for A/B —\n // but bot traffic is still excluded, or it would skew every variant rate.\n if (isBotSession) return;\n\n enqueue({\n ...event,\n type: 'conversion',\n t: Date.now(),\n url: currentUrl(),\n locale: event.locale ?? ambientLocale,\n });\n // Flush conversions promptly so they are not lost on immediate navigation.\n flush();\n };\n\n const getVariant: AnalyticsClient['getVariant'] = (\n experimentKey,\n variants,\n weights\n ) => assignVariant(sessionId, experimentKey, variants, weights);\n\n /** Attaches lifecycle + SPA-navigation listeners without blocking render. */\n const attachListeners = (): void => {\n if (typeof window === 'undefined') return;\n\n const onHidden = (): void => {\n if (document.visibilityState === 'hidden') flush(true);\n };\n const onPageHide = (): void => flush(true);\n const onPopState = (): void => trackPageView({ reason: 'route_change' });\n\n document.addEventListener('visibilitychange', onHidden);\n window.addEventListener('pagehide', onPageHide, { capture: true });\n window.addEventListener('popstate', onPopState);\n\n // Patch history.pushState so client-side route changes emit a page view.\n // `replaceState` is intentionally left untouched: routers use it for\n // same-page URL sync (scroll, params), which is not a navigation.\n const history = window.history;\n const originalPush = history.pushState;\n\n const patchedPushState = function patchedPush(\n this: History,\n ...args: Parameters<History['pushState']>\n ) {\n const result = originalPush.apply(this, args);\n trackPageView({ reason: 'route_change' });\n return result;\n };\n history.pushState = patchedPushState;\n\n detachListeners = (): void => {\n document.removeEventListener('visibilitychange', onHidden);\n window.removeEventListener('pagehide', onPageHide, { capture: true });\n window.removeEventListener('popstate', onPopState);\n // Only restore if nothing else re-patched pushState after us; restoring\n // blindly would clobber another library's wrapper.\n if (history.pushState === patchedPushState) {\n history.pushState = originalPush;\n }\n };\n };\n\n const start = (): void => {\n if (started || isBotSession) return;\n started = true;\n\n // Exchanged ahead of the first flush so a token is already cached by the\n // time a batch — including one sent during `pagehide` — goes out.\n publicToken.prime();\n\n flushTimer = setInterval(flush, config.flushInterval);\n // Some runtimes (Node during SSR) keep the process alive on timers.\n (flushTimer as { unref?: () => void })?.unref?.();\n\n onIdle(attachListeners);\n };\n\n const stop = (): void => {\n if (!started) return;\n started = false;\n\n if (flushTimer) {\n clearInterval(flushTimer);\n flushTimer = null;\n }\n detachListeners?.();\n detachListeners = null;\n flush(true);\n };\n\n return {\n setLocale,\n trackPageView,\n trackContentExposure,\n trackConversion,\n getVariant,\n flush,\n start,\n stop,\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,103 @@
1
+ //#region src/core/publicToken.ts
2
+ /**
3
+ * Short-lived ingest token used to authorise analytics batches.
4
+ *
5
+ * The SDK runs in the browser, where no confidential credential can be kept, so
6
+ * it exchanges the project's **public** `clientId` for a token scoped to
7
+ * analytics ingestion and nothing else. The backend authorises the exchange by
8
+ * the request's `Origin`, which the page cannot forge.
9
+ *
10
+ * The token is read synchronously at flush time — the flush-on-hide path runs
11
+ * during `pagehide` and cannot await anything — so it is fetched ahead of time
12
+ * and cached, both in memory and in `sessionStorage` so a multi-page visit
13
+ * exchanges once per tab rather than once per navigation.
14
+ */
15
+ /** `sessionStorage` key holding the cached token for this tab. */
16
+ const TOKEN_STORAGE_KEY = "__intlayer_public_token__";
17
+ /**
18
+ * Refresh margin: a token within this window of expiry is treated as expired,
19
+ * so a batch is never sent with a token that dies in flight.
20
+ */
21
+ const EXPIRY_MARGIN_MS = 6e4;
22
+ /** Reads the cached token for this tab, ignoring an unusable cache. */
23
+ const readStoredToken = () => {
24
+ try {
25
+ const raw = window.sessionStorage?.getItem(TOKEN_STORAGE_KEY);
26
+ if (!raw) return null;
27
+ const parsed = JSON.parse(raw);
28
+ if (typeof parsed?.token !== "string") return null;
29
+ if (typeof parsed?.expiresAt !== "number") return null;
30
+ return parsed;
31
+ } catch {
32
+ return null;
33
+ }
34
+ };
35
+ /** Persists the token for this tab. Best-effort: storage may be unavailable. */
36
+ const writeStoredToken = (cached) => {
37
+ try {
38
+ window.sessionStorage?.setItem(TOKEN_STORAGE_KEY, JSON.stringify(cached));
39
+ } catch {}
40
+ };
41
+ /**
42
+ * Creates the ingest token manager for a project.
43
+ *
44
+ * Every failure path is silent: analytics must never break, slow, or log noise
45
+ * into the host application. Without a token the client falls back to sending
46
+ * its public `clientId`, which the backend still accepts.
47
+ *
48
+ * @param options - Backend URL and the public project key.
49
+ * @returns A manager exposing a synchronous {@link PublicTokenManager.getToken}.
50
+ */
51
+ const createPublicTokenManager = ({ backendURL, clientId }) => {
52
+ const endpoint = `${backendURL.replace(/\/$/, "")}/api/public/token`;
53
+ let cached = null;
54
+ let pendingExchange = null;
55
+ const isUsable = (candidate) => candidate !== null && candidate.expiresAt - EXPIRY_MARGIN_MS > Date.now();
56
+ const getToken = () => {
57
+ if (isUsable(cached)) return cached.token;
58
+ if (typeof window !== "undefined") {
59
+ const stored = readStoredToken();
60
+ if (isUsable(stored)) {
61
+ cached = stored;
62
+ return stored.token;
63
+ }
64
+ }
65
+ };
66
+ const exchange = async () => {
67
+ try {
68
+ const response = await fetch(endpoint, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({ clientId }),
72
+ credentials: "omit",
73
+ mode: "cors"
74
+ });
75
+ if (!response.ok) return;
76
+ const payload = await response.json();
77
+ const token = payload?.data?.token;
78
+ const expiresIn = payload?.data?.expiresIn ?? 0;
79
+ if (!token || expiresIn <= 0) return;
80
+ cached = {
81
+ token,
82
+ expiresAt: Date.now() + expiresIn * 1e3
83
+ };
84
+ if (typeof window !== "undefined") writeStoredToken(cached);
85
+ } catch {} finally {
86
+ pendingExchange = null;
87
+ }
88
+ };
89
+ const prime = () => {
90
+ if (!clientId || typeof fetch !== "function") return;
91
+ if (getToken()) return;
92
+ if (pendingExchange) return;
93
+ pendingExchange = exchange();
94
+ };
95
+ return {
96
+ getToken,
97
+ prime
98
+ };
99
+ };
100
+
101
+ //#endregion
102
+ export { createPublicTokenManager };
103
+ //# sourceMappingURL=publicToken.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publicToken.mjs","names":[],"sources":["../../../src/core/publicToken.ts"],"sourcesContent":["/**\n * Short-lived ingest token used to authorise analytics batches.\n *\n * The SDK runs in the browser, where no confidential credential can be kept, so\n * it exchanges the project's **public** `clientId` for a token scoped to\n * analytics ingestion and nothing else. The backend authorises the exchange by\n * the request's `Origin`, which the page cannot forge.\n *\n * The token is read synchronously at flush time — the flush-on-hide path runs\n * during `pagehide` and cannot await anything — so it is fetched ahead of time\n * and cached, both in memory and in `sessionStorage` so a multi-page visit\n * exchanges once per tab rather than once per navigation.\n */\n\n/** `sessionStorage` key holding the cached token for this tab. */\nconst TOKEN_STORAGE_KEY = '__intlayer_public_token__';\n\n/**\n * Refresh margin: a token within this window of expiry is treated as expired,\n * so a batch is never sent with a token that dies in flight.\n */\nconst EXPIRY_MARGIN_MS = 60 * 1000;\n\ntype CachedToken = {\n token: string;\n /** Epoch milliseconds. */\n expiresAt: number;\n};\n\n/** Reads the cached token for this tab, ignoring an unusable cache. */\nconst readStoredToken = (): CachedToken | null => {\n try {\n const raw = window.sessionStorage?.getItem(TOKEN_STORAGE_KEY);\n if (!raw) return null;\n\n const parsed = JSON.parse(raw) as CachedToken;\n\n if (typeof parsed?.token !== 'string') return null;\n if (typeof parsed?.expiresAt !== 'number') return null;\n\n return parsed;\n } catch {\n // Private mode, disabled storage, or corrupted entry — start fresh.\n return null;\n }\n};\n\n/** Persists the token for this tab. Best-effort: storage may be unavailable. */\nconst writeStoredToken = (cached: CachedToken): void => {\n try {\n window.sessionStorage?.setItem(TOKEN_STORAGE_KEY, JSON.stringify(cached));\n } catch {\n /* ignore — the in-memory cache still applies for this page */\n }\n};\n\n/** Options accepted by {@link createPublicTokenManager}. */\nexport type PublicTokenManagerOptions = {\n /** Backend base URL, without a trailing slash. */\n backendURL: string;\n /** The project's public key (`editor.clientId`). */\n clientId?: string;\n};\n\n/** Synchronous accessor + background refresher for the ingest token. */\nexport type PublicTokenManager = {\n /**\n * The currently valid token, or `undefined` when none has been obtained yet.\n * Never blocks and never triggers a request.\n */\n getToken: () => string | undefined;\n /**\n * Ensures a valid token is being fetched. Safe to call repeatedly:\n * concurrent calls share a single in-flight exchange.\n */\n prime: () => void;\n};\n\n/**\n * Creates the ingest token manager for a project.\n *\n * Every failure path is silent: analytics must never break, slow, or log noise\n * into the host application. Without a token the client falls back to sending\n * its public `clientId`, which the backend still accepts.\n *\n * @param options - Backend URL and the public project key.\n * @returns A manager exposing a synchronous {@link PublicTokenManager.getToken}.\n */\nexport const createPublicTokenManager = ({\n backendURL,\n clientId,\n}: PublicTokenManagerOptions): PublicTokenManager => {\n const endpoint = `${backendURL.replace(/\\/$/, '')}/api/public/token`;\n\n let cached: CachedToken | null = null;\n let pendingExchange: Promise<void> | null = null;\n\n const isUsable = (candidate: CachedToken | null): candidate is CachedToken =>\n candidate !== null && candidate.expiresAt - EXPIRY_MARGIN_MS > Date.now();\n\n const getToken = (): string | undefined => {\n if (isUsable(cached)) return cached.token;\n\n if (typeof window !== 'undefined') {\n const stored = readStoredToken();\n if (isUsable(stored)) {\n cached = stored;\n return stored.token;\n }\n }\n\n return undefined;\n };\n\n const exchange = async (): Promise<void> => {\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ clientId }),\n // The exchange is authorised by `Origin`, never by cookies.\n credentials: 'omit',\n mode: 'cors',\n });\n\n if (!response.ok) return;\n\n const payload = (await response.json()) as {\n data?: { token?: string | null; expiresIn?: number };\n };\n\n const token = payload?.data?.token;\n const expiresIn = payload?.data?.expiresIn ?? 0;\n\n // A `null` token means the key is unknown or the origin is not allowed.\n if (!token || expiresIn <= 0) return;\n\n cached = { token, expiresAt: Date.now() + expiresIn * 1000 };\n\n if (typeof window !== 'undefined') writeStoredToken(cached);\n } catch {\n /* swallow — best-effort, the `clientId` fallback still reports */\n } finally {\n pendingExchange = null;\n }\n };\n\n const prime = (): void => {\n if (!clientId || typeof fetch !== 'function') return;\n if (getToken()) return;\n if (pendingExchange) return;\n\n pendingExchange = exchange();\n };\n\n return { getToken, prime };\n};\n"],"mappings":";;;;;;;;;;;;;;;AAeA,MAAM,oBAAoB;;;;;AAM1B,MAAM,mBAAmB;;AASzB,MAAM,wBAA4C;CAChD,IAAI;EACF,MAAM,MAAM,OAAO,gBAAgB,QAAQ,iBAAiB;EAC5D,IAAI,CAAC,KAAK,OAAO;EAEjB,MAAM,SAAS,KAAK,MAAM,GAAG;EAE7B,IAAI,OAAO,QAAQ,UAAU,UAAU,OAAO;EAC9C,IAAI,OAAO,QAAQ,cAAc,UAAU,OAAO;EAElD,OAAO;CACT,QAAQ;EAEN,OAAO;CACT;AACF;;AAGA,MAAM,oBAAoB,WAA8B;CACtD,IAAI;EACF,OAAO,gBAAgB,QAAQ,mBAAmB,KAAK,UAAU,MAAM,CAAC;CAC1E,QAAQ,CAER;AACF;;;;;;;;;;;AAkCA,MAAa,4BAA4B,EACvC,YACA,eACmD;CACnD,MAAM,WAAW,GAAG,WAAW,QAAQ,OAAO,EAAE,EAAE;CAElD,IAAI,SAA6B;CACjC,IAAI,kBAAwC;CAE5C,MAAM,YAAY,cAChB,cAAc,QAAQ,UAAU,YAAY,mBAAmB,KAAK,IAAI;CAE1E,MAAM,iBAAqC;EACzC,IAAI,SAAS,MAAM,GAAG,OAAO,OAAO;EAEpC,IAAI,OAAO,WAAW,aAAa;GACjC,MAAM,SAAS,gBAAgB;GAC/B,IAAI,SAAS,MAAM,GAAG;IACpB,SAAS;IACT,OAAO,OAAO;GAChB;EACF;CAGF;CAEA,MAAM,WAAW,YAA2B;EAC1C,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,UAAU;IACrC,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;IAC9C,MAAM,KAAK,UAAU,EAAE,SAAS,CAAC;IAEjC,aAAa;IACb,MAAM;GACR,CAAC;GAED,IAAI,CAAC,SAAS,IAAI;GAElB,MAAM,UAAW,MAAM,SAAS,KAAK;GAIrC,MAAM,QAAQ,SAAS,MAAM;GAC7B,MAAM,YAAY,SAAS,MAAM,aAAa;GAG9C,IAAI,CAAC,SAAS,aAAa,GAAG;GAE9B,SAAS;IAAE;IAAO,WAAW,KAAK,IAAI,IAAI,YAAY;GAAK;GAE3D,IAAI,OAAO,WAAW,aAAa,iBAAiB,MAAM;EAC5D,QAAQ,CAER,UAAU;GACR,kBAAkB;EACpB;CACF;CAEA,MAAM,cAAoB;EACxB,IAAI,CAAC,YAAY,OAAO,UAAU,YAAY;EAC9C,IAAI,SAAS,GAAG;EAChB,IAAI,iBAAiB;EAErB,kBAAkB,SAAS;CAC7B;CAEA,OAAO;EAAE;EAAU;CAAM;AAC3B"}
@@ -17,16 +17,22 @@
17
17
  * @param body - The batched payload.
18
18
  * @param options - Transport options.
19
19
  * @param options.useBeacon - Prefer `sendBeacon` (used when the page is hiding).
20
+ * @param options.token - Ingest token, sent as a bearer on the `fetch` path.
21
+ * `sendBeacon` cannot set headers, which is why the token is also carried in
22
+ * the body.
20
23
  * @returns `true` when a send was dispatched, `false` otherwise.
21
24
  */
22
- const sendEvents = (endpoint, body, { useBeacon = false } = {}) => {
25
+ const sendEvents = (endpoint, body, { useBeacon = false, token } = {}) => {
23
26
  try {
24
27
  const payload = JSON.stringify(body);
25
28
  if (useBeacon && typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") return navigator.sendBeacon(endpoint, payload);
26
29
  if (typeof fetch === "function") {
27
30
  fetch(endpoint, {
28
31
  method: "POST",
29
- headers: { "Content-Type": "application/json" },
32
+ headers: {
33
+ "Content-Type": "application/json",
34
+ ...token ? { Authorization: `Bearer ${token}` } : {}
35
+ },
30
36
  body: payload,
31
37
  keepalive: true,
32
38
  credentials: "omit",
@@ -1 +1 @@
1
- {"version":3,"file":"transport.mjs","names":[],"sources":["../../../src/core/transport.ts"],"sourcesContent":["import type { AnalyticsIngestBody } from './types';\n\n/**\n * Sends a batch of events to the backend. Every path is non-blocking and\n * no-throw — analytics must never break, slow, or delay the host application.\n *\n * Strategy:\n * 1. `navigator.sendBeacon` for the flush-on-hide path — survives page unload\n * and never blocks navigation. The payload is sent as `text/plain` (a\n * CORS-safelisted content type): a JSON `Blob` would require a CORS\n * preflight that browsers handle inconsistently during unload, silently\n * dropping the beacon. The backend parses `text/plain` bodies as JSON.\n * 2. `fetch(..., { keepalive: true })` for interval flushes — survives short\n * navigations without holding the main thread.\n * 3. Plain `fetch` fallback for runtimes without `keepalive` (older RN/Lynx).\n *\n * @param endpoint - Fully-qualified ingestion URL.\n * @param body - The batched payload.\n * @param options - Transport options.\n * @param options.useBeacon - Prefer `sendBeacon` (used when the page is hiding).\n * @returns `true` when a send was dispatched, `false` otherwise.\n */\nexport const sendEvents = (\n endpoint: string,\n body: AnalyticsIngestBody,\n { useBeacon = false }: { useBeacon?: boolean } = {}\n): boolean => {\n try {\n const payload = JSON.stringify(body);\n\n if (\n useBeacon &&\n typeof navigator !== 'undefined' &&\n typeof navigator.sendBeacon === 'function'\n ) {\n // Note: unlike the fetch path below, sendBeacon always includes\n // credentials; the ingest endpoint ignores them.\n return navigator.sendBeacon(endpoint, payload);\n }\n\n if (typeof fetch === 'function') {\n // keepalive lets the request outlive a same-tab navigation.\n void fetch(endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: payload,\n keepalive: true,\n // Analytics is best-effort; never send credentials.\n credentials: 'omit',\n mode: 'cors',\n }).catch(() => {\n /* swallow — best-effort delivery */\n });\n return true;\n }\n\n return false;\n } catch {\n // JSON serialization failed — drop the batch silently.\n return false;\n }\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAsBA,MAAa,cACX,UACA,MACA,EAAE,YAAY,UAAmC,CAAC,MACtC;CACZ,IAAI;EACF,MAAM,UAAU,KAAK,UAAU,IAAI;EAEnC,IACE,aACA,OAAO,cAAc,eACrB,OAAO,UAAU,eAAe,YAIhC,OAAO,UAAU,WAAW,UAAU,OAAO;EAG/C,IAAI,OAAO,UAAU,YAAY;GAE/B,AAAK,MAAM,UAAU;IACnB,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;IAC9C,MAAM;IACN,WAAW;IAEX,aAAa;IACb,MAAM;GACR,CAAC,CAAC,CAAC,YAAY,CAEf,CAAC;GACD,OAAO;EACT;EAEA,OAAO;CACT,QAAQ;EAEN,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"transport.mjs","names":[],"sources":["../../../src/core/transport.ts"],"sourcesContent":["import type { AnalyticsIngestBody } from './types';\n\n/**\n * Sends a batch of events to the backend. Every path is non-blocking and\n * no-throw — analytics must never break, slow, or delay the host application.\n *\n * Strategy:\n * 1. `navigator.sendBeacon` for the flush-on-hide path — survives page unload\n * and never blocks navigation. The payload is sent as `text/plain` (a\n * CORS-safelisted content type): a JSON `Blob` would require a CORS\n * preflight that browsers handle inconsistently during unload, silently\n * dropping the beacon. The backend parses `text/plain` bodies as JSON.\n * 2. `fetch(..., { keepalive: true })` for interval flushes — survives short\n * navigations without holding the main thread.\n * 3. Plain `fetch` fallback for runtimes without `keepalive` (older RN/Lynx).\n *\n * @param endpoint - Fully-qualified ingestion URL.\n * @param body - The batched payload.\n * @param options - Transport options.\n * @param options.useBeacon - Prefer `sendBeacon` (used when the page is hiding).\n * @param options.token - Ingest token, sent as a bearer on the `fetch` path.\n * `sendBeacon` cannot set headers, which is why the token is also carried in\n * the body.\n * @returns `true` when a send was dispatched, `false` otherwise.\n */\nexport const sendEvents = (\n endpoint: string,\n body: AnalyticsIngestBody,\n { useBeacon = false, token }: { useBeacon?: boolean; token?: string } = {}\n): boolean => {\n try {\n const payload = JSON.stringify(body);\n\n if (\n useBeacon &&\n typeof navigator !== 'undefined' &&\n typeof navigator.sendBeacon === 'function'\n ) {\n // Note: unlike the fetch path below, sendBeacon always includes\n // credentials; the ingest endpoint ignores them.\n return navigator.sendBeacon(endpoint, payload);\n }\n\n if (typeof fetch === 'function') {\n // keepalive lets the request outlive a same-tab navigation.\n void fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...(token ? { Authorization: `Bearer ${token}` } : {}),\n },\n body: payload,\n keepalive: true,\n // Analytics is best-effort; never send credentials.\n credentials: 'omit',\n mode: 'cors',\n }).catch(() => {\n /* swallow — best-effort delivery */\n });\n return true;\n }\n\n return false;\n } catch {\n // JSON serialization failed — drop the batch silently.\n return false;\n }\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAyBA,MAAa,cACX,UACA,MACA,EAAE,YAAY,OAAO,UAAmD,CAAC,MAC7D;CACZ,IAAI;EACF,MAAM,UAAU,KAAK,UAAU,IAAI;EAEnC,IACE,aACA,OAAO,cAAc,eACrB,OAAO,UAAU,eAAe,YAIhC,OAAO,UAAU,WAAW,UAAU,OAAO;EAG/C,IAAI,OAAO,UAAU,YAAY;GAE/B,AAAK,MAAM,UAAU;IACnB,QAAQ;IACR,SAAS;KACP,gBAAgB;KAChB,GAAI,QAAQ,EAAE,eAAe,UAAU,QAAQ,IAAI,CAAC;IACtD;IACA,MAAM;IACN,WAAW;IAEX,aAAa;IACb,MAAM;GACR,CAAC,CAAC,CAAC,YAAY,CAEf,CAAC;GACD,OAAO;EACT;EAEA,OAAO;CACT,QAAQ;EAEN,OAAO;CACT;AACF"}
@@ -1,3 +1,4 @@
1
+ import { BOT_USER_AGENT_PATTERN, isBotEnvironment, isBotUserAgent } from "./utils/isBot.mjs";
1
2
  import { assignVariant } from "./core/abTesting.mjs";
2
3
  import { createEventBuffer } from "./core/eventBuffer.mjs";
3
4
  import { isSampledIn } from "./core/sampling.mjs";
@@ -8,4 +9,4 @@ import { decrementAnalyticsClientRefCount, getGlobalAnalyticsClient, incrementAn
8
9
  import { initAnalyticsClient, stopAnalyticsClient } from "./core/initAnalyticsClient.mjs";
9
10
  import { buildContentExposure, serializeKeyPath } from "./events/contentExposure.mjs";
10
11
 
11
- export { DEFAULT_FLUSH_INTERVAL, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_SAMPLE_RATE, MAX_EVENTS_PER_SEND, assignVariant, buildContentExposure, createAnalyticsClient, createEventBuffer, decrementAnalyticsClientRefCount, getGlobalAnalyticsClient, getSessionId, incrementAnalyticsClientRefCount, initAnalyticsClient, isSampledIn, sendEvents, serializeKeyPath, setGlobalAnalyticsClient, stopAnalyticsClient };
12
+ export { BOT_USER_AGENT_PATTERN, DEFAULT_FLUSH_INTERVAL, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_SAMPLE_RATE, MAX_EVENTS_PER_SEND, assignVariant, buildContentExposure, createAnalyticsClient, createEventBuffer, decrementAnalyticsClientRefCount, getGlobalAnalyticsClient, getSessionId, incrementAnalyticsClientRefCount, initAnalyticsClient, isBotEnvironment, isBotUserAgent, isSampledIn, sendEvents, serializeKeyPath, setGlobalAnalyticsClient, stopAnalyticsClient };
@@ -1,3 +1,4 @@
1
+ import { isBotEnvironment } from "./utils/isBot.mjs";
1
2
  import { analytics, editor } from "@intlayer/config/built";
2
3
 
3
4
  //#region src/isEnabled.ts
@@ -20,9 +21,10 @@ const TREE_SHAKE_ANALYTICS = process.env.INTLAYER_ANALYTICS_ENABLED === "false";
20
21
  * - the user did not opt out (`analytics.enabled === true`, the default), and
21
22
  * - a project key is configured (`editor.clientId`), and
22
23
  * - we are on the client, and
23
- * - we are the top window (not embedded in the editor).
24
+ * - we are the top window (not embedded in the editor), and
25
+ * - the visitor is not a bot, crawler or automation runtime.
24
26
  */
25
- const isEnabled = !TREE_SHAKE_ANALYTICS && analytics?.enabled === true && Boolean(editor?.clientId) && typeof window !== "undefined" && window.self === window.top;
27
+ const isEnabled = !TREE_SHAKE_ANALYTICS && analytics?.enabled === true && Boolean(editor?.clientId) && typeof window !== "undefined" && window.self === window.top && !isBotEnvironment();
26
28
 
27
29
  //#endregion
28
30
  export { isEnabled };
@@ -1 +1 @@
1
- {"version":3,"file":"isEnabled.mjs","names":[],"sources":["../../src/isEnabled.ts"],"sourcesContent":["import { analytics, editor } from '@intlayer/config/built';\n\n/**\n * Compile-time kill switch. Bundlers replace `process.env.INTLAYER_ANALYTICS_ENABLED`\n * (set to `'false'` by `@intlayer/config` unless `analytics.enabled` is `true`\n * — the default once this package is installed — AND an `editor.clientId` is\n * configured) so the whole analytics integration is dead-code-eliminated for\n * apps that opted out.\n */\nconst TREE_SHAKE_ANALYTICS = process.env.INTLAYER_ANALYTICS_ENABLED === 'false';\n\n/**\n * Runtime guard mirroring `@intlayer/editor`'s `isEnabled`, but inverted with\n * respect to the iframe: analytics runs for real end users on the **top**\n * window and stays off inside the editor/CMS preview iframe so editor sessions\n * are never counted as traffic.\n *\n * Enabled when:\n * - the bundler did not tree-shake analytics away, and\n * - the user did not opt out (`analytics.enabled === true`, the default), and\n * - a project key is configured (`editor.clientId`), and\n * - we are on the client, and\n * - we are the top window (not embedded in the editor).\n */\nexport const isEnabled =\n !TREE_SHAKE_ANALYTICS &&\n analytics?.enabled === true &&\n Boolean(editor?.clientId) &&\n typeof window !== 'undefined' &&\n window.self === window.top;\n"],"mappings":";;;;;;;;;;AASA,MAAM,uBAAuB,QAAQ,IAAI,+BAA+B;;;;;;;;;;;;;;AAexE,MAAa,YACX,CAAC,wBACD,WAAW,YAAY,QACvB,QAAQ,QAAQ,QAAQ,KACxB,OAAO,WAAW,eAClB,OAAO,SAAS,OAAO"}
1
+ {"version":3,"file":"isEnabled.mjs","names":[],"sources":["../../src/isEnabled.ts"],"sourcesContent":["import { analytics, editor } from '@intlayer/config/built';\nimport { isBotEnvironment } from './utils/isBot';\n\n/**\n * Compile-time kill switch. Bundlers replace `process.env.INTLAYER_ANALYTICS_ENABLED`\n * (set to `'false'` by `@intlayer/config` unless `analytics.enabled` is `true`\n * — the default once this package is installed — AND an `editor.clientId` is\n * configured) so the whole analytics integration is dead-code-eliminated for\n * apps that opted out.\n */\nconst TREE_SHAKE_ANALYTICS = process.env.INTLAYER_ANALYTICS_ENABLED === 'false';\n\n/**\n * Runtime guard mirroring `@intlayer/editor`'s `isEnabled`, but inverted with\n * respect to the iframe: analytics runs for real end users on the **top**\n * window and stays off inside the editor/CMS preview iframe so editor sessions\n * are never counted as traffic.\n *\n * Enabled when:\n * - the bundler did not tree-shake analytics away, and\n * - the user did not opt out (`analytics.enabled === true`, the default), and\n * - a project key is configured (`editor.clientId`), and\n * - we are on the client, and\n * - we are the top window (not embedded in the editor), and\n * - the visitor is not a bot, crawler or automation runtime.\n */\nexport const isEnabled =\n !TREE_SHAKE_ANALYTICS &&\n analytics?.enabled === true &&\n Boolean(editor?.clientId) &&\n typeof window !== 'undefined' &&\n window.self === window.top &&\n !isBotEnvironment();\n"],"mappings":";;;;;;;;;;;AAUA,MAAM,uBAAuB,QAAQ,IAAI,+BAA+B;;;;;;;;;;;;;;;AAgBxE,MAAa,YACX,CAAC,wBACD,WAAW,YAAY,QACvB,QAAQ,QAAQ,QAAQ,KACxB,OAAO,WAAW,eAClB,OAAO,SAAS,OAAO,OACvB,CAAC,iBAAiB"}
@@ -0,0 +1,86 @@
1
+ //#region src/utils/isBot.ts
2
+ /**
3
+ * Bot / automation detection.
4
+ *
5
+ * Analytics must describe real people. Crawlers, link-preview fetchers, uptime
6
+ * monitors, AI agents and CI browsers (Lighthouse, Playwright, Puppeteer) all
7
+ * execute JavaScript nowadays, so they would otherwise be counted as visitors
8
+ * and inflate page views, content exposures and A/B conversion rates.
9
+ *
10
+ * Everything here is a pure string/environment check — no network, no storage.
11
+ */
12
+ /**
13
+ * User agents that look like a bot but belong to real user devices, checked
14
+ * before {@link BOT_USER_AGENT_PATTERN}. `CUBOT` is an Android phone brand
15
+ * whose model strings (`CUBOT_X19`) contain the `bot` token.
16
+ */
17
+ const HUMAN_USER_AGENT_EXCEPTION_PATTERN = /cubot/i;
18
+ /**
19
+ * Matches known non-human user agents. Grouped by family so the list stays
20
+ * reviewable:
21
+ * - generic tokens (`…bot`, `crawler`, `spider`, `scraper`)
22
+ * - HTTP clients used by scripts (curl, wget, requests, axios…)
23
+ * - headless / automation runtimes (Playwright, Puppeteer, Lighthouse…)
24
+ * - search-engine and SEO crawlers
25
+ * - social link-preview fetchers
26
+ * - AI / LLM crawlers and agents
27
+ */
28
+ const BOT_USER_AGENT_PATTERN = /bot\b|bot[/\-\s]|crawler|crawling|spider|scraper|slurp|archiver|feedfetcher|validator|curl\/|wget\/|python-requests|python-urllib|okhttp|axios\/|node-fetch|got \(|go-http-client|java\/|libwww|httpunit|http_request|apache-httpclient|headless|phantomjs|puppeteer|playwright|selenium|webdriver|cypress|lighthouse|pagespeed|gtmetrix|prerender|pingdom|uptime|statuscake|site24x7|bingpreview|yandex|baiduspider|sogou|exabot|semrush|ahrefs|mj12|dotbot|petalbot|applebot|amazonbot|bytespider|facebookexternalhit|meta-externalagent|embedly|outbrain|quora link preview|skypeuripreview|vkshare|w3c_validator|apis-google|mediapartners|adsbot|storebot-google|google-inspectiontool|google-read-aloud|google-extended|duplexweb-google|gptbot|oai-searchbot|chatgpt-user|perplexity|claudebot|claude-web|anthropic-ai|cohere-ai|ccbot|diffbot|imagesift|omgili|timpi|youbot/i;
29
+ /**
30
+ * Tells whether a user agent string identifies a bot, crawler or automation
31
+ * runtime rather than a human visitor.
32
+ *
33
+ * A missing or empty user agent is treated as a bot: every real browser sends
34
+ * one, so its absence means a script or a forged request.
35
+ *
36
+ * @param userAgent - The raw `User-Agent` string (header or `navigator.userAgent`).
37
+ * @returns `true` when the traffic must not be recorded.
38
+ *
39
+ * @example
40
+ * isBotUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1)'); // true
41
+ * isBotUserAgent('Mozilla/5.0 (Macintosh) Chrome/120'); // false
42
+ */
43
+ const isBotUserAgent = (userAgent) => {
44
+ if (!userAgent) return true;
45
+ if (HUMAN_USER_AGENT_EXCEPTION_PATTERN.test(userAgent)) return false;
46
+ return BOT_USER_AGENT_PATTERN.test(userAgent);
47
+ };
48
+ /**
49
+ * Globals injected by automation runtimes. Their mere presence means the page
50
+ * is driven by a script, whatever the user agent claims.
51
+ */
52
+ const AUTOMATION_GLOBAL_KEYS = [
53
+ "_phantom",
54
+ "__nightmare",
55
+ "callPhantom",
56
+ "__selenium_unwrapped",
57
+ "__webdriver_evaluate",
58
+ "__driver_evaluate",
59
+ "domAutomation",
60
+ "Cypress"
61
+ ];
62
+ /**
63
+ * Tells whether the current browser environment is automated, combining the
64
+ * user agent with automation fingerprints (`navigator.webdriver`, runtime
65
+ * globals) that headless browsers spoofing a normal user agent still expose.
66
+ *
67
+ * Always `false` outside a browser (SSR): there is nothing to collect there,
68
+ * and the server must not disable a client that has not run yet.
69
+ *
70
+ * @returns `true` when analytics must stay off for this environment.
71
+ *
72
+ * @example
73
+ * if (isBotEnvironment()) return; // never start the client for a crawler
74
+ */
75
+ const isBotEnvironment = () => {
76
+ if (typeof navigator === "undefined") return false;
77
+ if (navigator.webdriver === true) return true;
78
+ const automationGlobals = globalThis;
79
+ if (AUTOMATION_GLOBAL_KEYS.some((key) => key in automationGlobals)) return true;
80
+ if (!navigator.userAgent) return false;
81
+ return isBotUserAgent(navigator.userAgent);
82
+ };
83
+
84
+ //#endregion
85
+ export { BOT_USER_AGENT_PATTERN, isBotEnvironment, isBotUserAgent };
86
+ //# sourceMappingURL=isBot.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isBot.mjs","names":[],"sources":["../../../src/utils/isBot.ts"],"sourcesContent":["/**\n * Bot / automation detection.\n *\n * Analytics must describe real people. Crawlers, link-preview fetchers, uptime\n * monitors, AI agents and CI browsers (Lighthouse, Playwright, Puppeteer) all\n * execute JavaScript nowadays, so they would otherwise be counted as visitors\n * and inflate page views, content exposures and A/B conversion rates.\n *\n * Everything here is a pure string/environment check — no network, no storage.\n */\n\n/**\n * User agents that look like a bot but belong to real user devices, checked\n * before {@link BOT_USER_AGENT_PATTERN}. `CUBOT` is an Android phone brand\n * whose model strings (`CUBOT_X19`) contain the `bot` token.\n */\nconst HUMAN_USER_AGENT_EXCEPTION_PATTERN = /cubot/i;\n\n/**\n * Matches known non-human user agents. Grouped by family so the list stays\n * reviewable:\n * - generic tokens (`…bot`, `crawler`, `spider`, `scraper`)\n * - HTTP clients used by scripts (curl, wget, requests, axios…)\n * - headless / automation runtimes (Playwright, Puppeteer, Lighthouse…)\n * - search-engine and SEO crawlers\n * - social link-preview fetchers\n * - AI / LLM crawlers and agents\n */\nexport const BOT_USER_AGENT_PATTERN =\n /bot\\b|bot[/\\-\\s]|crawler|crawling|spider|scraper|slurp|archiver|feedfetcher|validator|curl\\/|wget\\/|python-requests|python-urllib|okhttp|axios\\/|node-fetch|got \\(|go-http-client|java\\/|libwww|httpunit|http_request|apache-httpclient|headless|phantomjs|puppeteer|playwright|selenium|webdriver|cypress|lighthouse|pagespeed|gtmetrix|prerender|pingdom|uptime|statuscake|site24x7|bingpreview|yandex|baiduspider|sogou|exabot|semrush|ahrefs|mj12|dotbot|petalbot|applebot|amazonbot|bytespider|facebookexternalhit|meta-externalagent|embedly|outbrain|quora link preview|skypeuripreview|vkshare|w3c_validator|apis-google|mediapartners|adsbot|storebot-google|google-inspectiontool|google-read-aloud|google-extended|duplexweb-google|gptbot|oai-searchbot|chatgpt-user|perplexity|claudebot|claude-web|anthropic-ai|cohere-ai|ccbot|diffbot|imagesift|omgili|timpi|youbot/i;\n\n/**\n * Tells whether a user agent string identifies a bot, crawler or automation\n * runtime rather than a human visitor.\n *\n * A missing or empty user agent is treated as a bot: every real browser sends\n * one, so its absence means a script or a forged request.\n *\n * @param userAgent - The raw `User-Agent` string (header or `navigator.userAgent`).\n * @returns `true` when the traffic must not be recorded.\n *\n * @example\n * isBotUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1)'); // true\n * isBotUserAgent('Mozilla/5.0 (Macintosh) Chrome/120'); // false\n */\nexport const isBotUserAgent = (userAgent?: string | null): boolean => {\n if (!userAgent) return true;\n if (HUMAN_USER_AGENT_EXCEPTION_PATTERN.test(userAgent)) return false;\n\n return BOT_USER_AGENT_PATTERN.test(userAgent);\n};\n\n/**\n * Globals injected by automation runtimes. Their mere presence means the page\n * is driven by a script, whatever the user agent claims.\n */\nconst AUTOMATION_GLOBAL_KEYS = [\n '_phantom',\n '__nightmare',\n 'callPhantom',\n '__selenium_unwrapped',\n '__webdriver_evaluate',\n '__driver_evaluate',\n 'domAutomation',\n 'Cypress',\n] as const;\n\n/**\n * Tells whether the current browser environment is automated, combining the\n * user agent with automation fingerprints (`navigator.webdriver`, runtime\n * globals) that headless browsers spoofing a normal user agent still expose.\n *\n * Always `false` outside a browser (SSR): there is nothing to collect there,\n * and the server must not disable a client that has not run yet.\n *\n * @returns `true` when analytics must stay off for this environment.\n *\n * @example\n * if (isBotEnvironment()) return; // never start the client for a crawler\n */\nexport const isBotEnvironment = (): boolean => {\n if (typeof navigator === 'undefined') return false;\n\n if (navigator.webdriver === true) return true;\n\n const automationGlobals = globalThis as unknown as Record<string, unknown>;\n if (AUTOMATION_GLOBAL_KEYS.some((key) => key in automationGlobals)) {\n return true;\n }\n\n // Unlike a server reading the `User-Agent` header, an absent user agent here\n // means a non-browser runtime (React Native, Lynx) — a real app, not a bot.\n if (!navigator.userAgent) return false;\n\n return isBotUserAgent(navigator.userAgent);\n};\n"],"mappings":";;;;;;;;;;;;;;;;AAgBA,MAAM,qCAAqC;;;;;;;;;;;AAY3C,MAAa,yBACX;;;;;;;;;;;;;;;AAgBF,MAAa,kBAAkB,cAAuC;CACpE,IAAI,CAAC,WAAW,OAAO;CACvB,IAAI,mCAAmC,KAAK,SAAS,GAAG,OAAO;CAE/D,OAAO,uBAAuB,KAAK,SAAS;AAC9C;;;;;AAMA,MAAM,yBAAyB;CAC7B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;;;;;;;AAeA,MAAa,yBAAkC;CAC7C,IAAI,OAAO,cAAc,aAAa,OAAO;CAE7C,IAAI,UAAU,cAAc,MAAM,OAAO;CAEzC,MAAM,oBAAoB;CAC1B,IAAI,uBAAuB,MAAM,QAAQ,OAAO,iBAAiB,GAC/D,OAAO;CAKT,IAAI,CAAC,UAAU,WAAW,OAAO;CAEjC,OAAO,eAAe,UAAU,SAAS;AAC3C"}
@@ -1 +1 @@
1
- {"version":3,"file":"AnalyticsClient.d.ts","names":[],"sources":["../../../src/core/AnalyticsClient.ts"],"mappings":";;;;;;KAmBY;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA,SAAS;;;cAIE;;cAEA;;cAEA;;;;;cAKA;;;;;KAQD;;EAEV,YAAY;;EAEZ,gBAAgB,QAAQ;;EAExB,uBACE,OAAO,KAAK,eAAe;;EAG7B,kBACE,OAAO,KAAK,eAAe;;EAG7B,aACE,uBACA,oBACA;;EAGF,QAAQ;;EAER;;EAEA;;;;;;;;;cA+BW,wBAAqB,QACxB,0BACP"}
1
+ {"version":3,"file":"AnalyticsClient.d.ts","names":[],"sources":["../../../src/core/AnalyticsClient.ts"],"mappings":";;;;;;KAqBY;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA,SAAS;;;cAIE;;cAEA;;cAEA;;;;;cAKA;;;;;KAQD;;EAEV,YAAY;;EAEZ,gBAAgB,QAAQ;;EAExB,uBACE,OAAO,KAAK,eAAe;;EAG7B,kBACE,OAAO,KAAK,eAAe;;EAG7B,aACE,uBACA,oBACA;;EAGF,QAAQ;;EAER;;EAEA;;;;;;;;;cA+BW,wBAAqB,QACxB,0BACP"}
@@ -0,0 +1,48 @@
1
+ //#region src/core/publicToken.d.ts
2
+ /**
3
+ * Short-lived ingest token used to authorise analytics batches.
4
+ *
5
+ * The SDK runs in the browser, where no confidential credential can be kept, so
6
+ * it exchanges the project's **public** `clientId` for a token scoped to
7
+ * analytics ingestion and nothing else. The backend authorises the exchange by
8
+ * the request's `Origin`, which the page cannot forge.
9
+ *
10
+ * The token is read synchronously at flush time — the flush-on-hide path runs
11
+ * during `pagehide` and cannot await anything — so it is fetched ahead of time
12
+ * and cached, both in memory and in `sessionStorage` so a multi-page visit
13
+ * exchanges once per tab rather than once per navigation.
14
+ */
15
+ /** Options accepted by {@link createPublicTokenManager}. */
16
+ type PublicTokenManagerOptions = {
17
+ /** Backend base URL, without a trailing slash. */
18
+ backendURL: string;
19
+ /** The project's public key (`editor.clientId`). */
20
+ clientId?: string;
21
+ };
22
+ /** Synchronous accessor + background refresher for the ingest token. */
23
+ type PublicTokenManager = {
24
+ /**
25
+ * The currently valid token, or `undefined` when none has been obtained yet.
26
+ * Never blocks and never triggers a request.
27
+ */
28
+ getToken: () => string | undefined;
29
+ /**
30
+ * Ensures a valid token is being fetched. Safe to call repeatedly:
31
+ * concurrent calls share a single in-flight exchange.
32
+ */
33
+ prime: () => void;
34
+ };
35
+ /**
36
+ * Creates the ingest token manager for a project.
37
+ *
38
+ * Every failure path is silent: analytics must never break, slow, or log noise
39
+ * into the host application. Without a token the client falls back to sending
40
+ * its public `clientId`, which the backend still accepts.
41
+ *
42
+ * @param options - Backend URL and the public project key.
43
+ * @returns A manager exposing a synchronous {@link PublicTokenManager.getToken}.
44
+ */
45
+ declare const createPublicTokenManager: ({ backendURL, clientId }: PublicTokenManagerOptions) => PublicTokenManager;
46
+ //#endregion
47
+ export { PublicTokenManager, PublicTokenManagerOptions, createPublicTokenManager };
48
+ //# sourceMappingURL=publicToken.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publicToken.d.ts","names":[],"sources":["../../../src/core/publicToken.ts"],"mappings":";;;;;;;;;;;;;;;KAyDY;;EAEV;;EAEA;;;KAIU;;;;;EAKV;;;;;EAKA;;;;;;;;;;;;cAaW,6BAAwB,YAAA,YAGlC,8BAA4B"}
@@ -18,10 +18,14 @@ import { AnalyticsIngestBody } from "./types.js";
18
18
  * @param body - The batched payload.
19
19
  * @param options - Transport options.
20
20
  * @param options.useBeacon - Prefer `sendBeacon` (used when the page is hiding).
21
+ * @param options.token - Ingest token, sent as a bearer on the `fetch` path.
22
+ * `sendBeacon` cannot set headers, which is why the token is also carried in
23
+ * the body.
21
24
  * @returns `true` when a send was dispatched, `false` otherwise.
22
25
  */
23
- declare const sendEvents: (endpoint: string, body: AnalyticsIngestBody, { useBeacon }?: {
26
+ declare const sendEvents: (endpoint: string, body: AnalyticsIngestBody, { useBeacon, token }?: {
24
27
  useBeacon?: boolean;
28
+ token?: string;
25
29
  }) => boolean;
26
30
  //#endregion
27
31
  export { sendEvents };
@@ -1 +1 @@
1
- {"version":3,"file":"transport.d.ts","names":[],"sources":["../../../src/core/transport.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;cAsBa,aAAU,kBACL,MACV,uBAAmB;EACA"}
1
+ {"version":3,"file":"transport.d.ts","names":[],"sources":["../../../src/core/transport.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;cAyBa,aAAU,kBACL,MACV,uBAAmB,WAAA;EACO;EAAqB"}
@@ -84,7 +84,18 @@ type TrackableEvent<E extends AnalyticsEvent = AnalyticsEvent> = Omit<E, 't' | '
84
84
  * The batched request body sent to `POST /api/analytics/events`.
85
85
  */
86
86
  type AnalyticsIngestBody = {
87
- /** Public project key — reused from `editor.clientId`. */
87
+ /**
88
+ * Short-lived, ingest-scoped token obtained by exchanging the public
89
+ * `clientId`. Sent in the body rather than an `Authorization` header because
90
+ * the flush-on-hide path uses `navigator.sendBeacon`, which cannot set
91
+ * headers; the `fetch` path sends it both ways.
92
+ */
93
+ token?: string;
94
+ /**
95
+ * Public project key — reused from `editor.clientId`. Sent only until the
96
+ * token exchange resolves, so a batch collected on the very first page view
97
+ * is still attributed.
98
+ */
88
99
  clientId?: string;
89
100
  /** Anonymous per-tab session identifier (new tab ⇒ new id; never durable). */
90
101
  sessionId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/core/types.ts"],"mappings":";;;;;;;;;KASY;;;;KAQA;;EAEV;;EAEA;;EAEA;;;;;;KAOU,gBAAgB;EAC1B;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;KAQU,uBAAuB;EACjC;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;KAMU,kBAAkB;EAC5B;;EAEA;;EAEA;;EAEA;;EAEA;;;;;KAMU,iBACR,gBACA,uBACA;;;;;KAMQ,eAAe,UAAU,iBAAiB,kBAAkB,KACtE;;EAIA;;;;;KAMU;;EAEV;;EAEA;;EAEA;;EAEA,QAAQ;;;;;;KAOE;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/core/types.ts"],"mappings":";;;;;;;;;KASY;;;;KAQA;;EAEV;;EAEA;;EAEA;;;;;;KAOU,gBAAgB;EAC1B;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;KAQU,uBAAuB;EACjC;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;KAMU,kBAAkB;EAC5B;;EAEA;;EAEA;;EAEA;;EAEA;;;;;KAMU,iBACR,gBACA,uBACA;;;;;KAMQ,eAAe,UAAU,iBAAiB,kBAAkB,KACtE;;EAIA;;;;;KAMU;;;;;;;EAOV;;;;;;EAMA;;EAEA;;EAEA;;EAEA,QAAQ;;;;;;KAOE;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA"}
@@ -8,4 +8,5 @@ import { isSampledIn } from "./core/sampling.js";
8
8
  import { getSessionId } from "./core/session.js";
9
9
  import { sendEvents } from "./core/transport.js";
10
10
  import { buildContentExposure, serializeKeyPath } from "./events/contentExposure.js";
11
- export { AnalyticsClient, AnalyticsClientConfig, AnalyticsEvent, AnalyticsEventType, AnalyticsIngestBody, BaseAnalyticsEvent, ContentExposureEvent, ConversionEvent, DEFAULT_FLUSH_INTERVAL, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_SAMPLE_RATE, EventBuffer, MAX_EVENTS_PER_SEND, PageViewEvent, PageViewInput, TrackableEvent, assignVariant, buildContentExposure, createAnalyticsClient, createEventBuffer, decrementAnalyticsClientRefCount, getGlobalAnalyticsClient, getSessionId, incrementAnalyticsClientRefCount, initAnalyticsClient, isSampledIn, sendEvents, serializeKeyPath, setGlobalAnalyticsClient, stopAnalyticsClient };
11
+ import { BOT_USER_AGENT_PATTERN, isBotEnvironment, isBotUserAgent } from "./utils/isBot.js";
12
+ export { AnalyticsClient, AnalyticsClientConfig, AnalyticsEvent, AnalyticsEventType, AnalyticsIngestBody, BOT_USER_AGENT_PATTERN, BaseAnalyticsEvent, ContentExposureEvent, ConversionEvent, DEFAULT_FLUSH_INTERVAL, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_SAMPLE_RATE, EventBuffer, MAX_EVENTS_PER_SEND, PageViewEvent, PageViewInput, TrackableEvent, assignVariant, buildContentExposure, createAnalyticsClient, createEventBuffer, decrementAnalyticsClientRefCount, getGlobalAnalyticsClient, getSessionId, incrementAnalyticsClientRefCount, initAnalyticsClient, isBotEnvironment, isBotUserAgent, isSampledIn, sendEvents, serializeKeyPath, setGlobalAnalyticsClient, stopAnalyticsClient };
@@ -10,7 +10,8 @@
10
10
  * - the user did not opt out (`analytics.enabled === true`, the default), and
11
11
  * - a project key is configured (`editor.clientId`), and
12
12
  * - we are on the client, and
13
- * - we are the top window (not embedded in the editor).
13
+ * - we are the top window (not embedded in the editor), and
14
+ * - the visitor is not a bot, crawler or automation runtime.
14
15
  */
15
16
  declare const isEnabled: boolean;
16
17
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"isEnabled.d.ts","names":[],"sources":["../../src/isEnabled.ts"],"mappings":";;;;;;;;;;;;;;cAwBa"}
1
+ {"version":3,"file":"isEnabled.d.ts","names":[],"sources":["../../src/isEnabled.ts"],"mappings":";;;;;;;;;;;;;;;cA0Ba"}
@@ -0,0 +1,54 @@
1
+ //#region src/utils/isBot.d.ts
2
+ /**
3
+ * Bot / automation detection.
4
+ *
5
+ * Analytics must describe real people. Crawlers, link-preview fetchers, uptime
6
+ * monitors, AI agents and CI browsers (Lighthouse, Playwright, Puppeteer) all
7
+ * execute JavaScript nowadays, so they would otherwise be counted as visitors
8
+ * and inflate page views, content exposures and A/B conversion rates.
9
+ *
10
+ * Everything here is a pure string/environment check — no network, no storage.
11
+ */
12
+ /**
13
+ * Matches known non-human user agents. Grouped by family so the list stays
14
+ * reviewable:
15
+ * - generic tokens (`…bot`, `crawler`, `spider`, `scraper`)
16
+ * - HTTP clients used by scripts (curl, wget, requests, axios…)
17
+ * - headless / automation runtimes (Playwright, Puppeteer, Lighthouse…)
18
+ * - search-engine and SEO crawlers
19
+ * - social link-preview fetchers
20
+ * - AI / LLM crawlers and agents
21
+ */
22
+ declare const BOT_USER_AGENT_PATTERN: RegExp;
23
+ /**
24
+ * Tells whether a user agent string identifies a bot, crawler or automation
25
+ * runtime rather than a human visitor.
26
+ *
27
+ * A missing or empty user agent is treated as a bot: every real browser sends
28
+ * one, so its absence means a script or a forged request.
29
+ *
30
+ * @param userAgent - The raw `User-Agent` string (header or `navigator.userAgent`).
31
+ * @returns `true` when the traffic must not be recorded.
32
+ *
33
+ * @example
34
+ * isBotUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1)'); // true
35
+ * isBotUserAgent('Mozilla/5.0 (Macintosh) Chrome/120'); // false
36
+ */
37
+ declare const isBotUserAgent: (userAgent?: string | null) => boolean;
38
+ /**
39
+ * Tells whether the current browser environment is automated, combining the
40
+ * user agent with automation fingerprints (`navigator.webdriver`, runtime
41
+ * globals) that headless browsers spoofing a normal user agent still expose.
42
+ *
43
+ * Always `false` outside a browser (SSR): there is nothing to collect there,
44
+ * and the server must not disable a client that has not run yet.
45
+ *
46
+ * @returns `true` when analytics must stay off for this environment.
47
+ *
48
+ * @example
49
+ * if (isBotEnvironment()) return; // never start the client for a crawler
50
+ */
51
+ declare const isBotEnvironment: () => boolean;
52
+ //#endregion
53
+ export { BOT_USER_AGENT_PATTERN, isBotEnvironment, isBotUserAgent };
54
+ //# sourceMappingURL=isBot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isBot.d.ts","names":[],"sources":["../../../src/utils/isBot.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;cA4Ba,wBAAsB;;;;;;;;;;;;;;;cAiBtB,iBAAc;;;;;;;;;;;;;;cAmCd"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intlayer/analytics",
3
- "version": "9.4.0-canary.0",
3
+ "version": "9.4.0",
4
4
  "private": false,
5
5
  "description": "Collects which Intlayer content is shown (page/locale + node-level) and powers content A/B testing. Optional, lazily-imported companion to @intlayer/editor.",
6
6
  "keywords": [
@@ -41,6 +41,11 @@
41
41
  "require": "./dist/cjs/isEnabled.cjs",
42
42
  "import": "./dist/esm/isEnabled.mjs"
43
43
  },
44
+ "./bot": {
45
+ "types": "./dist/types/utils/isBot.d.ts",
46
+ "require": "./dist/cjs/utils/isBot.cjs",
47
+ "import": "./dist/esm/utils/isBot.mjs"
48
+ },
44
49
  "./client": {
45
50
  "types": "./dist/types/core/index.d.ts",
46
51
  "require": "./dist/cjs/core/index.cjs",
@@ -82,10 +87,10 @@
82
87
  "dependencies": {
83
88
  "@intlayer/config": "9.4.0-canary.0",
84
89
  "@intlayer/core": "9.4.0-canary.0",
85
- "@intlayer/types": "9.4.0-canary.0"
90
+ "@intlayer/types": "9.4.0"
86
91
  },
87
92
  "devDependencies": {
88
- "@types/node": "26.2.0",
93
+ "@types/node": "26.3.0",
89
94
  "@utils/ts-config": "1.0.4",
90
95
  "@utils/ts-config-types": "1.0.4",
91
96
  "@utils/tsdown-config": "1.0.4",