From 53797500bfbe243f68678413fd44a43fdc5f386a Mon Sep 17 00:00:00 2001 From: Delirium Date: Thu, 24 Apr 2025 12:16:51 +0200 Subject: [PATCH] tracker css batching/inlining (#3334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tracker: initial css inlining functionality * tracker: add tests, adjust sheet id, stagger rule sending * removed sorting * upgrade css inliner * ui: better logging for ocunter * tracker: force-fetch mode for cssInliner * tracker: fix ts warns * tracker: use debug opts * tracker: 16.2.0 changelogs, inliner opts * tracker: remove debug options --------- Co-authored-by: Андрей Бабушкин --- frontend/app/player/web/TabManager.ts | 6 +- .../app/player/web/managers/DOM/VirtualDOM.ts | 1 + .../player/web/managers/WindowNodeCounter.ts | 29 +- tracker/tracker/CHANGELOG.md | 12 + tracker/tracker/src/main/app/index.ts | 12 +- .../src/main/app/observer/cssInliner.ts | 335 ++++++++++++++---- .../tracker/src/main/app/observer/observer.ts | 35 +- .../src/main/app/observer/top_observer.ts | 6 +- tracker/tracker/src/tests/cssInliner.test.ts | 20 +- 9 files changed, 360 insertions(+), 96 deletions(-) diff --git a/frontend/app/player/web/TabManager.ts b/frontend/app/player/web/TabManager.ts index 7ab04291f..574f7490e 100644 --- a/frontend/app/player/web/TabManager.ts +++ b/frontend/app/player/web/TabManager.ts @@ -348,19 +348,19 @@ export default class TabSessionManager { break; case MType.CreateTextNode: case MType.CreateElementNode: - this.windowNodeCounter.addNode(msg.id, msg.parentID); + this.windowNodeCounter.addNode(msg); this.performanceTrackManager.setCurrentNodesCount( this.windowNodeCounter.count, ); break; case MType.MoveNode: - this.windowNodeCounter.moveNode(msg.id, msg.parentID); + this.windowNodeCounter.moveNode(msg); this.performanceTrackManager.setCurrentNodesCount( this.windowNodeCounter.count, ); break; case MType.RemoveNode: - this.windowNodeCounter.removeNode(msg.id); + this.windowNodeCounter.removeNode(msg); this.performanceTrackManager.setCurrentNodesCount( this.windowNodeCounter.count, ); diff --git a/frontend/app/player/web/managers/DOM/VirtualDOM.ts b/frontend/app/player/web/managers/DOM/VirtualDOM.ts index 15ce85cf4..daeb7fe4e 100644 --- a/frontend/app/player/web/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/web/managers/DOM/VirtualDOM.ts @@ -257,6 +257,7 @@ export class VElement extends VParent { applyChanges() { this.prioritized && this.applyPrioritizedChanges(); + this.node.data = this.data; this.applyAttributeChanges(); super.applyChanges(); } diff --git a/frontend/app/player/web/managers/WindowNodeCounter.ts b/frontend/app/player/web/managers/WindowNodeCounter.ts index 17b5b4d58..97791a01a 100644 --- a/frontend/app/player/web/managers/WindowNodeCounter.ts +++ b/frontend/app/player/web/managers/WindowNodeCounter.ts @@ -54,40 +54,45 @@ export default class WindowNodeCounter { this.nodes = [this.root]; } - addNode(id: number, parentID: number) { + addNode(msg: { id: number, parentID: number, time: number }): boolean { + const { id, parentID } = msg; if (!this.nodes[parentID]) { // TODO: iframe case // console.error(`Wrong! Node with id ${ parentID } (parentId) not found.`); - return; + return false; } if (this.nodes[id]) { // console.error(`Wrong! Node with id ${ id } already exists.`); - return; + return false; } this.nodes[id] = this.nodes[parentID].newChild(); + return true; } - removeNode(id: number) { + removeNode({ id }: { id: number }) { if (!this.nodes[id]) { // Might be text node // console.error(`Wrong! Node with id ${ id } not found.`); - return; + return false; } this.nodes[id].removeNode(); + return true; } - moveNode(id: number, parentId: number) { + moveNode(msg: { id: number, parentID: number, time: number }) { + const { id, parentID, time } = msg; if (!this.nodes[id]) { - console.warn(`Node Counter: Node with id ${id} not found.`); - return; + console.warn(`Node Counter: Node with id ${id} (parent: ${parentID}) not found. time: ${time}`); + return false; } - if (!this.nodes[parentId]) { + if (!this.nodes[parentID]) { console.warn( - `Node Counter: Node with id ${parentId} (parentId) not found.`, + `Node Counter: Node with id ${parentID} (parentId) not found. time: ${time}`, ); - return; + return false; } - this.nodes[id].moveNode(this.nodes[parentId]); + this.nodes[id].moveNode(this.nodes[parentID]); + return true; } get count() { diff --git a/tracker/tracker/CHANGELOG.md b/tracker/tracker/CHANGELOG.md index e6705850b..434eb35e6 100644 --- a/tracker/tracker/CHANGELOG.md +++ b/tracker/tracker/CHANGELOG.md @@ -1,3 +1,15 @@ +## 16.2.0 + +- css batching and inlining via (!plain mode will cause fake text nodes in style tags occupying 99*10^6 id space, can conflict with crossdomain iframes!) + +``` +inlineRemoteCss: boolean + inlinerOptions?: { + forceFetch?: boolean, + forcePlain?: boolean, + } +``` + ## 16.1.4 - bump proxy version to .3 diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index 89de2be40..313c97936 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -319,16 +319,14 @@ export default class App { __save_canvas_locally: false, localStorage: null, sessionStorage: null, - disableStringDict: true, forceSingleTab: false, assistSocketHost: '', fixedCanvasScaling: false, disableCanvas: false, captureIFrames: true, - disableSprites: false, - inlineRemoteCss: true, - obscureTextEmails: true, + obscureTextEmails: false, obscureTextNumbers: false, + disableStringDict: false, crossdomain: { parentDomain: '*', }, @@ -339,6 +337,12 @@ export default class App { useAnimationFrame: false, }, forceNgOff: false, + inlineRemoteCss: false, + disableSprites: false, + inlinerOptions: { + forceFetch: false, + forcePlain: false, + } } this.options = simpleMerge(defaultOptions, options) diff --git a/tracker/tracker/src/main/app/observer/cssInliner.ts b/tracker/tracker/src/main/app/observer/cssInliner.ts index df0133dba..b8c5fb57d 100644 --- a/tracker/tracker/src/main/app/observer/cssInliner.ts +++ b/tracker/tracker/src/main/app/observer/cssInliner.ts @@ -1,3 +1,5 @@ +let fakeIdHolder = 1000000 * 99; + export function inlineRemoteCss( node: HTMLLinkElement, id: number, @@ -5,83 +7,286 @@ export function inlineRemoteCss( getNextID: () => number, insertRule: (id: number, cssText: string, index: number, baseHref: string) => any[], addOwner: (sheetId: number, ownerId: number) => any[], + forceFetch?: boolean, + sendPlain?: boolean, + onPlain?: (cssText: string, id: number) => void, ) { - const sheet = node.sheet; - const sheetId = getNextID() + const sheetId = getNextID(); addOwner(sheetId, id); - const processRules = (rules: CSSRuleList) => { - if (rules.length) { - setTimeout(() => { - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - insertRule(sheetId, rule.cssText, i, baseHref); - } - }, 0) - } - }; + const sheet = node.sheet; - const processCssText = (cssText: string) => { - cssText = cssText.replace(/\/\*[\s\S]*?\*\//g, ''); - - const ruleTexts: string[] = []; - let depth = 0; - let currentRule = ''; - - for (let i = 0; i < cssText.length; i++) { - const char = cssText[i]; - - if (char === '{') { - depth++; - } else if (char === '}') { - depth--; - if (depth === 0) { - currentRule += char; - ruleTexts.push(currentRule.trim()); - currentRule = ''; - continue; - } - } - - currentRule += char; - } - - for (let i = 0; i < ruleTexts.length; i++) { - const ruleText = ruleTexts[i]; - insertRule(sheetId, ruleText, i, baseHref); - } - }; - - if (sheet) { + if (sheet && !forceFetch) { try { - const rules = sheet.cssRules; - processRules(rules); - } catch (e) { - const href = node.href; - if (href) { - fetch(href) - .then(response => { - if (!response.ok) { - throw new Error(`Failed to fetch CSS: ${response.status}`); - } - return response.text(); - }) - .then(cssText => { - processCssText(cssText); - }) - .catch(error => { - console.error(`Failed to fetch or process CSS from ${href}:`, error); - }); + const cssText = stringifyStylesheet(sheet); + + if (cssText) { + processCssText(cssText); + return; } + } catch (e) { + console.warn("Could not stringify sheet, falling back to fetch:", e); } - } else if (node.href) { + } + + // Fall back to fetching if we couldn't get or stringify the sheet + if (node.href) { fetch(node.href) - .then(response => response.text()) + .then(response => { + if (!response.ok) { + throw new Error(`Failed to fetch CSS: ${response.status}`); + } + return response.text(); + }) .then(cssText => { + if (sendPlain && onPlain) { + onPlain(cssText, fakeIdHolder++); + } processCssText(cssText); }) .catch(error => { console.error(`Failed to fetch CSS from ${node.href}:`, error); }); } -} \ No newline at end of file + + function processCssText(cssText: string) { + // Remove comments + cssText = cssText.replace(/\/\*[\s\S]*?\*\//g, ''); + + // Parse and process the CSS text to extract rules + const ruleTexts = parseCSS(cssText); + + for (let i = 0; i < ruleTexts.length; i++) { + insertRule(sheetId, ruleTexts[i], i, baseHref); + } + } + + + function parseCSS(cssText: string): string[] { + const rules: string[] = []; + let inComment = false; + let inString = false; + let stringChar = ''; + let braceLevel = 0; + let currentRule = ''; + + for (let i = 0; i < cssText.length; i++) { + const char = cssText[i]; + const nextChar = cssText[i + 1] || ''; + + // comments + if (!inString && char === '/' && nextChar === '*') { + inComment = true; + i++; // Skip the next character + continue; + } + + if (inComment) { + if (char === '*' && nextChar === '/') { + inComment = false; + i++; // Skip the next character + } + continue; + } + + + if (!inString && (char === '"' || char === "'")) { + inString = true; + stringChar = char; + currentRule += char; + continue; + } + + if (inString) { + currentRule += char; + if (char === stringChar && cssText[i - 1] !== '\\') { + inString = false; + } + continue; + } + + + currentRule += char; + + if (char === '{') { + braceLevel++; + } else if (char === '}') { + braceLevel--; + + if (braceLevel === 0) { + // End of a top-level rule + rules.push(currentRule.trim()); + currentRule = ''; + } + } + } + + // Handle any remaining text (should be rare) + if (currentRule.trim()) { + rules.push(currentRule.trim()); + } + + return rules; + } + + + function stringifyStylesheet(s: CSSStyleSheet): string | null { + try { + const rules = s.rules || s.cssRules; + if (!rules) { + return null; + } + + let sheetHref = s.href; + if (!sheetHref && s.ownerNode && (s.ownerNode as HTMLElement).ownerDocument) { + // an inline