@thumbmarkjs/thumbmarkjs 1.8.1 → 1.9.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.
Files changed (56) hide show
  1. package/README.md +9 -4
  2. package/dist/thumbmark.cjs.js +1 -1
  3. package/dist/thumbmark.cjs.js.map +1 -1
  4. package/dist/thumbmark.esm.js +1 -1
  5. package/dist/thumbmark.esm.js.map +1 -1
  6. package/dist/thumbmark.umd.js +1 -1
  7. package/dist/thumbmark.umd.js.map +1 -1
  8. package/dist/types/components/audio/index.d.ts +2 -0
  9. package/dist/types/components/canvas/index.d.ts +3 -0
  10. package/dist/types/components/fonts/index.d.ts +4 -0
  11. package/dist/types/components/hardware/index.d.ts +2 -0
  12. package/dist/types/components/intl/index.d.ts +2 -0
  13. package/dist/types/components/locales/index.d.ts +2 -0
  14. package/dist/types/components/math/index.d.ts +2 -0
  15. package/dist/types/components/mathml/index.d.ts +2 -0
  16. package/dist/types/components/mediaDevices/index.d.ts +2 -0
  17. package/dist/types/components/permissions/index.d.ts +3 -0
  18. package/dist/types/components/plugins/index.d.ts +2 -0
  19. package/dist/types/components/screen/index.d.ts +2 -0
  20. package/dist/types/components/speech/index.d.ts +2 -0
  21. package/dist/types/components/system/browser.d.ts +9 -0
  22. package/dist/types/components/system/index.d.ts +2 -0
  23. package/dist/types/components/webgl/index.d.ts +13 -0
  24. package/dist/types/components/webrtc/index.d.ts +3 -0
  25. package/dist/types/factory.d.ts +66 -0
  26. package/dist/types/functions/api.d.ts +73 -0
  27. package/dist/types/functions/filterComponents.d.ts +15 -0
  28. package/dist/types/functions/index.d.ts +63 -0
  29. package/dist/types/functions/legacy_functions.d.ts +27 -0
  30. package/dist/types/index.d.ts +9 -0
  31. package/dist/types/options.d.ts +76 -0
  32. package/dist/types/thumbmark.d.ts +28 -0
  33. package/dist/types/utils/cache.d.ts +23 -0
  34. package/dist/types/utils/commonPixels.d.ts +1 -0
  35. package/dist/types/utils/ephemeralIFrame.d.ts +4 -0
  36. package/dist/types/utils/getMostFrequent.d.ts +5 -0
  37. package/dist/types/utils/hash.d.ts +5 -0
  38. package/dist/types/utils/imageDataToDataURL.d.ts +1 -0
  39. package/dist/types/utils/log.d.ts +9 -0
  40. package/dist/types/utils/raceAll.d.ts +10 -0
  41. package/dist/types/utils/sort.d.ts +8 -0
  42. package/dist/types/utils/stableStringify.d.ts +22 -0
  43. package/dist/types/utils/version.d.ts +4 -0
  44. package/dist/types/utils/visitorId.d.ts +14 -0
  45. package/package.json +3 -2
  46. package/src/components/audio/index.ts +0 -1
  47. package/src/components/system/browser.ts +24 -4
  48. package/src/components/webgl/index.test.ts +223 -0
  49. package/src/components/webgl/index.ts +188 -146
  50. package/src/components/webrtc/index.ts +15 -38
  51. package/src/functions/filterComponents.test.ts +35 -0
  52. package/src/functions/filterComponents.ts +2 -3
  53. package/src/functions/index.ts +70 -10
  54. package/src/utils/commonPixels.ts +25 -0
  55. package/src/utils/raceAll.ts +22 -12
  56. package/src/utils/stableStringify.ts +6 -4
@@ -161,6 +161,17 @@ describe('getExcludeList', () => {
161
161
  // Should not throw
162
162
  expect(() => getExcludeList({ ...defaultOptions }, obj)).not.toThrow();
163
163
  });
