HTTPRequest.ts (20478B)
1 /** 2 * @license 3 * Copyright 2020 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 import type {Protocol} from 'devtools-protocol'; 7 8 import type {ProtocolError} from '../common/Errors.js'; 9 import {debugError, isString} from '../common/util.js'; 10 import {assert} from '../util/assert.js'; 11 import {typedArrayToBase64} from '../util/encoding.js'; 12 13 import type {CDPSession} from './CDPSession.js'; 14 import type {Frame} from './Frame.js'; 15 import type {HTTPResponse} from './HTTPResponse.js'; 16 17 /** 18 * @public 19 */ 20 export interface ContinueRequestOverrides { 21 /** 22 * If set, the request URL will change. This is not a redirect. 23 */ 24 url?: string; 25 method?: string; 26 postData?: string; 27 headers?: Record<string, string>; 28 } 29 30 /** 31 * @public 32 */ 33 export interface InterceptResolutionState { 34 action: InterceptResolutionAction; 35 priority?: number; 36 } 37 38 /** 39 * Required response data to fulfill a request with. 40 * 41 * @public 42 */ 43 export interface ResponseForRequest { 44 status: number; 45 /** 46 * Optional response headers. 47 * 48 * The record values will be converted to string following: 49 * Arrays' values will be mapped to String 50 * (Used when you need multiple headers with the same name). 51 * Non-arrays will be converted to String. 52 */ 53 headers: Record<string, string | string[] | unknown>; 54 contentType: string; 55 body: string | Uint8Array; 56 } 57 58 /** 59 * Resource types for HTTPRequests as perceived by the rendering engine. 60 * 61 * @public 62 */ 63 export type ResourceType = Lowercase<Protocol.Network.ResourceType>; 64 65 /** 66 * The default cooperative request interception resolution priority 67 * 68 * @public 69 */ 70 export const DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0; 71 72 /** 73 * Represents an HTTP request sent by a page. 74 * @remarks 75 * 76 * Whenever the page sends a request, such as for a network resource, the 77 * following events are emitted by Puppeteer's `page`: 78 * 79 * - `request`: emitted when the request is issued by the page. 80 * 81 * - `requestfinished` - emitted when the response body is downloaded and the 82 * request is complete. 83 * 84 * If request fails at some point, then instead of `requestfinished` event the 85 * `requestfailed` event is emitted. 86 * 87 * All of these events provide an instance of `HTTPRequest` representing the 88 * request that occurred: 89 * 90 * ``` 91 * page.on('request', request => ...) 92 * ``` 93 * 94 * NOTE: HTTP Error responses, such as 404 or 503, are still successful 95 * responses from HTTP standpoint, so request will complete with 96 * `requestfinished` event. 97 * 98 * If request gets a 'redirect' response, the request is successfully finished 99 * with the `requestfinished` event, and a new request is issued to a 100 * redirected url. 101 * 102 * @public 103 */ 104 export abstract class HTTPRequest { 105 /** 106 * @internal 107 */ 108 abstract get id(): string; 109 110 /** 111 * @internal 112 */ 113 _interceptionId: string | undefined; 114 /** 115 * @internal 116 */ 117 _failureText: string | null = null; 118 /** 119 * @internal 120 */ 121 _response: HTTPResponse | null = null; 122 /** 123 * @internal 124 */ 125 _fromMemoryCache = false; 126 /** 127 * @internal 128 */ 129 _redirectChain: HTTPRequest[] = []; 130 131 /** 132 * @internal 133 */ 134 protected interception: { 135 enabled: boolean; 136 handled: boolean; 137 handlers: Array<() => void | PromiseLike<any>>; 138 resolutionState: InterceptResolutionState; 139 requestOverrides: ContinueRequestOverrides; 140 response: Partial<ResponseForRequest> | null; 141 abortReason: Protocol.Network.ErrorReason | null; 142 } = { 143 enabled: false, 144 handled: false, 145 handlers: [], 146 resolutionState: { 147 action: InterceptResolutionAction.None, 148 }, 149 requestOverrides: {}, 150 response: null, 151 abortReason: null, 152 }; 153 154 /** 155 * Warning! Using this client can break Puppeteer. Use with caution. 156 * 157 * @experimental 158 */ 159 abstract get client(): CDPSession; 160 161 /** 162 * @internal 163 */ 164 constructor() {} 165 166 /** 167 * The URL of the request 168 */ 169 abstract url(): string; 170 171 /** 172 * The `ContinueRequestOverrides` that will be used 173 * if the interception is allowed to continue (ie, `abort()` and 174 * `respond()` aren't called). 175 */ 176 continueRequestOverrides(): ContinueRequestOverrides { 177 assert(this.interception.enabled, 'Request Interception is not enabled!'); 178 return this.interception.requestOverrides; 179 } 180 181 /** 182 * The `ResponseForRequest` that gets used if the 183 * interception is allowed to respond (ie, `abort()` is not called). 184 */ 185 responseForRequest(): Partial<ResponseForRequest> | null { 186 assert(this.interception.enabled, 'Request Interception is not enabled!'); 187 return this.interception.response; 188 } 189 190 /** 191 * The most recent reason for aborting the request 192 */ 193 abortErrorReason(): Protocol.Network.ErrorReason | null { 194 assert(this.interception.enabled, 'Request Interception is not enabled!'); 195 return this.interception.abortReason; 196 } 197 198 /** 199 * An InterceptResolutionState object describing the current resolution 200 * action and priority. 201 * 202 * InterceptResolutionState contains: 203 * action: InterceptResolutionAction 204 * priority?: number 205 * 206 * InterceptResolutionAction is one of: `abort`, `respond`, `continue`, 207 * `disabled`, `none`, or `already-handled`. 208 */ 209 interceptResolutionState(): InterceptResolutionState { 210 if (!this.interception.enabled) { 211 return {action: InterceptResolutionAction.Disabled}; 212 } 213 if (this.interception.handled) { 214 return {action: InterceptResolutionAction.AlreadyHandled}; 215 } 216 return {...this.interception.resolutionState}; 217 } 218 219 /** 220 * Is `true` if the intercept resolution has already been handled, 221 * `false` otherwise. 222 */ 223 isInterceptResolutionHandled(): boolean { 224 return this.interception.handled; 225 } 226 227 /** 228 * Adds an async request handler to the processing queue. 229 * Deferred handlers are not guaranteed to execute in any particular order, 230 * but they are guaranteed to resolve before the request interception 231 * is finalized. 232 */ 233 enqueueInterceptAction( 234 pendingHandler: () => void | PromiseLike<unknown>, 235 ): void { 236 this.interception.handlers.push(pendingHandler); 237 } 238 239 /** 240 * @internal 241 */ 242 abstract _abort( 243 errorReason: Protocol.Network.ErrorReason | null, 244 ): Promise<void>; 245 246 /** 247 * @internal 248 */ 249 abstract _respond(response: Partial<ResponseForRequest>): Promise<void>; 250 251 /** 252 * @internal 253 */ 254 abstract _continue(overrides: ContinueRequestOverrides): Promise<void>; 255 256 /** 257 * Awaits pending interception handlers and then decides how to fulfill 258 * the request interception. 259 */ 260 async finalizeInterceptions(): Promise<void> { 261 await this.interception.handlers.reduce((promiseChain, interceptAction) => { 262 return promiseChain.then(interceptAction); 263 }, Promise.resolve()); 264 this.interception.handlers = []; 265 const {action} = this.interceptResolutionState(); 266 switch (action) { 267 case 'abort': 268 return await this._abort(this.interception.abortReason); 269 case 'respond': 270 if (this.interception.response === null) { 271 throw new Error('Response is missing for the interception'); 272 } 273 return await this._respond(this.interception.response); 274 case 'continue': 275 return await this._continue(this.interception.requestOverrides); 276 } 277 } 278 279 /** 280 * Contains the request's resource type as it was perceived by the rendering 281 * engine. 282 */ 283 abstract resourceType(): ResourceType; 284 285 /** 286 * The method used (`GET`, `POST`, etc.) 287 */ 288 abstract method(): string; 289 290 /** 291 * The request's post body, if any. 292 */ 293 abstract postData(): string | undefined; 294 295 /** 296 * True when the request has POST data. Note that {@link HTTPRequest.postData} 297 * might still be undefined when this flag is true when the data is too long 298 * or not readily available in the decoded form. In that case, use 299 * {@link HTTPRequest.fetchPostData}. 300 */ 301 abstract hasPostData(): boolean; 302 303 /** 304 * Fetches the POST data for the request from the browser. 305 */ 306 abstract fetchPostData(): Promise<string | undefined>; 307 308 /** 309 * An object with HTTP headers associated with the request. All 310 * header names are lower-case. 311 */ 312 abstract headers(): Record<string, string>; 313 314 /** 315 * A matching `HTTPResponse` object, or null if the response has not 316 * been received yet. 317 */ 318 abstract response(): HTTPResponse | null; 319 320 /** 321 * The frame that initiated the request, or null if navigating to 322 * error pages. 323 */ 324 abstract frame(): Frame | null; 325 326 /** 327 * True if the request is the driver of the current frame's navigation. 328 */ 329 abstract isNavigationRequest(): boolean; 330 331 /** 332 * The initiator of the request. 333 */ 334 abstract initiator(): Protocol.Network.Initiator | undefined; 335 336 /** 337 * A `redirectChain` is a chain of requests initiated to fetch a resource. 338 * @remarks 339 * 340 * `redirectChain` is shared between all the requests of the same chain. 341 * 342 * For example, if the website `http://example.com` has a single redirect to 343 * `https://example.com`, then the chain will contain one request: 344 * 345 * ```ts 346 * const response = await page.goto('http://example.com'); 347 * const chain = response.request().redirectChain(); 348 * console.log(chain.length); // 1 349 * console.log(chain[0].url()); // 'http://example.com' 350 * ``` 351 * 352 * If the website `https://google.com` has no redirects, then the chain will be empty: 353 * 354 * ```ts 355 * const response = await page.goto('https://google.com'); 356 * const chain = response.request().redirectChain(); 357 * console.log(chain.length); // 0 358 * ``` 359 * 360 * @returns the chain of requests - if a server responds with at least a 361 * single redirect, this chain will contain all requests that were redirected. 362 */ 363 abstract redirectChain(): HTTPRequest[]; 364 365 /** 366 * Access information about the request's failure. 367 * 368 * @remarks 369 * 370 * @example 371 * 372 * Example of logging all failed requests: 373 * 374 * ```ts 375 * page.on('requestfailed', request => { 376 * console.log(request.url() + ' ' + request.failure().errorText); 377 * }); 378 * ``` 379 * 380 * @returns `null` unless the request failed. If the request fails this can 381 * return an object with `errorText` containing a human-readable error 382 * message, e.g. `net::ERR_FAILED`. It is not guaranteed that there will be 383 * failure text if the request fails. 384 */ 385 abstract failure(): {errorText: string} | null; 386 387 #canBeIntercepted(): boolean { 388 return !this.url().startsWith('data:') && !this._fromMemoryCache; 389 } 390 391 /** 392 * Continues request with optional request overrides. 393 * 394 * @example 395 * 396 * ```ts 397 * await page.setRequestInterception(true); 398 * page.on('request', request => { 399 * // Override headers 400 * const headers = Object.assign({}, request.headers(), { 401 * foo: 'bar', // set "foo" header 402 * origin: undefined, // remove "origin" header 403 * }); 404 * request.continue({headers}); 405 * }); 406 * ``` 407 * 408 * @param overrides - optional overrides to apply to the request. 409 * @param priority - If provided, intercept is resolved using cooperative 410 * handling rules. Otherwise, intercept is resolved immediately. 411 * 412 * @remarks 413 * 414 * To use this, request interception should be enabled with 415 * {@link Page.setRequestInterception}. 416 * 417 * Exception is immediately thrown if the request interception is not enabled. 418 */ 419 async continue( 420 overrides: ContinueRequestOverrides = {}, 421 priority?: number, 422 ): Promise<void> { 423 if (!this.#canBeIntercepted()) { 424 return; 425 } 426 assert(this.interception.enabled, 'Request Interception is not enabled!'); 427 assert(!this.interception.handled, 'Request is already handled!'); 428 if (priority === undefined) { 429 return await this._continue(overrides); 430 } 431 this.interception.requestOverrides = overrides; 432 if ( 433 this.interception.resolutionState.priority === undefined || 434 priority > this.interception.resolutionState.priority 435 ) { 436 this.interception.resolutionState = { 437 action: InterceptResolutionAction.Continue, 438 priority, 439 }; 440 return; 441 } 442 if (priority === this.interception.resolutionState.priority) { 443 if ( 444 this.interception.resolutionState.action === 'abort' || 445 this.interception.resolutionState.action === 'respond' 446 ) { 447 return; 448 } 449 this.interception.resolutionState.action = 450 InterceptResolutionAction.Continue; 451 } 452 return; 453 } 454 455 /** 456 * Fulfills a request with the given response. 457 * 458 * @example 459 * An example of fulfilling all requests with 404 responses: 460 * 461 * ```ts 462 * await page.setRequestInterception(true); 463 * page.on('request', request => { 464 * request.respond({ 465 * status: 404, 466 * contentType: 'text/plain', 467 * body: 'Not Found!', 468 * }); 469 * }); 470 * ``` 471 * 472 * NOTE: Mocking responses for dataURL requests is not supported. 473 * Calling `request.respond` for a dataURL request is a noop. 474 * 475 * @param response - the response to fulfill the request with. 476 * @param priority - If provided, intercept is resolved using 477 * cooperative handling rules. Otherwise, intercept is resolved 478 * immediately. 479 * 480 * @remarks 481 * 482 * To use this, request 483 * interception should be enabled with {@link Page.setRequestInterception}. 484 * 485 * Exception is immediately thrown if the request interception is not enabled. 486 */ 487 async respond( 488 response: Partial<ResponseForRequest>, 489 priority?: number, 490 ): Promise<void> { 491 if (!this.#canBeIntercepted()) { 492 return; 493 } 494 assert(this.interception.enabled, 'Request Interception is not enabled!'); 495 assert(!this.interception.handled, 'Request is already handled!'); 496 if (priority === undefined) { 497 return await this._respond(response); 498 } 499 this.interception.response = response; 500 if ( 501 this.interception.resolutionState.priority === undefined || 502 priority > this.interception.resolutionState.priority 503 ) { 504 this.interception.resolutionState = { 505 action: InterceptResolutionAction.Respond, 506 priority, 507 }; 508 return; 509 } 510 if (priority === this.interception.resolutionState.priority) { 511 if (this.interception.resolutionState.action === 'abort') { 512 return; 513 } 514 this.interception.resolutionState.action = 515 InterceptResolutionAction.Respond; 516 } 517 } 518 519 /** 520 * Aborts a request. 521 * 522 * @param errorCode - optional error code to provide. 523 * @param priority - If provided, intercept is resolved using 524 * cooperative handling rules. Otherwise, intercept is resolved 525 * immediately. 526 * 527 * @remarks 528 * 529 * To use this, request interception should be enabled with 530 * {@link Page.setRequestInterception}. If it is not enabled, this method will 531 * throw an exception immediately. 532 */ 533 async abort( 534 errorCode: ErrorCode = 'failed', 535 priority?: number, 536 ): Promise<void> { 537 if (!this.#canBeIntercepted()) { 538 return; 539 } 540 const errorReason = errorReasons[errorCode]; 541 assert(errorReason, 'Unknown error code: ' + errorCode); 542 assert(this.interception.enabled, 'Request Interception is not enabled!'); 543 assert(!this.interception.handled, 'Request is already handled!'); 544 if (priority === undefined) { 545 return await this._abort(errorReason); 546 } 547 this.interception.abortReason = errorReason; 548 if ( 549 this.interception.resolutionState.priority === undefined || 550 priority >= this.interception.resolutionState.priority 551 ) { 552 this.interception.resolutionState = { 553 action: InterceptResolutionAction.Abort, 554 priority, 555 }; 556 return; 557 } 558 } 559 560 /** 561 * @internal 562 */ 563 static getResponse(body: string | Uint8Array): { 564 contentLength: number; 565 base64: string; 566 } { 567 // Needed to get the correct byteLength 568 const byteBody: Uint8Array = isString(body) 569 ? new TextEncoder().encode(body) 570 : body; 571 572 return { 573 contentLength: byteBody.byteLength, 574 base64: typedArrayToBase64(byteBody), 575 }; 576 } 577 } 578 579 /** 580 * @public 581 */ 582 export enum InterceptResolutionAction { 583 Abort = 'abort', 584 Respond = 'respond', 585 Continue = 'continue', 586 Disabled = 'disabled', 587 None = 'none', 588 AlreadyHandled = 'already-handled', 589 } 590 591 /** 592 * @public 593 */ 594 export type ErrorCode = 595 | 'aborted' 596 | 'accessdenied' 597 | 'addressunreachable' 598 | 'blockedbyclient' 599 | 'blockedbyresponse' 600 | 'connectionaborted' 601 | 'connectionclosed' 602 | 'connectionfailed' 603 | 'connectionrefused' 604 | 'connectionreset' 605 | 'internetdisconnected' 606 | 'namenotresolved' 607 | 'timedout' 608 | 'failed'; 609 610 /** 611 * @public 612 */ 613 export type ActionResult = 'continue' | 'abort' | 'respond'; 614 615 /** 616 * @internal 617 */ 618 export function headersArray( 619 headers: Record<string, string | string[]>, 620 ): Array<{name: string; value: string}> { 621 const result = []; 622 for (const name in headers) { 623 const value = headers[name]; 624 625 if (!Object.is(value, undefined)) { 626 const values = Array.isArray(value) ? value : [value]; 627 628 result.push( 629 ...values.map(value => { 630 return {name, value: value + ''}; 631 }), 632 ); 633 } 634 } 635 return result; 636 } 637 638 /** 639 * @internal 640 * 641 * @remarks 642 * List taken from {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml} 643 * with extra 306 and 418 codes. 644 */ 645 export const STATUS_TEXTS: Record<string, string> = { 646 '100': 'Continue', 647 '101': 'Switching Protocols', 648 '102': 'Processing', 649 '103': 'Early Hints', 650 '200': 'OK', 651 '201': 'Created', 652 '202': 'Accepted', 653 '203': 'Non-Authoritative Information', 654 '204': 'No Content', 655 '205': 'Reset Content', 656 '206': 'Partial Content', 657 '207': 'Multi-Status', 658 '208': 'Already Reported', 659 '226': 'IM Used', 660 '300': 'Multiple Choices', 661 '301': 'Moved Permanently', 662 '302': 'Found', 663 '303': 'See Other', 664 '304': 'Not Modified', 665 '305': 'Use Proxy', 666 '306': 'Switch Proxy', 667 '307': 'Temporary Redirect', 668 '308': 'Permanent Redirect', 669 '400': 'Bad Request', 670 '401': 'Unauthorized', 671 '402': 'Payment Required', 672 '403': 'Forbidden', 673 '404': 'Not Found', 674 '405': 'Method Not Allowed', 675 '406': 'Not Acceptable', 676 '407': 'Proxy Authentication Required', 677 '408': 'Request Timeout', 678 '409': 'Conflict', 679 '410': 'Gone', 680 '411': 'Length Required', 681 '412': 'Precondition Failed', 682 '413': 'Payload Too Large', 683 '414': 'URI Too Long', 684 '415': 'Unsupported Media Type', 685 '416': 'Range Not Satisfiable', 686 '417': 'Expectation Failed', 687 '418': "I'm a teapot", 688 '421': 'Misdirected Request', 689 '422': 'Unprocessable Entity', 690 '423': 'Locked', 691 '424': 'Failed Dependency', 692 '425': 'Too Early', 693 '426': 'Upgrade Required', 694 '428': 'Precondition Required', 695 '429': 'Too Many Requests', 696 '431': 'Request Header Fields Too Large', 697 '451': 'Unavailable For Legal Reasons', 698 '500': 'Internal Server Error', 699 '501': 'Not Implemented', 700 '502': 'Bad Gateway', 701 '503': 'Service Unavailable', 702 '504': 'Gateway Timeout', 703 '505': 'HTTP Version Not Supported', 704 '506': 'Variant Also Negotiates', 705 '507': 'Insufficient Storage', 706 '508': 'Loop Detected', 707 '510': 'Not Extended', 708 '511': 'Network Authentication Required', 709 } as const; 710 711 const errorReasons: Record<ErrorCode, Protocol.Network.ErrorReason> = { 712 aborted: 'Aborted', 713 accessdenied: 'AccessDenied', 714 addressunreachable: 'AddressUnreachable', 715 blockedbyclient: 'BlockedByClient', 716 blockedbyresponse: 'BlockedByResponse', 717 connectionaborted: 'ConnectionAborted', 718 connectionclosed: 'ConnectionClosed', 719 connectionfailed: 'ConnectionFailed', 720 connectionrefused: 'ConnectionRefused', 721 connectionreset: 'ConnectionReset', 722 internetdisconnected: 'InternetDisconnected', 723 namenotresolved: 'NameNotResolved', 724 timedout: 'TimedOut', 725 failed: 'Failed', 726 } as const; 727 728 /** 729 * @internal 730 */ 731 export function handleError(error: ProtocolError): void { 732 // Firefox throws an invalid argument error with a message starting with 733 // 'Expected "header" [...]'. 734 if ( 735 error.originalMessage.includes('Invalid header') || 736 error.originalMessage.includes('Unsafe header') || 737 error.originalMessage.includes('Expected "header"') || 738 // WebDriver BiDi error for invalid values, for example, headers. 739 error.originalMessage.includes('invalid argument') 740 ) { 741 throw error; 742 } 743 // In certain cases, protocol will return error if the request was 744 // already canceled or the page was closed. We should tolerate these 745 // errors. 746 debugError(error); 747 }