@thumbmarkjs/thumbmarkjs 1.3.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  import { componentInterface } from '../../factory';
2
2
  import { hash } from '../../utils/hash';
3
+ import { ephemeralIFrame } from '../../utils/ephemeralIFrame';
4
+ import { stableStringify } from '../../utils/stableStringify';
3
5
 
4
6
  const BLACKBOARD_BOLD = ['\uD835\uDD04', '\uD835\uDD05', '\u212D', '\uD835\uDD07', '\uD835\uDD08', '\uD835\uDD09', '\uD835\uDD38', '\uD835\uDD39', '\u2102', '\uD835\uDD3B', '\uD835\uDD3C', '\uD835\uDD3D'];
5
7
  const GREEK_SYMBOLS = ['\u03B2', '\u03C8', '\u03BB', '\u03B5', '\u03B6', '\u03B1', '\u03BE', '\u03BC', '\u03C1', '\u03C6', '\u03BA', '\u03C4', '\u03B7', '\u03C3', '\u03B9', '\u03C9', '\u03B3', '\u03BD', '\u03C7', '\u03B4', '\u03B8', '\u03C0', '\u03C5', '\u03BF'];
@@ -7,33 +9,58 @@ const GREEK_SYMBOLS = ['\u03B2', '\u03C8', '\u03BB', '\u03B5', '\u03B6', '\u03B1
7
9
  export default async function getMathML(): Promise<componentInterface | null> {
8
10
  return new Promise((resolve) => {
9
11
  try {
10
- if (!isMathMLSupported()) {
11
- resolve({
12
- supported: false,
13
- error: 'MathML not supported'
14
- });
15
- return;
16
- }
12
+ ephemeralIFrame(async ({ iframe }) => {
13
+ try {
14
+ if (!isMathMLSupported(iframe)) {
15
+ resolve({
16
+ supported: false,
17
+ error: 'MathML not supported'
18
+ });
19
+ return;
20
+ }
17
21
 
18
- const structures = [
19
- createMathML('integral', '<msubsup><mo>\u222B</mo><mi>a</mi><mi>b</mi></msubsup><mfrac><mrow><mi>f</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow><mrow><mi>g</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow></mfrac><mi>dx</mi>'),
20
- createMathML('fraction', '<mfrac><mrow><mi>\u03C0</mi><mo>\u00D7</mo><msup><mi>r</mi><mn>2</mn></msup></mrow><mrow><mn>2</mn><mi>\u03C3</mi></mrow></mfrac>'),
21
- createMathML('matrix', '<mo>[</mo><mtable><mtr><mtd><mi>\u03B1</mi></mtd><mtd><mi>\u03B2</mi></mtd></mtr><mtr><mtd><mi>\u03B3</mi></mtd><mtd><mi>\u03B4</mi></mtd></mtr></mtable><mo>]</mo>'),
22
- createComplexNestedStructure(),
23
- ...createSymbolStructures()
24
- ];
25
-
26
- const measurements: any = {};
27
- structures.forEach((struct, i) => {
28
- measurements[`struct_${i}`] = measureMathMLStructure(struct);
29
- });
22
+ const structures = [
23
+ createMathML('integral', '<msubsup><mo>\u222B</mo><mi>a</mi><mi>b</mi></msubsup><mfrac><mrow><mi>f</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow><mrow><mi>g</mi><mo>(</mo><mi>x</mi><mo>)</mo></mrow></mfrac><mi>dx</mi>'),
24
+ createMathML('fraction', '<mfrac><mrow><mi>\u03C0</mi><mo>\u00D7</mo><msup><mi>r</mi><mn>2</mn></msup></mrow><mrow><mn>2</mn><mi>\u03C3</mi></mrow></mfrac>'),
25
+ createMathML('matrix', '<mo>[</mo><mtable><mtr><mtd><mi>\u03B1</mi></mtd><mtd><mi>\u03B2</mi></mtd></mtr><mtr><mtd><mi>\u03B3</mi></mtd><mtd><mi>\u03B4</mi></mtd></mtr></mtable><mo>]</mo>'),
26
+ createComplexNestedStructure(),
27
+ ...createSymbolStructures()
28
+ ];
30
29
 
31
- resolve({
32
- //supported: true,
33
- //measurements,
34
- hash: hash(JSON.stringify(measurements))
35
- });
30
+ const dimensionsArray: any[] = [];
31
+ let fontStyleHash: string = '';
32
+
33
+ structures.forEach((struct, i) => {
34
+ const measurement = measureMathMLStructure(struct, iframe);
35
+ // Extract dimensions for this structure
36
+ dimensionsArray.push({
37
+ width: measurement.dimensions.width,
38
+ height: measurement.dimensions.height
39
+ });
40
+ // Capture font style hash from the first structure (it's the same for all)
41
+ if (i === 0 && measurement.fontInfo) {
42
+ fontStyleHash = hash(stableStringify(measurement.fontInfo));
43
+ }
44
+ });
36
45
 
46
+ const details = {
47
+ fontStyleHash,
48
+ dimensions: dimensionsArray
49
+ };
50
+
51
+ resolve({
52
+ //supported: true,
53
+ details,
54
+ hash: hash(stableStringify(details))
55
+ });
56
+
57
+ } catch (error) {
58
+ resolve({
59
+ supported: false,
60
+ error: `MathML error: ${(error as Error).message}`
61
+ });
62
+ }
63
+ });
37
64
  } catch (error) {
38
65
  resolve({
39
66
  supported: false,
@@ -43,17 +70,17 @@ export default async function getMathML(): Promise<componentInterface | null> {
43
70
  });
44
71
  }
45
72
 
46
- function isMathMLSupported(): boolean {
73
+ function isMathMLSupported(iframe: Document): boolean {
47
74
  try {
48
- const testElement = document.createElement('math');
75
+ const testElement = iframe.createElement('math');
49
76
  testElement.innerHTML = '<mrow><mi>x</mi></mrow>';
50
77
  testElement.style.position = 'absolute';
51
78
  testElement.style.visibility = 'hidden';
52
-
53
- document.body.appendChild(testElement);
79
+
80
+ iframe.body.appendChild(testElement);
54
81
  const rect = testElement.getBoundingClientRect();
55
- document.body.removeChild(testElement);
56
-
82
+ iframe.body.removeChild(testElement);
83
+
57
84
  return rect.width > 0 && rect.height > 0;
58
85
  } catch {
59
86
  return false;
@@ -66,7 +93,7 @@ function createMathML(name: string, content: string): string {
66
93
 
67
94
  function createComplexNestedStructure(): string {
68
95
  let nestedContent = '<mo>\u220F</mo>'; // Product symbol (∏)
69
-
96
+
70
97
  // Add all symbol combinations inside the main structure
71
98
  BLACKBOARD_BOLD.forEach((bbSymbol, bbIndex) => {
72
99
  const startIdx = bbIndex * 2;
@@ -76,7 +103,7 @@ function createComplexNestedStructure(): string {
76
103
  nestedContent += `<mmultiscripts><mi>${bbSymbol}</mi><none/><mi>${greekSet[1]}</mi><mprescripts></mprescripts><mi>${greekSet[0]}</mi><none/></mmultiscripts>`;
77
104
  }
78
105
  });
79
-
106
+
80
107
  return createMathML('complex_nested',
81
108
  `<munderover><mmultiscripts>${nestedContent}</mmultiscripts></munderover>`
82
109
  );
@@ -84,7 +111,7 @@ function createComplexNestedStructure(): string {
84
111
 
85
112
  function createSymbolStructures(): string[] {
86
113
  const structures: string[] = [];
87
-
114
+
88
115
  // Use blackboard bold as base symbols with Greek symbols as subscripts/superscripts
89
116
  BLACKBOARD_BOLD.forEach((bbSymbol, bbIndex) => {
90
117
  // Get 2 Greek symbols for this blackboard bold symbol (lower left, top right)
@@ -97,29 +124,30 @@ function createSymbolStructures(): string[] {
97
124
  ));
98
125
  }
99
126
  });
100
-
127
+
101
128
  return structures;
102
129
  }
103
130
 
104
- function measureMathMLStructure(mathml: string): any {
131
+ function measureMathMLStructure(mathml: string, iframe: Document): any {
105
132
  try {
106
- const mathElement = document.createElement('math');
133
+ const mathElement = iframe.createElement('math');
107
134
  mathElement.innerHTML = mathml.replace(/<\/?math>/g, '');
108
135
  mathElement.style.whiteSpace = 'nowrap';
109
136
  mathElement.style.position = 'absolute';
110
137
  mathElement.style.visibility = 'hidden';
111
138
  mathElement.style.top = '-9999px';
112
-
113
- document.body.appendChild(mathElement);
114
-
139
+
140
+ iframe.body.appendChild(mathElement);
141
+
115
142
  const rect = mathElement.getBoundingClientRect();
116
- const computedStyle = window.getComputedStyle(mathElement);
117
-
143
+ const iframeWindow = iframe.defaultView || window;
144
+ const computedStyle = iframeWindow.getComputedStyle(mathElement);
145
+
118
146
  const measurements = {
119
147
  dimensions: {
120
148
  width: rect.width,
121
149
  height: rect.height,
122
-
150
+
123
151
  },
124
152
  fontInfo: {
125
153
  fontFamily: computedStyle.fontFamily,
@@ -129,7 +157,7 @@ function measureMathMLStructure(mathml: string): any {
129
157
  lineHeight: computedStyle.lineHeight,
130
158
  // Enhanced font properties for better system detection
131
159
  fontVariant: computedStyle.fontVariant || 'normal',
132
- fontStretch: computedStyle.fontStretch || 'normal',
160
+ fontStretch: computedStyle.fontStretch || 'normal',
133
161
  fontSizeAdjust: computedStyle.fontSizeAdjust || 'none',
134
162
  textRendering: computedStyle.textRendering || 'auto',
135
163
  fontFeatureSettings: computedStyle.fontFeatureSettings || 'normal',
@@ -137,10 +165,10 @@ function measureMathMLStructure(mathml: string): any {
137
165
  fontKerning: computedStyle.fontKerning || 'auto'
138
166
  }
139
167
  };
140
-
141
- document.body.removeChild(mathElement);
168
+
169
+ iframe.body.removeChild(mathElement);
142
170
  return measurements;
143
-
171
+
144
172
  } catch (error) {
145
173
  return {
146
174
  error: (error as Error).message
@@ -25,13 +25,18 @@ const defaultPermissionKeys: PermissionName[] = [
25
25
  ] as PermissionName[];
26
26
 
27
27
  export default async function getPermissions(options?: optionsInterface): Promise<componentInterface> {
28
- let permission_keys = options?.permissions_to_check || defaultPermissionKeys;
28
+ const permission_keys = options?.permissions_to_check || defaultPermissionKeys;
29
29
  const retries = 3;
30
- const permissionPromises: Promise<componentInterface>[] = Array.from({length: retries}, () => getBrowserPermissionsOnce(permission_keys) );
31
- return Promise.all(permissionPromises).then((resolvedPermissions) => {
32
- const permission = mostFrequentValuesInArrayOfDictionaries(resolvedPermissions, permission_keys);
33
- return permission;
34
- });
30
+
31
+ // Run permission checks multiple times
32
+ const results = await Promise.all(
33
+ Array.from({ length: retries }, () => getBrowserPermissionsOnce(permission_keys))
34
+ );
35
+
36
+ // Get most frequent values across all retries
37
+ const permissionStatus = mostFrequentValuesInArrayOfDictionaries(results, permission_keys);
38
+
39
+ return permissionStatus;
35
40
  }
36
41
 
37
42
  async function getBrowserPermissionsOnce(permission_keys: PermissionName[]): Promise<componentInterface> {
@@ -0,0 +1,104 @@
1
+ import { componentInterface } from '../../factory';
2
+ import { hash } from '../../utils/hash';
3
+ import { stableStringify } from '../../utils/stableStringify';
4
+
5
+ const VOICE_LOAD_TIMEOUT = 800; // milliseconds to wait for voices to load
6
+
7
+ export default async function getSpeech(): Promise<componentInterface | null> {
8
+ return new Promise((resolve) => {
9
+ try {
10
+ // Check if Speech Synthesis API is available
11
+ if (typeof window === 'undefined' || !window.speechSynthesis || typeof window.speechSynthesis.getVoices !== 'function') {
12
+ resolve({
13
+ supported: false,
14
+ error: 'Speech Synthesis API not supported'
15
+ });
16
+ return;
17
+ }
18
+
19
+ let voicesResolved = false;
20
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
21
+
22
+ const processVoices = (voices: SpeechSynthesisVoice[]) => {
23
+ if (voicesResolved) return;
24
+ voicesResolved = true;
25
+
26
+ // Clear timeout if it exists
27
+ if (timeoutHandle) {
28
+ clearTimeout(timeoutHandle);
29
+ }
30
+
31
+ try {
32
+ // Collect voice signatures
33
+ const voiceSignatures = voices.map((voice) => {
34
+ // Escape commas and backslashes in voice properties
35
+ const escapeValue = (value: string): string => {
36
+ return value.replace(/\\/g, '\\\\').replace(/,/g, '\\,');
37
+ };
38
+
39
+ // Format: voiceURI,name,lang,localService,default
40
+ const signature = [
41
+ escapeValue(voice.voiceURI || ''),
42
+ escapeValue(voice.name || ''),
43
+ escapeValue(voice.lang || ''),
44
+ voice.localService ? '1' : '0',
45
+ voice.default ? '1' : '0'
46
+ ].join(',');
47
+
48
+ return signature;
49
+ });
50
+
51
+ // Sort alphabetically for consistent ordering
52
+ voiceSignatures.sort();
53
+
54
+ // Create details object with count and hash
55
+ const details = {
56
+ voiceCount: voices.length,
57
+ voicesHash: hash(stableStringify(voiceSignatures))
58
+ };
59
+
60
+ resolve({
61
+ details,
62
+ hash: hash(stableStringify(details))
63
+ });
64
+
65
+ } catch (error) {
66
+ resolve({
67
+ supported: true,
68
+ error: `Voice processing failed: ${(error as Error).message}`
69
+ });
70
+ }
71
+ };
72
+
73
+ // Try to get voices immediately
74
+ const voices = window.speechSynthesis.getVoices();
75
+
76
+ // If voices are available immediately, process them
77
+ if (voices.length > 0) {
78
+ processVoices(voices);
79
+ return;
80
+ }
81
+
82
+ // Set up timeout in case voices never load
83
+ timeoutHandle = setTimeout(() => {
84
+ const voices = window.speechSynthesis.getVoices();
85
+ processVoices(voices);
86
+ }, VOICE_LOAD_TIMEOUT);
87
+
88
+ // Listen for voiceschanged event (for browsers that load voices asynchronously)
89
+ const onVoicesChanged = () => {
90
+ window.speechSynthesis.removeEventListener('voiceschanged', onVoicesChanged);
91
+ const voices = window.speechSynthesis.getVoices();
92
+ processVoices(voices);
93
+ };
94
+
95
+ window.speechSynthesis.addEventListener('voiceschanged', onVoicesChanged);
96
+
97
+ } catch (error) {
98
+ resolve({
99
+ supported: false,
100
+ error: `Speech Synthesis error: ${(error as Error).message}`
101
+ });
102
+ }
103
+ });
104
+ }
@@ -1,5 +1,6 @@
1
1
  import { componentInterface } from '../../factory';
2
2
  import { hash } from '../../utils/hash';
3
+ import { stableStringify } from '../../utils/stableStringify';
3
4
 
4
5
  export default async function getWebRTC(): Promise<componentInterface | null> {
5
6
  return new Promise((resolve) => {
@@ -29,7 +30,7 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
29
30
  await connection.setLocalDescription(offer);
30
31
 
31
32
  const sdp = offer.sdp || '';
32
-
33
+
33
34
  // Extract RTP extensions
34
35
  const extensions = [...new Set((sdp.match(/extmap:\d+ [^\n\r]+/g) || []).map((x: string) => x.replace(/extmap:\d+ /, '')))].sort();
35
36
 
@@ -64,9 +65,19 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
64
65
  }).filter(Boolean);
65
66
  };
66
67
 
67
- const codecsSdp = {
68
- audio: constructDescriptions('audio', getDescriptors('audio')),
69
- video: constructDescriptions('video', getDescriptors('video'))
68
+ const audioCodecs = constructDescriptions('audio', getDescriptors('audio'));
69
+ const videoCodecs = constructDescriptions('video', getDescriptors('video'));
70
+
71
+ const compressedData = {
72
+ audio: {
73
+ count: audioCodecs.length,
74
+ hash: hash(stableStringify(audioCodecs))
75
+ },
76
+ video: {
77
+ count: videoCodecs.length,
78
+ hash: hash(stableStringify(videoCodecs))
79
+ },
80
+ extensionsHash: hash(stableStringify(extensions))
70
81
  };
71
82
 
72
83
  // Set up for ICE candidate collection with timeout
@@ -74,10 +85,9 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
74
85
  const timeout = setTimeout(() => {
75
86
  connection.removeEventListener('icecandidate', onIceCandidate);
76
87
  connection.close();
77
- resolveResult({
88
+ resolveResult({
78
89
  supported: true,
79
- codecsSdp,
80
- extensions: extensions as string[],
90
+ ...compressedData,
81
91
  timeout: true
82
92
  });
83
93
  }, 3000);
@@ -90,10 +100,9 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
90
100
  connection.removeEventListener('icecandidate', onIceCandidate);
91
101
  connection.close();
92
102
 
93
- resolveResult({
103
+ resolveResult({
94
104
  supported: true,
95
- codecsSdp,
96
- extensions: extensions as string[],
105
+ ...compressedData,
97
106
  candidateType: candidateObj.type || ''
98
107
  });
99
108
  };
@@ -102,7 +111,8 @@ export default async function getWebRTC(): Promise<componentInterface | null> {
102
111
  });
103
112
 
104
113
  resolve({
105
- hash: hash(JSON.stringify(result)),
114
+ details: result,
115
+ hash: hash(stableStringify(result)),
106
116
  });
107
117
 
108
118
  } catch (error) {
package/src/factory.ts CHANGED
@@ -23,6 +23,7 @@ import getWebGL from "./components/webgl";
23
23
  // Import experimental component functions
24
24
  import getWebRTC from "./components/webrtc";
25
25
  import getMathML from "./components/mathml";
26
+ import getSpeech from "./components/speech";
26
27
 
27
28
  /**
28
29
  * @description key->function map of built-in components. Do not call the function here.
@@ -46,7 +47,8 @@ export const tm_component_promises = {
46
47
  */
47
48
  export const tm_experimental_component_promises = {
48
49
  'webrtc': getWebRTC,
49
- 'mathml': getMathML
50
+ 'mathml': getMathML,
51
+ 'speech': getSpeech
50
52
  };
51
53
 
52
54
  // the component interface is the form of the JSON object the function's promise must return
@@ -1,8 +1,9 @@
1
- import { optionsInterface, API_ENDPOINT } from '../options';
1
+ import { optionsInterface, DEFAULT_API_ENDPOINT } from '../options';
2
2
  import { componentInterface } from '../factory';
3
3
  import { getVisitorId, setVisitorId } from '../utils/visitorId';
4
4
  import { getVersion } from "../utils/version";
5
5
  import { hash } from '../utils/hash';
6
+ import { stableStringify } from '../utils/stableStringify';
6
7
 
7
8
  // ===================== Types & Interfaces =====================
8
9
 
@@ -37,6 +38,7 @@ interface apiResponse {
37
38
  version?: string;
38
39
  components?: componentInterface;
39
40
  visitorId?: string;
41
+ thumbmark?: string;
40
42
  }
41
43
 
42
44
  // ===================== API Call Logic =====================
@@ -63,18 +65,19 @@ export const getApiPromise = (
63
65
  }
64
66
 
65
67
  // 3. Otherwise, initiate a new API call with timeout.
66
- const endpoint = `${API_ENDPOINT}/thumbmark`;
68
+ const apiEndpoint = options.api_endpoint || DEFAULT_API_ENDPOINT;
69
+ const endpoint = `${apiEndpoint}/thumbmark`;
67
70
  const visitorId = getVisitorId();
68
- const requestBody: any = {
69
- components,
70
- options,
71
- clientHash: hash(JSON.stringify(components)),
71
+ const requestBody: any = {
72
+ components,
73
+ options,
74
+ clientHash: hash(stableStringify(components)),
72
75
  version: getVersion()
73
76
  };
74
77
  if (visitorId) {
75
78
  requestBody.visitorId = visitorId;
76
79
  }
77
-
80
+
78
81
  const fetchPromise = fetch(endpoint, {
79
82
  method: 'POST',
80
83
  headers: {
@@ -7,13 +7,13 @@
7
7
  */
8
8
 
9
9
  import { defaultOptions, optionsInterface } from "../options";
10
- import {
11
- timeoutInstance,
12
- componentInterface,
13
- tm_component_promises,
14
- customComponents,
15
- tm_experimental_component_promises,
16
- includeComponent as globalIncludeComponent
10
+ import {
11
+ timeoutInstance,
12
+ componentInterface,
13
+ tm_component_promises,
14
+ customComponents,
15
+ tm_experimental_component_promises,
16
+ includeComponent as globalIncludeComponent
17
17
  } from "../factory";
18
18
  import { hash } from "../utils/hash";
19
19
  import { raceAllPerformance } from "../utils/raceAll";
@@ -21,20 +21,21 @@ import { getVersion } from "../utils/version";
21
21
  import { filterThumbmarkData } from './filterComponents'
22
22
  import { logThumbmarkData } from '../utils/log';
23
23
  import { getApiPromise, infoInterface } from "./api";
24
+ import { stableStringify } from "../utils/stableStringify";
24
25
 
25
26
 
26
27
  /**
27
28
  * Final thumbmark response structure
28
29
  */
29
30
  interface thumbmarkResponse {
30
- components: componentInterface,
31
- info: { [key: string]: any },
32
- version: string,
33
- thumbmark: string,
34
- visitorId?: string,
35
- elapsed?: any;
36
- error?: string;
37
- experimental?: componentInterface;
31
+ components: componentInterface,
32
+ info: { [key: string]: any },
33
+ version: string,
34
+ thumbmark: string,
35
+ visitorId?: string,
36
+ elapsed?: any;
37
+ error?: string;
38
+ experimental?: componentInterface;
38
39
  }
39
40
 
40
41
  /**
@@ -44,66 +45,73 @@ interface thumbmarkResponse {
44
45
  * @returns thumbmarkResponse (elapsed is present only if options.performance is true)
45
46
  */
46
47
  export async function getThumbmark(options?: optionsInterface): Promise<thumbmarkResponse> {
47
- const _options = { ...defaultOptions, ...options };
48
-
49
- // Early logging decision
50
- const shouldLog = (_options.logging && !sessionStorage.getItem("_tmjs_l") && Math.random() < 0.0001);
51
-
52
- // Merge built-in and user-registered components
53
- const allComponents = { ...tm_component_promises, ...customComponents };
54
- const { elapsed, resolvedComponents: clientComponentsResult } = await resolveClientComponents(allComponents, _options);
55
-
56
- // Resolve experimental components only when logging
57
- let experimentalComponents = {};
58
- if (shouldLog || _options.experimental) {
59
- const { resolvedComponents } = await resolveClientComponents(tm_experimental_component_promises, _options);
60
- experimentalComponents = resolvedComponents;
61
- }
48
+ const _options = { ...defaultOptions, ...options };
62
49
 
63
- const apiPromise = _options.api_key ? getApiPromise(_options, clientComponentsResult) : null;
64
- let apiResult = null;
65
-
66
- if (apiPromise) {
67
- try {
68
- apiResult = await apiPromise;
69
- } catch (error) {
70
- // Handle API key/quota errors
71
- if (error instanceof Error && error.message === 'INVALID_API_KEY') {
72
- return {
73
- error: 'Invalid API key or quota exceeded',
74
- components: {},
75
- info: {},
76
- version: getVersion(),
77
- thumbmark: ''
78
- };
79
- }
80
- throw error; // Re-throw other errors
81
- }
82
- }
50
+ // Early logging decision
51
+ const shouldLog = (_options.logging && !sessionStorage.getItem("_tmjs_l") && Math.random() < 0.0001);
52
+
53
+ // Merge built-in and user-registered components
54
+ const allComponents = { ...tm_component_promises, ...customComponents };
55
+ const { elapsed, resolvedComponents: clientComponentsResult } = await resolveClientComponents(allComponents, _options);
56
+
57
+ // Resolve experimental components only when logging
58
+ let experimentalComponents = {};
59
+ let experimentalElapsed = {};
60
+ if (shouldLog || _options.experimental) {
61
+ const { elapsed: expElapsed, resolvedComponents } = await resolveClientComponents(tm_experimental_component_promises, _options);
62
+ experimentalComponents = resolvedComponents;
63
+ experimentalElapsed = expElapsed;
64
+ }
65
+
66
+ const apiPromise = _options.api_key ? getApiPromise(_options, clientComponentsResult) : null;
67
+ let apiResult = null;
83
68
 
84
- // Only add 'elapsed' if performance is true
85
- const maybeElapsed = _options.performance ? { elapsed } : {};
86
- const apiComponents = filterThumbmarkData(apiResult?.components || {}, _options);
87
- const components = {...clientComponentsResult, ...apiComponents};
88
- const info: infoInterface = apiResult?.info || { uniqueness: { score: 'api only' } };
89
- const thumbmark = hash(JSON.stringify(components));
90
- const version = getVersion();
91
- // Only log to server when not in debug mode
92
- if (shouldLog) {
93
- logThumbmarkData(thumbmark, components, _options, experimentalComponents).catch(() => { /* do nothing */ });
69
+ if (apiPromise) {
70
+ try {
71
+ apiResult = await apiPromise;
72
+ } catch (error) {
73
+ // Handle API key/quota errors
74
+ if (error instanceof Error && error.message === 'INVALID_API_KEY') {
75
+ return {
76
+ error: 'Invalid API key or quota exceeded',
77
+ components: {},
78
+ info: {},
79
+ version: getVersion(),
80
+ thumbmark: ''
81
+ };
82
+ }
83
+ throw error; // Re-throw other errors
94
84
  }
95
-
96
- const result: thumbmarkResponse = {
97
- ...(apiResult?.visitorId && { visitorId: apiResult.visitorId }),
98
- thumbmark,
99
- components: components,
100
- info,
101
- version,
102
- ...maybeElapsed,
103
- ...(Object.keys(experimentalComponents).length > 0 && _options.experimental && { experimental: experimentalComponents }),
104
- };
105
-
106
- return result;
85
+ }
86
+
87
+ // Only add 'elapsed' if performance is true
88
+ const allElapsed = { ...elapsed, ...experimentalElapsed };
89
+ const maybeElapsed = _options.performance ? { elapsed: allElapsed } : {};
90
+ const apiComponents = filterThumbmarkData(apiResult?.components || {}, _options);
91
+ const components = { ...clientComponentsResult, ...apiComponents };
92
+ const info: infoInterface = apiResult?.info || { uniqueness: { score: 'api only' } };
93
+
94
+ // Use API thumbmark if available to ensure API/client sync, otherwise calculate locally
95
+ console.log(apiResult);
96
+ const thumbmark = apiResult?.thumbmark ?? hash(stableStringify(components));
97
+ const version = getVersion();
98
+
99
+ // Only log to server when not in debug mode
100
+ if (shouldLog) {
101
+ logThumbmarkData(thumbmark, components, _options, experimentalComponents).catch(() => { /* do nothing */ });
102
+ }
103
+
104
+ const result: thumbmarkResponse = {
105
+ ...(apiResult?.visitorId && { visitorId: apiResult.visitorId }),
106
+ thumbmark,
107
+ components: components,
108
+ info,
109
+ version,
110
+ ...maybeElapsed,
111
+ ...(Object.keys(experimentalComponents).length > 0 && _options.experimental && { experimental: experimentalComponents }),
112
+ };
113
+
114
+ return result;
107
115
  }
108
116
 
109
117
  // ===================== Component Resolution & Performance =====================