164
+
165
+ test('non-array string exclude does not throw and is ignored', () => {
166
+ const result = getExcludeList({ ...defaultOptions, exclude: 'one' as any, stabilize: [] });
167
+ expect(Array.isArray(result)).toBe(true);
168
+ expect(result).not.toContain('one');
169
+ });
170
+
171
+ test('non-array object exclude does not throw and is ignored', () => {
172
+ const result = getExcludeList({ ...defaultOptions, exclude: {} as any, stabilize: [] });
173
+ expect(Array.isArray(result)).toBe(true);
174
+ });
164
175
  });
165
176
 
166
177
  // ── filterThumbmarkData ─────────────────────────────────────────
@@ -235,4 +246,28 @@ describe('filterThumbmarkData', () => {
235
246
  expect((result.audio as componentInterface).other).toBe('jkl');
236
247
  expect((result.audio as componentInterface).sampleHash).toBeUndefined();
237
248
  });
249
+
250
+ test('non-array string include does not throw and does not rescue excluded keys', () => {
251
+ const result = filterThumbmarkData(testData, {
252
+ ...defaultOptions,
253
+ exclude: ['one'],
254
+ include: 'one' as any,
255
+ stabilize: [],
256
+ });
257
+ // Bad include is treated as empty, so 'one' stays excluded
258
+ expect(result.one).toBeUndefined();
259
+ expect(result.two).toBe(2);
260
+ });
261
+
262
+ test('null include does not throw and behaves as empty include', () => {
263
+ const result = filterThumbmarkData(testData, {
264
+ ...defaultOptions,
265
+ exclude: ['two'],
266
+ include: null as any,
267
+ stabilize: [],
268
+ });
269
+ // null include treated as empty, so 'two' stays excluded
270
+ expect(result.two).toBeUndefined();
271
+ expect(result.one).toBe('1');
272
+ });
238
273
  });
