har-collector.js (13873B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { 8 getLongStringFullText, 9 } = require("resource://devtools/client/shared/string-utils.js"); 10 11 // Helper tracer. Should be generic sharable by other modules (bug 1171927) 12 const trace = { 13 log() {}, 14 }; 15 16 /** 17 * This object is responsible for collecting data related to all 18 * HTTP requests executed by the page (including inner iframes). 19 */ 20 class HarCollector { 21 constructor(options) { 22 this.commands = options.commands; 23 24 this.onResourceAvailable = this.onResourceAvailable.bind(this); 25 this.onResourceUpdated = this.onResourceUpdated.bind(this); 26 this.onRequestHeaders = this.onRequestHeaders.bind(this); 27 this.onRequestCookies = this.onRequestCookies.bind(this); 28 this.onRequestPostData = this.onRequestPostData.bind(this); 29 this.onResponseHeaders = this.onResponseHeaders.bind(this); 30 this.onResponseCookies = this.onResponseCookies.bind(this); 31 this.onResponseContent = this.onResponseContent.bind(this); 32 this.onEventTimings = this.onEventTimings.bind(this); 33 34 this.clear(); 35 } 36 37 // Connection 38 39 async start() { 40 await this.commands.resourceCommand.watchResources( 41 [this.commands.resourceCommand.TYPES.NETWORK_EVENT], 42 { 43 onAvailable: this.onResourceAvailable, 44 onUpdated: this.onResourceUpdated, 45 } 46 ); 47 } 48 49 async stop() { 50 await this.commands.resourceCommand.unwatchResources( 51 [this.commands.resourceCommand.TYPES.NETWORK_EVENT], 52 { 53 onAvailable: this.onResourceAvailable, 54 onUpdated: this.onResourceUpdated, 55 } 56 ); 57 } 58 59 clear() { 60 // Any pending requests events will be ignored (they turn 61 // into zombies, since not present in the files array). 62 this.files = new Map(); 63 this.items = []; 64 this.firstRequestStart = -1; 65 this.lastRequestStart = -1; 66 this.requests = []; 67 } 68 69 waitForHarLoad() { 70 // There should be yet another timeout e.g.: 71 // 'devtools.netmonitor.har.pageLoadTimeout' 72 // that should force export even if page isn't fully loaded. 73 return new Promise(resolve => { 74 this.waitForResponses().then(() => { 75 trace.log("HarCollector.waitForHarLoad; DONE HAR loaded!"); 76 resolve(this); 77 }); 78 }); 79 } 80 81 waitForResponses() { 82 trace.log("HarCollector.waitForResponses; " + this.requests.length); 83 84 // All requests for additional data must be received to have complete 85 // HTTP info to generate the result HAR file. So, wait for all current 86 // promises. Note that new promises (requests) can be generated during the 87 // process of HTTP data collection. 88 return waitForAll(this.requests).then(() => { 89 // All responses are received from the backend now. We yet need to 90 // wait for a little while to see if a new request appears. If yes, 91 // lets's start gathering HTTP data again. If no, we can declare 92 // the page loaded. 93 // If some new requests appears in the meantime the promise will 94 // be rejected and we need to wait for responses all over again. 95 96 this.pageLoadDeferred = this.waitForTimeout().then( 97 () => { 98 // Page loaded! 99 }, 100 () => { 101 trace.log( 102 "HarCollector.waitForResponses; NEW requests " + 103 "appeared during page timeout!" 104 ); 105 // New requests executed, let's wait again. 106 return this.waitForResponses(); 107 } 108 ); 109 return this.pageLoadDeferred; 110 }); 111 } 112 113 // Page Loaded Timeout 114 115 /** 116 * The page is loaded when there are no new requests within given period 117 * of time. The time is set in preferences: 118 * 'devtools.netmonitor.har.pageLoadedTimeout' 119 */ 120 waitForTimeout() { 121 // The auto-export is not done if the timeout is set to zero (or less). 122 // This is useful in cases where the export is done manually through 123 // API exposed to the content. 124 const timeout = Services.prefs.getIntPref( 125 "devtools.netmonitor.har.pageLoadedTimeout" 126 ); 127 128 trace.log("HarCollector.waitForTimeout; " + timeout); 129 130 return new Promise((resolve, reject) => { 131 if (timeout <= 0) { 132 resolve(); 133 } 134 this.pageLoadReject = reject; 135 this.pageLoadTimeout = setTimeout(() => { 136 trace.log("HarCollector.onPageLoadTimeout;"); 137 resolve(); 138 }, timeout); 139 }); 140 } 141 142 resetPageLoadTimeout() { 143 // Remove the current timeout. 144 if (this.pageLoadTimeout) { 145 trace.log("HarCollector.resetPageLoadTimeout;"); 146 147 clearTimeout(this.pageLoadTimeout); 148 this.pageLoadTimeout = null; 149 } 150 151 // Reject the current page load promise 152 if (this.pageLoadReject) { 153 this.pageLoadReject(); 154 this.pageLoadReject = null; 155 } 156 } 157 158 // Collected Data 159 160 getFile(actorId) { 161 return this.files.get(actorId); 162 } 163 164 getItems() { 165 return this.items; 166 } 167 168 // Event Handlers 169 170 onResourceAvailable(resources) { 171 for (const resource of resources) { 172 trace.log("HarCollector.onNetworkEvent; ", resource); 173 174 const { actor, startedDateTime, method, url, isXHR } = resource; 175 const startTime = Date.parse(startedDateTime); 176 177 if (this.firstRequestStart == -1) { 178 this.firstRequestStart = startTime; 179 } 180 181 if (this.lastRequestEnd < startTime) { 182 this.lastRequestEnd = startTime; 183 } 184 185 let file = this.getFile(actor); 186 if (file) { 187 console.error( 188 "HarCollector.onNetworkEvent; ERROR " + "existing file conflict!" 189 ); 190 continue; 191 } 192 193 file = { 194 id: actor, 195 startedDeltaMs: startTime - this.firstRequestStart, 196 startedMs: startTime, 197 method, 198 url, 199 isXHR, 200 }; 201 202 this.files.set(actor, file); 203 204 // Mimic the Net panel data structure 205 this.items.push(file); 206 } 207 } 208 209 onResourceUpdated(updates) { 210 for (const { resource } of updates) { 211 // Skip events from unknown actors (not in the list). 212 // It can happen when there are zombie requests received after 213 // the target is closed or multiple tabs are attached through 214 // one connection (one DevToolsClient object). 215 const file = this.getFile(resource.actor); 216 if (!file) { 217 return; 218 } 219 220 const includeResponseBodies = Services.prefs.getBoolPref( 221 "devtools.netmonitor.har.includeResponseBodies" 222 ); 223 224 [ 225 { 226 type: "eventTimings", 227 method: "getEventTimings", 228 callbackName: "onEventTimings", 229 }, 230 { 231 type: "requestHeaders", 232 method: "getRequestHeaders", 233 callbackName: "onRequestHeaders", 234 }, 235 { 236 type: "requestPostData", 237 method: "getRequestPostData", 238 callbackName: "onRequestPostData", 239 }, 240 { 241 type: "responseHeaders", 242 method: "getResponseHeaders", 243 callbackName: "onResponseHeaders", 244 }, 245 { type: "responseStart" }, 246 { 247 type: "responseContent", 248 method: "getResponseContent", 249 callbackName: "onResponseContent", 250 }, 251 { 252 type: "requestCookies", 253 method: "getRequestCookies", 254 callbackName: "onRequestCookies", 255 }, 256 { 257 type: "responseCookies", 258 method: "getResponseCookies", 259 callbackName: "onResponseCookies", 260 }, 261 ].forEach(updateType => { 262 trace.log( 263 "HarCollector.onNetworkEventUpdate; " + updateType.type, 264 resource 265 ); 266 267 let request; 268 if (resource[`${updateType.type}Available`]) { 269 if (updateType.type == "responseStart") { 270 file.httpVersion = resource.httpVersion; 271 file.status = resource.status; 272 file.statusText = resource.statusText; 273 } else if (updateType.type == "responseContent") { 274 file.contentSize = resource.contentSize; 275 file.mimeType = resource.mimeType; 276 file.transferredSize = resource.transferredSize; 277 if (includeResponseBodies) { 278 request = this.getData( 279 resource.actor, 280 updateType.method, 281 this[updateType.callbackName] 282 ); 283 } 284 } else { 285 request = this.getData( 286 resource.actor, 287 updateType.method, 288 this[updateType.callbackName] 289 ); 290 } 291 } 292 293 if (request) { 294 this.requests.push(request); 295 } 296 this.resetPageLoadTimeout(); 297 }); 298 } 299 } 300 301 async getData(actor, method, callback) { 302 const file = this.getFile(actor); 303 304 trace.log( 305 "HarCollector.getData; REQUEST " + method + ", " + file.url, 306 file 307 ); 308 309 // Bug 1519082: We don't create fronts for NetworkEvent actors, 310 // so that we have to do the request manually via DevToolsClient.request() 311 const packet = { 312 to: actor, 313 type: method, 314 }; 315 const response = await this.commands.client.request(packet); 316 317 trace.log( 318 "HarCollector.getData; RESPONSE " + method + ", " + file.url, 319 response 320 ); 321 callback(response); 322 return response; 323 } 324 325 /** 326 * Handles additional information received for a "requestHeaders" packet. 327 * 328 * @param {object} response 329 * The message received from the server. 330 */ 331 onRequestHeaders(response) { 332 const file = this.getFile(response.from); 333 file.requestHeaders = response; 334 335 this.getLongHeaders(response.headers); 336 } 337 338 /** 339 * Handles additional information received for a "requestCookies" packet. 340 * 341 * @param {object} response 342 * The message received from the server. 343 */ 344 onRequestCookies(response) { 345 const file = this.getFile(response.from); 346 file.requestCookies = response; 347 348 this.getLongHeaders(response.cookies); 349 } 350 351 /** 352 * Handles additional information received for a "requestPostData" packet. 353 * 354 * @param {object} response 355 * The message received from the server. 356 */ 357 onRequestPostData(response) { 358 trace.log("HarCollector.onRequestPostData;", response); 359 360 const file = this.getFile(response.from); 361 file.requestPostData = response; 362 363 // Resolve long string 364 const { text } = response.postData; 365 if (typeof text == "object") { 366 this.getString(text).then(value => { 367 response.postData.text = value; 368 }); 369 } 370 } 371 372 /** 373 * Handles additional information received for a "responseHeaders" packet. 374 * 375 * @param {object} response 376 * The message received from the server. 377 */ 378 onResponseHeaders(response) { 379 const file = this.getFile(response.from); 380 file.responseHeaders = response; 381 382 this.getLongHeaders(response.headers); 383 } 384 385 /** 386 * Handles additional information received for a "responseCookies" packet. 387 * 388 * @param {object} response 389 * The message received from the server. 390 */ 391 onResponseCookies(response) { 392 const file = this.getFile(response.from); 393 file.responseCookies = response; 394 395 this.getLongHeaders(response.cookies); 396 } 397 398 /** 399 * Handles additional information received for a "responseContent" packet. 400 * 401 * @param {object} response 402 * The message received from the server. 403 */ 404 onResponseContent(response) { 405 const file = this.getFile(response.from); 406 file.responseContent = response; 407 408 // Resolve long string 409 const { text } = response.content; 410 if (typeof text == "object") { 411 this.getString(text).then(value => { 412 response.content.text = value; 413 }); 414 } 415 } 416 417 /** 418 * Handles additional information received for a "eventTimings" packet. 419 * 420 * @param {object} response 421 * The message received from the server. 422 */ 423 onEventTimings(response) { 424 const file = this.getFile(response.from); 425 file.eventTimings = response; 426 file.totalTime = response.totalTime; 427 } 428 429 // Helpers 430 getLongHeaders(headers) { 431 for (const header of headers) { 432 if (typeof header.value == "object") { 433 try { 434 this.getString(header.value).then(value => { 435 header.value = value; 436 }); 437 } catch (error) { 438 trace.log("HarCollector.getLongHeaders; ERROR when getString", error); 439 } 440 } 441 } 442 } 443 444 /** 445 * Fetches the full text of a string. 446 * 447 * @param {object | string} stringGrip 448 * The long string grip containing the corresponding actor. 449 * If you pass in a plain string (by accident or because you're lazy), 450 * then a promise of the same string is simply returned. 451 * @return {object} Promise 452 * A promise that is resolved when the full string contents 453 * are available, or rejected if something goes wrong. 454 */ 455 async getString(stringGrip) { 456 const promise = getLongStringFullText(this.commands.client, stringGrip); 457 this.requests.push(promise); 458 return promise; 459 } 460 } 461 462 // Helpers 463 464 /** 465 * Helper function that allows to wait for array of promises. It is 466 * possible to dynamically add new promises in the provided array. 467 * The function will wait even for the newly added promises. 468 * (this isn't possible with the default Promise.all); 469 */ 470 function waitForAll(promises) { 471 // Remove all from the original array and get clone of it. 472 const clone = promises.splice(0, promises.length); 473 474 // Wait for all promises in the given array. 475 return Promise.all(clone).then(() => { 476 // If there are new promises (in the original array) 477 // to wait for - chain them! 478 if (promises.length) { 479 return waitForAll(promises); 480 } 481 482 return undefined; 483 }); 484 } 485 486 // Exports from this module 487 exports.HarCollector = HarCollector;