diff options
Diffstat (limited to 'devtools/client/netmonitor/src/har/har-collector.js')
-rw-r--r-- | devtools/client/netmonitor/src/har/har-collector.js | 488 |
1 files changed, 488 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/har/har-collector.js b/devtools/client/netmonitor/src/har/har-collector.js new file mode 100644 index 0000000000..c5a4ae959d --- /dev/null +++ b/devtools/client/netmonitor/src/har/har-collector.js @@ -0,0 +1,488 @@ +/* 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 { + getLongStringFullText, +} = require("resource://devtools/client/shared/string-utils.js"); + +// Helper tracer. Should be generic sharable by other modules (bug 1171927) +const trace = { + log(...args) {}, +}; + +/** + * This object is responsible for collecting data related to all + * HTTP requests executed by the page (including inner iframes). + */ +function HarCollector(options) { + this.commands = options.commands; + + this.onResourceAvailable = this.onResourceAvailable.bind(this); + this.onResourceUpdated = this.onResourceUpdated.bind(this); + this.onRequestHeaders = this.onRequestHeaders.bind(this); + this.onRequestCookies = this.onRequestCookies.bind(this); + this.onRequestPostData = this.onRequestPostData.bind(this); + this.onResponseHeaders = this.onResponseHeaders.bind(this); + this.onResponseCookies = this.onResponseCookies.bind(this); + this.onResponseContent = this.onResponseContent.bind(this); + this.onEventTimings = this.onEventTimings.bind(this); + + this.clear(); +} + +HarCollector.prototype = { + // Connection + + async start() { + await this.commands.resourceCommand.watchResources( + [this.commands.resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable: this.onResourceAvailable, + onUpdated: this.onResourceUpdated, + } + ); + }, + + async stop() { + await this.commands.resourceCommand.unwatchResources( + [this.commands.resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable: this.onResourceAvailable, + onUpdated: this.onResourceUpdated, + } + ); + }, + + clear() { + // Any pending requests events will be ignored (they turn + // into zombies, since not present in the files array). + this.files = new Map(); + this.items = []; + this.firstRequestStart = -1; + this.lastRequestStart = -1; + this.requests = []; + }, + + waitForHarLoad() { + // There should be yet another timeout e.g.: + // 'devtools.netmonitor.har.pageLoadTimeout' + // that should force export even if page isn't fully loaded. + return new Promise(resolve => { + this.waitForResponses().then(() => { + trace.log("HarCollector.waitForHarLoad; DONE HAR loaded!"); + resolve(this); + }); + }); + }, + + waitForResponses() { + trace.log("HarCollector.waitForResponses; " + this.requests.length); + + // All requests for additional data must be received to have complete + // HTTP info to generate the result HAR file. So, wait for all current + // promises. Note that new promises (requests) can be generated during the + // process of HTTP data collection. + return waitForAll(this.requests).then(() => { + // All responses are received from the backend now. We yet need to + // wait for a little while to see if a new request appears. If yes, + // lets's start gathering HTTP data again. If no, we can declare + // the page loaded. + // If some new requests appears in the meantime the promise will + // be rejected and we need to wait for responses all over again. + + this.pageLoadDeferred = this.waitForTimeout().then( + () => { + // Page loaded! + }, + () => { + trace.log( + "HarCollector.waitForResponses; NEW requests " + + "appeared during page timeout!" + ); + // New requests executed, let's wait again. + return this.waitForResponses(); + } + ); + return this.pageLoadDeferred; + }); + }, + + // Page Loaded Timeout + + /** + * The page is loaded when there are no new requests within given period + * of time. The time is set in preferences: + * 'devtools.netmonitor.har.pageLoadedTimeout' + */ + waitForTimeout() { + // The auto-export is not done if the timeout is set to zero (or less). + // This is useful in cases where the export is done manually through + // API exposed to the content. + const timeout = Services.prefs.getIntPref( + "devtools.netmonitor.har.pageLoadedTimeout" + ); + + trace.log("HarCollector.waitForTimeout; " + timeout); + + return new Promise((resolve, reject) => { + if (timeout <= 0) { + resolve(); + } + this.pageLoadReject = reject; + this.pageLoadTimeout = setTimeout(() => { + trace.log("HarCollector.onPageLoadTimeout;"); + resolve(); + }, timeout); + }); + }, + + resetPageLoadTimeout() { + // Remove the current timeout. + if (this.pageLoadTimeout) { + trace.log("HarCollector.resetPageLoadTimeout;"); + + clearTimeout(this.pageLoadTimeout); + this.pageLoadTimeout = null; + } + + // Reject the current page load promise + if (this.pageLoadReject) { + this.pageLoadReject(); + this.pageLoadReject = null; + } + }, + + // Collected Data + + getFile(actorId) { + return this.files.get(actorId); + }, + + getItems() { + return this.items; + }, + + // Event Handlers + + onResourceAvailable(resources) { + for (const resource of resources) { + trace.log("HarCollector.onNetworkEvent; ", resource); + + const { actor, startedDateTime, method, url, isXHR } = resource; + const startTime = Date.parse(startedDateTime); + + if (this.firstRequestStart == -1) { + this.firstRequestStart = startTime; + } + + if (this.lastRequestEnd < startTime) { + this.lastRequestEnd = startTime; + } + + let file = this.getFile(actor); + if (file) { + console.error( + "HarCollector.onNetworkEvent; ERROR " + "existing file conflict!" + ); + continue; + } + + file = { + id: actor, + startedDeltaMs: startTime - this.firstRequestStart, + startedMs: startTime, + method, + url, + isXHR, + }; + + this.files.set(actor, file); + + // Mimic the Net panel data structure + this.items.push(file); + } + }, + + onResourceUpdated(updates) { + for (const { resource } of updates) { + // Skip events from unknown actors (not in the list). + // It can happen when there are zombie requests received after + // the target is closed or multiple tabs are attached through + // one connection (one DevToolsClient object). + const file = this.getFile(resource.actor); + if (!file) { + return; + } + + const includeResponseBodies = Services.prefs.getBoolPref( + "devtools.netmonitor.har.includeResponseBodies" + ); + + [ + { + type: "eventTimings", + method: "getEventTimings", + callbackName: "onEventTimings", + }, + { + type: "requestHeaders", + method: "getRequestHeaders", + callbackName: "onRequestHeaders", + }, + { + type: "requestPostData", + method: "getRequestPostData", + callbackName: "onRequestPostData", + }, + { + type: "responseHeaders", + method: "getResponseHeaders", + callbackName: "onResponseHeaders", + }, + { type: "responseStart" }, + { + type: "responseContent", + method: "getResponseContent", + callbackName: "onResponseContent", + }, + { + type: "requestCookies", + method: "getRequestCookies", + callbackName: "onRequestCookies", + }, + { + type: "responseCookies", + method: "getResponseCookies", + callbackName: "onResponseCookies", + }, + ].forEach(updateType => { + trace.log( + "HarCollector.onNetworkEventUpdate; " + updateType.type, + resource + ); + + let request; + if (resource[`${updateType.type}Available`]) { + if (updateType.type == "responseStart") { + file.httpVersion = resource.httpVersion; + file.status = resource.status; + file.statusText = resource.statusText; + } else if (updateType.type == "responseContent") { + file.contentSize = resource.contentSize; + file.mimeType = resource.mimeType; + file.transferredSize = resource.transferredSize; + if (includeResponseBodies) { + request = this.getData( + resource.actor, + updateType.method, + this[updateType.callbackName] + ); + } + } else { + request = this.getData( + resource.actor, + updateType.method, + this[updateType.callbackName] + ); + } + } + + if (request) { + this.requests.push(request); + } + this.resetPageLoadTimeout(); + }); + } + }, + + async getData(actor, method, callback) { + const file = this.getFile(actor); + + trace.log( + "HarCollector.getData; REQUEST " + method + ", " + file.url, + file + ); + + // Bug 1519082: We don't create fronts for NetworkEvent actors, + // so that we have to do the request manually via DevToolsClient.request() + const packet = { + to: actor, + type: method, + }; + const response = await this.commands.client.request(packet); + + trace.log( + "HarCollector.getData; RESPONSE " + method + ", " + file.url, + response + ); + callback(response); + return response; + }, + + /** + * Handles additional information received for a "requestHeaders" packet. + * + * @param object response + * The message received from the server. + */ + onRequestHeaders(response) { + const file = this.getFile(response.from); + file.requestHeaders = response; + + this.getLongHeaders(response.headers); + }, + + /** + * Handles additional information received for a "requestCookies" packet. + * + * @param object response + * The message received from the server. + */ + onRequestCookies(response) { + const file = this.getFile(response.from); + file.requestCookies = response; + + this.getLongHeaders(response.cookies); + }, + + /** + * Handles additional information received for a "requestPostData" packet. + * + * @param object response + * The message received from the server. + */ + onRequestPostData(response) { + trace.log("HarCollector.onRequestPostData;", response); + + const file = this.getFile(response.from); + file.requestPostData = response; + + // Resolve long string + const { text } = response.postData; + if (typeof text == "object") { + this.getString(text).then(value => { + response.postData.text = value; + }); + } + }, + + /** + * Handles additional information received for a "responseHeaders" packet. + * + * @param object response + * The message received from the server. + */ + onResponseHeaders(response) { + const file = this.getFile(response.from); + file.responseHeaders = response; + + this.getLongHeaders(response.headers); + }, + + /** + * Handles additional information received for a "responseCookies" packet. + * + * @param object response + * The message received from the server. + */ + onResponseCookies(response) { + const file = this.getFile(response.from); + file.responseCookies = response; + + this.getLongHeaders(response.cookies); + }, + + /** + * Handles additional information received for a "responseContent" packet. + * + * @param object response + * The message received from the server. + */ + onResponseContent(response) { + const file = this.getFile(response.from); + file.responseContent = response; + + // Resolve long string + const { text } = response.content; + if (typeof text == "object") { + this.getString(text).then(value => { + response.content.text = value; + }); + } + }, + + /** + * Handles additional information received for a "eventTimings" packet. + * + * @param object response + * The message received from the server. + */ + onEventTimings(response) { + const file = this.getFile(response.from); + file.eventTimings = response; + file.totalTime = response.totalTime; + }, + + // Helpers + + getLongHeaders(headers) { + for (const header of headers) { + if (typeof header.value == "object") { + try { + this.getString(header.value).then(value => { + header.value = value; + }); + } catch (error) { + trace.log("HarCollector.getLongHeaders; ERROR when getString", error); + } + } + } + }, + + /** + * Fetches the full text of a string. + * + * @param object | string stringGrip + * The long string grip containing the corresponding actor. + * If you pass in a plain string (by accident or because you're lazy), + * then a promise of the same string is simply returned. + * @return object Promise + * A promise that is resolved when the full string contents + * are available, or rejected if something goes wrong. + */ + async getString(stringGrip) { + const promise = getLongStringFullText(this.commands.client, stringGrip); + this.requests.push(promise); + return promise; + }, +}; + +// Helpers + +/** + * Helper function that allows to wait for array of promises. It is + * possible to dynamically add new promises in the provided array. + * The function will wait even for the newly added promises. + * (this isn't possible with the default Promise.all); + */ +function waitForAll(promises) { + // Remove all from the original array and get clone of it. + const clone = promises.splice(0, promises.length); + + // Wait for all promises in the given array. + return Promise.all(clone).then(() => { + // If there are new promises (in the original array) + // to wait for - chain them! + if (promises.length) { + return waitForAll(promises); + } + + return undefined; + }); +} + +// Exports from this module +exports.HarCollector = HarCollector; |