@@ -27,7 +27,7 @@ export function getExcludeList(options?: optionsInterface, obj?: componentInterf
27
27
 
28
28
  const name = browser.name.toLowerCase();
29
29
  const majorVer = parseInt(browser.version.split('.')[0] || '0', 10);
30
- const excludeList = [...(options?.exclude || [])];
30
+ const excludeList = Array.isArray(options?.exclude) ? [...options.exclude] : [];
31
31
  const stabilizationOptions = [...new Set([...(options?.stabilize || []), 'always'])];
32
32
 
33
33
  for (const option of stabilizationOptions) {
@@ -52,7 +52,7 @@ export function filterThumbmarkData(
52
52
  options?: optionsInterface,
53
53
  ): componentInterface {
54
54
  const excludeList = getExcludeList(options, obj);
55
- const includeList = options?.include || [];
55
+ const includeList = Array.isArray(options?.include) ? options.include : [];
56
56
 
57
57
  /**
58
58
  * Inner recursive function to perform the actual filtering.
@@ -84,6 +84,5 @@ export function filterThumbmarkData(
84
84
  return result;
85
85
  }
86
86
 
87
- // Start the filtering process
88
87
  return performFilter(obj);
89
88
  }
@@ -91,16 +91,18 @@ export async function getThumbmark(
91
91
  ...customComponents,
92
92
  ...instanceCustomComponents,
93
93
  } as Record<string, componentFunctionInterface>;
94
- const { elapsed, resolvedComponents: clientComponentsResult, errors: componentErrors } = await resolveClientComponents(allComponents, _options);
94
+ const { elapsed, resolvedComponents: clientComponentsResult, errors: componentErrors, pipelineTimings: mainPipelineTimings } = await resolveClientComponents(allComponents, _options);
95
95
  allErrors.push(...componentErrors);
96
96
 
97
97
  // Resolve experimental components only when logging
98
98
  let experimentalComponents = {};
99
99
  let experimentalElapsed = {};
100
+ let expPipelineTimings: Record<string, number> = {};
100
101
  if (shouldLog || _options.experimental) {
101
- const { elapsed: expElapsed, resolvedComponents, errors: expErrors } = await resolveClientComponents(tm_experimental_component_promises, _options);
102
+ const { elapsed: expElapsed, resolvedComponents, errors: expErrors, pipelineTimings: expTimings } = await resolveClientComponents(tm_experimental_component_promises, _options);
102
103
  experimentalComponents = resolvedComponents;
103
104
  experimentalElapsed = expElapsed;
105
+ expPipelineTimings = expTimings;
104
106
  allErrors.push(...expErrors);
105
107
  }
106
108
 
@@ -132,15 +134,29 @@ export async function getThumbmark(
132
134
  allErrors.push({ type: 'api_timeout', message: 'API request timed out' });
133
135
  }
134
136
 
135
- // Only add 'elapsed' if performance is true
136
- const allElapsed = { ...elapsed, ...experimentalElapsed };
137
- const maybeElapsed = _options.performance ? { elapsed: allElapsed } : {};
137
+ const filterStart = performance.now();
138
138
  const apiComponents = filterThumbmarkData(apiResult?.components || {}, _options);
139
+ const filterMs = performance.now() - filterStart;
140
+
139
141
  const components = { ...clientComponentsResult, ...apiComponents };
140
142
  const info: infoInterface = apiResult?.info || { uniqueness: { score: 'api only' } };
141
143
 
142
144
  // Use API thumbmark if available to ensure API/client sync, otherwise calculate locally
143
- const thumbmark = apiResult?.thumbmark ?? hash(stableStringify(components));
145
+ let thumbmark: string;
146
+ let stringifyMs = 0;
147
+ let hashMs = 0;
148
+ if (apiResult?.thumbmark) {
149
+ thumbmark = apiResult.thumbmark;
150
+ } else {
151
+ const stringifyStart = performance.now();
152
+ const stringified = stableStringify(components);
153
+ stringifyMs = performance.now() - stringifyStart;
154
+
155
+ const hashStart = performance.now();
156
+ thumbmark = hash(stringified);
157
+ hashMs = performance.now() - hashStart;
158
+ }
159
+
144
160
  const version = getVersion();
145
161
 
146
162
  // Only log to server when not in debug mode
@@ -148,6 +164,26 @@ export async function getThumbmark(
148
164
  logThumbmarkData(thumbmark, components, _options, experimentalComponents, allErrors).catch(() => { /* do nothing */ });
149
165
  }
150
166
 
