You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@unomi.apache.org by sh...@apache.org on 2026/10/26 16:42:55 UTC

(unomi-tracker) 01/02: UNOMI-1023, UNOMI-1024: Fix the browser build without Buffer, and stop sending an empty page "attributes"

This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch UNOMI-1023-UNOMI-1024-compat-fixes
in repository https://gitbox.apache.org/repos/asf/unomi-tracker.git

commit c40e7102e2fb02c2996119e026498b039074eeea
Author: Serge Huber <sh...@apache.org>
AuthorDate: Mon Oct 26 17:42:15 2026 +0100

    UNOMI-1023, UNOMI-1024: Fix the browser build without Buffer, and stop sending an empty page "attributes"
    
    UNOMI-1023: es6-crawler-detect calls Buffer.byteLength, which browsers
    do not have, so the UMD build threw on start. When Buffer exists, the
    library is called exactly as before. Otherwise it gets a
    Buffer.byteLength for the duration of the synchronous call only, with
    the same UTF-8 byte count; nothing is left on window.
    
    UNOMI-1024: Unomi drops events whose page has "attributes" (2.4 and
    later even when empty, UNOMI-790). An empty "attributes" is now left out
    of the page properties sent; a non-empty one is sent as before, for
    deployments that extended the page schema. digitalData.page is never
    changed.
    
    Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
 src/apache-unomi-tracker.js | 85 ++++++++++++++++++++++++++++++++++++++++++---
 src/global.d.ts             |  3 ++
 test/spec.js                | 59 +++++++++++++++++++++++++++++++
 3 files changed, 143 insertions(+), 4 deletions(-)

