From f1844fac226a35f7cc4a9bf388f336027871cd56 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Sun, 29 Jun 2025 20:19:21 +0100 Subject: [PATCH 01/12] Add HTTP Header speculation rules --- dist/performance.js | 62 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/dist/performance.js b/dist/performance.js index a79f311..070e4d9 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -3,6 +3,12 @@ const response_bodies = $WPT_BODIES; const script_response_bodies = $WPT_BODIES.filter(body => body.type === 'Script'); +function fetchWithTimeout(url) { + var controller = new AbortController(); + setTimeout(() => {controller.abort()}, 5000); + return fetch(url, {signal: controller.signal}); +} + function getRawHtmlDocument() { let rawHtml; if (response_bodies.length > 0) { @@ -324,17 +330,53 @@ function getLcpResponseObject(lcpUrl) { return responseObject; } -function getSpeculationRules() { - return Array.from(document.querySelectorAll('script[type=speculationrules]')).map(script => { - try { - return JSON.parse(script.innerHTML); - } catch (error) { - return null; - } - }); +function getParameterCaseInsensitive(object, key) { + return object[Object.keys(object).find(k => k.toLowerCase() === key.toLowerCase())]; +} + +async function getSpeculationRules() { + // Get rules from the HTML + const htmlRules = Array.from(document.querySelectorAll('script[type=speculationrules]')).map(script => { + try { + return JSON.parse(script.innerHTML); + } catch (error) { + return null; + } + }); + + // Get rules from the HTTP requests + const documentRequests = [$WPT_REQUESTS.find( req => req.url === document.location.href)] || []; + const httpRules = await Promise.all(documentRequests.map(async request => { + try { + const speculationRulesHeaders = getParameterCaseInsensitive(request.response_headers, 'Speculation-Rules'); + if (speculationRulesHeaders) { + return await Promise.all(speculationRulesHeaders.split(',').map(async (speculationRuleLocation) => { + try { + let url = decodeURI(speculationRuleLocation).slice(1, -1); + if (url.startsWith('/')) { + url = document.location.origin + url; + } else if (!url.startsWith('http')) { + url = document.location.href + url; + } + const response = await fetchWithTimeout(url); + const body = await response.text(); + return {url: speculationRuleLocation, rule: JSON.parse(body)}; + } catch(error) { + return {error}; + } + })); + } else { + return []; + } + } catch(error) { + return {error}; + }; + })); + + return {htmlRules: htmlRules, httpHeaderRules: httpRules}; } -return Promise.all([getLcpElement()]).then(([lcp_elem_stats]) => { +return Promise.all([getLcpElement(), getSpeculationRules()]).then(([lcp_elem_stats, speculation_rules]) => { const lcpUrl = lcp_elem_stats.url; const rawDoc = getRawHtmlDocument(); // Start out with true, only if LCP element is an external resource will we eval & potentially set to false. @@ -366,7 +408,7 @@ return Promise.all([getLcpElement()]).then(([lcp_elem_stats]) => { lcp_preload: lcpPreload, web_vitals_js: getWebVitalsJS(), gaming_metrics: gamingMetrics, - speculation_rules: getSpeculationRules(), + speculation_rules: speculation_rules, }; }).catch(error => { return {error}; From 0547bbea7460f931aadb8fa6c0bd457e67eaba86 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Sun, 29 Jun 2025 20:26:03 +0100 Subject: [PATCH 02/12] Linting --- dist/performance.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/performance.js b/dist/performance.js index 070e4d9..0bc8350 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -370,7 +370,7 @@ async function getSpeculationRules() { } } catch(error) { return {error}; - }; + } })); return {htmlRules: htmlRules, httpHeaderRules: httpRules}; From cc849141a85b01d7f4ed2cfcf9685560b3cf5020 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Sun, 29 Jun 2025 20:29:36 +0100 Subject: [PATCH 03/12] Better comment --- dist/performance.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/performance.js b/dist/performance.js index 0bc8350..93a43b9 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -344,7 +344,7 @@ async function getSpeculationRules() { } }); - // Get rules from the HTTP requests + // Get rules from Speculation-Rules HTTP Header on the document const documentRequests = [$WPT_REQUESTS.find( req => req.url === document.location.href)] || []; const httpRules = await Promise.all(documentRequests.map(async request => { try { From 4e49f70147b101e96bf0522a500af3b6cb098197 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Sun, 29 Jun 2025 20:50:09 +0100 Subject: [PATCH 04/12] Only look at first request --- dist/performance.js | 52 ++++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/dist/performance.js b/dist/performance.js index 93a43b9..49751ed 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -1,4 +1,4 @@ -//[performance] +[performance] const response_bodies = $WPT_BODIES; const script_response_bodies = $WPT_BODIES.filter(body => body.type === 'Script'); @@ -345,33 +345,31 @@ async function getSpeculationRules() { }); // Get rules from Speculation-Rules HTTP Header on the document - const documentRequests = [$WPT_REQUESTS.find( req => req.url === document.location.href)] || []; - const httpRules = await Promise.all(documentRequests.map(async request => { - try { - const speculationRulesHeaders = getParameterCaseInsensitive(request.response_headers, 'Speculation-Rules'); - if (speculationRulesHeaders) { - return await Promise.all(speculationRulesHeaders.split(',').map(async (speculationRuleLocation) => { - try { - let url = decodeURI(speculationRuleLocation).slice(1, -1); - if (url.startsWith('/')) { - url = document.location.origin + url; - } else if (!url.startsWith('http')) { - url = document.location.href + url; - } - const response = await fetchWithTimeout(url); - const body = await response.text(); - return {url: speculationRuleLocation, rule: JSON.parse(body)}; - } catch(error) { - return {error}; + let httpRules = []; + + const documentRequest = $WPT_REQUESTS.find( req => req.url === document.location.href); + if (documentRequest) { + + const speculationRulesHeaders = getParameterCaseInsensitive(documentRequest.response_headers, 'Speculation-Rules'); + if (speculationRulesHeaders) { + + await Promise.all(speculationRulesHeaders.split(',').map(async (speculationRuleLocation) => { + try { + let url = decodeURI(speculationRuleLocation).slice(1, -1); + if (url.startsWith('/')) { + url = document.location.origin + url; + } else if (!url.startsWith('http')) { + url = document.location.href + url; } - })); - } else { - return []; - } - } catch(error) { - return {error}; + const response = await fetchWithTimeout(url); + const body = await response.text(); + httpRules.push({url: speculationRuleLocation, rule: JSON.parse(body)}); + } catch(error) { + httpRules.push({errorName: error.name, errorMessage: error.message}); + } + })); } - })); + } return {htmlRules: htmlRules, httpHeaderRules: httpRules}; } @@ -411,5 +409,5 @@ return Promise.all([getLcpElement(), getSpeculationRules()]).then(([lcp_elem_sta speculation_rules: speculation_rules, }; }).catch(error => { - return {error}; + return {errorName: error.name, errorMessage: error.message}; }); From 469fea2146f8b135ade1226cf3c9d88db9467d9b Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Sun, 29 Jun 2025 21:04:03 +0100 Subject: [PATCH 05/12] Better navigation request --- dist/performance.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dist/performance.js b/dist/performance.js index 49751ed..f028766 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -1,4 +1,4 @@ -[performance] +//[performance] const response_bodies = $WPT_BODIES; const script_response_bodies = $WPT_BODIES.filter(body => body.type === 'Script'); @@ -347,12 +347,13 @@ async function getSpeculationRules() { // Get rules from Speculation-Rules HTTP Header on the document let httpRules = []; - const documentRequest = $WPT_REQUESTS.find( req => req.url === document.location.href); + // Get the first request matching the navigation as that should be the final document request (after any redirects) + // and only Speculation-Rules HTTP headers on that request count + const documentRequest = $WPT_REQUESTS.find( req => req.url === performance.getEntriesByType('navigation')[0].name); if (documentRequest) { - + // Get all Speculation-Rules headers const speculationRulesHeaders = getParameterCaseInsensitive(documentRequest.response_headers, 'Speculation-Rules'); if (speculationRulesHeaders) { - await Promise.all(speculationRulesHeaders.split(',').map(async (speculationRuleLocation) => { try { let url = decodeURI(speculationRuleLocation).slice(1, -1); From a88c0457bfc2e2de65b813f48a2b33d57a5b460a Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Sun, 29 Jun 2025 21:15:12 +0100 Subject: [PATCH 06/12] Clean up --- dist/performance.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dist/performance.js b/dist/performance.js index f028766..7f7189e 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -366,7 +366,7 @@ async function getSpeculationRules() { const body = await response.text(); httpRules.push({url: speculationRuleLocation, rule: JSON.parse(body)}); } catch(error) { - httpRules.push({errorName: error.name, errorMessage: error.message}); + httpRules.push({url: speculationRuleLocation, errorName: error.name, errorMessage: error.message}); } })); } @@ -375,7 +375,7 @@ async function getSpeculationRules() { return {htmlRules: htmlRules, httpHeaderRules: httpRules}; } -return Promise.all([getLcpElement(), getSpeculationRules()]).then(([lcp_elem_stats, speculation_rules]) => { +return Promise.all([getLcpElement(), getSpeculationRules()]).then(([lcp_elem_stats, speculationRules]) => { const lcpUrl = lcp_elem_stats.url; const rawDoc = getRawHtmlDocument(); // Start out with true, only if LCP element is an external resource will we eval & potentially set to false. @@ -407,7 +407,7 @@ return Promise.all([getLcpElement(), getSpeculationRules()]).then(([lcp_elem_sta lcp_preload: lcpPreload, web_vitals_js: getWebVitalsJS(), gaming_metrics: gamingMetrics, - speculation_rules: speculation_rules, + speculation_rules: speculationRules, }; }).catch(error => { return {errorName: error.name, errorMessage: error.message}; From f63245b1300324f4241913ef7a15cae2d74b2a24 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Wed, 2 Jul 2025 18:11:44 +0100 Subject: [PATCH 07/12] Simpler implementation --- dist/performance.js | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/dist/performance.js b/dist/performance.js index 7f7189e..bb058e8 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -344,33 +344,18 @@ async function getSpeculationRules() { } }); - // Get rules from Speculation-Rules HTTP Header on the document - let httpRules = []; - - // Get the first request matching the navigation as that should be the final document request (after any redirects) - // and only Speculation-Rules HTTP headers on that request count - const documentRequest = $WPT_REQUESTS.find( req => req.url === performance.getEntriesByType('navigation')[0].name); - if (documentRequest) { - // Get all Speculation-Rules headers - const speculationRulesHeaders = getParameterCaseInsensitive(documentRequest.response_headers, 'Speculation-Rules'); - if (speculationRulesHeaders) { - await Promise.all(speculationRulesHeaders.split(',').map(async (speculationRuleLocation) => { - try { - let url = decodeURI(speculationRuleLocation).slice(1, -1); - if (url.startsWith('/')) { - url = document.location.origin + url; - } else if (!url.startsWith('http')) { - url = document.location.href + url; - } - const response = await fetchWithTimeout(url); - const body = await response.text(); - httpRules.push({url: speculationRuleLocation, rule: JSON.parse(body)}); - } catch(error) { - httpRules.push({url: speculationRuleLocation, errorName: error.name, errorMessage: error.message}); - } - })); - } - } + // Get rules from Speculation-Rules HTTP responses + // There is an assumption this is actually used on the page(e.g. it could be fetched manually from JS and + // then not used, rather than fetched by browser from HTTP header), but think that's rare enough so OK. + const httpRules = response_bodies + .filter(req => getParameterCaseInsensitive(req.response_headers, 'content-type') === 'application/speculationrules+json') + .map(req => { + try { + return {url: req.url, rule: JSON.parse(req.response_body)}; + } catch(error) { + return {url: req.url, errorName: error.name, errorMessage: error.message}; + } + }) return {htmlRules: htmlRules, httpHeaderRules: httpRules}; } From 26ec46d4aec322fc10be821bf7f585307148e8a4 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Wed, 2 Jul 2025 18:32:56 +0100 Subject: [PATCH 08/12] Fix bug --- dist/performance.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dist/performance.js b/dist/performance.js index bb058e8..f0bf7cc 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -347,7 +347,8 @@ async function getSpeculationRules() { // Get rules from Speculation-Rules HTTP responses // There is an assumption this is actually used on the page(e.g. it could be fetched manually from JS and // then not used, rather than fetched by browser from HTTP header), but think that's rare enough so OK. - const httpRules = response_bodies + const httpRules = Array.from( + response_bodies .filter(req => getParameterCaseInsensitive(req.response_headers, 'content-type') === 'application/speculationrules+json') .map(req => { try { @@ -356,6 +357,7 @@ async function getSpeculationRules() { return {url: req.url, errorName: error.name, errorMessage: error.message}; } }) + ); return {htmlRules: htmlRules, httpHeaderRules: httpRules}; } From 16b1b0f1d980ad0865ac9652df5d67395db10c92 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Wed, 2 Jul 2025 18:43:00 +0100 Subject: [PATCH 09/12] Simplify --- dist/performance.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/dist/performance.js b/dist/performance.js index f0bf7cc..d26ab66 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -3,12 +3,6 @@ const response_bodies = $WPT_BODIES; const script_response_bodies = $WPT_BODIES.filter(body => body.type === 'Script'); -function fetchWithTimeout(url) { - var controller = new AbortController(); - setTimeout(() => {controller.abort()}, 5000); - return fetch(url, {signal: controller.signal}); -} - function getRawHtmlDocument() { let rawHtml; if (response_bodies.length > 0) { @@ -362,7 +356,7 @@ async function getSpeculationRules() { return {htmlRules: htmlRules, httpHeaderRules: httpRules}; } -return Promise.all([getLcpElement(), getSpeculationRules()]).then(([lcp_elem_stats, speculationRules]) => { +return Promise.all([getLcpElement()]).then(([lcp_elem_stats]) => { const lcpUrl = lcp_elem_stats.url; const rawDoc = getRawHtmlDocument(); // Start out with true, only if LCP element is an external resource will we eval & potentially set to false. @@ -394,7 +388,7 @@ return Promise.all([getLcpElement(), getSpeculationRules()]).then(([lcp_elem_sta lcp_preload: lcpPreload, web_vitals_js: getWebVitalsJS(), gaming_metrics: gamingMetrics, - speculation_rules: speculationRules, + speculation_rules: getSpeculationRules(), }; }).catch(error => { return {errorName: error.name, errorMessage: error.message}; From 6f44c3cbc0f05712f7913d3c56267a2c7e19de01 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Wed, 2 Jul 2025 18:53:58 +0100 Subject: [PATCH 10/12] No more async --- dist/performance.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/performance.js b/dist/performance.js index d26ab66..f06c1bf 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -328,7 +328,7 @@ function getParameterCaseInsensitive(object, key) { return object[Object.keys(object).find(k => k.toLowerCase() === key.toLowerCase())]; } -async function getSpeculationRules() { +function getSpeculationRules() { // Get rules from the HTML const htmlRules = Array.from(document.querySelectorAll('script[type=speculationrules]')).map(script => { try { From 3f730b63674d26bc09c51c3e17f5d4ec23bac609 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Wed, 2 Jul 2025 19:01:22 +0100 Subject: [PATCH 11/12] dd sec-fetch-dest check --- dist/performance.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dist/performance.js b/dist/performance.js index f06c1bf..7bbd0c2 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -339,11 +339,12 @@ function getSpeculationRules() { }); // Get rules from Speculation-Rules HTTP responses - // There is an assumption this is actually used on the page(e.g. it could be fetched manually from JS and - // then not used, rather than fetched by browser from HTTP header), but think that's rare enough so OK. + // There is an assumption this is actually used on the page but by checking both the `content-type` + // and the `sec-fetch-dest`, that should be the case. const httpRules = Array.from( response_bodies .filter(req => getParameterCaseInsensitive(req.response_headers, 'content-type') === 'application/speculationrules+json') + .filter(req => getParameterCaseInsensitive(req.response_headers, 'sec-fetch-dest') === 'speculationrules') .map(req => { try { return {url: req.url, rule: JSON.parse(req.response_body)}; From e774528cb3c54ce677753e8546a83195aa48713a Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Wed, 2 Jul 2025 19:19:59 +0100 Subject: [PATCH 12/12] Response -> request --- dist/performance.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/performance.js b/dist/performance.js index 7bbd0c2..05d5513 100644 --- a/dist/performance.js +++ b/dist/performance.js @@ -344,7 +344,7 @@ function getSpeculationRules() { const httpRules = Array.from( response_bodies .filter(req => getParameterCaseInsensitive(req.response_headers, 'content-type') === 'application/speculationrules+json') - .filter(req => getParameterCaseInsensitive(req.response_headers, 'sec-fetch-dest') === 'speculationrules') + .filter(req => getParameterCaseInsensitive(req.request_headers, 'sec-fetch-dest') === 'speculationrules') .map(req => { try { return {url: req.url, rule: JSON.parse(req.response_body)};