167
+ // Accumulate _pipeline timings from both the main and (if run) experimental component phases.
168
+ // Filter time includes: main component filter + optional experimental filter + apiComponents filter.
169
+ const expFilterMs = expPipelineTimings['_pipeline.filter'] ?? 0;
170
+ const _pipelineTimings: Record<string, number> = {
171
+ '_pipeline.dispatch': mainPipelineTimings['_pipeline.dispatch'],
172
+ '_pipeline.resolve': mainPipelineTimings['_pipeline.resolve'],
173
+ '_pipeline.filter': mainPipelineTimings['_pipeline.filter'] + expFilterMs + filterMs,
174
+ '_pipeline.stringify': stringifyMs,
175
+ '_pipeline.hash': hashMs,
176
+ '_pipeline.assembly': 0, // placeholder, updated below after result construction
177
+ };
178
+
179
+ // Only add 'elapsed' if performance is true
180
+ // allElapsed holds a live reference to _pipelineTimings entries via spread — we update assembly after.
181
+ // mainPipelineTimings contains both _pipeline.* keys (overridden by _pipelineTimings below) and
182
+ // _dispatch.<name> keys (per-component sync prelude timings) that flow through unchanged.
183
+ const allElapsed: Record<string, number> = { ...elapsed, ...experimentalElapsed, ...mainPipelineTimings, ..._pipelineTimings };
184
+ const maybeElapsed = _options.performance ? { elapsed: allElapsed } : {};
185
+
186
+ const assemblyStart = performance.now();
151
187
  const result: ThumbmarkResponse = {
152
188
  ...(apiResult?.visitorId && { visitorId: apiResult.visitorId }),
153
189
  thumbmark,
@@ -160,6 +196,8 @@ export async function getThumbmark(
160
196
  ...(apiResult?.requestId && { requestId: apiResult.requestId }),
161
197
  ...(apiResult?.metadata && { metadata: apiResult.metadata }),
162
198
  };
199
+ // Update assembly timing in allElapsed directly (allElapsed is the same object referenced by result.elapsed).
200
+ allElapsed['_pipeline.assembly'] = performance.now() - assemblyStart;
163
201
 
164
202
  return result;
165
203
  } catch (e) {
@@ -180,12 +218,12 @@ export async function getThumbmark(
180
218
  *
181
219
  * @param comps - Map of component functions
182
220
  * @param options - Options for filtering and timing
183
- * @returns Object with elapsed times and filtered resolved components
221
+ * @returns Object with elapsed times, filtered resolved components, errors, and pipeline phase timings
184
222
  */
185
223
  export async function resolveClientComponents(
186
224
  comps: { [key: string]: (options?: optionsInterface) => Promise<componentInterface | null> },
187
225
  options?: optionsInterface
188
- ): Promise<{ elapsed: Record<string, number>, resolvedComponents: componentInterface, errors: ThumbmarkError[] }> {
226
+ ): Promise<{ elapsed: Record<string, number>, resolvedComponents: componentInterface, errors: ThumbmarkError[], pipelineTimings: Record<string, number> }> {
189
227
  const opts = { ...defaultOptions, ...options };
190
228
  const topLevelExcludes = getExcludeList(opts).filter(e => !e.includes('.'));
191
229
  const filtered = Object.entries(comps)
@@ -197,8 +235,20 @@ export async function resolveClientComponents(
197
235
  : opts?.include?.length === 0 || opts?.include?.includes(key)
198
236
  );
199
237
  const keys = filtered.map(([key]) => key);
200
- const promises = filtered.map(([_, fn]) => fn(options));
238
+
239
+ const perComponentDispatch: Record<string, number> = {};
240
+ const dispatchStart = performance.now();
241
+ const promises = filtered.map(([key, fn]) => {
242
+ const t0 = performance.now();
243
+ const p = fn(options);
244
+ perComponentDispatch[`_dispatch.${key}`] = performance.now() - t0;
245
+ return p;
246
+ });
247
+ const dispatchMs = performance.now() - dispatchStart;
248
+
249
+ const resolveStart = performance.now();
201
250
  const resolvedValues = await raceAllPerformance(promises, opts?.timeout || 5000, timeoutInstance);
251
+ const resolveMs = performance.now() - resolveStart;
202
252
 
203
253
  const elapsed: Record<string, number> = {};
204
254
  const resolvedComponentsRaw: Record<string, componentInterface> = {};
@@ -219,8 +269,18 @@ export async function resolveClientComponents(
219
269
  }
220
270
  });
221
271
 
272
+ const filterStart = performance.now();
222
273
  const resolvedComponents = filterThumbmarkData(resolvedComponentsRaw, opts);
223
- return { elapsed, resolvedComponents, errors };
274
+ const filterMs = performance.now() - filterStart;
275
+
276
+ const pipelineTimings: Record<string, number> = {
277
+ '_pipeline.dispatch': dispatchMs,
278
+ '_pipeline.resolve': resolveMs,
279
+ '_pipeline.filter': filterMs,
280
+ ...perComponentDispatch,
281
+ };
282
+
283
+ return { elapsed, resolvedComponents, errors, pipelineTimings };
224
284
  }
225
285
 
226
286
  export { globalIncludeComponent as includeComponent };
