diff options
Diffstat (limited to 'devtools/client/netmonitor/har/har-builder.js')
-rw-r--r-- | devtools/client/netmonitor/har/har-builder.js | 491 |
1 files changed, 491 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/har/har-builder.js b/devtools/client/netmonitor/har/har-builder.js new file mode 100644 index 000000000..f28e43016 --- /dev/null +++ b/devtools/client/netmonitor/har/har-builder.js @@ -0,0 +1,491 @@ +/* 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 { defer, all } = require("promise"); +const { LocalizationHelper } = require("devtools/shared/l10n"); +const Services = require("Services"); +const appInfo = Services.appinfo; +const { CurlUtils } = require("devtools/client/shared/curl"); +const { getFormDataSections } = require("devtools/client/netmonitor/request-utils"); + +loader.lazyRequireGetter(this, "NetworkHelper", "devtools/shared/webconsole/network-helper"); + +loader.lazyGetter(this, "L10N", () => { + return new LocalizationHelper("devtools/client/locales/har.properties"); +}); + +const HAR_VERSION = "1.1"; + +/** + * 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 + * + * The following options are supported: + * + * - items {Array}: List of Network requests to be exported. It is possible + * to use directly: NetMonitorView.RequestsMenu.items + * + * - id {String}: ID of the exported page. + * + * - title {String}: Title of the exported page. + * + * - includeResponseBodies {Boolean}: Set to true to include HTTP response + * bodies in the result data structure. + */ +var HarBuilder = function (options) { + this._options = options; + this._pageMap = []; +}; + +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. + */ + build: function () { + this.promises = []; + + // Build basic structure for data. + let log = this.buildLog(); + + // Build entries. + let items = this._options.items; + for (let i = 0; i < items.length; i++) { + let file = items[i].attachment; + log.entries.push(this.buildEntry(log, file)); + } + + // Some data needs to be fetched from the backend during the + // build process, so wait till all is done. + let { resolve, promise } = defer(); + all(this.promises).then(results => resolve({ log: log })); + + return promise; + }, + + // Helpers + + buildLog: function () { + return { + version: HAR_VERSION, + creator: { + name: appInfo.name, + version: appInfo.version + }, + browser: { + name: appInfo.name, + version: appInfo.version + }, + pages: [], + entries: [], + }; + }, + + buildPage: function (file) { + let page = {}; + + // Page start time is set when the first request is processed + // (see buildEntry) + page.startedDateTime = 0; + page.id = "page_" + this._options.id; + page.title = this._options.title; + + return page; + }, + + getPage: function (log, file) { + let id = this._options.id; + let page = this._pageMap[id]; + if (page) { + return page; + } + + this._pageMap[id] = page = this.buildPage(file); + log.pages.push(page); + + return page; + }, + + buildEntry: function (log, file) { + let page = this.getPage(log, file); + + let entry = {}; + entry.pageref = page.id; + entry.startedDateTime = dateToJSON(new Date(file.startedMillis)); + entry.time = file.endedMillis - file.startedMillis; + + entry.request = this.buildRequest(file); + entry.response = this.buildResponse(file); + entry.cache = this.buildCache(file); + entry.timings = file.eventTimings ? file.eventTimings.timings : {}; + + if (file.remoteAddress) { + entry.serverIPAddress = file.remoteAddress; + } + + if (file.remotePort) { + entry.connection = file.remotePort + ""; + } + + // Compute page load start time according to the first request start time. + if (!page.startedDateTime) { + page.startedDateTime = entry.startedDateTime; + page.pageTimings = this.buildPageTimings(page, file); + } + + return entry; + }, + + buildPageTimings: function (page, file) { + // Event timing info isn't available + let timings = { + onContentLoad: -1, + onLoad: -1 + }; + + return timings; + }, + + buildRequest: function (file) { + let request = { + bodySize: 0 + }; + + request.method = file.method; + request.url = file.url; + request.httpVersion = file.httpVersion || ""; + + request.headers = this.buildHeaders(file.requestHeaders); + request.headers = this.appendHeadersPostData(request.headers, file); + request.cookies = this.buildCookies(file.requestCookies); + + request.queryString = NetworkHelper.parseQueryString( + NetworkHelper.nsIURL(file.url).query) || []; + + request.postData = this.buildPostData(file); + + request.headersSize = file.requestHeaders.headersSize; + + // Set request body size, but make sure the body is fetched + // from the backend. + if (file.requestPostData) { + this.fetchData(file.requestPostData.postData.text).then(value => { + request.bodySize = value.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: function (input) { + if (!input) { + return []; + } + + return this.buildNameValuePairs(input.headers); + }, + + appendHeadersPostData: function (input = [], file) { + if (!file.requestPostData) { + return input; + } + + this.fetchData(file.requestPostData.postData.text).then(value => { + let multipartHeaders = CurlUtils.getHeadersFromMultipartText(value); + for (let header of multipartHeaders) { + input.push(header); + } + }); + + return input; + }, + + buildCookies: function (input) { + if (!input) { + return []; + } + + return this.buildNameValuePairs(input.cookies); + }, + + buildNameValuePairs: function (entries) { + let 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: value + }); + }); + }); + + return result; + }, + + buildPostData: function (file) { + let postData = { + mimeType: findValue(file.requestHeaders.headers, "content-type"), + params: [], + text: "" + }; + + if (!file.requestPostData) { + return postData; + } + + if (file.requestPostData.postDataDiscarded) { + postData.comment = L10N.getStr("har.requestBodyNotIncluded"); + return postData; + } + + // Load request body from the backend. + this.fetchData(file.requestPostData.postData.text).then(postDataText => { + postData.text = postDataText; + + // If we are dealing with URL encoded body, parse parameters. + let { headers } = file.requestHeaders; + if (CurlUtils.isUrlEncodedRequest({ headers, postDataText })) { + postData.mimeType = "application/x-www-form-urlencoded"; + + // Extract form parameters and produce nice HAR array. + getFormDataSections( + file.requestHeaders, + file.requestHeadersFromUploadStream, + file.requestPostData, + this._options.getString + ).then(formDataSections => { + formDataSections.forEach(section => { + let paramsArray = NetworkHelper.parseQueryString(section); + if (paramsArray) { + postData.params = [...postData.params, ...paramsArray]; + } + }); + }); + } + }); + + return postData; + }, + + buildResponse: function (file) { + let response = { + status: 0 + }; + + // Arbitrary value if it's aborted to make sure status has a number + if (file.status) { + response.status = parseInt(file.status, 10); + } + + let responseHeaders = file.responseHeaders; + + response.statusText = file.statusText || ""; + response.httpVersion = file.httpVersion || ""; + + response.headers = this.buildHeaders(responseHeaders); + response.cookies = this.buildCookies(file.responseCookies); + response.content = this.buildContent(file); + + let headers = responseHeaders ? responseHeaders.headers : null; + let 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 file.transferredSize != "number") { + response.bodySize = (response.status == 304) ? 0 : -1; + } else { + response.bodySize = file.transferredSize; + } + + return response; + }, + + buildContent: function (file) { + let content = { + mimeType: file.mimeType, + size: -1 + }; + + let responseContent = file.responseContent; + if (responseContent && responseContent.content) { + content.size = responseContent.content.size; + content.encoding = responseContent.content.encoding; + } + + let includeBodies = this._options.includeResponseBodies; + let 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) { + let text = responseContent.content.text; + this.fetchData(text).then(value => { + content.text = value; + }); + } + + return content; + }, + + buildCache: function (file) { + let cache = {}; + + if (!file.fromCache) { + return cache; + } + + // There is no such info yet in the Net panel. + // cache.beforeRequest = {}; + + if (file.cacheEntry) { + cache.afterRequest = this.buildCacheEntry(file.cacheEntry); + } else { + cache.afterRequest = null; + } + + return cache; + }, + + buildCacheEntry: function (cacheEntry) { + let cache = {}; + + cache.expires = findValue(cacheEntry, "Expires"); + cache.lastAccess = findValue(cacheEntry, "Last Fetched"); + cache.eTag = ""; + cache.hitCount = findValue(cacheEntry, "Fetch Count"); + + return cache; + }, + + getBlockingEndTime: function (file) { + if (file.resolveStarted && file.connectStarted) { + return file.resolvingTime; + } + + if (file.connectStarted) { + return file.connectingTime; + } + + if (file.sendStarted) { + return file.sendingTime; + } + + return (file.sendingTime > file.startTime) ? + file.sendingTime : file.waitingForTime; + }, + + // RDP Helpers + + fetchData: function (string) { + let promise = this._options.getString(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 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(); + let 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 dateToJSON(date) { + function f(n, c) { + if (!c) { + c = 2; + } + let s = new String(n); + while (s.length < c) { + s = "0" + s; + } + return s; + } + + let 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(); + let positive = offset > 0; + + // Convert to positive number before using Math.floor (see issue 5512) + offset = Math.abs(offset); + let offsetHours = Math.floor(offset / 60); + let offsetMinutes = Math.floor(offset % 60); + let prettyOffset = (positive > 0 ? "-" : "+") + f(offsetHours) + + ":" + f(offsetMinutes); + + return result + prettyOffset; +} + +// Exports from this module +exports.HarBuilder = HarBuilder; |