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