@clianta/sdk 1.6.7 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/angular.cjs.js +635 -231
- package/dist/angular.cjs.js.map +1 -1
- package/dist/angular.d.ts +1 -1
- package/dist/angular.esm.js +635 -231
- package/dist/angular.esm.js.map +1 -1
- package/dist/clianta.cjs.js +635 -231
- package/dist/clianta.cjs.js.map +1 -1
- package/dist/clianta.esm.js +635 -231
- package/dist/clianta.esm.js.map +1 -1
- package/dist/clianta.umd.js +635 -231
- package/dist/clianta.umd.js.map +1 -1
- package/dist/clianta.umd.min.js +2 -2
- package/dist/clianta.umd.min.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/react.cjs.js +642 -238
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +2 -1
- package/dist/react.esm.js +643 -239
- package/dist/react.esm.js.map +1 -1
- package/dist/svelte.cjs.js +635 -231
- package/dist/svelte.cjs.js.map +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.esm.js +635 -231
- package/dist/svelte.esm.js.map +1 -1
- package/dist/vue.cjs.js +635 -231
- package/dist/vue.cjs.js.map +1 -1
- package/dist/vue.d.ts +1 -1
- package/dist/vue.esm.js +635 -231
- package/dist/vue.esm.js.map +1 -1
- package/package.json +1 -1
package/dist/clianta.umd.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Clianta SDK v1.
|
|
2
|
+
* Clianta SDK v1.7.1
|
|
3
3
|
* (c) 2026 Clianta
|
|
4
4
|
* Released under the MIT License.
|
|
5
5
|
*/
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* @see SDK_VERSION in core/config.ts
|
|
16
16
|
*/
|
|
17
17
|
/** SDK Version */
|
|
18
|
-
const SDK_VERSION = '1.
|
|
18
|
+
const SDK_VERSION = '1.7.0';
|
|
19
19
|
/** Default API endpoint — reads from env or falls back to localhost */
|
|
20
20
|
const getDefaultApiEndpoint = () => {
|
|
21
21
|
// Next.js (process.env)
|
|
@@ -41,6 +41,15 @@
|
|
|
41
41
|
if (typeof process !== 'undefined' && process.env?.CLIANTA_API_ENDPOINT) {
|
|
42
42
|
return process.env.CLIANTA_API_ENDPOINT;
|
|
43
43
|
}
|
|
44
|
+
// No env var found — warn if we're not on localhost (likely a production misconfiguration)
|
|
45
|
+
const isLocalhost = typeof window !== 'undefined' &&
|
|
46
|
+
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
|
47
|
+
if (!isLocalhost && typeof console !== 'undefined') {
|
|
48
|
+
console.warn('[Clianta] No API endpoint configured. ' +
|
|
49
|
+
'Set NEXT_PUBLIC_CLIANTA_API_ENDPOINT (Next.js), VITE_CLIANTA_API_ENDPOINT (Vite), ' +
|
|
50
|
+
'or pass apiEndpoint directly to clianta(). ' +
|
|
51
|
+
'Falling back to localhost — tracking will not work in production.');
|
|
52
|
+
}
|
|
44
53
|
return 'http://localhost:5000';
|
|
45
54
|
};
|
|
46
55
|
/** Core plugins enabled by default — all auto-track with zero config */
|
|
@@ -186,7 +195,9 @@
|
|
|
186
195
|
*/
|
|
187
196
|
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
|
188
197
|
const DEFAULT_MAX_RETRIES = 3;
|
|
189
|
-
const DEFAULT_RETRY_DELAY = 1000; // 1 second
|
|
198
|
+
const DEFAULT_RETRY_DELAY = 1000; // 1 second base — doubles each attempt (exponential backoff)
|
|
199
|
+
/** fetch keepalive hard limit in browsers (64KB) */
|
|
200
|
+
const KEEPALIVE_SIZE_LIMIT = 60000; // leave 4KB margin
|
|
190
201
|
/**
|
|
191
202
|
* Transport class for sending data to the backend
|
|
192
203
|
*/
|
|
@@ -205,6 +216,11 @@
|
|
|
205
216
|
async sendEvents(events) {
|
|
206
217
|
const url = `${this.config.apiEndpoint}/api/public/track/event`;
|
|
207
218
|
const payload = JSON.stringify({ events });
|
|
219
|
+
// keepalive has a 64KB hard limit — fall back to beacon if too large
|
|
220
|
+
if (payload.length > KEEPALIVE_SIZE_LIMIT) {
|
|
221
|
+
const sent = this.sendBeacon(events);
|
|
222
|
+
return sent ? { success: true } : this.send(url, payload, 1, false);
|
|
223
|
+
}
|
|
208
224
|
return this.send(url, payload);
|
|
209
225
|
}
|
|
210
226
|
/**
|
|
@@ -269,6 +285,15 @@
|
|
|
269
285
|
return false;
|
|
270
286
|
}
|
|
271
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Send an arbitrary POST request through the transport (with timeout + retry).
|
|
290
|
+
* Used for one-off calls like alias() that don't fit the event-batch or identify shapes.
|
|
291
|
+
*/
|
|
292
|
+
async sendPost(path, body) {
|
|
293
|
+
const url = `${this.config.apiEndpoint}${path}`;
|
|
294
|
+
const payload = JSON.stringify(body);
|
|
295
|
+
return this.send(url, payload);
|
|
296
|
+
}
|
|
272
297
|
/**
|
|
273
298
|
* Fetch data from the tracking API (GET request)
|
|
274
299
|
* Used for read-back APIs (visitor profile, activity, etc.)
|
|
@@ -303,38 +328,44 @@
|
|
|
303
328
|
}
|
|
304
329
|
}
|
|
305
330
|
/**
|
|
306
|
-
* Internal send with retry logic
|
|
331
|
+
* Internal send with exponential backoff retry logic
|
|
307
332
|
*/
|
|
308
|
-
async send(url, payload, attempt = 1) {
|
|
333
|
+
async send(url, payload, attempt = 1, useKeepalive = true) {
|
|
334
|
+
// Don't bother sending when offline — caller should re-queue
|
|
335
|
+
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
|
336
|
+
logger.warn('Device offline, skipping send');
|
|
337
|
+
return { success: false, error: new Error('offline') };
|
|
338
|
+
}
|
|
309
339
|
try {
|
|
310
340
|
const response = await this.fetchWithTimeout(url, {
|
|
311
341
|
method: 'POST',
|
|
312
|
-
headers: {
|
|
313
|
-
'Content-Type': 'application/json',
|
|
314
|
-
},
|
|
342
|
+
headers: { 'Content-Type': 'application/json' },
|
|
315
343
|
body: payload,
|
|
316
|
-
keepalive:
|
|
344
|
+
keepalive: useKeepalive,
|
|
317
345
|
});
|
|
318
346
|
if (response.ok) {
|
|
319
347
|
logger.debug('Request successful:', url);
|
|
320
348
|
return { success: true, status: response.status };
|
|
321
349
|
}
|
|
322
|
-
// Server error
|
|
350
|
+
// Server error — retry with exponential backoff
|
|
323
351
|
if (response.status >= 500 && attempt < this.config.maxRetries) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
352
|
+
const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
|
|
353
|
+
logger.warn(`Server error (${response.status}), retrying in ${backoff}ms...`);
|
|
354
|
+
await this.delay(backoff);
|
|
355
|
+
return this.send(url, payload, attempt + 1, useKeepalive);
|
|
327
356
|
}
|
|
328
|
-
//
|
|
357
|
+
// 4xx — don't retry (bad payload, auth failure, etc.)
|
|
329
358
|
logger.error(`Request failed with status ${response.status}`);
|
|
330
359
|
return { success: false, status: response.status };
|
|
331
360
|
}
|
|
332
361
|
catch (error) {
|
|
333
|
-
// Network error
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
362
|
+
// Network error — retry with exponential backoff if still online
|
|
363
|
+
const isOnline = typeof navigator === 'undefined' || navigator.onLine;
|
|
364
|
+
if (isOnline && attempt < this.config.maxRetries) {
|
|
365
|
+
const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
|
|
366
|
+
logger.warn(`Network error, retrying in ${backoff}ms (${attempt}/${this.config.maxRetries})...`);
|
|
367
|
+
await this.delay(backoff);
|
|
368
|
+
return this.send(url, payload, attempt + 1, useKeepalive);
|
|
338
369
|
}
|
|
339
370
|
logger.error('Request failed after retries:', error);
|
|
340
371
|
return { success: false, error: error };
|
|
@@ -683,12 +714,17 @@
|
|
|
683
714
|
this.queue = [];
|
|
684
715
|
this.flushTimer = null;
|
|
685
716
|
this.isFlushing = false;
|
|
717
|
+
this.isOnline = true;
|
|
686
718
|
/** Rate limiting: timestamps of recent events */
|
|
687
719
|
this.eventTimestamps = [];
|
|
688
720
|
/** Unload handler references for cleanup */
|
|
689
721
|
this.boundBeforeUnload = null;
|
|
690
722
|
this.boundVisibilityChange = null;
|
|
691
723
|
this.boundPageHide = null;
|
|
724
|
+
this.boundOnline = null;
|
|
725
|
+
this.boundOffline = null;
|
|
726
|
+
/** Guards against double-flush on unload (beforeunload + pagehide + visibilitychange all fire) */
|
|
727
|
+
this.unloadFlushed = false;
|
|
692
728
|
this.transport = transport;
|
|
693
729
|
this.config = {
|
|
694
730
|
batchSize: config.batchSize ?? 10,
|
|
@@ -697,6 +733,7 @@
|
|
|
697
733
|
storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
|
|
698
734
|
};
|
|
699
735
|
this.persistMode = config.persistMode || 'session';
|
|
736
|
+
this.isOnline = typeof navigator === 'undefined' || navigator.onLine;
|
|
700
737
|
// Restore persisted queue
|
|
701
738
|
this.restoreQueue();
|
|
702
739
|
// Start auto-flush timer
|
|
@@ -745,7 +782,7 @@
|
|
|
745
782
|
* Flush the queue (send all events)
|
|
746
783
|
*/
|
|
747
784
|
async flush() {
|
|
748
|
-
if (this.isFlushing || this.queue.length === 0) {
|
|
785
|
+
if (this.isFlushing || this.queue.length === 0 || !this.isOnline) {
|
|
749
786
|
return;
|
|
750
787
|
}
|
|
751
788
|
this.isFlushing = true;
|
|
@@ -776,11 +813,14 @@
|
|
|
776
813
|
}
|
|
777
814
|
}
|
|
778
815
|
/**
|
|
779
|
-
* Flush synchronously using sendBeacon (for page unload)
|
|
816
|
+
* Flush synchronously using sendBeacon (for page unload).
|
|
817
|
+
* Guarded: no-ops after the first call per navigation to prevent
|
|
818
|
+
* triple-flush from beforeunload + visibilitychange + pagehide.
|
|
780
819
|
*/
|
|
781
820
|
flushSync() {
|
|
782
|
-
if (this.queue.length === 0)
|
|
821
|
+
if (this.unloadFlushed || this.queue.length === 0)
|
|
783
822
|
return;
|
|
823
|
+
this.unloadFlushed = true;
|
|
784
824
|
const events = this.queue.splice(0, this.queue.length);
|
|
785
825
|
logger.debug(`Sync flushing ${events.length} events via beacon`);
|
|
786
826
|
const success = this.transport.sendBeacon(events);
|
|
@@ -818,17 +858,17 @@
|
|
|
818
858
|
clearInterval(this.flushTimer);
|
|
819
859
|
this.flushTimer = null;
|
|
820
860
|
}
|
|
821
|
-
// Remove unload handlers
|
|
822
861
|
if (typeof window !== 'undefined') {
|
|
823
|
-
if (this.boundBeforeUnload)
|
|
862
|
+
if (this.boundBeforeUnload)
|
|
824
863
|
window.removeEventListener('beforeunload', this.boundBeforeUnload);
|
|
825
|
-
|
|
826
|
-
if (this.boundVisibilityChange) {
|
|
864
|
+
if (this.boundVisibilityChange)
|
|
827
865
|
window.removeEventListener('visibilitychange', this.boundVisibilityChange);
|
|
828
|
-
|
|
829
|
-
if (this.boundPageHide) {
|
|
866
|
+
if (this.boundPageHide)
|
|
830
867
|
window.removeEventListener('pagehide', this.boundPageHide);
|
|
831
|
-
|
|
868
|
+
if (this.boundOnline)
|
|
869
|
+
window.removeEventListener('online', this.boundOnline);
|
|
870
|
+
if (this.boundOffline)
|
|
871
|
+
window.removeEventListener('offline', this.boundOffline);
|
|
832
872
|
}
|
|
833
873
|
}
|
|
834
874
|
/**
|
|
@@ -843,24 +883,38 @@
|
|
|
843
883
|
}, this.config.flushInterval);
|
|
844
884
|
}
|
|
845
885
|
/**
|
|
846
|
-
* Setup page unload handlers
|
|
886
|
+
* Setup page unload handlers and online/offline listeners
|
|
847
887
|
*/
|
|
848
888
|
setupUnloadHandlers() {
|
|
849
889
|
if (typeof window === 'undefined')
|
|
850
890
|
return;
|
|
851
|
-
//
|
|
891
|
+
// All three unload events share the same guarded flushSync()
|
|
852
892
|
this.boundBeforeUnload = () => this.flushSync();
|
|
853
893
|
window.addEventListener('beforeunload', this.boundBeforeUnload);
|
|
854
|
-
// Flush when page becomes hidden
|
|
855
894
|
this.boundVisibilityChange = () => {
|
|
856
895
|
if (document.visibilityState === 'hidden') {
|
|
857
896
|
this.flushSync();
|
|
858
897
|
}
|
|
898
|
+
else {
|
|
899
|
+
// Page became visible again (e.g. tab switch back) — reset guard
|
|
900
|
+
this.unloadFlushed = false;
|
|
901
|
+
}
|
|
859
902
|
};
|
|
860
903
|
window.addEventListener('visibilitychange', this.boundVisibilityChange);
|
|
861
|
-
// Flush on page hide (iOS Safari)
|
|
862
904
|
this.boundPageHide = () => this.flushSync();
|
|
863
905
|
window.addEventListener('pagehide', this.boundPageHide);
|
|
906
|
+
// Pause queue when offline, resume + flush when back online
|
|
907
|
+
this.boundOnline = () => {
|
|
908
|
+
logger.info('Connection restored — flushing queued events');
|
|
909
|
+
this.isOnline = true;
|
|
910
|
+
this.flush();
|
|
911
|
+
};
|
|
912
|
+
this.boundOffline = () => {
|
|
913
|
+
logger.warn('Connection lost — pausing event queue');
|
|
914
|
+
this.isOnline = false;
|
|
915
|
+
};
|
|
916
|
+
window.addEventListener('online', this.boundOnline);
|
|
917
|
+
window.addEventListener('offline', this.boundOffline);
|
|
864
918
|
}
|
|
865
919
|
/**
|
|
866
920
|
* Persist queue to storage based on persistMode
|
|
@@ -943,6 +997,8 @@
|
|
|
943
997
|
* Clianta SDK - Page View Plugin
|
|
944
998
|
* @see SDK_VERSION in core/config.ts
|
|
945
999
|
*/
|
|
1000
|
+
/** Sentinel flag to prevent double-wrapping history methods across multiple SDK instances */
|
|
1001
|
+
const WRAPPED_FLAG = '__clianta_pv_wrapped__';
|
|
946
1002
|
/**
|
|
947
1003
|
* Page View Plugin - Tracks page views
|
|
948
1004
|
*/
|
|
@@ -952,50 +1008,64 @@
|
|
|
952
1008
|
this.name = 'pageView';
|
|
953
1009
|
this.originalPushState = null;
|
|
954
1010
|
this.originalReplaceState = null;
|
|
1011
|
+
this.navHandler = null;
|
|
955
1012
|
this.popstateHandler = null;
|
|
956
1013
|
}
|
|
957
1014
|
init(tracker) {
|
|
958
1015
|
super.init(tracker);
|
|
959
1016
|
// Track initial page view
|
|
960
1017
|
this.trackPageView();
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1018
|
+
if (typeof window === 'undefined')
|
|
1019
|
+
return;
|
|
1020
|
+
// Only wrap history methods once — guard against multiple SDK instances (e.g. microfrontends)
|
|
1021
|
+
// wrapping them repeatedly, which would cause duplicate navigation events and broken cleanup.
|
|
1022
|
+
if (!history.pushState[WRAPPED_FLAG]) {
|
|
964
1023
|
this.originalPushState = history.pushState;
|
|
965
1024
|
this.originalReplaceState = history.replaceState;
|
|
966
|
-
|
|
967
|
-
const
|
|
1025
|
+
const originalPush = this.originalPushState;
|
|
1026
|
+
const originalReplace = this.originalReplaceState;
|
|
968
1027
|
history.pushState = function (...args) {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
// Notify other plugins (e.g. ScrollPlugin) about navigation
|
|
1028
|
+
originalPush.apply(history, args);
|
|
1029
|
+
// Dispatch event so all listening instances track the navigation
|
|
972
1030
|
window.dispatchEvent(new Event('clianta:navigation'));
|
|
973
1031
|
};
|
|
1032
|
+
history.pushState[WRAPPED_FLAG] = true;
|
|
974
1033
|
history.replaceState = function (...args) {
|
|
975
|
-
|
|
976
|
-
self.trackPageView();
|
|
1034
|
+
originalReplace.apply(history, args);
|
|
977
1035
|
window.dispatchEvent(new Event('clianta:navigation'));
|
|
978
1036
|
};
|
|
979
|
-
|
|
980
|
-
this.popstateHandler = () => this.trackPageView();
|
|
981
|
-
window.addEventListener('popstate', this.popstateHandler);
|
|
1037
|
+
history.replaceState[WRAPPED_FLAG] = true;
|
|
982
1038
|
}
|
|
1039
|
+
// Each instance listens to the shared navigation event rather than embedding
|
|
1040
|
+
// tracking directly in the pushState wrapper — decouples tracking from wrapping.
|
|
1041
|
+
this.navHandler = () => this.trackPageView();
|
|
1042
|
+
window.addEventListener('clianta:navigation', this.navHandler);
|
|
1043
|
+
// Handle back/forward navigation
|
|
1044
|
+
this.popstateHandler = () => this.trackPageView();
|
|
1045
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
983
1046
|
}
|
|
984
1047
|
destroy() {
|
|
985
|
-
|
|
1048
|
+
if (typeof window !== 'undefined') {
|
|
1049
|
+
if (this.navHandler) {
|
|
1050
|
+
window.removeEventListener('clianta:navigation', this.navHandler);
|
|
1051
|
+
this.navHandler = null;
|
|
1052
|
+
}
|
|
1053
|
+
if (this.popstateHandler) {
|
|
1054
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1055
|
+
this.popstateHandler = null;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// Restore original history methods only if this instance was the one that wrapped them
|
|
986
1059
|
if (this.originalPushState) {
|
|
987
1060
|
history.pushState = this.originalPushState;
|
|
1061
|
+
delete history.pushState[WRAPPED_FLAG];
|
|
988
1062
|
this.originalPushState = null;
|
|
989
1063
|
}
|
|
990
1064
|
if (this.originalReplaceState) {
|
|
991
1065
|
history.replaceState = this.originalReplaceState;
|
|
1066
|
+
delete history.replaceState[WRAPPED_FLAG];
|
|
992
1067
|
this.originalReplaceState = null;
|
|
993
1068
|
}
|
|
994
|
-
// Remove popstate listener
|
|
995
|
-
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
996
|
-
window.removeEventListener('popstate', this.popstateHandler);
|
|
997
|
-
this.popstateHandler = null;
|
|
998
|
-
}
|
|
999
1069
|
super.destroy();
|
|
1000
1070
|
}
|
|
1001
1071
|
trackPageView() {
|
|
@@ -1124,6 +1194,7 @@
|
|
|
1124
1194
|
this.trackedForms = new WeakSet();
|
|
1125
1195
|
this.formInteractions = new Set();
|
|
1126
1196
|
this.observer = null;
|
|
1197
|
+
this.observerTimer = null;
|
|
1127
1198
|
this.listeners = [];
|
|
1128
1199
|
}
|
|
1129
1200
|
init(tracker) {
|
|
@@ -1132,13 +1203,21 @@
|
|
|
1132
1203
|
return;
|
|
1133
1204
|
// Track existing forms
|
|
1134
1205
|
this.trackAllForms();
|
|
1135
|
-
// Watch for dynamically added forms
|
|
1206
|
+
// Watch for dynamically added forms — debounced to avoid O(DOM) cost on every mutation
|
|
1136
1207
|
if (typeof MutationObserver !== 'undefined') {
|
|
1137
|
-
this.observer = new MutationObserver(() =>
|
|
1208
|
+
this.observer = new MutationObserver(() => {
|
|
1209
|
+
if (this.observerTimer)
|
|
1210
|
+
clearTimeout(this.observerTimer);
|
|
1211
|
+
this.observerTimer = setTimeout(() => this.trackAllForms(), 100);
|
|
1212
|
+
});
|
|
1138
1213
|
this.observer.observe(document.body, { childList: true, subtree: true });
|
|
1139
1214
|
}
|
|
1140
1215
|
}
|
|
1141
1216
|
destroy() {
|
|
1217
|
+
if (this.observerTimer) {
|
|
1218
|
+
clearTimeout(this.observerTimer);
|
|
1219
|
+
this.observerTimer = null;
|
|
1220
|
+
}
|
|
1142
1221
|
if (this.observer) {
|
|
1143
1222
|
this.observer.disconnect();
|
|
1144
1223
|
this.observer = null;
|
|
@@ -1260,8 +1339,13 @@
|
|
|
1260
1339
|
super.destroy();
|
|
1261
1340
|
}
|
|
1262
1341
|
handleClick(e) {
|
|
1263
|
-
|
|
1264
|
-
|
|
1342
|
+
// Walk up the DOM to find the nearest trackable ancestor.
|
|
1343
|
+
// Without this, clicks on <span> or <img> inside a <button> are silently dropped.
|
|
1344
|
+
let target = e.target;
|
|
1345
|
+
while (target && !isTrackableClickElement(target)) {
|
|
1346
|
+
target = target.parentElement;
|
|
1347
|
+
}
|
|
1348
|
+
if (!target)
|
|
1265
1349
|
return;
|
|
1266
1350
|
const buttonText = getElementText(target, 100);
|
|
1267
1351
|
const elementInfo = getElementInfo(target);
|
|
@@ -1294,6 +1378,8 @@
|
|
|
1294
1378
|
this.engagementStartTime = 0;
|
|
1295
1379
|
this.isEngaged = false;
|
|
1296
1380
|
this.engagementTimeout = null;
|
|
1381
|
+
/** Guard: beforeunload + visibilitychange:hidden both fire on tab close — only report once */
|
|
1382
|
+
this.unloadReported = false;
|
|
1297
1383
|
this.boundMarkEngaged = null;
|
|
1298
1384
|
this.boundTrackTimeOnPage = null;
|
|
1299
1385
|
this.boundVisibilityHandler = null;
|
|
@@ -1315,8 +1401,9 @@
|
|
|
1315
1401
|
this.trackTimeOnPage();
|
|
1316
1402
|
}
|
|
1317
1403
|
else {
|
|
1318
|
-
//
|
|
1404
|
+
// Page is visible again — reset both the time counter and the unload guard
|
|
1319
1405
|
this.engagementStartTime = Date.now();
|
|
1406
|
+
this.unloadReported = false;
|
|
1320
1407
|
}
|
|
1321
1408
|
};
|
|
1322
1409
|
['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
|
|
@@ -1362,6 +1449,7 @@
|
|
|
1362
1449
|
this.pageLoadTime = Date.now();
|
|
1363
1450
|
this.engagementStartTime = Date.now();
|
|
1364
1451
|
this.isEngaged = false;
|
|
1452
|
+
this.unloadReported = false;
|
|
1365
1453
|
if (this.engagementTimeout) {
|
|
1366
1454
|
clearTimeout(this.engagementTimeout);
|
|
1367
1455
|
this.engagementTimeout = null;
|
|
@@ -1383,6 +1471,10 @@
|
|
|
1383
1471
|
}, 30000); // 30 seconds of inactivity
|
|
1384
1472
|
}
|
|
1385
1473
|
trackTimeOnPage() {
|
|
1474
|
+
// Guard: beforeunload and visibilitychange:hidden both fire on tab close — only report once
|
|
1475
|
+
if (this.unloadReported)
|
|
1476
|
+
return;
|
|
1477
|
+
this.unloadReported = true;
|
|
1386
1478
|
const timeSpent = Math.floor((Date.now() - this.engagementStartTime) / 1000);
|
|
1387
1479
|
if (timeSpent > 0) {
|
|
1388
1480
|
this.track('time_on_page', 'Time Spent', {
|
|
@@ -1541,12 +1633,16 @@
|
|
|
1541
1633
|
/**
|
|
1542
1634
|
* Error Tracking Plugin - Tracks JavaScript errors
|
|
1543
1635
|
*/
|
|
1636
|
+
/** Max unique errors to track per page (prevents queue flooding from error loops) */
|
|
1637
|
+
const MAX_UNIQUE_ERRORS = 20;
|
|
1544
1638
|
class ErrorsPlugin extends BasePlugin {
|
|
1545
1639
|
constructor() {
|
|
1546
1640
|
super(...arguments);
|
|
1547
1641
|
this.name = 'errors';
|
|
1548
1642
|
this.boundErrorHandler = null;
|
|
1549
1643
|
this.boundRejectionHandler = null;
|
|
1644
|
+
/** Seen error fingerprints — deduplicates repeated identical errors */
|
|
1645
|
+
this.seenErrors = new Set();
|
|
1550
1646
|
}
|
|
1551
1647
|
init(tracker) {
|
|
1552
1648
|
super.init(tracker);
|
|
@@ -1569,6 +1665,9 @@
|
|
|
1569
1665
|
super.destroy();
|
|
1570
1666
|
}
|
|
1571
1667
|
handleError(e) {
|
|
1668
|
+
const fingerprint = `${e.message}:${e.filename}:${e.lineno}`;
|
|
1669
|
+
if (!this.dedup(fingerprint))
|
|
1670
|
+
return;
|
|
1572
1671
|
this.track('error', 'JavaScript Error', {
|
|
1573
1672
|
message: e.message,
|
|
1574
1673
|
filename: e.filename,
|
|
@@ -1578,9 +1677,22 @@
|
|
|
1578
1677
|
});
|
|
1579
1678
|
}
|
|
1580
1679
|
handleRejection(e) {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1680
|
+
const reason = String(e.reason).substring(0, 200);
|
|
1681
|
+
if (!this.dedup(reason))
|
|
1682
|
+
return;
|
|
1683
|
+
this.track('error', 'Unhandled Promise Rejection', { reason });
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* Returns true if this error fingerprint is new (should be tracked).
|
|
1687
|
+
* Caps at MAX_UNIQUE_ERRORS to prevent queue flooding from error loops.
|
|
1688
|
+
*/
|
|
1689
|
+
dedup(fingerprint) {
|
|
1690
|
+
if (this.seenErrors.has(fingerprint))
|
|
1691
|
+
return false;
|
|
1692
|
+
if (this.seenErrors.size >= MAX_UNIQUE_ERRORS)
|
|
1693
|
+
return false;
|
|
1694
|
+
this.seenErrors.add(fingerprint);
|
|
1695
|
+
return true;
|
|
1584
1696
|
}
|
|
1585
1697
|
}
|
|
1586
1698
|
|
|
@@ -2248,113 +2360,173 @@
|
|
|
2248
2360
|
}
|
|
2249
2361
|
|
|
2250
2362
|
/**
|
|
2251
|
-
* Clianta SDK - Auto-Identify Plugin
|
|
2252
|
-
*
|
|
2253
|
-
*
|
|
2254
|
-
* Clerk, Firebase, Auth0, Supabase,
|
|
2363
|
+
* Clianta SDK - Auto-Identify Plugin (Production)
|
|
2364
|
+
*
|
|
2365
|
+
* Automatically detects logged-in users across ANY auth system:
|
|
2366
|
+
* - Window globals: Clerk, Firebase, Auth0, Supabase, __clianta_user
|
|
2367
|
+
* - JWT tokens in cookies (decoded client-side)
|
|
2368
|
+
* - JSON/JWT in localStorage & sessionStorage (guarded recursive deep-scan)
|
|
2369
|
+
* - Real-time storage change detection via `storage` event
|
|
2370
|
+
* - NextAuth session probing (only when NextAuth signals detected)
|
|
2255
2371
|
*
|
|
2256
|
-
*
|
|
2257
|
-
*
|
|
2258
|
-
*
|
|
2259
|
-
*
|
|
2260
|
-
*
|
|
2372
|
+
* Production safeguards:
|
|
2373
|
+
* - No monkey-patching of window.fetch or XMLHttpRequest
|
|
2374
|
+
* - Size-limited storage scanning (skips values > 50KB)
|
|
2375
|
+
* - Depth & key-count limited recursion (max 4 levels, 20 keys/level)
|
|
2376
|
+
* - Proper email regex validation
|
|
2377
|
+
* - Exponential backoff polling (2s → 5s → 10s → 30s)
|
|
2378
|
+
* - Zero console errors from probing
|
|
2379
|
+
*
|
|
2380
|
+
* Works universally: Next.js, Vite, CRA, Nuxt, SvelteKit, Remix,
|
|
2381
|
+
* Astro, plain HTML, Zustand, Redux, Pinia, MobX, or any custom auth.
|
|
2261
2382
|
*
|
|
2262
2383
|
* @see SDK_VERSION in core/config.ts
|
|
2263
2384
|
*/
|
|
2264
|
-
|
|
2385
|
+
// ────────────────────────────────────────────────
|
|
2386
|
+
// Constants
|
|
2387
|
+
// ────────────────────────────────────────────────
|
|
2388
|
+
/** Max recursion depth for JSON scanning */
|
|
2389
|
+
const MAX_SCAN_DEPTH = 4;
|
|
2390
|
+
/** Max object keys to inspect per recursion level */
|
|
2391
|
+
const MAX_KEYS_PER_LEVEL = 20;
|
|
2392
|
+
/** Max storage value size to parse (bytes) — skip large blobs */
|
|
2393
|
+
const MAX_STORAGE_VALUE_SIZE = 50000;
|
|
2394
|
+
/** Proper email regex — must have user@domain.tld (2+ char TLD) */
|
|
2395
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
2396
|
+
/** Known auth cookie name patterns */
|
|
2265
2397
|
const AUTH_COOKIE_PATTERNS = [
|
|
2266
|
-
//
|
|
2267
|
-
'__session',
|
|
2268
|
-
'
|
|
2269
|
-
// NextAuth
|
|
2270
|
-
'next-auth.session-token',
|
|
2271
|
-
'__Secure-next-auth.session-token',
|
|
2272
|
-
// Supabase
|
|
2398
|
+
// Provider-specific
|
|
2399
|
+
'__session', '__clerk_db_jwt',
|
|
2400
|
+
'next-auth.session-token', '__Secure-next-auth.session-token',
|
|
2273
2401
|
'sb-access-token',
|
|
2274
|
-
// Auth0
|
|
2275
2402
|
'auth0.is.authenticated',
|
|
2276
|
-
//
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
'jwt',
|
|
2280
|
-
'access_token',
|
|
2281
|
-
'session_token',
|
|
2282
|
-
'auth_token',
|
|
2283
|
-
'id_token',
|
|
2403
|
+
// Keycloak
|
|
2404
|
+
'KEYCLOAK_SESSION', 'KEYCLOAK_IDENTITY', 'KC_RESTART',
|
|
2405
|
+
// Generic
|
|
2406
|
+
'token', 'jwt', 'access_token', 'session_token', 'auth_token', 'id_token',
|
|
2284
2407
|
];
|
|
2285
|
-
/** localStorage/sessionStorage key patterns
|
|
2408
|
+
/** localStorage/sessionStorage key patterns */
|
|
2286
2409
|
const STORAGE_KEY_PATTERNS = [
|
|
2287
|
-
//
|
|
2288
|
-
'sb-',
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
'
|
|
2410
|
+
// Provider-specific
|
|
2411
|
+
'sb-', 'supabase.auth.', 'firebase:authUser:', 'auth0spajs', '@@auth0spajs@@',
|
|
2412
|
+
// Microsoft MSAL
|
|
2413
|
+
'msal.', 'msal.account',
|
|
2414
|
+
// AWS Cognito / Amplify
|
|
2415
|
+
'CognitoIdentityServiceProvider', 'amplify-signin-with-hostedUI',
|
|
2416
|
+
// Keycloak
|
|
2417
|
+
'kc-callback-',
|
|
2418
|
+
// State managers
|
|
2419
|
+
'persist:', '-storage',
|
|
2295
2420
|
// Generic
|
|
2296
|
-
'token',
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2421
|
+
'token', 'jwt', 'auth', 'user', 'session', 'credential', 'account',
|
|
2422
|
+
];
|
|
2423
|
+
/** JWT/user object fields containing email */
|
|
2424
|
+
const EMAIL_CLAIMS = ['email', 'sub', 'preferred_username', 'user_email', 'mail', 'emailAddress', 'e_mail'];
|
|
2425
|
+
/** Full name fields */
|
|
2426
|
+
const NAME_CLAIMS = ['name', 'full_name', 'display_name', 'displayName'];
|
|
2427
|
+
/** First name fields */
|
|
2428
|
+
const FIRST_NAME_CLAIMS = ['given_name', 'first_name', 'firstName', 'fname'];
|
|
2429
|
+
/** Last name fields */
|
|
2430
|
+
const LAST_NAME_CLAIMS = ['family_name', 'last_name', 'lastName', 'lname'];
|
|
2431
|
+
/** Polling schedule: exponential backoff (ms) */
|
|
2432
|
+
const POLL_SCHEDULE = [
|
|
2433
|
+
2000, // 2s — first check (auth providers need time to init)
|
|
2434
|
+
5000, // 5s — second check
|
|
2435
|
+
10000, // 10s
|
|
2436
|
+
10000, // 10s
|
|
2437
|
+
30000, // 30s — slower from here
|
|
2438
|
+
30000, // 30s
|
|
2439
|
+
30000, // 30s
|
|
2440
|
+
60000, // 1m
|
|
2441
|
+
60000, // 1m
|
|
2442
|
+
60000, // 1m — stop after ~4 min total
|
|
2301
2443
|
];
|
|
2302
|
-
/** Standard JWT claim fields for email */
|
|
2303
|
-
const EMAIL_CLAIMS = ['email', 'sub', 'preferred_username', 'user_email', 'mail'];
|
|
2304
|
-
const NAME_CLAIMS = ['name', 'full_name', 'display_name', 'given_name'];
|
|
2305
|
-
const FIRST_NAME_CLAIMS = ['given_name', 'first_name', 'firstName'];
|
|
2306
|
-
const LAST_NAME_CLAIMS = ['family_name', 'last_name', 'lastName'];
|
|
2307
2444
|
class AutoIdentifyPlugin extends BasePlugin {
|
|
2308
2445
|
constructor() {
|
|
2309
2446
|
super(...arguments);
|
|
2310
2447
|
this.name = 'autoIdentify';
|
|
2311
|
-
this.
|
|
2448
|
+
this.pollTimeouts = [];
|
|
2312
2449
|
this.identifiedEmail = null;
|
|
2313
|
-
this.
|
|
2314
|
-
this.
|
|
2315
|
-
this.CHECK_INTERVAL_MS = 10000; // Check every 10 seconds
|
|
2450
|
+
this.storageHandler = null;
|
|
2451
|
+
this.sessionProbed = false;
|
|
2316
2452
|
}
|
|
2317
2453
|
init(tracker) {
|
|
2318
2454
|
super.init(tracker);
|
|
2319
2455
|
if (typeof window === 'undefined')
|
|
2320
2456
|
return;
|
|
2321
|
-
//
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
}
|
|
2326
|
-
catch { /* silently fail */ }
|
|
2327
|
-
}, 2000);
|
|
2328
|
-
// Then check periodically
|
|
2329
|
-
this.checkInterval = setInterval(() => {
|
|
2330
|
-
this.checkCount++;
|
|
2331
|
-
if (this.checkCount >= this.MAX_CHECKS) {
|
|
2332
|
-
if (this.checkInterval) {
|
|
2333
|
-
clearInterval(this.checkInterval);
|
|
2334
|
-
this.checkInterval = null;
|
|
2335
|
-
}
|
|
2336
|
-
return;
|
|
2337
|
-
}
|
|
2338
|
-
try {
|
|
2339
|
-
this.checkForAuthUser();
|
|
2340
|
-
}
|
|
2341
|
-
catch { /* silently fail */ }
|
|
2342
|
-
}, this.CHECK_INTERVAL_MS);
|
|
2457
|
+
// Schedule poll checks with exponential backoff
|
|
2458
|
+
this.schedulePollChecks();
|
|
2459
|
+
// Listen for storage changes (real-time detection of login/logout)
|
|
2460
|
+
this.listenForStorageChanges();
|
|
2343
2461
|
}
|
|
2344
2462
|
destroy() {
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2463
|
+
// Clear all scheduled polls
|
|
2464
|
+
for (const t of this.pollTimeouts)
|
|
2465
|
+
clearTimeout(t);
|
|
2466
|
+
this.pollTimeouts = [];
|
|
2467
|
+
// Remove storage listener
|
|
2468
|
+
if (this.storageHandler && typeof window !== 'undefined') {
|
|
2469
|
+
window.removeEventListener('storage', this.storageHandler);
|
|
2470
|
+
this.storageHandler = null;
|
|
2348
2471
|
}
|
|
2349
2472
|
super.destroy();
|
|
2350
2473
|
}
|
|
2474
|
+
// ════════════════════════════════════════════════
|
|
2475
|
+
// SCHEDULING
|
|
2476
|
+
// ════════════════════════════════════════════════
|
|
2477
|
+
/**
|
|
2478
|
+
* Schedule poll checks with exponential backoff.
|
|
2479
|
+
* Much lighter than setInterval — each check is self-contained.
|
|
2480
|
+
*/
|
|
2481
|
+
schedulePollChecks() {
|
|
2482
|
+
let cumulativeDelay = 0;
|
|
2483
|
+
for (let i = 0; i < POLL_SCHEDULE.length; i++) {
|
|
2484
|
+
cumulativeDelay += POLL_SCHEDULE[i];
|
|
2485
|
+
const timeout = setTimeout(() => {
|
|
2486
|
+
if (this.identifiedEmail)
|
|
2487
|
+
return; // Already identified, skip
|
|
2488
|
+
try {
|
|
2489
|
+
this.checkForAuthUser();
|
|
2490
|
+
}
|
|
2491
|
+
catch { /* silently fail */ }
|
|
2492
|
+
// On the 4th check (~27s), probe NextAuth if signals detected
|
|
2493
|
+
if (i === 3 && !this.sessionProbed) {
|
|
2494
|
+
this.sessionProbed = true;
|
|
2495
|
+
this.guardedSessionProbe();
|
|
2496
|
+
}
|
|
2497
|
+
}, cumulativeDelay);
|
|
2498
|
+
this.pollTimeouts.push(timeout);
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2351
2501
|
/**
|
|
2352
|
-
*
|
|
2502
|
+
* Listen for `storage` events — fired when another tab or the app
|
|
2503
|
+
* modifies localStorage. Enables real-time detection after login.
|
|
2353
2504
|
*/
|
|
2505
|
+
listenForStorageChanges() {
|
|
2506
|
+
this.storageHandler = (event) => {
|
|
2507
|
+
if (this.identifiedEmail)
|
|
2508
|
+
return; // Already identified
|
|
2509
|
+
if (!event.key || !event.newValue)
|
|
2510
|
+
return;
|
|
2511
|
+
const keyLower = event.key.toLowerCase();
|
|
2512
|
+
const isAuthKey = STORAGE_KEY_PATTERNS.some(p => keyLower.includes(p.toLowerCase()));
|
|
2513
|
+
if (!isAuthKey)
|
|
2514
|
+
return;
|
|
2515
|
+
// Auth-related storage changed — run a check
|
|
2516
|
+
try {
|
|
2517
|
+
this.checkForAuthUser();
|
|
2518
|
+
}
|
|
2519
|
+
catch { /* silently fail */ }
|
|
2520
|
+
};
|
|
2521
|
+
window.addEventListener('storage', this.storageHandler);
|
|
2522
|
+
}
|
|
2523
|
+
// ════════════════════════════════════════════════
|
|
2524
|
+
// MAIN CHECK — scan all sources (priority order)
|
|
2525
|
+
// ════════════════════════════════════════════════
|
|
2354
2526
|
checkForAuthUser() {
|
|
2355
2527
|
if (!this.tracker || this.identifiedEmail)
|
|
2356
2528
|
return;
|
|
2357
|
-
// 0. Check well-known auth provider globals (most reliable)
|
|
2529
|
+
// 0. Check well-known auth provider globals (most reliable, zero overhead)
|
|
2358
2530
|
try {
|
|
2359
2531
|
const providerUser = this.checkAuthProviders();
|
|
2360
2532
|
if (providerUser) {
|
|
@@ -2363,8 +2535,8 @@
|
|
|
2363
2535
|
}
|
|
2364
2536
|
}
|
|
2365
2537
|
catch { /* provider check failed */ }
|
|
2538
|
+
// 1. Check cookies for JWTs
|
|
2366
2539
|
try {
|
|
2367
|
-
// 1. Check cookies for JWTs
|
|
2368
2540
|
const cookieUser = this.checkCookies();
|
|
2369
2541
|
if (cookieUser) {
|
|
2370
2542
|
this.identifyUser(cookieUser);
|
|
@@ -2372,8 +2544,8 @@
|
|
|
2372
2544
|
}
|
|
2373
2545
|
}
|
|
2374
2546
|
catch { /* cookie access blocked */ }
|
|
2547
|
+
// 2. Check localStorage (guarded deep scan)
|
|
2375
2548
|
try {
|
|
2376
|
-
// 2. Check localStorage
|
|
2377
2549
|
if (typeof localStorage !== 'undefined') {
|
|
2378
2550
|
const localUser = this.checkStorage(localStorage);
|
|
2379
2551
|
if (localUser) {
|
|
@@ -2383,8 +2555,8 @@
|
|
|
2383
2555
|
}
|
|
2384
2556
|
}
|
|
2385
2557
|
catch { /* localStorage access blocked */ }
|
|
2558
|
+
// 3. Check sessionStorage (guarded deep scan)
|
|
2386
2559
|
try {
|
|
2387
|
-
// 3. Check sessionStorage
|
|
2388
2560
|
if (typeof sessionStorage !== 'undefined') {
|
|
2389
2561
|
const sessionUser = this.checkStorage(sessionStorage);
|
|
2390
2562
|
if (sessionUser) {
|
|
@@ -2395,20 +2567,18 @@
|
|
|
2395
2567
|
}
|
|
2396
2568
|
catch { /* sessionStorage access blocked */ }
|
|
2397
2569
|
}
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
*/
|
|
2570
|
+
// ════════════════════════════════════════════════
|
|
2571
|
+
// AUTH PROVIDER GLOBALS
|
|
2572
|
+
// ════════════════════════════════════════════════
|
|
2402
2573
|
checkAuthProviders() {
|
|
2403
2574
|
const win = window;
|
|
2404
2575
|
// ─── Clerk ───
|
|
2405
|
-
// Clerk exposes window.Clerk after initialization
|
|
2406
2576
|
try {
|
|
2407
2577
|
const clerkUser = win.Clerk?.user;
|
|
2408
2578
|
if (clerkUser) {
|
|
2409
2579
|
const email = clerkUser.primaryEmailAddress?.emailAddress
|
|
2410
2580
|
|| clerkUser.emailAddresses?.[0]?.emailAddress;
|
|
2411
|
-
if (email) {
|
|
2581
|
+
if (email && this.isValidEmail(email)) {
|
|
2412
2582
|
return {
|
|
2413
2583
|
email,
|
|
2414
2584
|
firstName: clerkUser.firstName || undefined,
|
|
@@ -2422,7 +2592,7 @@
|
|
|
2422
2592
|
try {
|
|
2423
2593
|
const fbAuth = win.firebase?.auth?.();
|
|
2424
2594
|
const fbUser = fbAuth?.currentUser;
|
|
2425
|
-
if (fbUser?.email) {
|
|
2595
|
+
if (fbUser?.email && this.isValidEmail(fbUser.email)) {
|
|
2426
2596
|
const parts = (fbUser.displayName || '').split(' ');
|
|
2427
2597
|
return {
|
|
2428
2598
|
email: fbUser.email,
|
|
@@ -2436,10 +2606,9 @@
|
|
|
2436
2606
|
try {
|
|
2437
2607
|
const sbClient = win.__SUPABASE_CLIENT__ || win.supabase;
|
|
2438
2608
|
if (sbClient?.auth) {
|
|
2439
|
-
// Supabase v2 stores session
|
|
2440
2609
|
const session = sbClient.auth.session?.() || sbClient.auth.getSession?.();
|
|
2441
2610
|
const user = session?.data?.session?.user || session?.user;
|
|
2442
|
-
if (user?.email) {
|
|
2611
|
+
if (user?.email && this.isValidEmail(user.email)) {
|
|
2443
2612
|
const meta = user.user_metadata || {};
|
|
2444
2613
|
return {
|
|
2445
2614
|
email: user.email,
|
|
@@ -2455,7 +2624,7 @@
|
|
|
2455
2624
|
const auth0 = win.__auth0Client || win.auth0Client;
|
|
2456
2625
|
if (auth0?.isAuthenticated?.()) {
|
|
2457
2626
|
const user = auth0.getUser?.();
|
|
2458
|
-
if (user?.email) {
|
|
2627
|
+
if (user?.email && this.isValidEmail(user.email)) {
|
|
2459
2628
|
return {
|
|
2460
2629
|
email: user.email,
|
|
2461
2630
|
firstName: user.given_name || user.name?.split(' ')[0] || undefined,
|
|
@@ -2465,11 +2634,88 @@
|
|
|
2465
2634
|
}
|
|
2466
2635
|
}
|
|
2467
2636
|
catch { /* Auth0 not available */ }
|
|
2637
|
+
// ─── Google Identity Services (Google OAuth / Sign In With Google) ───
|
|
2638
|
+
// GIS stores the credential JWT from the callback; also check gapi
|
|
2639
|
+
try {
|
|
2640
|
+
const gisCredential = win.__google_credential_response?.credential;
|
|
2641
|
+
if (gisCredential && typeof gisCredential === 'string') {
|
|
2642
|
+
const user = this.extractUserFromToken(gisCredential);
|
|
2643
|
+
if (user)
|
|
2644
|
+
return user;
|
|
2645
|
+
}
|
|
2646
|
+
// Legacy gapi.auth2
|
|
2647
|
+
const gapiUser = win.gapi?.auth2?.getAuthInstance?.()?.currentUser?.get?.();
|
|
2648
|
+
const profile = gapiUser?.getBasicProfile?.();
|
|
2649
|
+
if (profile) {
|
|
2650
|
+
const email = profile.getEmail?.();
|
|
2651
|
+
if (email && this.isValidEmail(email)) {
|
|
2652
|
+
return {
|
|
2653
|
+
email,
|
|
2654
|
+
firstName: profile.getGivenName?.() || undefined,
|
|
2655
|
+
lastName: profile.getFamilyName?.() || undefined,
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
catch { /* Google auth not available */ }
|
|
2661
|
+
// ─── Microsoft MSAL (Microsoft OAuth / Azure AD) ───
|
|
2662
|
+
// MSAL stores account info in window.msalInstance or PublicClientApplication
|
|
2663
|
+
try {
|
|
2664
|
+
const msalInstance = win.msalInstance || win.__msalInstance;
|
|
2665
|
+
if (msalInstance) {
|
|
2666
|
+
const accounts = msalInstance.getAllAccounts?.() || [];
|
|
2667
|
+
const account = accounts[0];
|
|
2668
|
+
if (account?.username && this.isValidEmail(account.username)) {
|
|
2669
|
+
const nameParts = (account.name || '').split(' ');
|
|
2670
|
+
return {
|
|
2671
|
+
email: account.username,
|
|
2672
|
+
firstName: nameParts[0] || undefined,
|
|
2673
|
+
lastName: nameParts.slice(1).join(' ') || undefined,
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
catch { /* MSAL not available */ }
|
|
2679
|
+
// ─── AWS Cognito / Amplify ───
|
|
2680
|
+
try {
|
|
2681
|
+
// Amplify v6+
|
|
2682
|
+
const amplifyUser = win.aws_amplify_currentUser || win.__amplify_user;
|
|
2683
|
+
if (amplifyUser?.signInDetails?.loginId && this.isValidEmail(amplifyUser.signInDetails.loginId)) {
|
|
2684
|
+
return {
|
|
2685
|
+
email: amplifyUser.signInDetails.loginId,
|
|
2686
|
+
firstName: amplifyUser.attributes?.given_name || undefined,
|
|
2687
|
+
lastName: amplifyUser.attributes?.family_name || undefined,
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
// Check Cognito localStorage keys directly
|
|
2691
|
+
if (typeof localStorage !== 'undefined') {
|
|
2692
|
+
const cognitoUser = this.checkCognitoStorage();
|
|
2693
|
+
if (cognitoUser)
|
|
2694
|
+
return cognitoUser;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
catch { /* Cognito/Amplify not available */ }
|
|
2698
|
+
// ─── Keycloak ───
|
|
2699
|
+
try {
|
|
2700
|
+
const keycloak = win.keycloak || win.Keycloak;
|
|
2701
|
+
if (keycloak?.authenticated && keycloak.tokenParsed) {
|
|
2702
|
+
const claims = keycloak.tokenParsed;
|
|
2703
|
+
const email = claims.email || claims.preferred_username;
|
|
2704
|
+
if (email && this.isValidEmail(email)) {
|
|
2705
|
+
return {
|
|
2706
|
+
email,
|
|
2707
|
+
firstName: claims.given_name || undefined,
|
|
2708
|
+
lastName: claims.family_name || undefined,
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
catch { /* Keycloak not available */ }
|
|
2468
2714
|
// ─── Global clianta identify hook ───
|
|
2469
2715
|
// Any auth system can set: window.__clianta_user = { email, firstName, lastName }
|
|
2470
2716
|
try {
|
|
2471
2717
|
const manualUser = win.__clianta_user;
|
|
2472
|
-
if (manualUser?.email && typeof manualUser.email === 'string' && manualUser.email
|
|
2718
|
+
if (manualUser?.email && typeof manualUser.email === 'string' && this.isValidEmail(manualUser.email)) {
|
|
2473
2719
|
return {
|
|
2474
2720
|
email: manualUser.email,
|
|
2475
2721
|
firstName: manualUser.firstName || undefined,
|
|
@@ -2480,9 +2726,9 @@
|
|
|
2480
2726
|
catch { /* manual user not set */ }
|
|
2481
2727
|
return null;
|
|
2482
2728
|
}
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2729
|
+
// ════════════════════════════════════════════════
|
|
2730
|
+
// IDENTIFY USER
|
|
2731
|
+
// ════════════════════════════════════════════════
|
|
2486
2732
|
identifyUser(user) {
|
|
2487
2733
|
if (!this.tracker || this.identifiedEmail === user.email)
|
|
2488
2734
|
return;
|
|
@@ -2491,15 +2737,14 @@
|
|
|
2491
2737
|
firstName: user.firstName,
|
|
2492
2738
|
lastName: user.lastName,
|
|
2493
2739
|
});
|
|
2494
|
-
//
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
*/
|
|
2740
|
+
// Cancel all remaining polls — we found the user
|
|
2741
|
+
for (const t of this.pollTimeouts)
|
|
2742
|
+
clearTimeout(t);
|
|
2743
|
+
this.pollTimeouts = [];
|
|
2744
|
+
}
|
|
2745
|
+
// ════════════════════════════════════════════════
|
|
2746
|
+
// COOKIE SCANNING
|
|
2747
|
+
// ════════════════════════════════════════════════
|
|
2503
2748
|
checkCookies() {
|
|
2504
2749
|
if (typeof document === 'undefined')
|
|
2505
2750
|
return null;
|
|
@@ -2509,7 +2754,6 @@
|
|
|
2509
2754
|
const [name, ...valueParts] = cookie.split('=');
|
|
2510
2755
|
const value = valueParts.join('=');
|
|
2511
2756
|
const cookieName = name.trim().toLowerCase();
|
|
2512
|
-
// Check if this cookie matches known auth patterns
|
|
2513
2757
|
const isAuthCookie = AUTH_COOKIE_PATTERNS.some(pattern => cookieName.includes(pattern.toLowerCase()));
|
|
2514
2758
|
if (isAuthCookie && value) {
|
|
2515
2759
|
const user = this.extractUserFromToken(decodeURIComponent(value));
|
|
@@ -2519,13 +2763,13 @@
|
|
|
2519
2763
|
}
|
|
2520
2764
|
}
|
|
2521
2765
|
catch {
|
|
2522
|
-
// Cookie access may fail
|
|
2766
|
+
// Cookie access may fail (cross-origin iframe, etc.)
|
|
2523
2767
|
}
|
|
2524
2768
|
return null;
|
|
2525
2769
|
}
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2770
|
+
// ════════════════════════════════════════════════
|
|
2771
|
+
// STORAGE SCANNING (GUARDED DEEP RECURSIVE)
|
|
2772
|
+
// ════════════════════════════════════════════════
|
|
2529
2773
|
checkStorage(storage) {
|
|
2530
2774
|
try {
|
|
2531
2775
|
for (let i = 0; i < storage.length; i++) {
|
|
@@ -2538,16 +2782,19 @@
|
|
|
2538
2782
|
const value = storage.getItem(key);
|
|
2539
2783
|
if (!value)
|
|
2540
2784
|
continue;
|
|
2785
|
+
// Size guard — skip values larger than 50KB
|
|
2786
|
+
if (value.length > MAX_STORAGE_VALUE_SIZE)
|
|
2787
|
+
continue;
|
|
2541
2788
|
// Try as direct JWT
|
|
2542
2789
|
const user = this.extractUserFromToken(value);
|
|
2543
2790
|
if (user)
|
|
2544
2791
|
return user;
|
|
2545
|
-
// Try as JSON
|
|
2792
|
+
// Try as JSON — guarded deep recursive scan
|
|
2546
2793
|
try {
|
|
2547
2794
|
const json = JSON.parse(value);
|
|
2548
|
-
const
|
|
2549
|
-
if (
|
|
2550
|
-
return
|
|
2795
|
+
const jsonUser = this.deepScanForUser(json, 0);
|
|
2796
|
+
if (jsonUser)
|
|
2797
|
+
return jsonUser;
|
|
2551
2798
|
}
|
|
2552
2799
|
catch {
|
|
2553
2800
|
// Not JSON, skip
|
|
@@ -2560,11 +2807,63 @@
|
|
|
2560
2807
|
}
|
|
2561
2808
|
return null;
|
|
2562
2809
|
}
|
|
2810
|
+
// ════════════════════════════════════════════════
|
|
2811
|
+
// DEEP RECURSIVE SCANNING (guarded)
|
|
2812
|
+
// ════════════════════════════════════════════════
|
|
2813
|
+
/**
|
|
2814
|
+
* Recursively scan a JSON object for user data.
|
|
2815
|
+
* Guards: max depth (4), max keys per level (20), no array traversal.
|
|
2816
|
+
*
|
|
2817
|
+
* Handles ANY nesting pattern:
|
|
2818
|
+
* - Zustand persist: { state: { user: { email } } }
|
|
2819
|
+
* - Redux persist: { auth: { user: { email } } }
|
|
2820
|
+
* - Pinia: { auth: { userData: { email } } }
|
|
2821
|
+
* - NextAuth: { user: { email }, expires: ... }
|
|
2822
|
+
* - Direct: { email, name }
|
|
2823
|
+
*/
|
|
2824
|
+
deepScanForUser(data, depth) {
|
|
2825
|
+
if (depth > MAX_SCAN_DEPTH || !data || typeof data !== 'object' || Array.isArray(data)) {
|
|
2826
|
+
return null;
|
|
2827
|
+
}
|
|
2828
|
+
const obj = data;
|
|
2829
|
+
const keys = Object.keys(obj);
|
|
2830
|
+
// 1. Try direct extraction at this level
|
|
2831
|
+
const user = this.extractUserFromClaims(obj);
|
|
2832
|
+
if (user)
|
|
2833
|
+
return user;
|
|
2834
|
+
// Guard: limit keys scanned per level
|
|
2835
|
+
const keysToScan = keys.slice(0, MAX_KEYS_PER_LEVEL);
|
|
2836
|
+
// 2. Check for JWT strings at this level
|
|
2837
|
+
for (const key of keysToScan) {
|
|
2838
|
+
const val = obj[key];
|
|
2839
|
+
if (typeof val === 'string' && val.length > 30 && val.length < 4000) {
|
|
2840
|
+
// Only check strings that could plausibly be JWTs (30-4000 chars)
|
|
2841
|
+
const dotCount = (val.match(/\./g) || []).length;
|
|
2842
|
+
if (dotCount === 2) {
|
|
2843
|
+
const tokenUser = this.extractUserFromToken(val);
|
|
2844
|
+
if (tokenUser)
|
|
2845
|
+
return tokenUser;
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
// 3. Recurse into nested objects
|
|
2850
|
+
for (const key of keysToScan) {
|
|
2851
|
+
const val = obj[key];
|
|
2852
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
2853
|
+
const nestedUser = this.deepScanForUser(val, depth + 1);
|
|
2854
|
+
if (nestedUser)
|
|
2855
|
+
return nestedUser;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
return null;
|
|
2859
|
+
}
|
|
2860
|
+
// ════════════════════════════════════════════════
|
|
2861
|
+
// TOKEN & CLAIMS EXTRACTION
|
|
2862
|
+
// ════════════════════════════════════════════════
|
|
2563
2863
|
/**
|
|
2564
|
-
* Try to extract user info from a JWT token string
|
|
2864
|
+
* Try to extract user info from a JWT token string (header.payload.signature)
|
|
2565
2865
|
*/
|
|
2566
2866
|
extractUserFromToken(token) {
|
|
2567
|
-
// JWT format: header.payload.signature
|
|
2568
2867
|
const parts = token.split('.');
|
|
2569
2868
|
if (parts.length !== 3)
|
|
2570
2869
|
return null;
|
|
@@ -2577,48 +2876,29 @@
|
|
|
2577
2876
|
}
|
|
2578
2877
|
}
|
|
2579
2878
|
/**
|
|
2580
|
-
* Extract user
|
|
2581
|
-
|
|
2582
|
-
extractUserFromJson(data) {
|
|
2583
|
-
if (!data || typeof data !== 'object')
|
|
2584
|
-
return null;
|
|
2585
|
-
// Direct user object
|
|
2586
|
-
const user = this.extractUserFromClaims(data);
|
|
2587
|
-
if (user)
|
|
2588
|
-
return user;
|
|
2589
|
-
// Nested: { user: { email } } or { data: { user: { email } } }
|
|
2590
|
-
for (const key of ['user', 'data', 'session', 'currentUser', 'authUser', 'access_token', 'token']) {
|
|
2591
|
-
if (data[key]) {
|
|
2592
|
-
if (typeof data[key] === 'string') {
|
|
2593
|
-
// Might be a JWT inside JSON
|
|
2594
|
-
const tokenUser = this.extractUserFromToken(data[key]);
|
|
2595
|
-
if (tokenUser)
|
|
2596
|
-
return tokenUser;
|
|
2597
|
-
}
|
|
2598
|
-
else if (typeof data[key] === 'object') {
|
|
2599
|
-
const nestedUser = this.extractUserFromClaims(data[key]);
|
|
2600
|
-
if (nestedUser)
|
|
2601
|
-
return nestedUser;
|
|
2602
|
-
}
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
|
-
return null;
|
|
2606
|
-
}
|
|
2607
|
-
/**
|
|
2608
|
-
* Extract user from JWT claims or user object
|
|
2879
|
+
* Extract user from JWT claims or user-like object.
|
|
2880
|
+
* Uses proper email regex validation.
|
|
2609
2881
|
*/
|
|
2610
2882
|
extractUserFromClaims(claims) {
|
|
2611
2883
|
if (!claims || typeof claims !== 'object')
|
|
2612
2884
|
return null;
|
|
2613
|
-
// Find email
|
|
2885
|
+
// Find email — check standard claim fields
|
|
2614
2886
|
let email = null;
|
|
2615
2887
|
for (const claim of EMAIL_CLAIMS) {
|
|
2616
2888
|
const value = claims[claim];
|
|
2617
|
-
if (value && typeof value === 'string' &&
|
|
2889
|
+
if (value && typeof value === 'string' && this.isValidEmail(value)) {
|
|
2618
2890
|
email = value;
|
|
2619
2891
|
break;
|
|
2620
2892
|
}
|
|
2621
2893
|
}
|
|
2894
|
+
// Check nested email objects (Clerk pattern)
|
|
2895
|
+
if (!email) {
|
|
2896
|
+
const nestedEmail = claims.primaryEmailAddress?.emailAddress
|
|
2897
|
+
|| claims.emailAddresses?.[0]?.emailAddress;
|
|
2898
|
+
if (nestedEmail && typeof nestedEmail === 'string' && this.isValidEmail(nestedEmail)) {
|
|
2899
|
+
email = nestedEmail;
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2622
2902
|
if (!email)
|
|
2623
2903
|
return null;
|
|
2624
2904
|
// Find name
|
|
@@ -2636,7 +2916,7 @@
|
|
|
2636
2916
|
break;
|
|
2637
2917
|
}
|
|
2638
2918
|
}
|
|
2639
|
-
//
|
|
2919
|
+
// Try full name if no first/last found
|
|
2640
2920
|
if (!firstName) {
|
|
2641
2921
|
for (const claim of NAME_CLAIMS) {
|
|
2642
2922
|
if (claims[claim] && typeof claims[claim] === 'string') {
|
|
@@ -2649,6 +2929,117 @@
|
|
|
2649
2929
|
}
|
|
2650
2930
|
return { email, firstName, lastName };
|
|
2651
2931
|
}
|
|
2932
|
+
// ════════════════════════════════════════════════
|
|
2933
|
+
// GUARDED SESSION PROBING (NextAuth only)
|
|
2934
|
+
// ════════════════════════════════════════════════
|
|
2935
|
+
/**
|
|
2936
|
+
* Probe NextAuth session endpoint ONLY if NextAuth signals are present.
|
|
2937
|
+
* Signals: `next-auth.session-token` cookie, `__NEXTAUTH` or `__NEXT_DATA__` globals.
|
|
2938
|
+
* This prevents unnecessary 404 errors on non-NextAuth sites.
|
|
2939
|
+
*/
|
|
2940
|
+
async guardedSessionProbe() {
|
|
2941
|
+
if (this.identifiedEmail)
|
|
2942
|
+
return;
|
|
2943
|
+
// Check for NextAuth signals before probing
|
|
2944
|
+
const hasNextAuthCookie = typeof document !== 'undefined' &&
|
|
2945
|
+
(document.cookie.includes('next-auth.session-token') ||
|
|
2946
|
+
document.cookie.includes('__Secure-next-auth.session-token'));
|
|
2947
|
+
const hasNextAuthGlobal = typeof window !== 'undefined' &&
|
|
2948
|
+
(window.__NEXTAUTH != null || window.__NEXT_DATA__ != null);
|
|
2949
|
+
if (!hasNextAuthCookie && !hasNextAuthGlobal)
|
|
2950
|
+
return;
|
|
2951
|
+
// NextAuth detected — safe to probe /api/auth/session
|
|
2952
|
+
try {
|
|
2953
|
+
const response = await fetch('/api/auth/session', {
|
|
2954
|
+
method: 'GET',
|
|
2955
|
+
credentials: 'include',
|
|
2956
|
+
headers: { 'Accept': 'application/json' },
|
|
2957
|
+
});
|
|
2958
|
+
if (response.ok) {
|
|
2959
|
+
const body = await response.json();
|
|
2960
|
+
// NextAuth returns { user: { name, email, image }, expires }
|
|
2961
|
+
if (body && typeof body === 'object' && Object.keys(body).length > 0) {
|
|
2962
|
+
const user = this.deepScanForUser(body, 0);
|
|
2963
|
+
if (user) {
|
|
2964
|
+
this.identifyUser(user);
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
catch {
|
|
2970
|
+
// Endpoint failed — silently ignore
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
// ════════════════════════════════════════════════
|
|
2974
|
+
// AWS COGNITO STORAGE SCANNING
|
|
2975
|
+
// ════════════════════════════════════════════════
|
|
2976
|
+
/**
|
|
2977
|
+
* Scan localStorage for AWS Cognito / Amplify user data.
|
|
2978
|
+
* Cognito stores tokens under keys like:
|
|
2979
|
+
* CognitoIdentityServiceProvider.<clientId>.<username>.idToken
|
|
2980
|
+
* CognitoIdentityServiceProvider.<clientId>.<username>.userData
|
|
2981
|
+
*/
|
|
2982
|
+
checkCognitoStorage() {
|
|
2983
|
+
try {
|
|
2984
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
2985
|
+
const key = localStorage.key(i);
|
|
2986
|
+
if (!key)
|
|
2987
|
+
continue;
|
|
2988
|
+
// Look for Cognito ID tokens (contain email in JWT claims)
|
|
2989
|
+
if (key.startsWith('CognitoIdentityServiceProvider.') && key.endsWith('.idToken')) {
|
|
2990
|
+
const value = localStorage.getItem(key);
|
|
2991
|
+
if (value && value.length < MAX_STORAGE_VALUE_SIZE) {
|
|
2992
|
+
const user = this.extractUserFromToken(value);
|
|
2993
|
+
if (user)
|
|
2994
|
+
return user;
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
// Look for Cognito userData (JSON with email attribute)
|
|
2998
|
+
if (key.startsWith('CognitoIdentityServiceProvider.') && key.endsWith('.userData')) {
|
|
2999
|
+
const value = localStorage.getItem(key);
|
|
3000
|
+
if (value && value.length < MAX_STORAGE_VALUE_SIZE) {
|
|
3001
|
+
try {
|
|
3002
|
+
const data = JSON.parse(value);
|
|
3003
|
+
// Cognito userData format: { UserAttributes: [{ Name: 'email', Value: '...' }] }
|
|
3004
|
+
const attrs = data.UserAttributes || data.attributes || [];
|
|
3005
|
+
const emailAttr = attrs.find?.((a) => a.Name === 'email' || a.name === 'email');
|
|
3006
|
+
if (emailAttr?.Value && this.isValidEmail(emailAttr.Value)) {
|
|
3007
|
+
const nameAttr = attrs.find?.((a) => a.Name === 'name' || a.name === 'name');
|
|
3008
|
+
const givenNameAttr = attrs.find?.((a) => a.Name === 'given_name' || a.name === 'given_name');
|
|
3009
|
+
const familyNameAttr = attrs.find?.((a) => a.Name === 'family_name' || a.name === 'family_name');
|
|
3010
|
+
let firstName = givenNameAttr?.Value;
|
|
3011
|
+
let lastName = familyNameAttr?.Value;
|
|
3012
|
+
if (!firstName && nameAttr?.Value) {
|
|
3013
|
+
const parts = nameAttr.Value.split(' ');
|
|
3014
|
+
firstName = parts[0];
|
|
3015
|
+
lastName = lastName || parts.slice(1).join(' ') || undefined;
|
|
3016
|
+
}
|
|
3017
|
+
return {
|
|
3018
|
+
email: emailAttr.Value,
|
|
3019
|
+
firstName: firstName || undefined,
|
|
3020
|
+
lastName: lastName || undefined,
|
|
3021
|
+
};
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
catch { /* invalid JSON */ }
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
catch { /* storage access failed */ }
|
|
3030
|
+
return null;
|
|
3031
|
+
}
|
|
3032
|
+
// ════════════════════════════════════════════════
|
|
3033
|
+
// UTILITIES
|
|
3034
|
+
// ════════════════════════════════════════════════
|
|
3035
|
+
/**
|
|
3036
|
+
* Validate email with proper regex.
|
|
3037
|
+
* Rejects: user@v2.0, config@internal, tokens with @ signs.
|
|
3038
|
+
* Accepts: user@domain.com, user@sub.domain.co.uk
|
|
3039
|
+
*/
|
|
3040
|
+
isValidEmail(value) {
|
|
3041
|
+
return EMAIL_REGEX.test(value);
|
|
3042
|
+
}
|
|
2652
3043
|
}
|
|
2653
3044
|
|
|
2654
3045
|
/**
|
|
@@ -3240,28 +3631,17 @@
|
|
|
3240
3631
|
}
|
|
3241
3632
|
const prevId = previousId || this.visitorId;
|
|
3242
3633
|
logger.info('Aliasing visitor:', { from: prevId, to: newId });
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
newId,
|
|
3252
|
-
}),
|
|
3253
|
-
});
|
|
3254
|
-
if (response.ok) {
|
|
3255
|
-
logger.info('Alias successful');
|
|
3256
|
-
return true;
|
|
3257
|
-
}
|
|
3258
|
-
logger.error('Alias failed:', response.status);
|
|
3259
|
-
return false;
|
|
3260
|
-
}
|
|
3261
|
-
catch (error) {
|
|
3262
|
-
logger.error('Alias request failed:', error);
|
|
3263
|
-
return false;
|
|
3634
|
+
const result = await this.transport.sendPost('/api/public/track/alias', {
|
|
3635
|
+
workspaceId: this.workspaceId,
|
|
3636
|
+
previousId: prevId,
|
|
3637
|
+
newId,
|
|
3638
|
+
});
|
|
3639
|
+
if (result.success) {
|
|
3640
|
+
logger.info('Alias successful');
|
|
3641
|
+
return true;
|
|
3264
3642
|
}
|
|
3643
|
+
logger.error('Alias failed:', result.error ?? result.status);
|
|
3644
|
+
return false;
|
|
3265
3645
|
}
|
|
3266
3646
|
/**
|
|
3267
3647
|
* Track a screen view (for mobile-first PWAs and SPAs).
|
|
@@ -3624,13 +4004,24 @@
|
|
|
3624
4004
|
* });
|
|
3625
4005
|
*/
|
|
3626
4006
|
function clianta(workspaceId, config) {
|
|
3627
|
-
// Return existing instance if same workspace
|
|
4007
|
+
// Return existing instance if same workspace and no config change
|
|
3628
4008
|
if (globalInstance && globalInstance.getWorkspaceId() === workspaceId) {
|
|
4009
|
+
if (config && Object.keys(config).length > 0) {
|
|
4010
|
+
// Config was passed to an already-initialized instance — warn the developer
|
|
4011
|
+
// because the new config is ignored. They must call destroy() first to reconfigure.
|
|
4012
|
+
if (typeof console !== 'undefined') {
|
|
4013
|
+
console.warn('[Clianta] clianta() called with config on an already-initialized instance ' +
|
|
4014
|
+
'for workspace "' + workspaceId + '". The new config was ignored. ' +
|
|
4015
|
+
'Call tracker.destroy() first if you need to reconfigure.');
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
3629
4018
|
return globalInstance;
|
|
3630
4019
|
}
|
|
3631
|
-
// Destroy existing instance if workspace changed
|
|
4020
|
+
// Destroy existing instance if workspace changed (fire-and-forget flush, then destroy)
|
|
3632
4021
|
if (globalInstance) {
|
|
3633
|
-
|
|
4022
|
+
// Kick off async flush+destroy without blocking the new instance creation.
|
|
4023
|
+
// Using void to make the intentional fire-and-forget explicit.
|
|
4024
|
+
void globalInstance.destroy();
|
|
3634
4025
|
}
|
|
3635
4026
|
// Create new instance
|
|
3636
4027
|
globalInstance = new Tracker(workspaceId, config);
|
|
@@ -3658,8 +4049,21 @@
|
|
|
3658
4049
|
const projectId = script.getAttribute('data-project-id');
|
|
3659
4050
|
if (!projectId)
|
|
3660
4051
|
return;
|
|
3661
|
-
const
|
|
3662
|
-
|
|
4052
|
+
const initConfig = {
|
|
4053
|
+
debug: script.hasAttribute('data-debug'),
|
|
4054
|
+
};
|
|
4055
|
+
// Support additional config via script tag attributes:
|
|
4056
|
+
// data-api-endpoint="https://api.yourhost.com"
|
|
4057
|
+
// data-cookieless (boolean flag)
|
|
4058
|
+
// data-use-cookies (boolean flag)
|
|
4059
|
+
const apiEndpoint = script.getAttribute('data-api-endpoint');
|
|
4060
|
+
if (apiEndpoint)
|
|
4061
|
+
initConfig.apiEndpoint = apiEndpoint;
|
|
4062
|
+
if (script.hasAttribute('data-cookieless'))
|
|
4063
|
+
initConfig.cookielessMode = true;
|
|
4064
|
+
if (script.hasAttribute('data-use-cookies'))
|
|
4065
|
+
initConfig.useCookies = true;
|
|
4066
|
+
const instance = clianta(projectId, initConfig);
|
|
3663
4067
|
// Expose the auto-initialized instance globally
|
|
3664
4068
|
window.__clianta = instance;
|
|
3665
4069
|
};
|