summaryrefslogtreecommitdiffstats
path: root/devtools/client/netmonitor/src/har/har-builder.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/client/netmonitor/src/har/har-builder.js656
1 files changed, 656 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/har/har-builder.js b/devtools/client/netmonitor/src/har/har-builder.js
new file mode 100644
index 0000000000..9361df7f03
--- /dev/null
+++ b/devtools/client/netmonitor/src/har/har-builder.js
@@ -0,0 +1,656 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const appInfo = Services.appinfo;
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const { CurlUtils } = require("resource://devtools/client/shared/curl.js");
+const {
+ getFormDataSections,
+ getUrlQuery,
+ parseQueryString,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+const {
+ buildHarLog,
+} = require("resource://devtools/client/netmonitor/src/har/har-builder-utils.js");
+const L10N = new LocalizationHelper("devtools/client/locales/har.properties");
+const {
+ TIMING_KEYS,
+} = require("resource://devtools/client/netmonitor/src/constants.js");
+
+/**
+ * This object is responsible for building HAR file. See HAR spec:
+ * https://dvcs.w3.org/hg/webperf/raw-file/tip/specs/HAR/Overview.html
+ * http://www.softwareishard.com/blog/har-12-spec/
+ *
+ * @param {Object} options
+ * configuration object
+ * @param {Boolean} options.connector
+ * Set to true to include HTTP response bodies in the result data
+ * structure.
+ * @param {String} options.id
+ * ID of the exported page.
+ * @param {Boolean} options.includeResponseBodies
+ * Set to true to include HTTP response bodies in the result data
+ * structure.
+ * @param {Array} options.items
+ * List of network events to be exported.
+ * @param {Boolean} options.supportsMultiplePages
+ * Set to true to create distinct page entries for each navigation.
+ */
+var HarBuilder = function (options) {
+ this._connector = options.connector;
+ this._id = options.id;
+ this._includeResponseBodies = options.includeResponseBodies;
+ this._items = options.items;
+ // Page id counter, only used when options.supportsMultiplePages is true.
+ this._pageId = options.supportsMultiplePages ? 0 : options.id;
+ this._pageMap = [];
+ this._supportsMultiplePages = options.supportsMultiplePages;
+ this._title = this._connector.currentTarget.title;
+};
+
+HarBuilder.prototype = {
+ // Public API
+
+ /**
+ * This is the main method used to build the entire result HAR data.
+ * The process is asynchronous since it can involve additional RDP
+ * communication (e.g. resolving long strings).
+ *
+ * @returns {Promise} A promise that resolves to the HAR object when
+ * the entire build process is done.
+ */
+ async build() {
+ this.promises = [];
+
+ // Build basic structure for data.
+ const harLog = buildHarLog(appInfo);
+
+ // Build pages.
+ this.buildPages(harLog.log);
+
+ // Build entries.
+ for (const request of this._items) {
+ const entry = await this.buildEntry(harLog.log, request);
+ if (entry) {
+ harLog.log.entries.push(entry);
+ }
+ }
+
+ // Some data needs to be fetched from the backend during the
+ // build process, so wait till all is done.
+ await Promise.all(this.promises);
+
+ return harLog;
+ },
+
+ // Helpers
+
+ buildPages(log) {
+ if (this._supportsMultiplePages) {
+ this.buildPagesFromTargetTitles(log);
+ } else if (this._items.length) {
+ const firstRequest = this._items[0];
+ const page = this.buildPage(this._title, firstRequest);
+ log.pages.push(page);
+ this._pageMap[this._id] = page;
+ }
+ },
+
+ buildPagesFromTargetTitles(log) {
+ // Retrieve the additional HAR data collected by the connector.
+ const { initialTargetTitle, navigationRequests, targetTitlesPerURL } =
+ this._connector.getHarData();
+ const firstNavigationRequest = navigationRequests[0];
+ const firstRequest = this._items[0];
+
+ if (
+ !firstNavigationRequest ||
+ firstRequest.resourceId !== firstNavigationRequest.resourceId
+ ) {
+ // If the first request is not a navigation request, it must be related
+ // to the initial page. Create a first page entry for such early requests.
+ const initialPage = this.buildPage(initialTargetTitle, firstRequest);
+ log.pages.push(initialPage);
+ }
+
+ for (const request of navigationRequests) {
+ if (targetTitlesPerURL.has(request.url)) {
+ const title = targetTitlesPerURL.get(request.url);
+ const page = this.buildPage(title, request);
+ log.pages.push(page);
+ } else {
+ console.warn(
+ `Could not find any page corresponding to a navigation to ${request.url}`
+ );
+ }
+ }
+ },
+
+ buildPage(title, networkEvent) {
+ const page = {};
+
+ page.id = "page_" + this._pageId;
+ page.pageTimings = this.buildPageTimings(page, networkEvent);
+ page.startedDateTime = dateToHarString(new Date(networkEvent.startedMs));
+ page.title = title;
+
+ // Increase the pageId, for upcoming calls to buildPage.
+ // If supportsMultiplePages is disabled this method is only called once.
+ this._pageId++;
+
+ return page;
+ },
+
+ getPage(log, entry) {
+ const existingPage = log.pages.findLast(
+ ({ startedDateTime }) => startedDateTime <= entry.startedDateTime
+ );
+
+ if (!existingPage) {
+ throw new Error(
+ "Could not find a page for request: " + entry.request.url
+ );
+ }
+
+ return existingPage;
+ },
+
+ async buildEntry(log, networkEvent) {
+ const entry = {};
+ entry.startedDateTime = dateToHarString(new Date(networkEvent.startedMs));
+
+ let { eventTimings, id } = networkEvent;
+ try {
+ if (!eventTimings && this._connector.requestData) {
+ eventTimings = await this._connector.requestData(id, "eventTimings");
+ }
+
+ entry.request = await this.buildRequest(networkEvent);
+ entry.response = await this.buildResponse(networkEvent);
+ entry.cache = await this.buildCache(networkEvent);
+ } catch (e) {
+ // Ignore any request for which we can't retrieve lazy data
+ // The request has most likely been destroyed on the server side,
+ // either because persist is disabled or the request's target/WindowGlobal/process
+ // has been destroyed.
+ console.warn("HAR builder failed on", networkEvent.url, e, e.stack);
+ return null;
+ }
+ entry.timings = eventTimings ? eventTimings.timings : {};
+
+ // Calculate total time by summing all timings. Note that
+ // `networkEvent.totalTime` can't be used since it doesn't have to
+ // correspond to plain summary of individual timings.
+ // With TCP Fast Open and TLS early data sending data can
+ // start at the same time as connect (we can send data on
+ // TCP syn packet). Also TLS handshake can carry application
+ // data thereby overlapping a sending data period and TLS
+ // handshake period.
+ entry.time = TIMING_KEYS.reduce((sum, type) => {
+ const time = entry.timings[type];
+ return typeof time != "undefined" && time != -1 ? sum + time : sum;
+ }, 0);
+
+ // Security state isn't part of HAR spec, and so create
+ // custom field that needs to use '_' prefix.
+ entry._securityState = networkEvent.securityState;
+
+ if (networkEvent.remoteAddress) {
+ entry.serverIPAddress = networkEvent.remoteAddress;
+ }
+
+ if (networkEvent.remotePort) {
+ entry.connection = networkEvent.remotePort + "";
+ }
+
+ const page = this.getPage(log, entry);
+ entry.pageref = page.id;
+
+ return entry;
+ },
+
+ buildPageTimings(page, networkEvent) {
+ // Event timing info isn't available
+ const timings = {
+ onContentLoad: -1,
+ onLoad: -1,
+ };
+
+ // TODO: This method currently ignores the networkEvent and always retrieves
+ // the same timing markers for all pages. Seee Bug 1833806.
+ if (this._connector.getTimingMarker) {
+ timings.onContentLoad = this._connector.getTimingMarker(
+ "firstDocumentDOMContentLoadedTimestamp"
+ );
+ timings.onLoad = this._connector.getTimingMarker(
+ "firstDocumentLoadTimestamp"
+ );
+ }
+
+ return timings;
+ },
+
+ async buildRequest(networkEvent) {
+ // When using HarAutomation, HarCollector will automatically fetch requestHeaders
+ // and requestCookies, but when we use it from netmonitor, FirefoxDataProvider
+ // should fetch it itself lazily, via requestData.
+
+ let { id, requestHeaders } = networkEvent;
+ if (!requestHeaders && this._connector.requestData) {
+ requestHeaders = await this._connector.requestData(id, "requestHeaders");
+ }
+
+ let { requestCookies } = networkEvent;
+ if (!requestCookies && this._connector.requestData) {
+ requestCookies = await this._connector.requestData(id, "requestCookies");
+ }
+
+ const request = {
+ bodySize: 0,
+ };
+ request.method = networkEvent.method;
+ request.url = networkEvent.url;
+ request.httpVersion = networkEvent.httpVersion || "";
+ request.headers = this.buildHeaders(requestHeaders);
+ request.headers = this.appendHeadersPostData(request.headers, networkEvent);
+ request.cookies = this.buildCookies(requestCookies);
+ request.queryString = parseQueryString(getUrlQuery(networkEvent.url)) || [];
+ request.headersSize = requestHeaders.headersSize;
+ request.postData = await this.buildPostData(networkEvent);
+
+ if (request.postData?.text) {
+ request.bodySize = request.postData.text.length;
+ }
+
+ return request;
+ },
+
+ /**
+ * Fetch all header values from the backend (if necessary) and
+ * build the result HAR structure.
+ *
+ * @param {Object} input Request or response header object.
+ */
+ buildHeaders(input) {
+ if (!input) {
+ return [];
+ }
+
+ return this.buildNameValuePairs(input.headers);
+ },
+
+ appendHeadersPostData(input = [], networkEvent) {
+ if (!networkEvent.requestPostData) {
+ return input;
+ }
+
+ this.fetchData(networkEvent.requestPostData.postData.text).then(value => {
+ const multipartHeaders = CurlUtils.getHeadersFromMultipartText(value);
+ for (const header of multipartHeaders) {
+ input.push(header);
+ }
+ });
+
+ return input;
+ },
+
+ buildCookies(input) {
+ if (!input) {
+ return [];
+ }
+
+ return this.buildNameValuePairs(input.cookies || input);
+ },
+
+ buildNameValuePairs(entries) {
+ const result = [];
+
+ // HAR requires headers array to be presented, so always
+ // return at least an empty array.
+ if (!entries) {
+ return result;
+ }
+
+ // Make sure header values are fully fetched from the server.
+ entries.forEach(entry => {
+ this.fetchData(entry.value).then(value => {
+ result.push({
+ name: entry.name,
+ value,
+ });
+ });
+ });
+
+ return result;
+ },
+
+ async buildPostData(networkEvent) {
+ // When using HarAutomation, HarCollector will automatically fetch requestPostData
+ // and requestHeaders, but when we use it from netmonitor, FirefoxDataProvider
+ // should fetch it itself lazily, via requestData.
+ let { id, requestHeaders, requestPostData } = networkEvent;
+ let requestHeadersFromUploadStream;
+
+ if (!requestPostData && this._connector.requestData) {
+ requestPostData = await this._connector.requestData(
+ id,
+ "requestPostData"
+ );
+ requestHeadersFromUploadStream = requestPostData.uploadHeaders;
+ }
+
+ if (!requestPostData.postData.text) {
+ return undefined;
+ }
+
+ if (!requestHeaders && this._connector.requestData) {
+ requestHeaders = await this._connector.requestData(id, "requestHeaders");
+ }
+
+ const postData = {
+ mimeType: findValue(requestHeaders.headers, "content-type"),
+ params: [],
+ text: requestPostData.postData.text,
+ };
+
+ if (requestPostData.postDataDiscarded) {
+ postData.comment = L10N.getStr("har.requestBodyNotIncluded");
+ return postData;
+ }
+
+ // If we are dealing with URL encoded body, parse parameters.
+ if (
+ CurlUtils.isUrlEncodedRequest({
+ headers: requestHeaders.headers,
+ postDataText: postData.text,
+ })
+ ) {
+ postData.mimeType = "application/x-www-form-urlencoded";
+ // Extract form parameters and produce nice HAR array.
+ const formDataSections = await getFormDataSections(
+ requestHeaders,
+ requestHeadersFromUploadStream,
+ requestPostData,
+ this._connector.getLongString
+ );
+
+ formDataSections.forEach(section => {
+ const paramsArray = parseQueryString(section);
+ if (paramsArray) {
+ postData.params = [...postData.params, ...paramsArray];
+ }
+ });
+ }
+
+ return postData;
+ },
+
+ async buildResponse(networkEvent) {
+ // When using HarAutomation, HarCollector will automatically fetch responseHeaders
+ // and responseCookies, but when we use it from netmonitor, FirefoxDataProvider
+ // should fetch it itself lazily, via requestData.
+
+ let { id, responseCookies, responseHeaders } = networkEvent;
+ if (!responseHeaders && this._connector.requestData) {
+ responseHeaders = await this._connector.requestData(
+ id,
+ "responseHeaders"
+ );
+ }
+
+ if (!responseCookies && this._connector.requestData) {
+ responseCookies = await this._connector.requestData(
+ id,
+ "responseCookies"
+ );
+ }
+
+ const response = {
+ status: 0,
+ };
+
+ // Arbitrary value if it's aborted to make sure status has a number
+ if (networkEvent.status) {
+ response.status = parseInt(networkEvent.status, 10);
+ }
+ response.statusText = networkEvent.statusText || "";
+ response.httpVersion = networkEvent.httpVersion || "";
+
+ response.headers = this.buildHeaders(responseHeaders);
+ response.cookies = this.buildCookies(responseCookies);
+ response.content = await this.buildContent(networkEvent);
+
+ const headers = responseHeaders ? responseHeaders.headers : null;
+ const headersSize = responseHeaders ? responseHeaders.headersSize : -1;
+
+ response.redirectURL = findValue(headers, "Location");
+ response.headersSize = headersSize;
+
+ // 'bodySize' is size of the received response body in bytes.
+ // Set to zero in case of responses coming from the cache (304).
+ // Set to -1 if the info is not available.
+ if (typeof networkEvent.transferredSize != "number") {
+ response.bodySize = response.status == 304 ? 0 : -1;
+ } else {
+ response.bodySize = networkEvent.transferredSize;
+ }
+
+ return response;
+ },
+
+ async buildContent(networkEvent) {
+ const content = {
+ mimeType: networkEvent.mimeType,
+ size: -1,
+ };
+
+ // When using HarAutomation, HarCollector will automatically fetch responseContent,
+ // but when we use it from netmonitor, FirefoxDataProvider should fetch it itself
+ // lazily, via requestData.
+ let { responseContent } = networkEvent;
+ if (!responseContent && this._connector.requestData) {
+ responseContent = await this._connector.requestData(
+ networkEvent.id,
+ "responseContent"
+ );
+ }
+ if (responseContent?.content) {
+ content.size = responseContent.content.size;
+ content.encoding = responseContent.content.encoding;
+ }
+
+ const includeBodies = this._includeResponseBodies;
+ const contentDiscarded = responseContent
+ ? responseContent.contentDiscarded
+ : false;
+
+ // The comment is appended only if the response content
+ // is explicitly discarded.
+ if (!includeBodies || contentDiscarded) {
+ content.comment = L10N.getStr("har.responseBodyNotIncluded");
+ return content;
+ }
+
+ if (responseContent) {
+ const { text } = responseContent.content;
+ this.fetchData(text).then(value => {
+ content.text = value;
+ });
+ }
+
+ return content;
+ },
+
+ async buildCache(networkEvent) {
+ const cache = {};
+
+ // if resource has changed, return early
+ if (networkEvent.status != "304") {
+ return cache;
+ }
+
+ if (networkEvent.responseCacheAvailable && this._connector.requestData) {
+ const responseCache = await this._connector.requestData(
+ networkEvent.id,
+ "responseCache"
+ );
+ if (responseCache.cache) {
+ cache.afterRequest = this.buildCacheEntry(responseCache.cache);
+ }
+ } else if (networkEvent.responseCache?.cache) {
+ cache.afterRequest = this.buildCacheEntry(
+ networkEvent.responseCache.cache
+ );
+ } else {
+ cache.afterRequest = null;
+ }
+
+ return cache;
+ },
+
+ buildCacheEntry(cacheEntry) {
+ const cache = {};
+
+ if (typeof cacheEntry !== "undefined") {
+ cache.expires = findKeys(cacheEntry, ["expirationTime", "expires"]);
+ cache.lastFetched = findKeys(cacheEntry, ["lastFetched"]);
+
+ // TODO: eTag support
+ // Har format expects cache entries to provide information about eTag,
+ // however this is not currently exposed on nsICacheEntry.
+ // This should be stored under cache.eTag. See Bug 1799844.
+
+ cache.fetchCount = findKeys(cacheEntry, ["fetchCount"]);
+
+ // har-importer.js, along with other files, use buildCacheEntry
+ // initial value comes from properties without underscores.
+ // this checks for both in appropriate order.
+ cache._dataSize = findKeys(cacheEntry, ["storageDataSize", "_dataSize"]);
+ cache._lastModified = findKeys(cacheEntry, [
+ "lastModified",
+ "_lastModified",
+ ]);
+ cache._device = findKeys(cacheEntry, ["deviceID", "_device"]);
+ }
+
+ return cache;
+ },
+
+ // RDP Helpers
+
+ fetchData(string) {
+ const promise = this._connector.getLongString(string).then(value => {
+ return value;
+ });
+
+ // Building HAR is asynchronous and not done till all
+ // collected promises are resolved.
+ this.promises.push(promise);
+
+ return promise;
+ },
+};
+
+// Helpers
+
+/**
+ * Find specified keys within an object.
+ * Searches object for keys passed in, returns first value returned,
+ * or an empty string.
+ *
+ * @param obj (object)
+ * @param keys (array)
+ * @returns {string}
+ */
+function findKeys(obj, keys) {
+ if (!keys) {
+ return "";
+ }
+
+ const keyFound = keys.filter(key => obj[key]);
+ if (!keys.length) {
+ return "";
+ }
+
+ const value = obj[keyFound[0]];
+ if (typeof value === "undefined" || typeof value === "object") {
+ return "";
+ }
+
+ return String(value);
+}
+
+/**
+ * Find specified value within an array of name-value pairs
+ * (used for headers, cookies and cache entries)
+ */
+function findValue(arr, name) {
+ if (!arr) {
+ return "";
+ }
+
+ name = name.toLowerCase();
+ const result = arr.find(entry => entry.name.toLowerCase() == name);
+ return result ? result.value : "";
+}
+
+/**
+ * Generate HAR representation of a date.
+ * (YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.45+01:00)
+ * See also HAR Schema: http://janodvarko.cz/har/viewer/
+ *
+ * Note: it would be great if we could utilize Date.toJSON(), but
+ * it doesn't return proper time zone offset.
+ *
+ * An example:
+ * This helper returns: 2015-05-29T16:10:30.424+02:00
+ * Date.toJSON() returns: 2015-05-29T14:10:30.424Z
+ *
+ * @param date {Date} The date object we want to convert.
+ */
+function dateToHarString(date) {
+ function f(n, c) {
+ if (!c) {
+ c = 2;
+ }
+ let s = String(n);
+ while (s.length < c) {
+ s = "0" + s;
+ }
+ return s;
+ }
+
+ const result =
+ date.getFullYear() +
+ "-" +
+ f(date.getMonth() + 1) +
+ "-" +
+ f(date.getDate()) +
+ "T" +
+ f(date.getHours()) +
+ ":" +
+ f(date.getMinutes()) +
+ ":" +
+ f(date.getSeconds()) +
+ "." +
+ f(date.getMilliseconds(), 3);
+
+ let offset = date.getTimezoneOffset();
+ const positive = offset > 0;
+
+ // Convert to positive number before using Math.floor (see issue 5512)
+ offset = Math.abs(offset);
+ const offsetHours = Math.floor(offset / 60);
+ const offsetMinutes = Math.floor(offset % 60);
+ const prettyOffset =
+ (positive > 0 ? "-" : "+") + f(offsetHours) + ":" + f(offsetMinutes);
+
+ return result + prettyOffset;
+}
+
+// Exports from this module
+exports.HarBuilder = HarBuilder;