@truxl/javascript-sdk 1.0.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.
@@ -0,0 +1,1409 @@
1
+ const DEFAULT_AUTOCAPTURE_CONFIG = {
2
+ pageview: 'full-url',
3
+ click: true,
4
+ dead_click: true,
5
+ input: true,
6
+ rage_click: true,
7
+ scroll: true,
8
+ submit: true,
9
+ capture_text_content: false,
10
+ };
11
+ const DEFAULT_CONFIG = {
12
+ apiEndpoint: 'https://ingestion.api.stage.truxl.com',
13
+ batchSize: 20,
14
+ flushInterval: 5000,
15
+ autocapture: false,
16
+ track_pageview: true,
17
+ debug: false,
18
+ maxQueueSize: 10000,
19
+ retryAttempts: 5,
20
+ retryBaseDelay: 1000,
21
+ transport: 'http',
22
+ };
23
+ /**
24
+ * Resolve the autocapture option into a full config object, or null if disabled.
25
+ */
26
+ function resolveAutocaptureConfig(autocapture) {
27
+ if (!autocapture)
28
+ return null;
29
+ if (autocapture === true)
30
+ return { ...DEFAULT_AUTOCAPTURE_CONFIG };
31
+ return { ...DEFAULT_AUTOCAPTURE_CONFIG, ...autocapture };
32
+ }
33
+
34
+ class EventQueue {
35
+ constructor(config, transport) {
36
+ this.queue = [];
37
+ this.timer = null;
38
+ this.sending = false;
39
+ this.config = config;
40
+ this.transport = transport;
41
+ this.loadFromStorage();
42
+ this.startTimer();
43
+ if (typeof window !== 'undefined') {
44
+ window.addEventListener('beforeunload', () => this.flush());
45
+ }
46
+ }
47
+ enqueue(event) {
48
+ if (this.queue.length >= this.config.maxQueueSize) {
49
+ this.queue.shift();
50
+ }
51
+ this.queue.push(event);
52
+ this.saveToStorage();
53
+ if (this.queue.length >= this.config.batchSize) {
54
+ this.flush();
55
+ }
56
+ }
57
+ async flush() {
58
+ if (this.sending || this.queue.length === 0)
59
+ return;
60
+ this.sending = true;
61
+ const batch = this.queue.splice(0, this.config.batchSize);
62
+ try {
63
+ const result = await this.transport.send({ events: batch });
64
+ if (!result.ok) {
65
+ // Only re-queue on transient errors (5xx / network failure).
66
+ // 4xx errors (401, 403, 422, etc.) are permanent — drop the batch.
67
+ const isTransient = result.status === 0 || result.status >= 500;
68
+ if (isTransient) {
69
+ this.queue.unshift(...batch);
70
+ }
71
+ }
72
+ this.saveToStorage();
73
+ }
74
+ catch {
75
+ this.queue.unshift(...batch);
76
+ this.saveToStorage();
77
+ }
78
+ finally {
79
+ this.sending = false;
80
+ }
81
+ }
82
+ destroy() {
83
+ if (this.timer) {
84
+ clearInterval(this.timer);
85
+ this.timer = null;
86
+ }
87
+ this.transport.destroy?.();
88
+ }
89
+ startTimer() {
90
+ this.timer = setInterval(() => this.flush(), this.config.flushInterval);
91
+ }
92
+ saveToStorage() {
93
+ if (typeof requestIdleCallback === 'function') {
94
+ requestIdleCallback(() => {
95
+ try {
96
+ localStorage.setItem('truxl_queue', JSON.stringify(this.queue));
97
+ }
98
+ catch { }
99
+ });
100
+ }
101
+ else {
102
+ setTimeout(() => {
103
+ try {
104
+ localStorage.setItem('truxl_queue', JSON.stringify(this.queue));
105
+ }
106
+ catch { }
107
+ }, 0);
108
+ }
109
+ }
110
+ loadFromStorage() {
111
+ try {
112
+ const stored = localStorage.getItem('truxl_queue');
113
+ if (stored) {
114
+ this.queue = JSON.parse(stored);
115
+ }
116
+ }
117
+ catch { }
118
+ }
119
+ }
120
+
121
+ async function signPayload(payload, secret) {
122
+ const encoder = new TextEncoder();
123
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
124
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(payload));
125
+ return Array.from(new Uint8Array(signature))
126
+ .map((b) => b.toString(16).padStart(2, '0'))
127
+ .join('');
128
+ }
129
+
130
+ /**
131
+ * Fetch-based HTTP transport with exponential back-off retry.
132
+ *
133
+ * Retry delays follow the pattern: baseDelay * 2^attempt
134
+ * attempt 0 -> 1 s
135
+ * attempt 1 -> 2 s
136
+ * attempt 2 -> 4 s
137
+ * attempt 3 -> 8 s
138
+ * ...
139
+ * capped at MAX_RETRY_DELAY (60 s)
140
+ *
141
+ * Only 5xx (server) errors and network failures are retried. 4xx responses
142
+ * are treated as permanent failures and are NOT retried.
143
+ */
144
+ /** Maximum delay between retries (in milliseconds). */
145
+ const MAX_RETRY_DELAY = 60000;
146
+ /**
147
+ * Low-level HTTP transport. Send a JSON payload to the Truxl ingestion
148
+ * endpoint with HMAC signing and automatic retry on transient errors.
149
+ */
150
+ class HttpTransport {
151
+ constructor(options) {
152
+ this.config = options.config;
153
+ }
154
+ /**
155
+ * Send a JSON-serialisable payload to the batch endpoint.
156
+ *
157
+ * @param payload The body to POST (will be JSON-stringified).
158
+ * @returns A result object indicating success/failure and number of
159
+ * retries performed.
160
+ */
161
+ async send(payload) {
162
+ const body = JSON.stringify(payload);
163
+ return this.sendWithRetry(body, 0);
164
+ }
165
+ // -----------------------------------------------------------------------
166
+ // Internal
167
+ // -----------------------------------------------------------------------
168
+ async sendWithRetry(body, attempt) {
169
+ try {
170
+ const signature = await signPayload(body, this.config.clientSecret);
171
+ const response = await fetch(`${this.config.apiEndpoint}/v1/batch`, {
172
+ method: 'POST',
173
+ headers: {
174
+ 'Content-Type': 'application/json',
175
+ 'X-Project-Token': this.config.projectToken,
176
+ 'X-Signature': signature,
177
+ },
178
+ body,
179
+ });
180
+ if (response.ok) {
181
+ return { ok: true, status: response.status, retries: attempt };
182
+ }
183
+ // Only retry on server errors (5xx)
184
+ if (response.status >= 500 && attempt < this.config.retryAttempts) {
185
+ await this.wait(attempt);
186
+ return this.sendWithRetry(body, attempt + 1);
187
+ }
188
+ return { ok: false, status: response.status, retries: attempt };
189
+ }
190
+ catch {
191
+ // Network error -- retry if we haven't exhausted attempts
192
+ if (attempt < this.config.retryAttempts) {
193
+ await this.wait(attempt);
194
+ return this.sendWithRetry(body, attempt + 1);
195
+ }
196
+ return { ok: false, status: 0, retries: attempt };
197
+ }
198
+ }
199
+ /**
200
+ * Calculate the delay for a given retry attempt and sleep.
201
+ * delay = min(baseDelay * 2^attempt, MAX_RETRY_DELAY)
202
+ */
203
+ wait(attempt) {
204
+ const delay = Math.min(this.config.retryBaseDelay * Math.pow(2, attempt), MAX_RETRY_DELAY);
205
+ return new Promise((resolve) => setTimeout(resolve, delay));
206
+ }
207
+ }
208
+
209
+ const MAX_RECONNECT_DELAY = 30000;
210
+ const INITIAL_RECONNECT_DELAY = 1000;
211
+ class WebSocketTransport {
212
+ constructor(options) {
213
+ this.ws = null;
214
+ this.reconnectAttempt = 0;
215
+ this.reconnectTimer = null;
216
+ this.pendingMessages = [];
217
+ this.connected = false;
218
+ this.destroyed = false;
219
+ this.config = options.config;
220
+ this.connect();
221
+ }
222
+ async send(payload) {
223
+ const body = JSON.stringify(payload);
224
+ const signature = await signPayload(body, this.config.clientSecret);
225
+ const message = JSON.stringify({
226
+ token: this.config.projectToken,
227
+ signature,
228
+ payload: body,
229
+ });
230
+ if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
231
+ this.ws.send(message);
232
+ return { ok: true, status: 200, retries: 0 };
233
+ }
234
+ return new Promise((resolve, reject) => {
235
+ this.pendingMessages.push({ data: message, resolve, reject });
236
+ });
237
+ }
238
+ destroy() {
239
+ this.destroyed = true;
240
+ if (this.reconnectTimer) {
241
+ clearTimeout(this.reconnectTimer);
242
+ this.reconnectTimer = null;
243
+ }
244
+ if (this.ws) {
245
+ this.ws.onclose = null;
246
+ this.ws.close();
247
+ this.ws = null;
248
+ }
249
+ for (const msg of this.pendingMessages) {
250
+ msg.reject(new Error('Transport destroyed'));
251
+ }
252
+ this.pendingMessages = [];
253
+ }
254
+ connect() {
255
+ if (this.destroyed)
256
+ return;
257
+ const wsEndpoint = this.config.apiEndpoint
258
+ .replace(/^http/, 'ws') + '/ws/events';
259
+ this.ws = new WebSocket(wsEndpoint);
260
+ this.ws.onopen = () => {
261
+ this.connected = true;
262
+ this.reconnectAttempt = 0;
263
+ for (const msg of this.pendingMessages) {
264
+ this.ws.send(msg.data);
265
+ msg.resolve({ ok: true, status: 200, retries: 0 });
266
+ }
267
+ this.pendingMessages = [];
268
+ };
269
+ this.ws.onclose = () => {
270
+ this.connected = false;
271
+ this.scheduleReconnect();
272
+ };
273
+ this.ws.onerror = () => {
274
+ // onclose fires after onerror, reconnect handled there
275
+ };
276
+ }
277
+ scheduleReconnect() {
278
+ if (this.destroyed)
279
+ return;
280
+ const delay = Math.min(INITIAL_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempt), MAX_RECONNECT_DELAY);
281
+ this.reconnectAttempt++;
282
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Browser environment utilities.
288
+ * Gathers user-agent, screen size, referrer, viewport, language, and other
289
+ * contextual information that is attached to tracked events.
290
+ */
291
+ /**
292
+ * Collect a snapshot of the current browser/page context.
293
+ * Every property access is wrapped so this function never throws, even in
294
+ * non-browser environments.
295
+ */
296
+ function getBrowserInfo() {
297
+ return {
298
+ userAgent: getUserAgent(),
299
+ language: getLanguage(),
300
+ languages: getLanguages(),
301
+ screenWidth: getScreenWidth(),
302
+ screenHeight: getScreenHeight(),
303
+ viewportWidth: getViewportWidth(),
304
+ viewportHeight: getViewportHeight(),
305
+ devicePixelRatio: getDevicePixelRatio(),
306
+ referrer: getReferrer(),
307
+ url: getCurrentUrl(),
308
+ path: getCurrentPath(),
309
+ title: getPageTitle(),
310
+ hostname: getHostname(),
311
+ timezone: getTimezone(),
312
+ timezoneOffset: getTimezoneOffset(),
313
+ online: getOnlineStatus(),
314
+ cookiesEnabled: getCookiesEnabled(),
315
+ };
316
+ }
317
+ // ---------------------------------------------------------------------------
318
+ // Individual accessors
319
+ // ---------------------------------------------------------------------------
320
+ function getUserAgent() {
321
+ try {
322
+ return navigator.userAgent;
323
+ }
324
+ catch {
325
+ return '';
326
+ }
327
+ }
328
+ function getLanguage() {
329
+ try {
330
+ return navigator.language || '';
331
+ }
332
+ catch {
333
+ return '';
334
+ }
335
+ }
336
+ function getLanguages() {
337
+ try {
338
+ return navigator.languages ?? [getLanguage()];
339
+ }
340
+ catch {
341
+ return [];
342
+ }
343
+ }
344
+ function getScreenWidth() {
345
+ try {
346
+ return screen.width;
347
+ }
348
+ catch {
349
+ return 0;
350
+ }
351
+ }
352
+ function getScreenHeight() {
353
+ try {
354
+ return screen.height;
355
+ }
356
+ catch {
357
+ return 0;
358
+ }
359
+ }
360
+ function getViewportWidth() {
361
+ try {
362
+ return window.innerWidth;
363
+ }
364
+ catch {
365
+ return 0;
366
+ }
367
+ }
368
+ function getViewportHeight() {
369
+ try {
370
+ return window.innerHeight;
371
+ }
372
+ catch {
373
+ return 0;
374
+ }
375
+ }
376
+ function getDevicePixelRatio() {
377
+ try {
378
+ return window.devicePixelRatio ?? 1;
379
+ }
380
+ catch {
381
+ return 1;
382
+ }
383
+ }
384
+ function getReferrer() {
385
+ try {
386
+ return document.referrer;
387
+ }
388
+ catch {
389
+ return '';
390
+ }
391
+ }
392
+ function getCurrentUrl() {
393
+ try {
394
+ return window.location.href;
395
+ }
396
+ catch {
397
+ return '';
398
+ }
399
+ }
400
+ function getCurrentPath() {
401
+ try {
402
+ return window.location.pathname;
403
+ }
404
+ catch {
405
+ return '';
406
+ }
407
+ }
408
+ function getPageTitle() {
409
+ try {
410
+ return document.title;
411
+ }
412
+ catch {
413
+ return '';
414
+ }
415
+ }
416
+ function getHostname() {
417
+ try {
418
+ return window.location.hostname;
419
+ }
420
+ catch {
421
+ return '';
422
+ }
423
+ }
424
+ function getTimezone() {
425
+ try {
426
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
427
+ }
428
+ catch {
429
+ return '';
430
+ }
431
+ }
432
+ function getTimezoneOffset() {
433
+ try {
434
+ return new Date().getTimezoneOffset();
435
+ }
436
+ catch {
437
+ return 0;
438
+ }
439
+ }
440
+ function getOnlineStatus() {
441
+ try {
442
+ return navigator.onLine;
443
+ }
444
+ catch {
445
+ return true;
446
+ }
447
+ }
448
+ function getCookiesEnabled() {
449
+ try {
450
+ return navigator.cookieEnabled;
451
+ }
452
+ catch {
453
+ return false;
454
+ }
455
+ }
456
+ // ---------------------------------------------------------------------------
457
+ // Browser / OS / Device parsing from user-agent
458
+ // ---------------------------------------------------------------------------
459
+ /**
460
+ * Detect the browser name from the user-agent string.
461
+ */
462
+ function parseBrowserName(ua) {
463
+ const s = ua || getUserAgent();
464
+ if (!s)
465
+ return '';
466
+ // Order matters — Edge UA contains "Chrome"
467
+ if (s.includes('Edg/') || s.includes('Edge/'))
468
+ return 'Edge';
469
+ if (s.includes('OPR/') || s.includes('Opera'))
470
+ return 'Opera';
471
+ if (s.includes('Firefox/'))
472
+ return 'Firefox';
473
+ if (s.includes('Chrome/') && s.includes('Safari/'))
474
+ return 'Chrome';
475
+ if (s.includes('Safari/') && !s.includes('Chrome/'))
476
+ return 'Safari';
477
+ return 'Other';
478
+ }
479
+ /**
480
+ * Detect the browser version from the user-agent string.
481
+ */
482
+ function parseBrowserVersion(ua) {
483
+ const s = ua || getUserAgent();
484
+ if (!s)
485
+ return '';
486
+ const patterns = [
487
+ [/Edg\/(\d+[\d.]*)/, 1],
488
+ [/OPR\/(\d+[\d.]*)/, 1],
489
+ [/Firefox\/(\d+[\d.]*)/, 1],
490
+ [/Chrome\/(\d+[\d.]*)/, 1],
491
+ [/Version\/(\d+[\d.]*).*Safari/, 1],
492
+ ];
493
+ for (const [re, group] of patterns) {
494
+ const m = s.match(re);
495
+ if (m)
496
+ return m[group];
497
+ }
498
+ return '';
499
+ }
500
+ /**
501
+ * Detect the OS name from the user-agent string.
502
+ */
503
+ function parseOsName(ua) {
504
+ const s = ua || getUserAgent();
505
+ if (!s)
506
+ return '';
507
+ if (s.includes('Windows'))
508
+ return 'Windows';
509
+ if (s.includes('Mac OS') || s.includes('Macintosh'))
510
+ return 'macOS';
511
+ if (s.includes('iPhone') || s.includes('iPad') || s.includes('iPod'))
512
+ return 'iOS';
513
+ if (s.includes('Android'))
514
+ return 'Android';
515
+ if (s.includes('CrOS'))
516
+ return 'Chrome OS';
517
+ if (s.includes('Linux'))
518
+ return 'Linux';
519
+ return 'Other';
520
+ }
521
+ /**
522
+ * Detect the OS version from the user-agent string.
523
+ */
524
+ function parseOsVersion(ua) {
525
+ const s = ua || getUserAgent();
526
+ if (!s)
527
+ return '';
528
+ const patterns = [
529
+ [/Windows NT ([\d.]+)/, 1],
530
+ [/Mac OS X ([\d_.]+)/, 1],
531
+ [/iPhone OS ([\d_]+)/, 1],
532
+ [/iPad.*? OS ([\d_]+)/, 1],
533
+ [/Android ([\d.]+)/, 1],
534
+ [/CrOS [^\s]+ ([\d.]+)/, 1],
535
+ ];
536
+ for (const [re, group] of patterns) {
537
+ const m = s.match(re);
538
+ if (m)
539
+ return m[group].replace(/_/g, '.');
540
+ }
541
+ return '';
542
+ }
543
+ /**
544
+ * Detect device type from user-agent string.
545
+ */
546
+ function parseDeviceType(ua) {
547
+ const s = ua || getUserAgent();
548
+ if (!s)
549
+ return 'desktop';
550
+ if (/Mobi|Android.*Mobile|iPhone|iPod/.test(s))
551
+ return 'mobile';
552
+ if (/Tablet|iPad/.test(s))
553
+ return 'tablet';
554
+ return 'desktop';
555
+ }
556
+ /**
557
+ * Extract UTM parameters from the current page URL.
558
+ * Once captured on the landing page they are cached in sessionStorage
559
+ * so subsequent page navigations within the same session retain them.
560
+ */
561
+ function getUtmParams() {
562
+ const STORAGE_KEY = 'truxl_utm';
563
+ // Try to return cached UTM from this session first
564
+ try {
565
+ const cached = sessionStorage.getItem(STORAGE_KEY);
566
+ if (cached)
567
+ return JSON.parse(cached);
568
+ }
569
+ catch { /* ignore */ }
570
+ const empty = {
571
+ utmSource: '',
572
+ utmMedium: '',
573
+ utmCampaign: '',
574
+ utmTerm: '',
575
+ utmContent: '',
576
+ };
577
+ try {
578
+ const params = new URLSearchParams(window.location.search);
579
+ const utm = {
580
+ utmSource: params.get('utm_source') || '',
581
+ utmMedium: params.get('utm_medium') || '',
582
+ utmCampaign: params.get('utm_campaign') || '',
583
+ utmTerm: params.get('utm_term') || '',
584
+ utmContent: params.get('utm_content') || '',
585
+ };
586
+ // Only cache if at least one UTM param is present
587
+ const hasAny = Object.values(utm).some((v) => v !== '');
588
+ if (hasAny) {
589
+ try {
590
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(utm));
591
+ }
592
+ catch { /* ignore */ }
593
+ }
594
+ return hasAny ? utm : empty;
595
+ }
596
+ catch {
597
+ return empty;
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Referrer parsing utilities.
603
+ * Extracts domain, detects search engines, and persists the initial referrer
604
+ * for attribution tracking across the user's first visit.
605
+ */
606
+ const SEARCH_ENGINE_DOMAINS = {
607
+ 'google': 'google',
608
+ 'bing.com': 'bing',
609
+ 'yahoo.com': 'yahoo',
610
+ 'duckduckgo.com': 'duckduckgo',
611
+ 'baidu.com': 'baidu',
612
+ 'yandex.com': 'yandex',
613
+ 'yandex.ru': 'yandex',
614
+ };
615
+ const INITIAL_REFERRER_KEY = 'truxl_initial_referrer';
616
+ /**
617
+ * Extract the hostname from a URL string.
618
+ * Returns `$direct` if the URL is empty or invalid.
619
+ */
620
+ function extractDomain(url) {
621
+ if (!url)
622
+ return '$direct';
623
+ try {
624
+ return new URL(url).hostname;
625
+ }
626
+ catch {
627
+ return '$direct';
628
+ }
629
+ }
630
+ /**
631
+ * Detect if a referrer domain is a known search engine.
632
+ * Returns the search engine name (e.g., 'google', 'bing') or empty string.
633
+ */
634
+ function parseSearchEngine(referrerDomain) {
635
+ if (!referrerDomain || referrerDomain === '$direct')
636
+ return '';
637
+ const domain = referrerDomain.toLowerCase();
638
+ // Check exact matches first
639
+ for (const [pattern, engine] of Object.entries(SEARCH_ENGINE_DOMAINS)) {
640
+ if (domain === pattern || domain.endsWith('.' + pattern)) {
641
+ return engine;
642
+ }
643
+ }
644
+ // Google uses country-specific TLDs (google.co.in, google.de, etc.)
645
+ if (domain.match(/^(www\.)?google\.[a-z.]+$/)) {
646
+ return 'google';
647
+ }
648
+ return '';
649
+ }
650
+ /**
651
+ * Get the initial referrer for this user.
652
+ * On the first visit, persists `document.referrer` (or `$direct`) to localStorage.
653
+ * Returns the stored value on subsequent visits.
654
+ */
655
+ function getInitialReferrer() {
656
+ try {
657
+ let stored = localStorage.getItem(INITIAL_REFERRER_KEY);
658
+ if (stored === null) {
659
+ stored = document.referrer || '$direct';
660
+ localStorage.setItem(INITIAL_REFERRER_KEY, stored);
661
+ }
662
+ return stored;
663
+ }
664
+ catch {
665
+ return document.referrer || '$direct';
666
+ }
667
+ }
668
+
669
+ /**
670
+ * High-level event tracking helpers.
671
+ *
672
+ * Provides convenience wrappers around `TruxlClient.track()` for common
673
+ * event types such as custom events and page views. Each helper enriches
674
+ * the event payload with contextual browser information.
675
+ */
676
+ /**
677
+ * Track a custom event.
678
+ *
679
+ * @param client The TruxlClient instance.
680
+ * @param eventName A descriptive name for the event (e.g. "button_click",
681
+ * "signup_started").
682
+ * @param properties Arbitrary key/value properties to attach to the event.
683
+ */
684
+ function trackEvent(client, eventName, properties) {
685
+ const browser = getBrowserInfo();
686
+ client.track(eventName, {
687
+ ...properties,
688
+ $browser_info: {
689
+ user_agent: browser.userAgent,
690
+ language: browser.language,
691
+ screen_width: browser.screenWidth,
692
+ screen_height: browser.screenHeight,
693
+ viewport_width: browser.viewportWidth,
694
+ viewport_height: browser.viewportHeight,
695
+ },
696
+ $url: browser.url,
697
+ $path: browser.path,
698
+ $referrer: browser.referrer,
699
+ $title: browser.title,
700
+ $hostname: browser.hostname,
701
+ });
702
+ }
703
+ /**
704
+ * Track a page view event.
705
+ *
706
+ * Automatically captures the current URL, path, referrer, page title and
707
+ * other contextual data.
708
+ *
709
+ * @param client The TruxlClient instance.
710
+ * @param properties Optional extra properties to merge into the event.
711
+ */
712
+ function trackPageView(client, properties) {
713
+ const browser = getBrowserInfo();
714
+ client.track('$pageview', {
715
+ ...properties,
716
+ $url: browser.url,
717
+ $path: browser.path,
718
+ $referrer: browser.referrer,
719
+ $title: browser.title,
720
+ $hostname: browser.hostname,
721
+ $screen_width: browser.screenWidth,
722
+ $screen_height: browser.screenHeight,
723
+ $viewport_width: browser.viewportWidth,
724
+ $viewport_height: browser.viewportHeight,
725
+ $device_pixel_ratio: browser.devicePixelRatio,
726
+ $language: browser.language,
727
+ $timezone: browser.timezone,
728
+ $online: browser.online,
729
+ });
730
+ }
731
+
732
+ /**
733
+ * Form submission tracking.
734
+ *
735
+ * Intercepts native `<form>` submit events via a delegated listener on
736
+ * `document` and tracks a `$form_submit` event with metadata about the
737
+ * form and its fields (excluding sensitive inputs such as passwords and
738
+ * credit card numbers).
739
+ */
740
+ /** Teardown callback for stopping form tracking. */
741
+ let teardownFn = null;
742
+ /**
743
+ * Start tracking form submissions. Idempotent -- calling this multiple
744
+ * times will not register duplicate listeners.
745
+ *
746
+ * @param client The TruxlClient instance to report events to.
747
+ */
748
+ function startFormTracking(client) {
749
+ if (typeof document === 'undefined') {
750
+ return;
751
+ }
752
+ // Prevent duplicate listeners
753
+ if (teardownFn) {
754
+ return;
755
+ }
756
+ const handleSubmit = (event) => {
757
+ const form = event.target;
758
+ if (!form || form.tagName.toLowerCase() !== 'form')
759
+ return;
760
+ const properties = {
761
+ $form_id: form.id || undefined,
762
+ $form_name: form.getAttribute('name') || undefined,
763
+ $form_action: form.action || undefined,
764
+ $form_method: (form.method || 'GET').toUpperCase(),
765
+ $form_fields: collectFieldMetadata(form),
766
+ };
767
+ trackEvent(client, '$form_submit', properties);
768
+ };
769
+ document.addEventListener('submit', handleSubmit, true);
770
+ teardownFn = () => {
771
+ document.removeEventListener('submit', handleSubmit, true);
772
+ };
773
+ }
774
+ /**
775
+ * Stop tracking form submissions and remove the event listener.
776
+ */
777
+ function stopFormTracking() {
778
+ if (teardownFn) {
779
+ teardownFn();
780
+ teardownFn = null;
781
+ }
782
+ }
783
+ // ---------------------------------------------------------------------------
784
+ // Internal helpers
785
+ // ---------------------------------------------------------------------------
786
+ /** Input types whose values must NEVER be captured. */
787
+ const SENSITIVE_TYPES = new Set([
788
+ 'password',
789
+ 'hidden',
790
+ 'file',
791
+ ]);
792
+ /** Input names (lowercase) that suggest sensitive content. */
793
+ const SENSITIVE_NAME_PATTERNS$1 = [
794
+ 'password',
795
+ 'passwd',
796
+ 'secret',
797
+ 'token',
798
+ 'credit',
799
+ 'card',
800
+ 'cvv',
801
+ 'cvc',
802
+ 'ssn',
803
+ 'social',
804
+ ];
805
+ /**
806
+ * Iterate over form elements and collect non-sensitive metadata.
807
+ */
808
+ function collectFieldMetadata(form) {
809
+ const fields = [];
810
+ const elements = form.elements;
811
+ for (let i = 0; i < elements.length; i++) {
812
+ const el = elements[i];
813
+ const tag = el.tagName.toLowerCase();
814
+ if (!['input', 'select', 'textarea'].includes(tag))
815
+ continue;
816
+ const type = el.type?.toLowerCase() ?? '';
817
+ const name = el.name || undefined;
818
+ const id = el.id || undefined;
819
+ const isSensitive = isSensitiveField(type, name);
820
+ const meta = {
821
+ tag,
822
+ type: type || undefined,
823
+ name,
824
+ id,
825
+ redacted: isSensitive,
826
+ };
827
+ if (!isSensitive) {
828
+ meta.value = truncateValue(el.value);
829
+ }
830
+ fields.push(meta);
831
+ }
832
+ return fields;
833
+ }
834
+ /**
835
+ * Determine whether a field is sensitive based on its type and name.
836
+ */
837
+ function isSensitiveField(type, name) {
838
+ if (SENSITIVE_TYPES.has(type))
839
+ return true;
840
+ if (name) {
841
+ const lower = name.toLowerCase();
842
+ return SENSITIVE_NAME_PATTERNS$1.some((pattern) => lower.includes(pattern));
843
+ }
844
+ return false;
845
+ }
846
+ /**
847
+ * Truncate field values to avoid sending excessively large payloads.
848
+ */
849
+ function truncateValue(value, maxLength = 512) {
850
+ if (value.length <= maxLength)
851
+ return value;
852
+ return value.slice(0, maxLength) + '...';
853
+ }
854
+
855
+ /**
856
+ * Auto-capture module.
857
+ *
858
+ * When enabled, automatically tracks based on the resolved AutocaptureConfig:
859
+ * - click: Click events on buttons and links
860
+ * - dead_click: Clicks that produce no DOM mutation
861
+ * - rage_click: Rapid repeated clicks on the same element
862
+ * - input: Input field value changes (sensitive fields redacted)
863
+ * - scroll: Scroll depth milestones (25%, 50%, 75%, 100%)
864
+ * - submit: Form submissions (delegates to form tracking module)
865
+ * - pageview: Page view events on URL changes
866
+ *
867
+ * Auto-capture is opt-in via `TruxlConfig.autocapture`.
868
+ */
869
+ /** Keeps track of teardown callbacks so auto-capture can be stopped. */
870
+ let teardownFns = [];
871
+ // ---------------------------------------------------------------------------
872
+ // Sensitive field detection (shared with input tracking)
873
+ // ---------------------------------------------------------------------------
874
+ const SENSITIVE_INPUT_TYPES = new Set(['password', 'hidden', 'file']);
875
+ const SENSITIVE_NAME_PATTERNS = [
876
+ 'password', 'passwd', 'secret', 'token', 'credit', 'card',
877
+ 'cvv', 'cvc', 'ssn', 'social',
878
+ ];
879
+ function isSensitiveInput(el) {
880
+ const type = el.type?.toLowerCase() ?? '';
881
+ if (SENSITIVE_INPUT_TYPES.has(type))
882
+ return true;
883
+ const name = (el.name || el.id || '').toLowerCase();
884
+ return SENSITIVE_NAME_PATTERNS.some((p) => name.includes(p));
885
+ }
886
+ const RAGE_CLICK_THRESHOLD = 3;
887
+ const RAGE_CLICK_WINDOW_MS = 1000;
888
+ // ---------------------------------------------------------------------------
889
+ // Dead-click detection helpers
890
+ // ---------------------------------------------------------------------------
891
+ const DEAD_CLICK_WAIT_MS = 1000;
892
+ /**
893
+ * Start auto-capturing interactions. Idempotent -- calling this multiple
894
+ * times will not register duplicate listeners.
895
+ */
896
+ function startAutocapture(client, config) {
897
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
898
+ return;
899
+ }
900
+ // Prevent duplicate listeners
901
+ if (teardownFns.length > 0) {
902
+ return;
903
+ }
904
+ const captureText = config.capture_text_content;
905
+ // ------------------------------------------------------------------
906
+ // 1. Click auto-capture (delegation on document)
907
+ // ------------------------------------------------------------------
908
+ if (config.click || config.dead_click || config.rage_click) {
909
+ const recentClicks = [];
910
+ const handleClick = (event) => {
911
+ const target = event.target;
912
+ if (!target)
913
+ return;
914
+ const element = findTrackableAncestor(target);
915
+ // --- Standard click tracking ---
916
+ if (config.click && element) {
917
+ const tagName = element.tagName.toLowerCase();
918
+ const properties = {
919
+ $element_tag: tagName,
920
+ $element_classes: element.className || undefined,
921
+ $element_id: element.id || undefined,
922
+ };
923
+ if (captureText) {
924
+ properties.$element_text = truncate(element.textContent?.trim() ?? '', 256);
925
+ }
926
+ if (tagName === 'a') {
927
+ properties.$element_href = element.href;
928
+ }
929
+ if (element.dataset.truxlEvent) {
930
+ properties.$custom_event_name = element.dataset.truxlEvent;
931
+ }
932
+ trackEvent(client, '$autocapture_click', properties);
933
+ }
934
+ // --- Rage-click detection ---
935
+ if (config.rage_click && element) {
936
+ const now = Date.now();
937
+ recentClicks.push({ target: element, timestamp: now });
938
+ // Remove old entries outside the time window
939
+ while (recentClicks.length > 0 && now - recentClicks[0].timestamp > RAGE_CLICK_WINDOW_MS) {
940
+ recentClicks.shift();
941
+ }
942
+ const sameTargetClicks = recentClicks.filter((c) => c.target === element);
943
+ if (sameTargetClicks.length >= RAGE_CLICK_THRESHOLD) {
944
+ const tagName = element.tagName.toLowerCase();
945
+ trackEvent(client, '$rage_click', {
946
+ $element_tag: tagName,
947
+ $element_classes: element.className || undefined,
948
+ $element_id: element.id || undefined,
949
+ $element_text: captureText ? truncate(element.textContent?.trim() ?? '', 256) : undefined,
950
+ $click_count: sameTargetClicks.length,
951
+ });
952
+ // Reset to avoid repeated rage-click events for same burst
953
+ recentClicks.length = 0;
954
+ }
955
+ }
956
+ // --- Dead-click detection ---
957
+ if (config.dead_click) {
958
+ const clickedElement = element || target;
959
+ detectDeadClick(client, clickedElement, captureText);
960
+ }
961
+ };
962
+ document.addEventListener('click', handleClick, true);
963
+ teardownFns.push(() => document.removeEventListener('click', handleClick, true));
964
+ }
965
+ // ------------------------------------------------------------------
966
+ // 2. Input change tracking
967
+ // ------------------------------------------------------------------
968
+ if (config.input) {
969
+ const handleChange = (event) => {
970
+ const el = event.target;
971
+ if (!el)
972
+ return;
973
+ const tag = el.tagName.toLowerCase();
974
+ if (!['input', 'select', 'textarea'].includes(tag))
975
+ return;
976
+ const sensitive = isSensitiveInput(el);
977
+ const type = el.type?.toLowerCase() ?? '';
978
+ const properties = {
979
+ $element_tag: tag,
980
+ $element_type: type || undefined,
981
+ $element_name: el.name || undefined,
982
+ $element_id: el.id || undefined,
983
+ $value_redacted: sensitive,
984
+ };
985
+ if (!sensitive && captureText) {
986
+ properties.$element_value = truncate(el.value, 256);
987
+ }
988
+ if (!sensitive && !captureText) {
989
+ // Still capture value length for analytics without exposing content
990
+ properties.$value_length = el.value.length;
991
+ }
992
+ trackEvent(client, '$autocapture_input', properties);
993
+ };
994
+ document.addEventListener('change', handleChange, true);
995
+ teardownFns.push(() => document.removeEventListener('change', handleChange, true));
996
+ }
997
+ // ------------------------------------------------------------------
998
+ // 3. Scroll depth tracking
999
+ // ------------------------------------------------------------------
1000
+ if (config.scroll) {
1001
+ const milestones = [25, 50, 75, 100];
1002
+ const reachedMilestones = new Set();
1003
+ let scrollPath = window.location.pathname;
1004
+ const handleScroll = () => {
1005
+ // Reset milestones on navigation
1006
+ if (window.location.pathname !== scrollPath) {
1007
+ reachedMilestones.clear();
1008
+ scrollPath = window.location.pathname;
1009
+ }
1010
+ const scrollTop = window.scrollY || document.documentElement.scrollTop;
1011
+ const docHeight = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
1012
+ const viewportHeight = window.innerHeight;
1013
+ if (docHeight <= viewportHeight)
1014
+ return; // No scrollable content
1015
+ const scrollPercent = Math.round(((scrollTop + viewportHeight) / docHeight) * 100);
1016
+ for (const milestone of milestones) {
1017
+ if (scrollPercent >= milestone && !reachedMilestones.has(milestone)) {
1018
+ reachedMilestones.add(milestone);
1019
+ trackEvent(client, '$autocapture_scroll', {
1020
+ $scroll_depth: milestone,
1021
+ $scroll_depth_pixels: scrollTop + viewportHeight,
1022
+ $document_height: docHeight,
1023
+ $url: window.location.href,
1024
+ $path: window.location.pathname,
1025
+ });
1026
+ }
1027
+ }
1028
+ };
1029
+ // Throttle scroll events
1030
+ let scrollTimeout = null;
1031
+ const throttledScroll = () => {
1032
+ if (scrollTimeout)
1033
+ return;
1034
+ scrollTimeout = setTimeout(() => {
1035
+ scrollTimeout = null;
1036
+ handleScroll();
1037
+ }, 200);
1038
+ };
1039
+ window.addEventListener('scroll', throttledScroll, { passive: true });
1040
+ teardownFns.push(() => window.removeEventListener('scroll', throttledScroll));
1041
+ }
1042
+ // ------------------------------------------------------------------
1043
+ // 4. Form submit tracking (delegates to forms module)
1044
+ // ------------------------------------------------------------------
1045
+ if (config.submit) {
1046
+ startFormTracking(client);
1047
+ teardownFns.push(() => stopFormTracking());
1048
+ }
1049
+ // ------------------------------------------------------------------
1050
+ // 5. Page view on URL change (only if autocapture.pageview is set)
1051
+ // ------------------------------------------------------------------
1052
+ if (config.pageview) {
1053
+ let lastUrl = window.location.href;
1054
+ const checkUrlChange = () => {
1055
+ const currentUrl = window.location.href;
1056
+ if (currentUrl !== lastUrl) {
1057
+ lastUrl = currentUrl;
1058
+ trackPageView(client, buildPageviewProperties(config.pageview));
1059
+ }
1060
+ };
1061
+ // Track initial page view
1062
+ trackPageView(client, buildPageviewProperties(config.pageview));
1063
+ // Monitor pushState / replaceState
1064
+ const originalPushState = history.pushState.bind(history);
1065
+ const originalReplaceState = history.replaceState.bind(history);
1066
+ history.pushState = function (...args) {
1067
+ originalPushState(...args);
1068
+ checkUrlChange();
1069
+ };
1070
+ history.replaceState = function (...args) {
1071
+ originalReplaceState(...args);
1072
+ checkUrlChange();
1073
+ };
1074
+ teardownFns.push(() => {
1075
+ history.pushState = originalPushState;
1076
+ history.replaceState = originalReplaceState;
1077
+ });
1078
+ // Monitor back/forward navigation
1079
+ const handlePopState = () => checkUrlChange();
1080
+ window.addEventListener('popstate', handlePopState);
1081
+ teardownFns.push(() => window.removeEventListener('popstate', handlePopState));
1082
+ }
1083
+ }
1084
+ /**
1085
+ * Stop auto-capture and remove all registered event listeners.
1086
+ */
1087
+ function stopAutocapture() {
1088
+ for (const fn of teardownFns) {
1089
+ fn();
1090
+ }
1091
+ teardownFns = [];
1092
+ }
1093
+ // ---------------------------------------------------------------------------
1094
+ // Internal helpers
1095
+ // ---------------------------------------------------------------------------
1096
+ /**
1097
+ * Build extra properties for pageview events based on the pageview config.
1098
+ */
1099
+ function buildPageviewProperties(pageviewMode) {
1100
+ if (pageviewMode === 'path-only') {
1101
+ return { $pageview_mode: 'path-only' };
1102
+ }
1103
+ return undefined;
1104
+ }
1105
+ /**
1106
+ * Detect dead clicks: clicks that produce no visible DOM mutation within a timeout.
1107
+ * Uses MutationObserver to watch for changes after a click.
1108
+ */
1109
+ function detectDeadClick(client, element, captureText) {
1110
+ let mutated = false;
1111
+ const observer = new MutationObserver(() => {
1112
+ mutated = true;
1113
+ observer.disconnect();
1114
+ });
1115
+ observer.observe(document.body, {
1116
+ childList: true,
1117
+ subtree: true,
1118
+ attributes: true,
1119
+ characterData: true,
1120
+ });
1121
+ setTimeout(() => {
1122
+ observer.disconnect();
1123
+ if (!mutated) {
1124
+ const tagName = element.tagName.toLowerCase();
1125
+ trackEvent(client, '$dead_click', {
1126
+ $element_tag: tagName,
1127
+ $element_classes: element.className || undefined,
1128
+ $element_id: element.id || undefined,
1129
+ $element_text: captureText ? truncate(element.textContent?.trim() ?? '', 256) : undefined,
1130
+ });
1131
+ }
1132
+ }, DEAD_CLICK_WAIT_MS);
1133
+ }
1134
+ /**
1135
+ * Walk up the DOM tree from `element` to find the nearest `<a>` or
1136
+ * `<button>` ancestor (including the element itself). Returns `null` when
1137
+ * the target is not a trackable element.
1138
+ */
1139
+ function findTrackableAncestor(element) {
1140
+ let current = element;
1141
+ const maxDepth = 5;
1142
+ for (let i = 0; i < maxDepth && current; i++) {
1143
+ const tag = current.tagName.toLowerCase();
1144
+ if (tag === 'a' ||
1145
+ tag === 'button' ||
1146
+ current.getAttribute('role') === 'button') {
1147
+ return current;
1148
+ }
1149
+ current = current.parentElement;
1150
+ }
1151
+ return null;
1152
+ }
1153
+ /**
1154
+ * Truncate a string to `maxLength`, appending "..." when truncated.
1155
+ */
1156
+ function truncate(value, maxLength) {
1157
+ if (value.length <= maxLength)
1158
+ return value;
1159
+ return value.slice(0, maxLength) + '...';
1160
+ }
1161
+
1162
+ const SDK_VERSION = '0.1.0';
1163
+ class TruxlClient {
1164
+ constructor(config) {
1165
+ this.distinctId = null;
1166
+ // ------------------------------------------------------------------
1167
+ // Standalone pageview tracking (when track_pageview=true but autocapture.pageview is off)
1168
+ // ------------------------------------------------------------------
1169
+ this.pageviewTeardownFns = [];
1170
+ this.config = { ...DEFAULT_CONFIG, ...config };
1171
+ this.deviceId = this.getOrCreateDeviceId();
1172
+ this.distinctId = this.loadDistinctId();
1173
+ this.initialReferrer = getInitialReferrer();
1174
+ this.transport = this.config.transport === 'websocket'
1175
+ ? new WebSocketTransport({ config: this.config })
1176
+ : new HttpTransport({ config: this.config });
1177
+ this.queue = new EventQueue(this.config, this.transport);
1178
+ // Start autocapture if enabled
1179
+ const autocaptureConfig = resolveAutocaptureConfig(this.config.autocapture);
1180
+ if (autocaptureConfig) {
1181
+ startAutocapture(this, autocaptureConfig);
1182
+ }
1183
+ // Track initial pageview if track_pageview is enabled (and autocapture.pageview isn't already handling it)
1184
+ if (this.config.track_pageview && !(autocaptureConfig && autocaptureConfig.pageview)) {
1185
+ this.startPageviewTracking();
1186
+ }
1187
+ if (this.config.debug) {
1188
+ console.log('[Truxl] Initialized with config:', this.config);
1189
+ }
1190
+ }
1191
+ track(eventName, properties) {
1192
+ const referrer = getReferrer();
1193
+ const referrerDomain = extractDomain(referrer);
1194
+ const mergedProperties = { ...(properties || {}) };
1195
+ const utm = getUtmParams();
1196
+ const event = {
1197
+ eventName: eventName,
1198
+ distinctId: this.distinctId || this.deviceId,
1199
+ deviceId: this.deviceId,
1200
+ timestamp: Date.now(),
1201
+ properties: mergedProperties,
1202
+ sessionId: this.getSessionId(),
1203
+ sdk: 'web',
1204
+ sdkVersion: SDK_VERSION,
1205
+ screenWidth: String(getScreenWidth()),
1206
+ screenHeight: String(getScreenHeight()),
1207
+ browser: parseBrowserName(),
1208
+ browserVersion: parseBrowserVersion(),
1209
+ os: parseOsName(),
1210
+ osVersion: parseOsVersion(),
1211
+ deviceType: parseDeviceType(),
1212
+ referrer: referrer,
1213
+ userTimezone: getTimezone(),
1214
+ initialReferrer: this.initialReferrer,
1215
+ searchEngine: parseSearchEngine(referrerDomain),
1216
+ utmSource: utm.utmSource,
1217
+ utmMedium: utm.utmMedium,
1218
+ utmCampaign: utm.utmCampaign,
1219
+ utmTerm: utm.utmTerm,
1220
+ utmContent: utm.utmContent,
1221
+ };
1222
+ this.queue.enqueue(event);
1223
+ if (this.config.debug) {
1224
+ console.log('[Truxl] Track:', eventName, properties);
1225
+ }
1226
+ }
1227
+ identify(distinctId, userProperties) {
1228
+ this.distinctId = distinctId;
1229
+ try {
1230
+ localStorage.setItem('truxl_distinct_id', distinctId);
1231
+ }
1232
+ catch { }
1233
+ if (userProperties) {
1234
+ this.track('$identify', { $set: userProperties });
1235
+ }
1236
+ if (this.config.debug) {
1237
+ console.log('[Truxl] Identify:', distinctId, userProperties);
1238
+ }
1239
+ }
1240
+ reset() {
1241
+ this.distinctId = null;
1242
+ try {
1243
+ localStorage.removeItem('truxl_distinct_id');
1244
+ }
1245
+ catch { }
1246
+ }
1247
+ flush() {
1248
+ this.queue.flush();
1249
+ }
1250
+ destroy() {
1251
+ stopAutocapture();
1252
+ this.stopPageviewTracking();
1253
+ this.queue.destroy();
1254
+ }
1255
+ startPageviewTracking() {
1256
+ if (typeof window === 'undefined')
1257
+ return;
1258
+ let lastUrl = window.location.href;
1259
+ const checkUrlChange = () => {
1260
+ const currentUrl = window.location.href;
1261
+ if (currentUrl !== lastUrl) {
1262
+ lastUrl = currentUrl;
1263
+ trackPageView(this);
1264
+ }
1265
+ };
1266
+ // Track initial page view
1267
+ trackPageView(this);
1268
+ // Monitor pushState / replaceState
1269
+ const originalPushState = history.pushState.bind(history);
1270
+ const originalReplaceState = history.replaceState.bind(history);
1271
+ history.pushState = function (...args) {
1272
+ originalPushState(...args);
1273
+ checkUrlChange();
1274
+ };
1275
+ history.replaceState = function (...args) {
1276
+ originalReplaceState(...args);
1277
+ checkUrlChange();
1278
+ };
1279
+ this.pageviewTeardownFns.push(() => {
1280
+ history.pushState = originalPushState;
1281
+ history.replaceState = originalReplaceState;
1282
+ });
1283
+ const handlePopState = () => checkUrlChange();
1284
+ window.addEventListener('popstate', handlePopState);
1285
+ this.pageviewTeardownFns.push(() => window.removeEventListener('popstate', handlePopState));
1286
+ }
1287
+ stopPageviewTracking() {
1288
+ for (const fn of this.pageviewTeardownFns) {
1289
+ fn();
1290
+ }
1291
+ this.pageviewTeardownFns = [];
1292
+ }
1293
+ loadDistinctId() {
1294
+ try {
1295
+ return localStorage.getItem('truxl_distinct_id');
1296
+ }
1297
+ catch {
1298
+ return null;
1299
+ }
1300
+ }
1301
+ getOrCreateDeviceId() {
1302
+ const key = 'truxl_device_id';
1303
+ try {
1304
+ let id = localStorage.getItem(key);
1305
+ if (!id) {
1306
+ id = crypto.randomUUID();
1307
+ localStorage.setItem(key, id);
1308
+ }
1309
+ return id;
1310
+ }
1311
+ catch {
1312
+ return crypto.randomUUID();
1313
+ }
1314
+ }
1315
+ getSessionId() {
1316
+ const key = 'truxl_session_id';
1317
+ try {
1318
+ let id = sessionStorage.getItem(key);
1319
+ if (!id) {
1320
+ id = crypto.randomUUID();
1321
+ sessionStorage.setItem(key, id);
1322
+ }
1323
+ return id;
1324
+ }
1325
+ catch {
1326
+ return crypto.randomUUID();
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ /**
1332
+ * Beacon transport for page unload scenarios.
1333
+ *
1334
+ * Uses `navigator.sendBeacon()` which is designed to reliably deliver small
1335
+ * payloads even when the page is being closed. This is the preferred
1336
+ * transport for `beforeunload` / `visibilitychange` events where a normal
1337
+ * `fetch()` would likely be cancelled by the browser.
1338
+ *
1339
+ * Falls back to a synchronous `XMLHttpRequest` (keepalive-style) when
1340
+ * `sendBeacon` is not available.
1341
+ */
1342
+ class BeaconTransport {
1343
+ constructor(options) {
1344
+ this.config = options.config;
1345
+ }
1346
+ /**
1347
+ * Send a payload via `navigator.sendBeacon`. Returns `true` when the
1348
+ * browser accepted the request for delivery (note: this does NOT
1349
+ * guarantee the server received it).
1350
+ *
1351
+ * Because `sendBeacon` is fire-and-forget, HMAC signing is performed
1352
+ * synchronously using the pre-computed signature passed in via headers
1353
+ * encoded as a Blob.
1354
+ */
1355
+ async send(payload) {
1356
+ const body = JSON.stringify(payload);
1357
+ const signature = await signPayload(body, this.config.clientSecret);
1358
+ const url = `${this.config.apiEndpoint}/v1/batch`;
1359
+ if (this.isBeaconAvailable()) {
1360
+ return this.sendViaBeacon(url, body, signature);
1361
+ }
1362
+ return this.sendViaXHR(url, body, signature);
1363
+ }
1364
+ /**
1365
+ * Synchronous variant that can be called from `beforeunload` handlers
1366
+ * where async work is unreliable. Requires the caller to pre-compute
1367
+ * the HMAC signature.
1368
+ */
1369
+ sendSync(payload, precomputedSignature) {
1370
+ const body = JSON.stringify(payload);
1371
+ const url = `${this.config.apiEndpoint}/v1/batch`;
1372
+ if (this.isBeaconAvailable()) {
1373
+ return this.sendViaBeacon(url, body, precomputedSignature);
1374
+ }
1375
+ return this.sendViaXHR(url, body, precomputedSignature);
1376
+ }
1377
+ // -----------------------------------------------------------------------
1378
+ // Internal
1379
+ // -----------------------------------------------------------------------
1380
+ isBeaconAvailable() {
1381
+ return (typeof navigator !== 'undefined' &&
1382
+ typeof navigator.sendBeacon === 'function');
1383
+ }
1384
+ sendViaBeacon(url, body, signature) {
1385
+ // sendBeacon does not support custom headers, so we encode the
1386
+ // metadata as query parameters and send a JSON blob.
1387
+ const beaconUrl = `${url}?token=${encodeURIComponent(this.config.projectToken)}&sig=${encodeURIComponent(signature)}`;
1388
+ const blob = new Blob([body], { type: 'application/json' });
1389
+ return navigator.sendBeacon(beaconUrl, blob);
1390
+ }
1391
+ sendViaXHR(url, body, signature) {
1392
+ try {
1393
+ const xhr = new XMLHttpRequest();
1394
+ // Use synchronous XHR as last resort during page unload
1395
+ xhr.open('POST', url, false);
1396
+ xhr.setRequestHeader('Content-Type', 'application/json');
1397
+ xhr.setRequestHeader('X-Project-Token', this.config.projectToken);
1398
+ xhr.setRequestHeader('X-Signature', signature);
1399
+ xhr.send(body);
1400
+ return xhr.status >= 200 && xhr.status < 300;
1401
+ }
1402
+ catch {
1403
+ return false;
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ export { BeaconTransport, HttpTransport, TruxlClient, WebSocketTransport };
1409
+ //# sourceMappingURL=truxl.esm.js.map