NetworkRequest.sys.mjs (16848B)
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 const lazy = {}; 6 ChromeUtils.defineESModuleGetters(lazy, { 7 NetworkHelper: 8 "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs", 9 NetworkUtils: 10 "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", 11 12 generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", 13 NavigableManager: "chrome://remote/content/shared/NavigableManager.sys.mjs", 14 NavigationState: "chrome://remote/content/shared/NavigationManager.sys.mjs", 15 NetworkDataBytes: "chrome://remote/content/shared/NetworkDataBytes.sys.mjs", 16 notifyNavigationStarted: 17 "chrome://remote/content/shared/NavigationManager.sys.mjs", 18 }); 19 20 /** 21 * The NetworkRequest class is a wrapper around the internal channel which 22 * provides getters and methods closer to fetch's response concept 23 * (https://fetch.spec.whatwg.org/#concept-response). 24 */ 25 export class NetworkRequest { 26 #alreadyCompleted; 27 #channel; 28 #contextId; 29 #eventRecord; 30 #isDataURL; 31 #navigationId; 32 #navigationManager; 33 #postData; 34 #postDataSize; 35 #rawHeaders; 36 #redirectCount; 37 #requestId; 38 #timedChannel; 39 #wrappedChannel; 40 41 /** 42 * NetworkRequest relies on WrappedChannel's id to identify requests. However 43 * this id is generated based on a counter in each process. Therefore we can 44 * have overlaps for network requests handled in different processes 45 */ 46 static UNIQUE_ID_SUFFIX = lazy.generateUUID(); 47 48 /** 49 * 50 * @param {nsIChannel} channel 51 * The channel for the request. 52 * @param {object} params 53 * @param {NetworkEventRecord} params.networkEventRecord 54 * The NetworkEventRecord owning this NetworkRequest. 55 * @param {NavigationManager} params.navigationManager 56 * The NavigationManager where navigations for the current session are 57 * monitored. 58 * @param {string=} params.rawHeaders 59 * The request's raw (ie potentially compressed) headers 60 */ 61 constructor(channel, params) { 62 const { eventRecord, navigationManager, rawHeaders = "" } = params; 63 64 this.#channel = channel; 65 this.#eventRecord = eventRecord; 66 this.#isDataURL = this.#channel instanceof Ci.nsIDataChannel; 67 this.#navigationManager = navigationManager; 68 this.#rawHeaders = rawHeaders; 69 70 // Platform timestamp is in microseconds. 71 const currentTimeStamp = Date.now() * 1000; 72 this.#timedChannel = 73 this.#channel instanceof Ci.nsITimedChannel 74 ? this.#channel.QueryInterface(Ci.nsITimedChannel) 75 : { 76 redirectCount: 0, 77 initiatorType: "", 78 asyncOpenTime: currentTimeStamp, 79 redirectStartTime: 0, 80 redirectEndTime: 0, 81 domainLookupStartTime: currentTimeStamp, 82 domainLookupEndTime: currentTimeStamp, 83 connectStartTime: currentTimeStamp, 84 connectEndTime: currentTimeStamp, 85 secureConnectionStartTime: currentTimeStamp, 86 requestStartTime: currentTimeStamp, 87 responseStartTime: currentTimeStamp, 88 responseEndTime: currentTimeStamp, 89 }; 90 this.#wrappedChannel = ChannelWrapper.get(channel); 91 92 this.#redirectCount = this.#timedChannel.redirectCount; 93 94 // The wrappedChannel id remains identical across redirects, whereas 95 // nsIChannel.channelId is different for each and every request. 96 // Add a suffix unique to the process where the event is handled. 97 this.#requestId = `${this.#wrappedChannel.id.toString()}-${NetworkRequest.UNIQUE_ID_SUFFIX}`; 98 99 this.#contextId = this.#getContextId(); 100 this.#navigationId = this.#getNavigationId(); 101 102 // The postData will no longer be available after the channel is closed. 103 // Compute the postData and postDataSize properties, to be updated later if 104 // `setRequestBody` is used. 105 this.#updatePostData(); 106 } 107 108 get alreadyCompleted() { 109 return this.#alreadyCompleted; 110 } 111 112 get channel() { 113 return this.#channel; 114 } 115 116 get contextId() { 117 return this.#contextId; 118 } 119 120 get destination() { 121 return this.#channel.loadInfo?.fetchDestination; 122 } 123 124 get errorText() { 125 // TODO: Update with a proper error text. Bug 1873037. 126 return ChromeUtils.getXPCOMErrorName(this.#channel.status); 127 } 128 129 get headers() { 130 return this.#getHeadersList(); 131 } 132 133 get headersSize() { 134 // TODO: rawHeaders will not be updated after modifying the headers via 135 // request interception. Need to find another way to retrieve the 136 // information dynamically. 137 return this.#rawHeaders.length; 138 } 139 140 get initiatorType() { 141 const initiatorType = this.#timedChannel.initiatorType; 142 if (initiatorType === "") { 143 return null; 144 } 145 146 if (this.#isTopLevelDocumentLoad()) { 147 return null; 148 } 149 150 return initiatorType; 151 } 152 153 get isHttpChannel() { 154 return this.#channel instanceof Ci.nsIHttpChannel; 155 } 156 157 get method() { 158 return this.#isDataURL ? "GET" : this.#channel.requestMethod; 159 } 160 161 get navigationId() { 162 return this.#navigationId; 163 } 164 165 get postData() { 166 return this.#postData; 167 } 168 169 get postDataSize() { 170 return this.#postDataSize; 171 } 172 173 get redirectCount() { 174 return this.#redirectCount; 175 } 176 177 get requestId() { 178 return this.#requestId; 179 } 180 181 get serializedURL() { 182 return this.#channel.URI.spec; 183 } 184 185 get supportsInterception() { 186 // The request which doesn't have `wrappedChannel` can not be intercepted. 187 return !!this.#wrappedChannel; 188 } 189 190 get timings() { 191 return this.#getFetchTimings(); 192 } 193 194 get wrappedChannel() { 195 return this.#wrappedChannel; 196 } 197 198 set alreadyCompleted(value) { 199 this.#alreadyCompleted = value; 200 } 201 202 /** 203 * Add information about raw headers, collected from NetworkObserver events. 204 * 205 * @param {string} rawHeaders 206 * The raw headers. 207 */ 208 addRawHeaders(rawHeaders) { 209 this.#rawHeaders = rawHeaders || ""; 210 } 211 212 /** 213 * Clear a request header from the request's headers list. 214 * 215 * @param {string} name 216 * The header's name. 217 */ 218 clearRequestHeader(name) { 219 this.#channel.setRequestHeader( 220 name, // aName 221 "", // aValue="" as an empty value 222 false // aMerge=false to force clearing the header 223 ); 224 } 225 226 /** 227 * Returns the NetworkDataBytes instance representing the request body for 228 * this request. 229 * 230 * @returns {NetworkDataBytes} 231 */ 232 readAndProcessRequestBody = () => { 233 return new lazy.NetworkDataBytes({ 234 getBytesValue: () => this.#postData.text, 235 isBase64: this.#postData.isBase64, 236 }); 237 }; 238 239 /** 240 * Redirect the request to another provided URL. 241 * 242 * @param {string} url 243 * The URL to redirect to. 244 */ 245 redirectTo(url) { 246 this.#channel.transparentRedirectTo(Services.io.newURI(url)); 247 } 248 249 /** 250 * Set the request post body 251 * 252 * @param {string} body 253 * The body to set. 254 */ 255 setRequestBody(body) { 256 // Update the requestObserversCalled flag to allow modifying the request, 257 // and reset once done. 258 this.#channel.requestObserversCalled = false; 259 260 try { 261 this.#channel.QueryInterface(Ci.nsIUploadChannel2); 262 const bodyStream = Cc[ 263 "@mozilla.org/io/string-input-stream;1" 264 ].createInstance(Ci.nsIStringInputStream); 265 bodyStream.setByteStringData(body); 266 this.#channel.explicitSetUploadStream( 267 bodyStream, 268 null, 269 -1, 270 this.#channel.requestMethod, 271 false 272 ); 273 } finally { 274 // Make sure to reset the flag once the modification was attempted. 275 this.#channel.requestObserversCalled = true; 276 this.#updatePostData(); 277 } 278 } 279 280 /** 281 * Set a request header 282 * 283 * @param {string} name 284 * The header's name. 285 * @param {string} value 286 * The header's value. 287 * @param {object} options 288 * @param {boolean} options.merge 289 * True if the value should be merged with the existing value, false if it 290 * should override it. Defaults to false. 291 */ 292 setRequestHeader(name, value, options) { 293 const { merge = false } = options; 294 this.#channel.setRequestHeader(name, value, merge); 295 } 296 297 /** 298 * Update the request's method. 299 * 300 * @param {string} method 301 * The method to set. 302 */ 303 setRequestMethod(method) { 304 // Update the requestObserversCalled flag to allow modifying the request, 305 // and reset once done. 306 this.#channel.requestObserversCalled = false; 307 308 try { 309 this.#channel.requestMethod = method; 310 } finally { 311 // Make sure to reset the flag once the modification was attempted. 312 this.#channel.requestObserversCalled = true; 313 } 314 } 315 316 /** 317 * Allows to bypass the actual network request and immediately respond with 318 * the provided nsIReplacedHttpResponse. 319 * 320 * @param {nsIReplacedHttpResponse} replacedHttpResponse 321 * The replaced response to use. 322 */ 323 setResponseOverride(replacedHttpResponse) { 324 this.wrappedChannel.channel 325 .QueryInterface(Ci.nsIHttpChannelInternal) 326 .setResponseOverride(replacedHttpResponse); 327 328 const rawHeaders = []; 329 replacedHttpResponse.visitResponseHeaders({ 330 visitHeader(name, value) { 331 rawHeaders.push(`${name}: ${value}`); 332 }, 333 }); 334 335 // Setting an override bypasses the usual codepath for network responses. 336 // There will be no notification about receiving a response. 337 // However, there will be a notification about the end of the response. 338 // Therefore, simulate a addResponseStart here to make sure we handle 339 // addResponseContent properly. 340 this.#eventRecord.prepareResponseStart({ 341 channel: this.#channel, 342 fromCache: false, 343 rawHeaders: rawHeaders.join("\n"), 344 }); 345 } 346 347 /** 348 * Return a static version of the class instance. 349 * This method is used to prepare the data to be sent with the events for cached resources 350 * generated from the content process but need to be sent to the parent. 351 */ 352 toJSON() { 353 return { 354 destination: this.destination, 355 headers: this.headers, 356 headersSize: this.headersSize, 357 initiatorType: this.initiatorType, 358 method: this.method, 359 navigationId: this.navigationId, 360 postData: this.postData, 361 postDataSize: this.postDataSize, 362 redirectCount: this.redirectCount, 363 requestId: this.requestId, 364 serializedURL: this.serializedURL, 365 // Since this data is meant to be sent to the parent process 366 // it will not be possible to intercept such request. 367 supportsInterception: false, 368 timings: this.timings, 369 }; 370 } 371 372 /** 373 * Convert the provided request timing to a timing relative to the beginning 374 * of the request. Note that https://w3c.github.io/resource-timing/#dfn-convert-fetch-timestamp 375 * only expects high resolution timestamps (double in milliseconds) as inputs 376 * of this method, but since platform timestamps are integers in microseconds, 377 * they will be converted on the fly in this helper. 378 * 379 * @param {number} timing 380 * Platform TimeStamp for a request timing relative from the time origin 381 * in microseconds. 382 * @param {number} requestTime 383 * Platform TimeStamp for the request start time relative from the time 384 * origin, in microseconds. 385 * 386 * @returns {number} 387 * High resolution timestamp (https://www.w3.org/TR/hr-time-3/#dom-domhighrestimestamp) 388 * for the request timing relative to the start time of the request, or 0 389 * if the provided timing was 0. 390 */ 391 #convertTimestamp(timing, requestTime) { 392 if (timing == 0) { 393 return 0; 394 } 395 396 // Convert from platform timestamp to high resolution timestamp. 397 return (timing - requestTime) / 1000; 398 } 399 400 #getContextId() { 401 const id = lazy.NetworkUtils.getChannelBrowsingContextID(this.#channel); 402 const browsingContext = BrowsingContext.get(id); 403 return lazy.NavigableManager.getIdForBrowsingContext(browsingContext); 404 } 405 406 /** 407 * Retrieve the Fetch timings for the NetworkRequest. 408 * 409 * @returns {object} 410 * Object with keys corresponding to fetch timing names, and their 411 * corresponding values. 412 */ 413 #getFetchTimings() { 414 const { 415 asyncOpenTime, 416 redirectStartTime, 417 redirectEndTime, 418 dispatchFetchEventStartTime, 419 cacheReadStartTime, 420 domainLookupStartTime, 421 domainLookupEndTime, 422 connectStartTime, 423 connectEndTime, 424 secureConnectionStartTime, 425 requestStartTime, 426 responseStartTime, 427 responseEndTime, 428 } = this.#timedChannel; 429 430 // fetchStart should be the post-redirect start time, which should be the 431 // first non-zero timing from: dispatchFetchEventStart, cacheReadStart and 432 // domainLookupStart. See https://www.w3.org/TR/navigation-timing-2/#processing-model 433 const fetchStartTime = 434 dispatchFetchEventStartTime || 435 cacheReadStartTime || 436 domainLookupStartTime; 437 438 // Bug 1805478: Per spec, the origin time should match Performance API's 439 // timeOrigin for the global which initiated the request. This is not 440 // available in the parent process, so for now we will use 0. 441 const timeOrigin = 0; 442 443 return { 444 timeOrigin, 445 requestTime: this.#convertTimestamp(asyncOpenTime, timeOrigin), 446 redirectStart: this.#convertTimestamp(redirectStartTime, timeOrigin), 447 redirectEnd: this.#convertTimestamp(redirectEndTime, timeOrigin), 448 fetchStart: this.#convertTimestamp(fetchStartTime, timeOrigin), 449 dnsStart: this.#convertTimestamp(domainLookupStartTime, timeOrigin), 450 dnsEnd: this.#convertTimestamp(domainLookupEndTime, timeOrigin), 451 connectStart: this.#convertTimestamp(connectStartTime, timeOrigin), 452 connectEnd: this.#convertTimestamp(connectEndTime, timeOrigin), 453 tlsStart: this.#convertTimestamp(secureConnectionStartTime, timeOrigin), 454 tlsEnd: this.#convertTimestamp(connectEndTime, timeOrigin), 455 requestStart: this.#convertTimestamp(requestStartTime, timeOrigin), 456 responseStart: this.#convertTimestamp(responseStartTime, timeOrigin), 457 responseEnd: this.#convertTimestamp(responseEndTime, timeOrigin), 458 }; 459 } 460 461 /** 462 * Retrieve the list of headers for the NetworkRequest. 463 * 464 * @returns {Array.Array} 465 * Array of (name, value) tuples. 466 */ 467 #getHeadersList() { 468 const headers = []; 469 470 if (this.#channel instanceof Ci.nsIHttpChannel) { 471 this.#channel.visitRequestHeaders({ 472 visitHeader(name, value) { 473 // The `Proxy-Authorization` header even though it appears on the channel is not 474 // actually sent to the server for non CONNECT requests after the HTTP/HTTPS tunnel 475 // is setup by the proxy. 476 if (name == "Proxy-Authorization") { 477 return; 478 } 479 headers.push([name, value]); 480 }, 481 }); 482 } 483 484 if (this.#channel instanceof Ci.nsIDataChannel) { 485 // Data channels have no request headers. 486 return []; 487 } 488 489 if (this.#channel instanceof Ci.nsIFileChannel) { 490 // File channels have no request headers. 491 return []; 492 } 493 494 return headers; 495 } 496 497 #getNavigationId() { 498 if (!this.#channel.isDocument) { 499 return null; 500 } 501 502 const browsingContext = lazy.NavigableManager.getBrowsingContextById( 503 this.#contextId 504 ); 505 506 let navigation = 507 this.#navigationManager.getNavigationForBrowsingContext(browsingContext); 508 509 // `onBeforeRequestSent` might be too early for the NavigationManager. 510 // If there is no ongoing navigation, create one ourselves. 511 // TODO: Bug 1835704 to detect navigations earlier and avoid this. 512 if (!navigation || navigation.state !== lazy.NavigationState.Started) { 513 navigation = lazy.notifyNavigationStarted({ 514 contextDetails: { context: browsingContext }, 515 url: this.serializedURL, 516 }); 517 } 518 519 return navigation ? navigation.navigationId : null; 520 } 521 522 #isTopLevelDocumentLoad() { 523 if (!this.#channel.isDocument) { 524 return false; 525 } 526 527 const browsingContext = lazy.NavigableManager.getBrowsingContextById( 528 this.#contextId 529 ); 530 return !browsingContext.parent; 531 } 532 533 #readPostDataFromRequestAsUTF8() { 534 const postData = lazy.NetworkHelper.readPostDataFromRequest( 535 this.#channel, 536 "UTF-8" 537 ); 538 539 if (postData === null || postData.data === null) { 540 return null; 541 } 542 543 return { 544 text: postData.isDecodedAsText ? postData.data : btoa(postData.data), 545 isBase64: !postData.isDecodedAsText, 546 }; 547 } 548 549 #updatePostData() { 550 const sentBody = this.#readPostDataFromRequestAsUTF8(); 551 if (sentBody) { 552 this.#postData = sentBody; 553 this.#postDataSize = sentBody.text.length; 554 } else { 555 this.#postData = null; 556 this.#postDataSize = 0; 557 } 558 } 559 }