@@ -1,4 +1,29 @@
1
1
  export function getCommonPixels(images: ImageData[], width: number, height: number ): ImageData {
2
+ // Short-circuit: single image — every byte is trivially its own mode.
3
+ if (images.length === 1) {
4
+ return images[0];
5
+ }
6
+
7
+ // Fast path: exactly 3 images — inline 3-way comparison that mirrors
8
+ // getMostFrequent's tie-breaking exactly for all 5 value-combination cases:
9
+ // all-equal → that value
10
+ // a===b → a (freq 2)
11
+ // a===c → a (freq 2)
12
+ // b===c → b (freq 2)
13
+ // all-diff → a (mostFrequent stays arr[0], no key beats freq 1)
14
+ if (images.length === 3) {
15
+ const a = images[0].data;
16
+ const b = images[1].data;
17
+ const c = images[2].data;
18
+ const out = new Uint8ClampedArray(a.length);
19
+ for (let i = 0; i < a.length; i++) {
20
+ const x = a[i], y = b[i], z = c[i];
21
+ out[i] = (x === y) ? x : (x === z) ? x : (y === z) ? y : x;
22
+ }
23
+ return new ImageData(out, width, height);
24
+ }
25
+
26
+ // Generic fallback for any other length (no current caller uses this path).
2
27
  let finalData: number[] = [];
3
28
  for (let i = 0; i < images[0].data.length; i++) {
4
29
  let indice: number[] = [];
@@ -21,21 +21,31 @@ export function raceAllPerformance<T>(
21
21
  return Promise.all(
22
22
  promises.map((p) => {
23
23
  const startTime = performance.now();
24
- return Promise.race([
25
- p.then((value) => ({
26
- value,
27
- elapsed: performance.now() - startTime,
28
- })).catch((err: unknown) => ({
24
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
25
+
26
+ const resultPromise = p.then((value) => {
27
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
28
+ return { value, elapsed: performance.now() - startTime };
29
+ }).catch((err: unknown) => {
30
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
31
+ return {
29
32
  value: timeoutVal,
30
33
  elapsed: performance.now() - startTime,
31
34
  error: err instanceof Error ? err.message : String(err),
32
- })),
33
- delay(timeoutTime, timeoutVal).then((value) => ({
34
- value,
35
- elapsed: performance.now() - startTime,
36
- error: 'timeout' as string,
37
- })),
38
- ]);
35
+ };
36
+ });
37
+
38
+ const timeoutPromise = new Promise<RaceResult<T>>((resolve) => {
39
+ timeoutId = setTimeout(() => {
40
+ resolve({
41
+ value: timeoutVal,
42
+ elapsed: performance.now() - startTime,
43
+ error: 'timeout',
44
+ });
45
+ }, timeoutTime);
46
+ });
47
+
48
+ return Promise.race([resultPromise, timeoutPromise]);
39
49
  })
40
50
  );
41
51
  }
@@ -22,7 +22,9 @@
22
22
  */
23
23
  export function stableStringify(data: any): string {
24
24
 
25
- const seen: any[] = [];
25
+ // Use a Set for O(1) cycle detection instead of O(N) array indexOf.
26
+ // The serialisation logic (key sorting, value recursion, output format) is unchanged.
27
+ const seen = new Set<any>();
26
28
 
27
29
  return (function stringify(node: any): string | undefined {
28
30
  if (node && node.toJSON && typeof node.toJSON === 'function') {
@@ -47,11 +49,11 @@ export function stableStringify(data: any): string {
47
49
 
48
50
  if (node === null) return 'null';
49
51
 
50
- if (seen.indexOf(node) !== -1) {
52
+ if (seen.has(node)) {
51
53
  throw new TypeError('Converting circular structure to JSON');
52
54
  }
53
55
 
54
- const seenIndex = seen.push(node) - 1;
56
+ seen.add(node);
55
57
  const keys = Object.keys(node).sort();
56
58
  out = '';
57
59
 
@@ -64,7 +66,7 @@ export function stableStringify(data: any): string {
64
66
  out += JSON.stringify(key) + ':' + value;
65
67
  }
66
68
 
67
- seen.splice(seenIndex, 1);
69
+ seen.delete(node);
68
70
  return '{' + out + '}';
69
71
  })(data) || '';
70
72
  }