From 4354bffef7d08b55c3bbabbea0e0bc2af08ec17d Mon Sep 17 00:00:00 2001 From: JackSpiece <285515994+JackSpiece@users.noreply.github.com> Date: Tue, 19 May 2026 02:27:26 +0800 Subject: [PATCH] Add opt-in multipage HAR navigation support --- CHANGELOG.md | 4 ++ README.md | 10 ++++ index.js | 54 +++++++++++++++++++--- test/tests.js | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e3158..2c2cdbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## Unreleased +### Added +* Add opt-in `allowMultiPage` support for main-frame scheduled or requested navigations. + ## 1.3.1 - 2026-05-19 * Fix for render blocking stats [#158](https://github.com/sitespeedio/chrome-har/pull/158). diff --git a/README.md b/README.md index 9440510..b4ec8db 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,13 @@ client.on('Network.requestIntercepted', async (params: any) => { const har = harFromMessages(harEvents, {includeTextFromResponseBody: true}); ``` + +## Multi-page main frame navigations + +By default, main-document redirects and scripted navigations stay on the current HAR page. This keeps the historical one-page-per-run behavior. + +Set `allowMultiPage` to `true` to create a new HAR page when Chrome reports a main-frame scheduled or requested navigation and the next main-document request starts: + +```javascript +const har = harFromMessages(harEvents, {allowMultiPage: true}); +``` diff --git a/index.js b/index.js index a4f4aeb..7367a7d 100644 --- a/index.js +++ b/index.js @@ -20,7 +20,8 @@ const log = debug('chrome-har'); const defaultOptions = { includeResourcesFromDiskCache: false, - includeTextFromResponseBody: false + includeTextFromResponseBody: false, + allowMultiPage: false }; const isEmpty = o => !o; @@ -69,7 +70,8 @@ export function harFromMessages(messages, options) { options = Object.assign({}, defaultOptions, options); const ignoredRequests = new Set(), - rootFrameMappings = new Map(); + rootFrameMappings = new Map(), + pendingRootFrameNavigations = new Map(); let pages = [], entries = [], @@ -90,11 +92,23 @@ export function harFromMessages(messages, options) { switch (method) { case 'Page.frameStartedLoading': + case 'Page.frameScheduledNavigation': case 'Page.frameRequestedNavigation': case 'Page.navigatedWithinDocument': { { const frameId = params.frameId; const rootFrame = rootFrameMappings.get(frameId) || frameId; + const shouldCreatePageFromPendingNavigation = + options.allowMultiPage && + rootFrame === frameId && + (method === 'Page.frameScheduledNavigation' || + method === 'Page.frameRequestedNavigation') && + pages.some(page => page.__frameId === rootFrame); + if (shouldCreatePageFromPendingNavigation) { + pendingRootFrameNavigations.set(rootFrame, params.url || ''); + continue; + } + if (pages.some(page => page.__frameId === rootFrame)) { continue; } @@ -178,7 +192,28 @@ export function harFromMessages(messages, options) { ignoredRequests.add(params.requestId); continue; } - const page = pages.at(-1); + const rootFrame = + rootFrameMappings.get(params.frameId) || params.frameId; + const isRootFrameDocumentRequest = + rootFrame === params.frameId && params.type === 'Document'; + let page = pages.at(-1); + if ( + options.allowMultiPage && + page && + isRootFrameDocumentRequest && + pendingRootFrameNavigations.has(rootFrame) + ) { + currentPageId = randomUUID(); + page = { + id: currentPageId, + startedDateTime: '', + title: pendingRootFrameNavigations.get(rootFrame), + pageTimings: {}, + __frameId: rootFrame + }; + pages.push(page); + pendingRootFrameNavigations.delete(rootFrame); + } const cookieHeader = getHeaderValue(request.headers, 'Cookie'); //Before we used to remove the hash framgment because of Chrome do that but: @@ -428,10 +463,8 @@ export function harFromMessages(messages, options) { continue; } - const frameId = - rootFrameMappings.get(params.frameId) || params.frameId; const page = - pages.find(page => page.__frameId === frameId) || pages.at(-1); + pages.find(page => page.id === entry.pageref) || pages.at(-1); if (!page) { log( `Received network response for requestId ${params.requestId} that can't be mapped to any page.` @@ -584,6 +617,15 @@ export function harFromMessages(messages, options) { break; } + case 'Page.frameClearedScheduledNavigation': { + { + const frameId = params.frameId; + const rootFrame = rootFrameMappings.get(frameId) || frameId; + pendingRootFrameNavigations.delete(rootFrame); + } + break; + } + case 'Network.loadingFailed': { { if (ignoredRequests.has(params.requestId)) { diff --git a/test/tests.js b/test/tests.js index 04bf18f..7ccb5af 100644 --- a/test/tests.js +++ b/test/tests.js @@ -316,6 +316,129 @@ test('navigatedWithinDocument', t => { }); }); +test('Root frame navigation can create a new page when enabled', t => { + const frameId = 'F1'; + const request = (requestId, url, timestamp, wallTime, type = 'Document') => [ + { + method: 'Network.requestWillBeSent', + params: { + requestId, + frameId, + loaderId: `L${requestId}`, + documentURL: url, + request: { + url, + method: 'GET', + headers: {}, + initialPriority: 'High' + }, + timestamp, + wallTime, + initiator: { type: 'other' }, + type + } + }, + { + method: 'Network.responseReceived', + params: { + requestId, + frameId, + loaderId: `L${requestId}`, + timestamp: timestamp + 0.1, + type, + response: { + url, + status: 200, + statusText: 'OK', + headers: { 'content-type': 'text/html' }, + mimeType: 'text/html', + fromDiskCache: false, + fromServiceWorker: false, + encodedDataLength: 100, + protocol: 'http/1.1', + connectionId: Number(requestId), + remoteIPAddress: '127.0.0.1', + timing: { + requestTime: timestamp, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + sendStart: 0, + sendEnd: 1, + receiveHeadersEnd: 2 + } + } + } + }, + { + method: 'Network.loadingFinished', + params: { + requestId, + timestamp: timestamp + 0.2, + encodedDataLength: 100 + } + } + ]; + const page1Url = 'https://example.com/page1.html'; + const page2Url = 'https://example.com/page2.html'; + const page2WallTime = 1_800_000_000; + const messages = [ + { method: 'Page.frameStartedLoading', params: { frameId } }, + ...request('1', page1Url, 1, 1_700_000_000), + ...request( + '2', + 'https://example.com/page1.png', + 1.5, + 1_700_000_000.5, + 'Image' + ), + { + method: 'Page.frameScheduledNavigation', + params: { + delay: 0, + frameId, + reason: 'scriptInitiated', + url: page2Url + } + }, + { method: 'Page.frameStartedLoading', params: { frameId } }, + ...request('3', page2Url, 3, page2WallTime), + ...request( + '4', + 'https://example.com/page2.png', + 3.5, + page2WallTime + 0.5, + 'Image' + ) + ]; + + const flatHar = harFromMessages(messages); + t.is(flatHar.log.pages.length, 1); + t.true(flatHar.log.entries.every(entry => entry.pageref === 'page_1')); + + const multiPageHar = harFromMessages(messages, { allowMultiPage: true }); + t.is(multiPageHar.log.pages.length, 2); + t.deepEqual( + multiPageHar.log.pages.map(page => page.title), + [page1Url, page2Url] + ); + + const entriesByUrl = Object.fromEntries( + multiPageHar.log.entries.map(entry => [entry.request.url, entry]) + ); + t.is(entriesByUrl[page1Url].pageref, 'page_1'); + t.is(entriesByUrl['https://example.com/page1.png'].pageref, 'page_1'); + t.is(entriesByUrl[page2Url].pageref, 'page_2'); + t.is(entriesByUrl['https://example.com/page2.png'].pageref, 'page_2'); + t.is( + entriesByUrl[page2Url].startedDateTime, + new Date(page2WallTime * 1000).toISOString() + ); +}); + test('Generates multiple pages', t => { const perflogPath = perflog('www.wikipedia.org.json'); return parsePerflog(perflogPath).then(har => {