diff --git a/src/apache-unomi-tracker.js b/src/apache-unomi-tracker.js
index 053958f..309497d 100644
--- a/src/apache-unomi-tracker.js
+++ b/src/apache-unomi-tracker.js
@@ -87,10 +87,10 @@ export const useTracker = () => {
             // Check before start
             let cookieDisabled = !navigator.cookieEnabled;
             let noSessionID = !wem.sessionID || wem.sessionID === '';
+            /** @type {string | boolean} */
             let crawlerDetected = navigator.userAgent;
             if (crawlerDetected) {
-                const browserDetector = new Crawler();
-                crawlerDetected = browserDetector.isCrawler(navigator.userAgent);
+                crawlerDetected = wem._isCrawler(navigator.userAgent);
             }
             if (cookieDisabled || noSessionID || crawlerDetected) {
                 document.addEventListener('DOMContentLoaded', function () {
@@ -588,7 +588,7 @@ export const useTracker = () => {
          * @returns {UnomiEvent["target"]} the target page
          */
         buildTargetPage: function () {
-            return wem.buildTarget(wem.digitalData.page.pageInfo.pageID, 'page', wem.digitalData.page);
+            return wem.buildTarget(wem.digitalData.page.pageInfo.pageID, 'page', wem._pageProperties());
         },
 
         // eslint-disable-next-line valid-jsdoc
@@ -598,7 +598,7 @@ export const useTracker = () => {
          * @returns {UnomiEvent["source"]} the source page
          */
         buildSourcePage: function () {
-            return wem.buildSource(wem.digitalData.page.pageInfo.pageID, 'page', wem.digitalData.page);
+            return wem.buildSource(wem.digitalData.page.pageInfo.pageID, 'page', wem._pageProperties());
         },
 
         // eslint-disable-next-line valid-jsdoc
@@ -1511,6 +1511,83 @@ export const useTracker = () => {
          */
         _isObject: function (obj) {
             return obj && typeof obj === 'object';
+        },
+
+        // eslint-disable-next-line valid-jsdoc
+        /**
+         * The page properties sent with events: digitalData.page, without an empty "attributes".
+         * Unomi rejects page "attributes" with content, and Unomi 2.4 and later also reject an empty one,
+         * so an empty one would only get the event dropped. A non-empty one is sent as before, for
+         * deployments that extended the page schema to accept it.
+         * digitalData.page itself is never changed.
+         *
+         * @private
+         * @return {DigitalData["page"]} the page properties
+         */
+        _pageProperties: function () {
+            var page = wem.digitalData.page;
+            var attributes = page ? page.attributes : undefined;
+            if (!attributes || typeof attributes !== 'object' || Object.keys(attributes).length > 0) {
+                return page;
+            }
+            /** @type {Record<string, unknown>} */
+            var properties = {};
+            Object.keys(page).forEach(function (key) {
+                if (key !== 'attributes') {
+                    properties[key] = page[/** @type {keyof DigitalData["page"]} */ (key)];
+                }
+            });
+            return /** @type {DigitalData["page"]} */ (properties);
+        },
+
+        /**
+         * Number of bytes of a string encoded in UTF-8, as Node's Buffer.byteLength(value, 'utf8')
+         *
+         * @private
+         * @param {string} value the string
+         * @return {number} the number of bytes
+         */
+        _utf8ByteLength: function (value) {
+            var bytes = 0;
+            for (var i = 0; i < value.length; i++) {
+                var code = value.charCodeAt(i);
+                if (code < 0x80) {
+                    bytes += 1;
+                } else if (code < 0x800) {
+                    bytes += 2;
+                } else if (code >= 0xD800 && code <= 0xDBFF && i + 1 < value.length && (value.charCodeAt(i + 1) & 0xFC00) === 0xDC00) {
+                    bytes += 4;
+                    i++;
+                } else {
+                    // includes unpaired surrogates, which UTF-8 encoders replace with U+FFFD
+                    bytes += 3;
+                }
+            }
+            return bytes;
+        },
+
+        /**
+         * Whether the user agent is a web crawler, using es6-crawler-detect.
+         * The library calls Buffer.byteLength, which browsers do not have. When Buffer exists (Node, or a
+         * bundler polyfill) the library is called as is. Otherwise it gets a Buffer.byteLength for the
+         * duration of the call only: the call is synchronous, so no other code can see it.
+         *
+         * @private
+         * @param {string} userAgent the user agent
+         * @return {boolean} true for a crawler
+         */
+        _isCrawler: function (userAgent) {
+            var detector = new Crawler();
+            if (typeof Buffer !== 'undefined') {
+                return detector.isCrawler(userAgent);
+            }
+            var root = typeof window !== 'undefined' ? window : globalThis;
+            root.Buffer = { byteLength: wem._utf8ByteLength };
+            try {
+                return detector.isCrawler(userAgent);
+            } finally {
+                delete root.Buffer;
+            }
         }
     };
 
diff --git a/src/global.d.ts b/src/global.d.ts
index 03e5367..3de3d87 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -2,6 +2,9 @@ declare namespace globalThis {
   // Help TypeScript with this object only available in IE 8-11
   let XDomainRequest = XMLHttpRequest;
 
+  // Node's Buffer, which browsers do not have unless a bundler adds a polyfill
+  var Buffer: { byteLength: (value: string, encoding?: string) => number } | undefined;
+
   interface Window {
     optimizedContentAreas: Record<string, { selectedVariant: string }>;
   }
diff --git a/test/spec.js b/test/spec.js
index 6b068e9..01540d8 100644
--- a/test/spec.js
+++ b/test/spec.js
@@ -66,4 +66,63 @@ assert.strictEqual(requestFor({ apiKey: '' }).headers['X-Unomi-Api-Key'], undefi
 assert.strictEqual(requestFor({ apiKey: 'public-key' }).headers['X-Unomi-Api-Key'], 'public-key', 'API key header is sent when apiKey is set');
 assert.strictEqual(requestFor({ apiKey: 'public-key' }).headers['Content-Type'], 'text/plain;charset=UTF-8', 'other headers are unchanged');
 
+// Crawler detection: the same result with and without Node's Buffer
+const crawlerTracker = unomi.useTracker();
+const edgeStrings = ['', 'abc', 'é漢字😀', '\uD800', 'a\uDC00b', '😀\uD800', 'x'.repeat(4096), 'é'.repeat(2048), 'é'.repeat(2049)];
+for (let i = 0; i < 500; i++) {
+    let value = '';
+    for (let j = 0; j < 30; j++) {
+        value += String.fromCharCode(Math.floor(Math.random() * 0x10000));
+    }
+    edgeStrings.push(value);
+}
+for (const value of edgeStrings) {
+    assert.strictEqual(crawlerTracker._utf8ByteLength(value), globalThis.Buffer.byteLength(value, 'utf8'), 'UTF-8 length of ' + JSON.stringify(value));
+}
+
+const userAgents = [
+    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
+    'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
+    'curl/8.4.0',
+    'Googlebot/2.1 ' + 'x'.repeat(5000),
+    'Googlebot/2.1 ' + 'é'.repeat(2045),
+    'Googlebot/2.1 ' + 'é'.repeat(2040)
+];
+const withBuffer = userAgents.map(userAgent => crawlerTracker._isCrawler(userAgent));
+assert.deepStrictEqual(withBuffer.slice(0, 3), [false, true, true], 'crawlers are detected');
+const nodeBuffer = globalThis.Buffer;
+delete globalThis.Buffer;
+try {
+    const withoutBuffer = userAgents.map(userAgent => crawlerTracker._isCrawler(userAgent));
+    assert.strictEqual(typeof globalThis.Buffer, 'undefined', 'no Buffer is left behind');
+    assert.deepStrictEqual(withoutBuffer, withBuffer, 'the same result without Buffer');
+} finally {
+    globalThis.Buffer = nodeBuffer;
+}
+
+// Page properties sent with events: an empty "attributes" is left out, digitalData.page is unchanged
+function pagePropertiesFor(attributes) {
+    const page = { pageInfo: { pageID: 'home', pageName: 'Home' }, consentTypes: [] };
+    if (attributes !== undefined) {
+        page.attributes = attributes;
+    }
+    const t = unomi.useTracker();
+    t.initTracker({ scope: 'test', site: { siteInfo: { siteID: 'test' } }, page: page, events: [], wemInitConfig: { contextServerUrl: '', timeoutInMilliseconds: -1 } });
+    return { page, source: t.buildSourcePage().properties, target: t.buildTargetPage().properties };
+}
+
+let pageResult = pagePropertiesFor(undefined);
+assert.strictEqual(pageResult.source, pageResult.page, 'a page without attributes is sent as is');
+assert.strictEqual(pageResult.target, pageResult.page, 'a page without attributes is sent as is');
+
+pageResult = pagePropertiesFor({ color: 'blue' });
+assert.strictEqual(pageResult.source, pageResult.page, 'a page with non-empty attributes is sent as is');
+assert.strictEqual(pageResult.target, pageResult.page, 'a page with non-empty attributes is sent as is');
+
+pageResult = pagePropertiesFor({});
+assert.deepStrictEqual(Object.keys(pageResult.source), ['pageInfo', 'consentTypes'], 'empty attributes are left out');
+assert.deepStrictEqual(Object.keys(pageResult.target), ['pageInfo', 'consentTypes'], 'empty attributes are left out');
+assert.strictEqual(pageResult.source.pageInfo, pageResult.page.pageInfo, 'pageInfo is the same object');
+assert.deepStrictEqual(pageResult.page.attributes, {}, 'digitalData.page keeps its attributes');
+
 console.log('Tests passed');
\ No newline at end of file