From ffdf49c6e9f44177f80b320efdbfdb85a4da0756 Mon Sep 17 00:00:00 2001 From: Andrew Pomeroy Date: Mon, 12 Feb 2024 09:19:17 -0800 Subject: [PATCH] Capture stylesheets designated as `rel="preload"` (#1374) * feat(Snapshot): Capture stylesheets designated as `rel="preload"` * fix(Snapshot): Harden asset file extension matching * Add changeset * chore: Lint * Tweak regex, add try-catch block on URL constructor --- .changeset/smooth-papayas-boil.md | 6 +++ packages/rrweb-snapshot/src/snapshot.ts | 9 +++- packages/rrweb-snapshot/src/utils.ts | 20 ++++++++ packages/rrweb-snapshot/test/utils.test.ts | 53 +++++++++++++++++++++- 4 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 .changeset/smooth-papayas-boil.md diff --git a/.changeset/smooth-papayas-boil.md b/.changeset/smooth-papayas-boil.md new file mode 100644 index 0000000000..dcf4d36899 --- /dev/null +++ b/.changeset/smooth-papayas-boil.md @@ -0,0 +1,6 @@ +--- +'rrweb-snapshot': patch +'rrweb': patch +--- + +Capture stylesheets designated as `rel="preload"` diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 301f84430e..75fd863e0b 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -23,6 +23,7 @@ import { stringifyStylesheet, getInputType, toLowerCase, + extractFileExtension, } from './utils'; let _id = 1; @@ -847,7 +848,7 @@ function slimDOMExcluded( (sn.tagName === 'link' && sn.attributes.rel === 'prefetch' && typeof sn.attributes.href === 'string' && - sn.attributes.href.endsWith('.js'))) + extractFileExtension(sn.attributes.href) === 'js')) ) { return true; } else if ( @@ -1177,7 +1178,11 @@ export function serializeNodeWithId( if ( serializedNode.type === NodeType.Element && serializedNode.tagName === 'link' && - serializedNode.attributes.rel === 'stylesheet' + typeof serializedNode.attributes.rel === 'string' && + (serializedNode.attributes.rel === 'stylesheet' || + (serializedNode.attributes.rel === 'preload' && + typeof serializedNode.attributes.href === 'string' && + extractFileExtension(serializedNode.attributes.href) === 'css')) ) { onceStylesheetLoaded( n as HTMLLinkElement, diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 95444c18b3..5ccc9082ed 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -331,3 +331,23 @@ export function getInputType(element: HTMLElement): Lowercase | null { toLowerCase(type) : null; } + +/** + * Extracts the file extension from an a path, considering search parameters and fragments. + * @param path - Path to file + * @param baseURL - [optional] Base URL of the page, used to resolve relative paths. Defaults to current page URL. + */ +export function extractFileExtension( + path: string, + baseURL?: string, +): string | null { + let url; + try { + url = new URL(path, baseURL ?? window.location.href); + } catch (err) { + return null; + } + const regex = /\.([0-9a-z]+)(?:$)/i; + const match = url.pathname.match(regex); + return match?.[1] ?? null; +} diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index d635165c1f..96a5768d7d 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -3,7 +3,7 @@ */ import { serializedNodeWithId } from '@amplitude/rrweb-snapshot'; import { NodeType, serializedNode } from '../src/types'; -import { isNodeMetaEqual } from '../src/utils'; +import { extractFileExtension, isNodeMetaEqual } from '../src/utils'; describe('utils', () => { describe('isNodeMetaEqual()', () => { @@ -147,4 +147,55 @@ describe('utils', () => { expect(isNodeMetaEqual(element2, element3)).toBeFalsy(); }); }); + describe('extractFileExtension', () => { + test('absolute path', () => { + const path = 'https://example.com/styles/main.css'; + const extension = extractFileExtension(path); + expect(extension).toBe('css'); + }); + + test('relative path', () => { + const path = 'styles/main.css'; + const baseURL = 'https://example.com/'; + const extension = extractFileExtension(path, baseURL); + expect(extension).toBe('css'); + }); + + test('path with search parameters', () => { + const path = 'https://example.com/scripts/app.js?version=1.0'; + const extension = extractFileExtension(path); + expect(extension).toBe('js'); + }); + + test('path with fragment', () => { + const path = 'https://example.com/styles/main.css#section1'; + const extension = extractFileExtension(path); + expect(extension).toBe('css'); + }); + + test('path with search parameters and fragment', () => { + const path = 'https://example.com/scripts/app.js?version=1.0#section1'; + const extension = extractFileExtension(path); + expect(extension).toBe('js'); + }); + + test('path without extension', () => { + const path = 'https://example.com/path/to/directory/'; + const extension = extractFileExtension(path); + expect(extension).toBeNull(); + }); + + test('invalid URL', () => { + const path = '!@#$%^&*()'; + const baseURL = 'invalid'; + const extension = extractFileExtension(path, baseURL); + expect(extension).toBeNull(); + }); + + test('path with multiple dots', () => { + const path = 'https://example.com/scripts/app.min.js?version=1.0'; + const extension = extractFileExtension(path); + expect(extension).toBe('js'); + }); + }); });