UNPKG

20.4 kBPlain TextView Raw
1/**
2 * @license
3 * Copyright 2020 Google Inc.
4 * SPDX-License-Identifier: Apache-2.0
5 */
6import type {Protocol} from 'devtools-protocol';
7
8import type {ProtocolError} from '../common/Errors.js';
9import {debugError, isString} from '../common/util.js';
10import {assert} from '../util/assert.js';
11import {typedArrayToBase64} from '../util/encoding.js';
12
13import type {CDPSession} from './CDPSession.js';
14import type {Frame} from './Frame.js';
15import type {HTTPResponse} from './HTTPResponse.js';
16
17/**
18 * @public
19 */
20export 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 */
33export interface InterceptResolutionState {
34 action: InterceptResolutionAction;
35 priority?: number;
36}
37
38/**
39 * Required response data to fulfill a request with.
40 *
41 * @public
42 */
43export 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 */
63export type ResourceType = Lowercase<Protocol.Network.ResourceType>;
64
65/**
66 * The default cooperative request interception resolution priority
67 *
68 * @public
69 */
70export 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 */
104export 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 */
582export 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 */
594export 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 */
613export type ActionResult = 'continue' | 'abort' | 'respond';
614
615/**
616 * @internal
617 */
618export 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 */
645export 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
711const 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 */
731export 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('Expected "header"') ||
737 // WebDriver BiDi error for invalid values, for example, headers.
738 error.originalMessage.includes('invalid argument')
739 ) {
740 throw error;
741 }
742 // In certain cases, protocol will return error if the request was
743 // already canceled or the page was closed. We should tolerate these
744 // errors.
745 debugError(error);
746}