diff --git a/.changeset/six-llamas-brush.md b/.changeset/six-llamas-brush.md new file mode 100644 index 0000000000..9aa1d08b7f --- /dev/null +++ b/.changeset/six-llamas-brush.md @@ -0,0 +1,5 @@ +--- +"rrweb-snapshot": patch +--- + +Fix `url()` rewrite for nested stylesheets by rewriting during stringification instead of after diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 7b8a5d3db4..80bf64902f 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -10,6 +10,7 @@ "test:watch": "vitest watch", "retest:update": "vitest run --update", "test:update": "yarn build && vitest run --update", + "bench": "vite build && vitest bench", "dev": "vite build --watch", "build": "yarn turbo prepublish -F rrweb-snapshot", "check-types": "tsc --noEmit", diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 88b6c8b0ae..7ceea14096 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -25,6 +25,7 @@ import { getInputType, toLowerCase, extractFileExtension, + absolutifyURLs, } from './utils'; let _id = 1; @@ -53,71 +54,9 @@ function getValidTagName(element: HTMLElement): Lowercase { return processedTagName; } -function extractOrigin(url: string): string { - let origin = ''; - if (url.indexOf('//') > -1) { - origin = url.split('/').slice(0, 3).join('/'); - } else { - origin = url.split('/')[0]; - } - origin = origin.split('?')[0]; - return origin; -} - let canvasService: HTMLCanvasElement | null; let canvasCtx: CanvasRenderingContext2D | null; -const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm; -const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i; -const URL_WWW_MATCH = /^www\..*/i; -const DATA_URI = /^(data:)([^,]*),(.*)/i; -export function absoluteToStylesheet( - cssText: string | null, - href: string, -): string { - return (cssText || '').replace( - URL_IN_CSS_REF, - ( - origin: string, - quote1: string, - path1: string, - quote2: string, - path2: string, - path3: string, - ) => { - const filePath = path1 || path2 || path3; - const maybeQuote = quote1 || quote2 || ''; - if (!filePath) { - return origin; - } - if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) { - return `url(${maybeQuote}${filePath}${maybeQuote})`; - } - if (DATA_URI.test(filePath)) { - return `url(${maybeQuote}${filePath}${maybeQuote})`; - } - if (filePath[0] === '/') { - return `url(${maybeQuote}${ - extractOrigin(href) + filePath - }${maybeQuote})`; - } - const stack = href.split('/'); - const parts = filePath.split('/'); - stack.pop(); - for (const part of parts) { - if (part === '.') { - continue; - } else if (part === '..') { - stack.pop(); - } else { - stack.push(part); - } - } - return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`; - }, - ); -} - // eslint-disable-next-line no-control-regex const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space // eslint-disable-next-line no-control-regex @@ -254,7 +193,7 @@ export function transformAttribute( } else if (name === 'srcset') { return getAbsoluteSrcsetString(doc, value); } else if (name === 'style') { - return absoluteToStylesheet(value, getHref(doc)); + return absolutifyURLs(value, getHref(doc)); } else if (tagName === 'object' && name === 'data') { return absoluteToDoc(doc, value); } @@ -584,7 +523,7 @@ function serializeTextNode( n, ); } - textContent = absoluteToStylesheet(textContent, getHref(options.doc)); + textContent = absolutifyURLs(textContent, getHref(options.doc)); } if (isScript) { textContent = 'SCRIPT_PLACEHOLDER'; @@ -664,7 +603,7 @@ function serializeElementNode( if (cssText) { delete attributes.rel; delete attributes.href; - attributes._cssText = absoluteToStylesheet(cssText, stylesheet!.href!); + attributes._cssText = cssText; } } // dynamic stylesheet @@ -678,7 +617,7 @@ function serializeElementNode( (n as HTMLStyleElement).sheet as CSSStyleSheet, ); if (cssText) { - attributes._cssText = absoluteToStylesheet(cssText, getHref(doc)); + attributes._cssText = cssText; } } // form fields diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 13da20f0f5..79139ba0dc 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -96,19 +96,21 @@ export function escapeImportStatement(rule: CSSImportRule): string { export function stringifyStylesheet(s: CSSStyleSheet): string | null { try { const rules = s.rules || s.cssRules; - return rules - ? fixBrowserCompatibilityIssuesInCSS( - Array.from(rules, stringifyRule).join(''), - ) - : null; + if (!rules) { + return null; + } + const stringifiedRules = Array.from(rules, (rule: CSSRule) => + stringifyRule(rule, s.href), + ).join(''); + return fixBrowserCompatibilityIssuesInCSS(stringifiedRules); } catch (error) { return null; } } -export function stringifyRule(rule: CSSRule): string { - let importStringified; +export function stringifyRule(rule: CSSRule, sheetHref: string | null): string { if (isCSSImportRule(rule)) { + let importStringified; try { importStringified = // for same-origin stylesheets, @@ -117,15 +119,25 @@ export function stringifyRule(rule: CSSRule): string { // work around browser issues with the raw string `@import url(...)` statement escapeImportStatement(rule); } catch (error) { - // ignore + importStringified = rule.cssText; + } + if (rule.styleSheet.href) { + // url()s within the imported stylesheet are relative to _that_ sheet's href + return absolutifyURLs(importStringified, rule.styleSheet.href); + } + return importStringified; + } else { + let ruleStringified = rule.cssText; + if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) { + // Safari does not escape selectors with : properly + // see https://bugs.webkit.org/show_bug.cgi?id=184604 + ruleStringified = fixSafariColons(ruleStringified); } - } else if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) { - // Safari does not escape selectors with : properly - // see https://bugs.webkit.org/show_bug.cgi?id=184604 - return fixSafariColons(rule.cssText); + if (sheetHref) { + return absolutifyURLs(ruleStringified, sheetHref); + } + return ruleStringified; } - - return importStringified || rule.cssText; } export function fixSafariColons(cssStringified: string): string { @@ -351,3 +363,62 @@ export function extractFileExtension( const match = url.pathname.match(regex); return match?.[1] ?? null; } + +function extractOrigin(url: string): string { + let origin = ''; + if (url.indexOf('//') > -1) { + origin = url.split('/').slice(0, 3).join('/'); + } else { + origin = url.split('/')[0]; + } + origin = origin.split('?')[0]; + return origin; +} + +const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm; +const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i; +const URL_WWW_MATCH = /^www\..*/i; +const DATA_URI = /^(data:)([^,]*),(.*)/i; +export function absolutifyURLs(cssText: string | null, href: string): string { + return (cssText || '').replace( + URL_IN_CSS_REF, + ( + origin: string, + quote1: string, + path1: string, + quote2: string, + path2: string, + path3: string, + ) => { + const filePath = path1 || path2 || path3; + const maybeQuote = quote1 || quote2 || ''; + if (!filePath) { + return origin; + } + if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) { + return `url(${maybeQuote}${filePath}${maybeQuote})`; + } + if (DATA_URI.test(filePath)) { + return `url(${maybeQuote}${filePath}${maybeQuote})`; + } + if (filePath[0] === '/') { + return `url(${maybeQuote}${ + extractOrigin(href) + filePath + }${maybeQuote})`; + } + const stack = href.split('/'); + const parts = filePath.split('/'); + stack.pop(); + for (const part of parts) { + if (part === '.') { + continue; + } else if (part === '..') { + stack.pop(); + } else { + stack.push(part); + } + } + return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`; + }, + ); +} diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index ee9d95245d..0bb9c8f860 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -489,7 +489,7 @@ exports[`integration tests > [html file]: with-style-sheet.html 1`] = ` with style sheet - + " `; @@ -500,7 +500,8 @@ exports[`integration tests > [html file]: with-style-sheet-with-import.html 1`] with style sheet with import - + + " `; diff --git a/packages/rrweb-snapshot/test/alt-css/alt-style.css b/packages/rrweb-snapshot/test/alt-css/alt-style.css new file mode 100644 index 0000000000..bda88053d8 --- /dev/null +++ b/packages/rrweb-snapshot/test/alt-css/alt-style.css @@ -0,0 +1,12 @@ +body { + margin: 0; + background: url('../should-be-in-root-folder.jpg'); + border-image: url('data:image/svg+xml;utf8,'); + } + p { + color: red; + background: url('./should-be-in-alt-css-folder.jpg'); + } + body > p { + color: yellow; + } diff --git a/packages/rrweb-snapshot/test/css/style-with-import.css b/packages/rrweb-snapshot/test/css/style-with-import.css index 5fa59d8039..a24d901947 100644 --- a/packages/rrweb-snapshot/test/css/style-with-import.css +++ b/packages/rrweb-snapshot/test/css/style-with-import.css @@ -1,2 +1,3 @@ @import '//fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&family=Roboto:wght@100;300;400;500;700&display=swap"'; @import './style.css'; +@import '../alt-css/alt-style.css'; diff --git a/packages/rrweb-snapshot/test/css/style.css b/packages/rrweb-snapshot/test/css/style.css index 2b3faf2a77..29b1da8ec8 100644 --- a/packages/rrweb-snapshot/test/css/style.css +++ b/packages/rrweb-snapshot/test/css/style.css @@ -1,11 +1,11 @@ body { margin: 0; - background: url('../a.jpg'); + background: url('../should-be-in-root-folder.jpg'); border-image: url('data:image/svg+xml;utf8,'); } p { color: red; - background: url('./b.jpg'); + background: url('./should-be-in-css-folder.jpg'); } body > p { color: yellow; diff --git a/packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html b/packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html index 6b45f65bc5..d98ff7b9fa 100644 --- a/packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html +++ b/packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html @@ -7,6 +7,10 @@ with style sheet with import + diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index de1d79eb6d..7bf6141e44 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -3,63 +3,59 @@ */ import { JSDOM } from 'jsdom'; import { describe, it, expect } from 'vitest'; -import { - absoluteToStylesheet, - serializeNodeWithId, - _isBlockedElement, -} from '../src/snapshot'; +import { serializeNodeWithId, _isBlockedElement } from '../src/snapshot'; import snapshot from '../src/snapshot'; import { serializedNodeWithId, elementNode } from '../src/types'; -import { Mirror } from '../src/utils'; +import { Mirror, absolutifyURLs } from '../src/utils'; describe('absolute url to stylesheet', () => { const href = 'http://localhost/css/style.css'; it('can handle relative path', () => { - expect(absoluteToStylesheet('url(a.jpg)', href)).toEqual( + expect(absolutifyURLs('url(a.jpg)', href)).toEqual( `url(http://localhost/css/a.jpg)`, ); }); it('can handle same level path', () => { - expect(absoluteToStylesheet('url("./a.jpg")', href)).toEqual( + expect(absolutifyURLs('url("./a.jpg")', href)).toEqual( `url("http://localhost/css/a.jpg")`, ); }); it('can handle parent level path', () => { - expect(absoluteToStylesheet('url("../a.jpg")', href)).toEqual( + expect(absolutifyURLs('url("../a.jpg")', href)).toEqual( `url("http://localhost/a.jpg")`, ); }); it('can handle absolute path', () => { - expect(absoluteToStylesheet('url("/a.jpg")', href)).toEqual( + expect(absolutifyURLs('url("/a.jpg")', href)).toEqual( `url("http://localhost/a.jpg")`, ); }); it('can handle external path', () => { - expect(absoluteToStylesheet('url("http://localhost/a.jpg")', href)).toEqual( + expect(absolutifyURLs('url("http://localhost/a.jpg")', href)).toEqual( `url("http://localhost/a.jpg")`, ); }); it('can handle single quote path', () => { - expect(absoluteToStylesheet(`url('./a.jpg')`, href)).toEqual( + expect(absolutifyURLs(`url('./a.jpg')`, href)).toEqual( `url('http://localhost/css/a.jpg')`, ); }); it('can handle no quote path', () => { - expect(absoluteToStylesheet('url(./a.jpg)', href)).toEqual( + expect(absolutifyURLs('url(./a.jpg)', href)).toEqual( `url(http://localhost/css/a.jpg)`, ); }); it('can handle multiple no quote paths', () => { expect( - absoluteToStylesheet( + absolutifyURLs( 'background-image: url(images/b.jpg);background: #aabbcc url(images/a.jpg) 50% 50% repeat;', href, ), @@ -70,11 +66,11 @@ describe('absolute url to stylesheet', () => { }); it('can handle data url image', () => { + expect(absolutifyURLs('url(data:image/gif;base64,ABC)', href)).toEqual( + 'url(data:image/gif;base64,ABC)', + ); expect( - absoluteToStylesheet('url(data:image/gif;base64,ABC)', href), - ).toEqual('url(data:image/gif;base64,ABC)'); - expect( - absoluteToStylesheet( + absolutifyURLs( 'url(data:application/font-woff;base64,d09GMgABAAAAAAm)', href, ), @@ -83,7 +79,7 @@ describe('absolute url to stylesheet', () => { it('preserves quotes around inline svgs with spaces', () => { expect( - absoluteToStylesheet( + absolutifyURLs( "url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")", href, ), @@ -91,7 +87,7 @@ describe('absolute url to stylesheet', () => { "url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")", ); expect( - absoluteToStylesheet( + absolutifyURLs( 'url(\'data:image/svg+xml;utf8,\')', href, ), @@ -99,7 +95,7 @@ describe('absolute url to stylesheet', () => { 'url(\'data:image/svg+xml;utf8,\')', ); expect( - absoluteToStylesheet( + absolutifyURLs( 'url("data:image/svg+xml;utf8,")', href, ), @@ -108,7 +104,7 @@ describe('absolute url to stylesheet', () => { ); }); it('can handle empty path', () => { - expect(absoluteToStylesheet(`url('')`, href)).toEqual(`url('')`); + expect(absolutifyURLs(`url('')`, href)).toEqual(`url('')`); }); }); diff --git a/packages/rrweb-snapshot/test/stringify-stylesheet.bench.ts b/packages/rrweb-snapshot/test/stringify-stylesheet.bench.ts new file mode 100644 index 0000000000..1e42bab1a6 --- /dev/null +++ b/packages/rrweb-snapshot/test/stringify-stylesheet.bench.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment jsdom + */ +import { bench } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { stringifyStylesheet } from '../src/utils'; +import * as CSSOM from 'cssom'; + +describe('stringifyStylesheet', () => { + let benchmarkStylesheet: CSSStyleSheet; + + const cssText = fs.readFileSync( + path.resolve(__dirname, './css/benchmark.css'), + 'utf8', + ); + benchmarkStylesheet = CSSOM.parse(cssText); + benchmarkStylesheet.href = 'https://example.com/style.css'; + + it.skip('stringify', () => { + // written just to ensure it's working + const cssText = '.x { background: url(./relative.jpg) }'; + const styleSheet = CSSOM.parse(cssText); + styleSheet.href = 'https://example.com/style.css'; + expect(stringifyStylesheet(styleSheet)).toEqual( + 'x {background: url(https://example.com/relative.jpg);}', + ); + }); + + bench( + 'stringify', + () => { + stringifyStylesheet(benchmarkStylesheet); + }, + { time: 1000 }, + ); +}); diff --git a/packages/rrweb-snapshot/vitest.config.ts b/packages/rrweb-snapshot/vitest.config.ts index 39888437cf..1b5a8b7e3e 100644 --- a/packages/rrweb-snapshot/vitest.config.ts +++ b/packages/rrweb-snapshot/vitest.config.ts @@ -6,7 +6,7 @@ export default mergeConfig( configShared, defineProject({ test: { - // ... custom test config here + globals: true, }, }), ); diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 5395cde15d..c2bbacc6ff 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -67,7 +67,7 @@ export class StylesheetManager { styles.push({ styleId, rules: Array.from(sheet.rules || CSSRule, (r, index) => ({ - rule: stringifyRule(r), + rule: stringifyRule(r, sheet.href), index, })), });