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