diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index ecf4416..6e54193 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -14,9 +14,9 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
- node-version: '10.x'
- - name: Install dependencies
+ node-version: '20.x'
+ - name: Install dependencies
run: npm ci
- - name: Verify lint
- run: npm run lint
+ - name: Verify build
+ run: npm run build
\ No newline at end of file
diff --git a/index.html b/index.html
index cad0299..81c8bc6 100644
--- a/index.html
+++ b/index.html
@@ -53,6 +53,7 @@
+
@@ -91,11 +92,16 @@
}
getAndSet("har1", "harurl");
getAndSet("har2", "harurl2");
+ // Carry stripVersion through share links — `?stripVersion=1`
+ // mirrors the start-page checkbox so a shared URL renders the
+ // same comparison the sender saw.
+ const stripVersionFromUrl = getParam("stripVersion") === "1";
if (getParam("har1") != undefined && getParam("har2") != undefined) {
showLoading();
loadHARsFromConfig({
har1: { url: getParam("har1") },
- har2: { url: getParam("har2") }
+ har2: { url: getParam("har2") },
+ stripVersion: stripVersionFromUrl
});
} else if (
(getParam("har1") != undefined || getParam("har2") != undefined) &&
@@ -104,7 +110,8 @@
showLoading();
loadHARsFromConfig({
har1: { url: getParam("har1") || getParam("har2") },
- har2: {}
+ har2: {},
+ stripVersion: stripVersionFromUrl
});
} else if (getParam("gist") !== undefined) {
showLoading();
@@ -116,25 +123,27 @@
document.addEventListener("paste", function (e) {
const paste = e.clipboardData.getData("Text");
- // Is it a HAR or a gist?
+ // Is it a HAR, a bundle, or a gist?
try {
- const har = JSON.parse(paste);
+ const parsed = JSON.parse(paste);
showLoading();
- if (har.log) {
+ if (isBundle(parsed)) {
+ loadFromBundle(parsed);
+ } else if (parsed.log) {
generate({
har1: {
- har: har,
+ har: parsed,
run: 0,
label: "HAR1"
},
har2: {
- har: har,
- run: har.log.pages.length > 1 ? 1 : 0,
+ har: parsed,
+ run: parsed.log.pages.length > 1 ? 1 : 0,
label: "HAR2"
}
});
} else {
- readConfig(har);
+ readConfig(parsed);
}
} catch (e) {
if (paste.startsWith("https://gist.github.com/")) {
@@ -229,6 +238,7 @@
Compare
+
diff --git a/public/js/compare/generate.js b/public/js/compare/generate.js
index f1ddd46..5be3b19 100644
--- a/public/js/compare/generate.js
+++ b/public/js/compare/generate.js
@@ -1,4 +1,4 @@
-/* global getLastTiming, removeAndHide, createUpload, getAllDomains, hideUpload, objectPropertiesToArray, registerTemplateHelpers, parseTemplate, getTotalDiff, generateVisualProgress, formatDate, getUniqueRequests, getFilmstrip, compareWaterfall */
+/* global getLastTiming, removeAndHide, createUpload, getAllDomains, hideUpload, objectPropertiesToArray, registerTemplateHelpers, parseTemplate, getTotalDiff, generateVisualProgress, formatDate, getUniqueRequests, getFilmstrip, compareWaterfall, renderShareControls */
/* exported showUpload, formatDate, generate, toggleRow, regenerate, formatTime, showLoading*/
/**
@@ -11,17 +11,28 @@ function regenerate(switchHar) {
const e2 = document.getElementById('run2Option');
const runIndex = e ? e.options[e.selectedIndex].value : 0;
const runIndex2 = e2 ? e2.options[e2.selectedIndex].value : 0;
+ const prev = window.har || {};
+ // Carry per-HAR metadata (url) and top-level config (title,
+ // firstParty, stripVersion, comments) across a Switch / run change.
+ // Without this, the share UI would flip from "Copy share link" to
+ // "Download bundle" the moment the user toggled anything.
generate({
har1: {
- har: switchHar ? window.har.har2.har : window.har.har1.har,
+ har: switchHar ? prev.har2.har : prev.har1.har,
run: switchHar ? runIndex2 : runIndex,
- label: switchHar ? window.har.har2.label : window.har.har1.label
+ label: switchHar ? prev.har2.label : prev.har1.label,
+ url: switchHar ? prev.har2.url : prev.har1.url
},
har2: {
- har: switchHar ? window.har.har1.har : window.har.har2.har,
+ har: switchHar ? prev.har1.har : prev.har2.har,
run: switchHar ? runIndex : runIndex2,
- label: switchHar ? window.har.har1.label : window.har.har2.label
- }
+ label: switchHar ? prev.har1.label : prev.har2.label,
+ url: switchHar ? prev.har1.url : prev.har2.url
+ },
+ title: prev.title,
+ firstParty: prev.firstParty,
+ stripVersion: prev.stripVersion,
+ comments: prev.comments
});
}
@@ -293,4 +304,11 @@ function generate(config) {
createUpload('har1upload');
createUpload('har2upload');
+
+ // Render/refresh the share affordance now that window.har is set.
+ // Guarded so a missing share.js (e.g. embedded usage) is a no-op
+ // rather than a hard error.
+ if (typeof renderShareControls === 'function') {
+ renderShareControls();
+ }
}
diff --git a/public/js/compare/har.js b/public/js/compare/har.js
index b44d175..493172c 100644
--- a/public/js/compare/har.js
+++ b/public/js/compare/har.js
@@ -107,21 +107,21 @@ function getUniqueRequests(har1, run1, har2, run2, options) {
const urls2 = getURLs(har2, run2, options.stripVersion);
const all = [];
const minDiffInBytes = 1000;
+ // Use `in` for presence checks because a URL whose transfer size is
+ // unknown is stored as 0 — truthy checks would mis-classify those as
+ // removed/added.
for (let url of Object.keys(urls1)) {
- if (
- (urls2[url] && urls2[url] === urls1[url]) ||
- (urls2[url] &&
- urls2[url] - urls1[url] < minDiffInBytes &&
- urls2[url] - urls1[url] > -minDiffInBytes)
- ) {
- // TODO no diff, do nada
- } else if (urls2[url]) {
- // There's a diff in size
+ if (url in urls2) {
+ const delta = urls2[url] - urls1[url];
+ if (delta < minDiffInBytes && delta > -minDiffInBytes) {
+ // no meaningful diff, skip
+ continue;
+ }
all.push({
url: url,
har1: urls1[url],
har2: urls2[url],
- diff: urls2[url] - urls1[url]
+ diff: delta
});
} else {
all.push({
@@ -132,7 +132,7 @@ function getUniqueRequests(har1, run1, har2, run2, options) {
}
}
for (let url of Object.keys(urls2)) {
- if (urls2[url] && !urls1[url]) {
+ if (!(url in urls1)) {
all.push({
url: url,
diff: urls2[url],
@@ -162,7 +162,30 @@ function getURLs(har, run, stripVersion) {
if (stripVersion) {
url = url.replace(/version=[A-Za-z0-9]+/i, '');
}
- urls[url] = entry.response.bodySize;
+ urls[url] = bestTransferSize(entry.response);
}
return urls;
}
+
+// Best-effort "bytes over the wire" for a HAR response. The request
+// diff cares about network cost, not decoded payload size, so prefer
+// _transferSize (added by Chrome devtools / sitespeed.io / WPT) which
+// is exactly that. HAR-spec bodySize is the encoded body length when
+// known and -1 when not — usable as a fallback. content.size is the
+// decoded body and a last resort. Anything missing or <= 0 (spec uses
+// -1 for "unknown") becomes 0 so totals don't go negative.
+function bestTransferSize(response) {
+ if (!response) return 0;
+ if (typeof response._transferSize === 'number' && response._transferSize > 0) {
+ return response._transferSize;
+ }
+ if (typeof response.bodySize === 'number' && response.bodySize > 0) {
+ return response.bodySize;
+ }
+ if (response.content &&
+ typeof response.content.size === 'number' &&
+ response.content.size > 0) {
+ return response.content.size;
+ }
+ return 0;
+}
diff --git a/public/js/compare/load.js b/public/js/compare/load.js
index e29c1a4..ef85a83 100644
--- a/public/js/compare/load.js
+++ b/public/js/compare/load.js
@@ -1,5 +1,5 @@
/* global isFileGzipped, isFileZipped, gzipArrayBufferToJSON, readGZipFile, errorMessage, generate, showUpload */
-/* exported readHar, fetchHar, getHarURL, loadFilesFromURL, loadFilesFromGist, loadFilesFromConfig*/
+/* exported readHar, fetchHar, getHarURL, loadFilesFromURL, loadFilesFromGist, loadFilesFromConfig, loadFromBundle, isBundle*/
/**
* Help functions to read HAR/JSON files from file
@@ -166,6 +166,43 @@ function loadFilesFromGist(id) {
});
}
+// Compare bundle: a single JSON file with both HARs embedded, produced
+// by the "Download bundle" share action. We detect it by the explicit
+// `compareBundle` flag so a future format bump doesn't get mistaken
+// for the current shape.
+function isBundle(obj) {
+ return !!(obj && obj.compareBundle === true && obj.har1 && obj.har2);
+}
+
+function loadFromBundle(bundle) {
+ if (!bundle.har1 || !bundle.har1.har || !bundle.har1.har.log) {
+ errorMessage('Bundle is missing har1.');
+ showUpload();
+ return;
+ }
+ if (!bundle.har2 || !bundle.har2.har || !bundle.har2.har.log) {
+ errorMessage('Bundle is missing har2.');
+ showUpload();
+ return;
+ }
+ generate({
+ har1: {
+ har: bundle.har1.har,
+ run: bundle.har1.run || 0,
+ label: bundle.har1.label || 'HAR1'
+ },
+ har2: {
+ har: bundle.har2.har,
+ run: bundle.har2.run || 0,
+ label: bundle.har2.label || 'HAR2'
+ },
+ title: bundle.title || 'Compare HAR files',
+ firstParty: bundle.firstParty || undefined,
+ stripVersion: !!bundle.stripVersion,
+ comments: bundle.comments || undefined
+ });
+}
+
function loadHARsFromConfig(config) {
// The runs/pages are zero based since it's an array but
// in configuration we wanna use 1 based since it makes more sense
@@ -201,16 +238,23 @@ function loadHARsFromConfig(config) {
const har2Run = sameHar
? (har1.log.pages.length > 1 ? 1 : 0)
: (reworkedConfig2.run || config.har2.run || 0);
+ // Preserve the *user-supplied* URLs (not the rewritten sitespeed
+ // .har.gz paths) so a share link sends the recipient to the same
+ // landing context the original viewer used. The single-HAR case
+ // (`?compare=1&har1=…`) intentionally leaves har2.url unset — the
+ // share UI then offers a bundle download instead.
return generate({
har1: {
har: har1,
run: har1Run,
- label: config.har1.label || 'HAR1'
+ label: config.har1.label || 'HAR1',
+ url: config.har1.url
},
har2: {
har: har2,
run: har2Run,
- label: config.har2.label || 'HAR2'
+ label: config.har2.label || 'HAR2',
+ url: sameHar ? undefined : config.har2.url
},
comments: config.comments || undefined,
title: config.title || 'Compare HAR files',
diff --git a/public/js/compare/share.js b/public/js/compare/share.js
new file mode 100644
index 0000000..743c810
--- /dev/null
+++ b/public/js/compare/share.js
@@ -0,0 +1,132 @@
+/* exported renderShareControls */
+
+// The share affordance for the result page. Two modes:
+//
+// * Both HARs were fetched from URLs → "Copy share link". The
+// recipient opens the URL and lands on the same comparison.
+//
+// * One or both HARs are local (drop / paste / single-HAR upload) →
+// "Download bundle". The recipient can't reach a local file, so we
+// ship a single JSON with both HARs embedded; drop / paste of that
+// bundle on the start page re-renders the same comparison.
+//
+// renderShareControls() reads window.har (populated by generate.js) so
+// it always reflects the current comparison after a swap or run change.
+
+function renderShareControls() {
+ const container = document.getElementById('shareControls');
+ if (!container) return;
+ container.innerHTML = '';
+ const cfg = window.har;
+ if (!cfg || !cfg.har1 || !cfg.har2) return;
+
+ const u1 = cfg.har1.url;
+ const u2 = cfg.har2.url;
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'submit submit-smaller share-button';
+
+ if (u1 && u2) {
+ btn.textContent = 'Copy share link';
+ btn.title = 'Copy a link that opens this comparison';
+ btn.addEventListener('click', function () {
+ copyShareLink(btn, u1, u2, cfg.stripVersion);
+ });
+ } else {
+ btn.textContent = 'Download bundle';
+ btn.title = 'Save both HARs into a single file you can re-open or share';
+ btn.addEventListener('click', function () {
+ downloadBundle(btn, cfg);
+ });
+ }
+ container.appendChild(btn);
+}
+
+function copyShareLink(btn, u1, u2, stripVersion) {
+ const url = new URL(window.location.origin + window.location.pathname);
+ url.searchParams.set('har1', u1);
+ url.searchParams.set('har2', u2);
+ if (stripVersion) url.searchParams.set('stripVersion', '1');
+ const link = url.toString();
+ copyToClipboard(link).then(function (ok) {
+ flashShareFeedback(btn, ok ? 'Link copied' : 'Copy failed');
+ });
+}
+
+function copyToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ return navigator.clipboard.writeText(text)
+ .then(function () { return true; })
+ .catch(function () { return fallbackCopy(text); });
+ }
+ return Promise.resolve(fallbackCopy(text));
+}
+
+// Old execCommand path — kept for browsers / contexts where the async
+// Clipboard API is unavailable (insecure origins, some embeds).
+function fallbackCopy(text) {
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'absolute';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(ta);
+ return ok;
+ } catch (e) {
+ return false;
+ }
+}
+
+function flashShareFeedback(btn, text) {
+ const previous = btn.textContent;
+ btn.textContent = text;
+ btn.disabled = true;
+ setTimeout(function () {
+ btn.textContent = previous;
+ btn.disabled = false;
+ }, 1500);
+}
+
+function downloadBundle(btn, cfg) {
+ const bundle = {
+ compareBundle: true,
+ version: 1,
+ title: cfg.title || undefined,
+ firstParty: cfg.firstParty || undefined,
+ stripVersion: !!cfg.stripVersion,
+ comments: cfg.comments || undefined,
+ har1: {
+ har: cfg.har1.har,
+ run: cfg.har1.run,
+ label: cfg.har1.label
+ },
+ har2: {
+ har: cfg.har2.har,
+ run: cfg.har2.run,
+ label: cfg.har2.label
+ }
+ };
+ const blob = new Blob([JSON.stringify(bundle)], { type: 'application/json' });
+ const objectUrl = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = objectUrl;
+ a.download = bundleFilename(cfg);
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 0);
+ flashShareFeedback(btn, 'Downloaded');
+}
+
+function bundleFilename(cfg) {
+ function safe(s) {
+ return (s || '').toString().replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 40);
+ }
+ const a = safe(cfg.har1 && cfg.har1.label) || 'har1';
+ const b = safe(cfg.har2 && cfg.har2.label) || 'har2';
+ return 'compare-' + a + '-vs-' + b + '.json';
+}
diff --git a/public/js/compare/templates.js b/public/js/compare/templates.js
index fe92c80..7cbf715 100644
--- a/public/js/compare/templates.js
+++ b/public/js/compare/templates.js
@@ -644,7 +644,7 @@ function domainsTemplate(d) {
//
function requestDiffTemplate(d) {
if (!d.requestDiff) return '';
- let html = '';
+ let html = '';
html += '';
if (!d.requestDiff.length) {
diff --git a/public/js/compare/upload.js b/public/js/compare/upload.js
index f975e06..5767b51 100644
--- a/public/js/compare/upload.js
+++ b/public/js/compare/upload.js
@@ -1,4 +1,4 @@
-/* global readHar, errorMessage, generate, showUpload, showLoading, removeAndHide */
+/* global readHar, errorMessage, generate, showUpload, showLoading, removeAndHide, isBundle, loadFromBundle */
/* exported createMainDropZone, createUpload */
// Native drag-and-drop + click-to-pick replacement for the old
@@ -58,6 +58,13 @@ function createMainDropZone(id) {
readHar(files[0])
.then(har => {
removeAndHide();
+ // Bundles round-trip through the same file picker as HARs,
+ // so detect by the explicit flag and route to the bundle
+ // loader (which fills in both HARs from the embedded data).
+ if (isBundle(har)) {
+ loadFromBundle(har);
+ return;
+ }
generate({
har1: { har: har, run: 0, label: 'HAR1' },
har2: {
@@ -131,17 +138,27 @@ function createUpload(id) {
const optionName = changeHar1 ? 'run2Option' : 'run1Option';
const e2 = document.getElementById(optionName);
const run = e2 ? e2.options[e2.selectedIndex].value : 0;
- const har1 = changeHar1 ? har : window.har.har1.har;
- const har2 = changeHar1 ? window.har.har2.har : har;
+ const prev = window.har || {};
+ const har1 = changeHar1 ? har : prev.har1.har;
+ const har2 = changeHar1 ? prev.har2.har : har;
const run1 = changeHar1 ? 0 : run;
const run2 = changeHar1 ? run : 0;
- const label1 = changeHar1 ? 'HAR1' : window.har.har1.label;
- const label2 = changeHar1 ? window.har.har2.label : 'HAR2';
+ const label1 = changeHar1 ? 'HAR1' : prev.har1.label;
+ const label2 = changeHar1 ? prev.har2.label : 'HAR2';
+ // The freshly-uploaded HAR has no source URL; preserve the
+ // other HAR's URL so the share UI keeps "Copy share link"
+ // available if both are still URL-backed (else it correctly
+ // falls through to "Download bundle").
+ const url1 = changeHar1 ? undefined : prev.har1 && prev.har1.url;
+ const url2 = changeHar1 ? (prev.har2 && prev.har2.url) : undefined;
generate({
- har1: { har: har1, run: run1, label: label1 },
- har2: { har: har2, run: run2, label: label2 },
- stripVersion: stripVersion
+ har1: { har: har1, run: run1, label: label1, url: url1 },
+ har2: { har: har2, run: run2, label: label2, url: url2 },
+ stripVersion: stripVersion,
+ title: prev.title,
+ firstParty: prev.firstParty,
+ comments: prev.comments
});
})
.catch(e => {
diff --git a/src/css/result-header.css b/src/css/result-header.css
index a6915a7..0796dd0 100644
--- a/src/css/result-header.css
+++ b/src/css/result-header.css
@@ -4,6 +4,17 @@
* Section-jump nav that sits inside the sticky `.header-result`.
*/
+.share-controls {
+ display: flex;
+ justify-content: center;
+ margin: 8px 0 0 0;
+ min-height: 1.5em;
+}
+
+.share-controls .share-button {
+ font-size: 0.875rem;
+}
+
.header-links {
display: flex;
flex-wrap: wrap;