Source code
Revision control
Copy as Markdown
Other Tools
/* 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
"use strict";
const {
getLongStringFullText,
} = require("resource://devtools/client/shared/string-utils.js");
const trace = {
log() {},
};
/**
* 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
);
// 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;