From 39f1b13449d2a6cc115b904f109d84fd3786330f Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 13 May 2020 15:57:21 +0200 Subject: [PATCH] chore: extract `Request` and `Response` into its own module (#5861) * chore: extract `Request` and `Response` into its own module --- src/FrameManager.ts | 3 +- src/LifecycleWatcher.ts | 3 +- src/NetworkManager.ts | 428 +--------------------------------------- src/Page.ts | 8 +- src/Request.ts | 316 +++++++++++++++++++++++++++++ src/Response.ts | 142 +++++++++++++ src/api.ts | 4 +- 7 files changed, 473 insertions(+), 431 deletions(-) create mode 100644 src/Request.ts create mode 100644 src/Response.ts diff --git a/src/FrameManager.ts b/src/FrameManager.ts index 5bdca2257acc0..5427c0ab4adfd 100644 --- a/src/FrameManager.ts +++ b/src/FrameManager.ts @@ -20,12 +20,13 @@ import { Events } from './Events'; import { ExecutionContext, EVALUATION_SCRIPT_URL } from './ExecutionContext'; import { LifecycleWatcher, PuppeteerLifeCycleEvent } from './LifecycleWatcher'; import { DOMWorld, WaitForSelectorOptions } from './DOMWorld'; -import { NetworkManager, Response } from './NetworkManager'; +import { NetworkManager } from './NetworkManager'; import { TimeoutSettings } from './TimeoutSettings'; import { CDPSession } from './Connection'; import { JSHandle, ElementHandle } from './JSHandle'; import { MouseButtonInput } from './Input'; import { Page } from './Page'; +import { Response } from './Response'; const UTILITY_WORLD_NAME = '__puppeteer_utility_world__'; diff --git a/src/LifecycleWatcher.ts b/src/LifecycleWatcher.ts index 7de9f85c66fe4..4fbd70a0567ba 100644 --- a/src/LifecycleWatcher.ts +++ b/src/LifecycleWatcher.ts @@ -18,7 +18,8 @@ import { helper, assert, PuppeteerEventListener } from './helper'; import { Events } from './Events'; import { TimeoutError } from './Errors'; import { FrameManager, Frame } from './FrameManager'; -import { Request, Response } from './NetworkManager'; +import { Request } from './Request'; +import { Response } from './Response'; export type PuppeteerLifeCycleEvent = | 'load' diff --git a/src/NetworkManager.ts b/src/NetworkManager.ts index 6018184d1e147..54899a6bc5f38 100644 --- a/src/NetworkManager.ts +++ b/src/NetworkManager.ts @@ -17,8 +17,9 @@ import * as EventEmitter from 'events'; import { helper, assert, debugError } from './helper'; import { Events } from './Events'; import { CDPSession } from './Connection'; -import { FrameManager, Frame } from './FrameManager'; -import { SecurityDetails } from './SecurityDetails'; +import { FrameManager } from './FrameManager'; +import { Request } from './Request'; +import { Response } from './Response'; export interface Credentials { username: string; @@ -274,8 +275,7 @@ export class NetworkManager extends EventEmitter { const response = new Response(this._client, request, responsePayload); request._response = response; request._redirectChain.push(request); - response._bodyLoadedPromiseFulfill.call( - null, + response._resolveBody( new Error('Response body is unavailable for redirect responses') ); this._requestIdToRequest.delete(request._requestId); @@ -301,8 +301,7 @@ export class NetworkManager extends EventEmitter { // Under certain conditions we never get the Network.responseReceived // event from protocol. @see https://crbug.com/883475 - if (request.response()) - request.response()._bodyLoadedPromiseFulfill.call(null); + if (request.response()) request.response()._resolveBody(null); this._requestIdToRequest.delete(request._requestId); this._attemptedAuthentications.delete(request._interceptionId); this.emit(Events.NetworkManager.RequestFinished, request); @@ -315,424 +314,9 @@ export class NetworkManager extends EventEmitter { if (!request) return; request._failureText = event.errorText; const response = request.response(); - if (response) response._bodyLoadedPromiseFulfill.call(null); + if (response) response._resolveBody(null); this._requestIdToRequest.delete(request._requestId); this._attemptedAuthentications.delete(request._interceptionId); this.emit(Events.NetworkManager.RequestFailed, request); } } - -export class Request { - _client: CDPSession; - _requestId: string; - _isNavigationRequest: boolean; - _interceptionId: string; - _allowInterception: boolean; - _interceptionHandled = false; - _response: Response | null = null; - _failureText = null; - _url: string; - _resourceType: string; - - _method: string; - _postData?: string; - _headers: Record = {}; - _frame: Frame; - - _redirectChain: Request[]; - _fromMemoryCache = false; - - constructor( - client: CDPSession, - frame: Frame, - interceptionId: string, - allowInterception: boolean, - event: Protocol.Network.requestWillBeSentPayload, - redirectChain: Request[] - ) { - this._client = client; - this._requestId = event.requestId; - this._isNavigationRequest = - event.requestId === event.loaderId && event.type === 'Document'; - this._interceptionId = interceptionId; - this._allowInterception = allowInterception; - this._url = event.request.url; - this._resourceType = event.type.toLowerCase(); - this._method = event.request.method; - this._postData = event.request.postData; - this._frame = frame; - this._redirectChain = redirectChain; - - for (const key of Object.keys(event.request.headers)) - this._headers[key.toLowerCase()] = event.request.headers[key]; - } - - url(): string { - return this._url; - } - - resourceType(): string { - return this._resourceType; - } - - method(): string { - return this._method; - } - - postData(): string | undefined { - return this._postData; - } - - headers(): Record { - return this._headers; - } - - response(): Response | null { - return this._response; - } - - frame(): Frame | null { - return this._frame; - } - - isNavigationRequest(): boolean { - return this._isNavigationRequest; - } - - redirectChain(): Request[] { - return this._redirectChain.slice(); - } - - /** - * @return {?{errorText: string}} - */ - failure(): { errorText: string } | null { - if (!this._failureText) return null; - return { - errorText: this._failureText, - }; - } - - async continue( - overrides: { - url?: string; - method?: string; - postData?: string; - headers?: Record; - } = {} - ): Promise { - // Request interception is not supported for data: urls. - if (this._url.startsWith('data:')) return; - assert(this._allowInterception, 'Request Interception is not enabled!'); - assert(!this._interceptionHandled, 'Request is already handled!'); - const { url, method, postData, headers } = overrides; - this._interceptionHandled = true; - await this._client - .send('Fetch.continueRequest', { - requestId: this._interceptionId, - url, - method, - postData, - headers: headers ? headersArray(headers) : undefined, - }) - .catch((error) => { - // In certain cases, protocol will return error if the request was already canceled - // or the page was closed. We should tolerate these errors. - debugError(error); - }); - } - - async respond(response: { - status: number; - headers: Record; - contentType: string; - body: string | Buffer; - }): Promise { - // Mocking responses for dataURL requests is not currently supported. - if (this._url.startsWith('data:')) return; - assert(this._allowInterception, 'Request Interception is not enabled!'); - assert(!this._interceptionHandled, 'Request is already handled!'); - this._interceptionHandled = true; - - const responseBody: Buffer | null = - response.body && helper.isString(response.body) - ? Buffer.from(response.body) - : (response.body as Buffer) || null; - - const responseHeaders: Record = {}; - if (response.headers) { - for (const header of Object.keys(response.headers)) - responseHeaders[header.toLowerCase()] = response.headers[header]; - } - if (response.contentType) - responseHeaders['content-type'] = response.contentType; - if (responseBody && !('content-length' in responseHeaders)) - responseHeaders['content-length'] = String( - Buffer.byteLength(responseBody) - ); - - await this._client - .send('Fetch.fulfillRequest', { - requestId: this._interceptionId, - responseCode: response.status || 200, - responsePhrase: STATUS_TEXTS[response.status || 200], - responseHeaders: headersArray(responseHeaders), - body: responseBody ? responseBody.toString('base64') : undefined, - }) - .catch((error) => { - // In certain cases, protocol will return error if the request was already canceled - // or the page was closed. We should tolerate these errors. - debugError(error); - }); - } - - async abort(errorCode: ErrorCode = 'failed'): Promise { - // Request interception is not supported for data: urls. - if (this._url.startsWith('data:')) return; - const errorReason = errorReasons[errorCode]; - assert(errorReason, 'Unknown error code: ' + errorCode); - assert(this._allowInterception, 'Request Interception is not enabled!'); - assert(!this._interceptionHandled, 'Request is already handled!'); - this._interceptionHandled = true; - await this._client - .send('Fetch.failRequest', { - requestId: this._interceptionId, - errorReason, - }) - .catch((error) => { - // In certain cases, protocol will return error if the request was already canceled - // or the page was closed. We should tolerate these errors. - debugError(error); - }); - } -} - -type ErrorCode = - | 'aborted' - | 'accessdenied' - | 'addressunreachable' - | 'blockedbyclient' - | 'blockedbyresponse' - | 'connectionaborted' - | 'connectionclosed' - | 'connectionfailed' - | 'connectionrefused' - | 'connectionreset' - | 'internetdisconnected' - | 'namenotresolved' - | 'timedout' - | 'failed'; - -const errorReasons: Record = { - aborted: 'Aborted', - accessdenied: 'AccessDenied', - addressunreachable: 'AddressUnreachable', - blockedbyclient: 'BlockedByClient', - blockedbyresponse: 'BlockedByResponse', - connectionaborted: 'ConnectionAborted', - connectionclosed: 'ConnectionClosed', - connectionfailed: 'ConnectionFailed', - connectionrefused: 'ConnectionRefused', - connectionreset: 'ConnectionReset', - internetdisconnected: 'InternetDisconnected', - namenotresolved: 'NameNotResolved', - timedout: 'TimedOut', - failed: 'Failed', -} as const; - -interface RemoteAddress { - ip: string; - port: number; -} - -export class Response { - _client: CDPSession; - _request: Request; - _contentPromise: Promise | null = null; - _bodyLoadedPromise: Promise; - _bodyLoadedPromiseFulfill: (x: boolean) => void; - _remoteAddress: RemoteAddress; - _status: number; - _statusText: string; - _url: string; - _fromDiskCache: boolean; - _fromServiceWorker: boolean; - _headers: Record = {}; - _securityDetails: SecurityDetails | null; - - constructor( - client: CDPSession, - request: Request, - responsePayload: Protocol.Network.Response - ) { - this._client = client; - this._request = request; - - this._bodyLoadedPromise = new Promise((fulfill) => { - this._bodyLoadedPromiseFulfill = fulfill; - }); - - this._remoteAddress = { - ip: responsePayload.remoteIPAddress, - port: responsePayload.remotePort, - }; - this._status = responsePayload.status; - this._statusText = responsePayload.statusText; - this._url = request.url(); - this._fromDiskCache = !!responsePayload.fromDiskCache; - this._fromServiceWorker = !!responsePayload.fromServiceWorker; - for (const key of Object.keys(responsePayload.headers)) - this._headers[key.toLowerCase()] = responsePayload.headers[key]; - this._securityDetails = responsePayload.securityDetails - ? new SecurityDetails(responsePayload.securityDetails) - : null; - } - - remoteAddress(): RemoteAddress { - return this._remoteAddress; - } - - url(): string { - return this._url; - } - - ok(): boolean { - return this._status === 0 || (this._status >= 200 && this._status <= 299); - } - - status(): number { - return this._status; - } - - statusText(): string { - return this._statusText; - } - - headers(): Record { - return this._headers; - } - - securityDetails(): SecurityDetails | null { - return this._securityDetails; - } - - buffer(): Promise { - if (!this._contentPromise) { - this._contentPromise = this._bodyLoadedPromise.then(async (error) => { - if (error) throw error; - const response = await this._client.send('Network.getResponseBody', { - requestId: this._request._requestId, - }); - return Buffer.from( - response.body, - response.base64Encoded ? 'base64' : 'utf8' - ); - }); - } - return this._contentPromise; - } - - async text(): Promise { - const content = await this.buffer(); - return content.toString('utf8'); - } - - async json(): Promise { - const content = await this.text(); - return JSON.parse(content); - } - - request(): Request { - return this._request; - } - - fromCache(): boolean { - return this._fromDiskCache || this._request._fromMemoryCache; - } - - fromServiceWorker(): boolean { - return this._fromServiceWorker; - } - - frame(): Frame | null { - return this._request.frame(); - } -} - -function headersArray( - headers: Record -): Array<{ name: string; value: string }> { - const result = []; - for (const name in headers) { - if (!Object.is(headers[name], undefined)) - result.push({ name, value: headers[name] + '' }); - } - return result; -} - -// List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes. -const STATUS_TEXTS = { - '100': 'Continue', - '101': 'Switching Protocols', - '102': 'Processing', - '103': 'Early Hints', - '200': 'OK', - '201': 'Created', - '202': 'Accepted', - '203': 'Non-Authoritative Information', - '204': 'No Content', - '205': 'Reset Content', - '206': 'Partial Content', - '207': 'Multi-Status', - '208': 'Already Reported', - '226': 'IM Used', - '300': 'Multiple Choices', - '301': 'Moved Permanently', - '302': 'Found', - '303': 'See Other', - '304': 'Not Modified', - '305': 'Use Proxy', - '306': 'Switch Proxy', - '307': 'Temporary Redirect', - '308': 'Permanent Redirect', - '400': 'Bad Request', - '401': 'Unauthorized', - '402': 'Payment Required', - '403': 'Forbidden', - '404': 'Not Found', - '405': 'Method Not Allowed', - '406': 'Not Acceptable', - '407': 'Proxy Authentication Required', - '408': 'Request Timeout', - '409': 'Conflict', - '410': 'Gone', - '411': 'Length Required', - '412': 'Precondition Failed', - '413': 'Payload Too Large', - '414': 'URI Too Long', - '415': 'Unsupported Media Type', - '416': 'Range Not Satisfiable', - '417': 'Expectation Failed', - '418': "I'm a teapot", - '421': 'Misdirected Request', - '422': 'Unprocessable Entity', - '423': 'Locked', - '424': 'Failed Dependency', - '425': 'Too Early', - '426': 'Upgrade Required', - '428': 'Precondition Required', - '429': 'Too Many Requests', - '431': 'Request Header Fields Too Large', - '451': 'Unavailable For Legal Reasons', - '500': 'Internal Server Error', - '501': 'Not Implemented', - '502': 'Bad Gateway', - '503': 'Service Unavailable', - '504': 'Gateway Timeout', - '505': 'HTTP Version Not Supported', - '506': 'Variant Also Negotiates', - '507': 'Insufficient Storage', - '508': 'Loop Detected', - '510': 'Not Extended', - '511': 'Network Authentication Required', -} as const; diff --git a/src/Page.ts b/src/Page.ts index ea608a2c51d9c..59c03feb5bcd9 100644 --- a/src/Page.ts +++ b/src/Page.ts @@ -31,11 +31,9 @@ import { Browser, BrowserContext } from './Browser'; import { Target } from './Target'; import { createJSHandle, JSHandle, ElementHandle } from './JSHandle'; import type { Viewport } from './PuppeteerViewport'; -import { - Request as PuppeteerRequest, - Response as PuppeteerResponse, - Credentials, -} from './NetworkManager'; +import { Credentials } from './NetworkManager'; +import { Request as PuppeteerRequest } from './Request'; +import { Response as PuppeteerResponse } from './Response'; import { Accessibility } from './Accessibility'; import { TimeoutSettings } from './TimeoutSettings'; import { FileChooser } from './FileChooser'; diff --git a/src/Request.ts b/src/Request.ts new file mode 100644 index 0000000000000..c3266c82f47d1 --- /dev/null +++ b/src/Request.ts @@ -0,0 +1,316 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CDPSession } from './Connection'; +import { Frame } from './FrameManager'; +import { Response } from './Response'; +import { helper, assert, debugError } from './helper'; + +export class Request { + _requestId: string; + _interceptionId: string; + _failureText = null; + _response: Response | null = null; + + _fromMemoryCache = false; + _redirectChain: Request[]; + + private _client: CDPSession; + private _isNavigationRequest: boolean; + private _allowInterception: boolean; + private _interceptionHandled = false; + private _url: string; + private _resourceType: string; + + private _method: string; + private _postData?: string; + private _headers: Record = {}; + private _frame: Frame; + + constructor( + client: CDPSession, + frame: Frame, + interceptionId: string, + allowInterception: boolean, + event: Protocol.Network.requestWillBeSentPayload, + redirectChain: Request[] + ) { + this._client = client; + this._requestId = event.requestId; + this._isNavigationRequest = + event.requestId === event.loaderId && event.type === 'Document'; + this._interceptionId = interceptionId; + this._allowInterception = allowInterception; + this._url = event.request.url; + this._resourceType = event.type.toLowerCase(); + this._method = event.request.method; + this._postData = event.request.postData; + this._frame = frame; + this._redirectChain = redirectChain; + + for (const key of Object.keys(event.request.headers)) + this._headers[key.toLowerCase()] = event.request.headers[key]; + } + + url(): string { + return this._url; + } + + resourceType(): string { + return this._resourceType; + } + + method(): string { + return this._method; + } + + postData(): string | undefined { + return this._postData; + } + + headers(): Record { + return this._headers; + } + + response(): Response | null { + return this._response; + } + + frame(): Frame | null { + return this._frame; + } + + isNavigationRequest(): boolean { + return this._isNavigationRequest; + } + + redirectChain(): Request[] { + return this._redirectChain.slice(); + } + + /** + * @return {?{errorText: string}} + */ + failure(): { errorText: string } | null { + if (!this._failureText) return null; + return { + errorText: this._failureText, + }; + } + + async continue( + overrides: { + url?: string; + method?: string; + postData?: string; + headers?: Record; + } = {} + ): Promise { + // Request interception is not supported for data: urls. + if (this._url.startsWith('data:')) return; + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + const { url, method, postData, headers } = overrides; + this._interceptionHandled = true; + await this._client + .send('Fetch.continueRequest', { + requestId: this._interceptionId, + url, + method, + postData, + headers: headers ? headersArray(headers) : undefined, + }) + .catch((error) => { + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors. + debugError(error); + }); + } + + async respond(response: { + status: number; + headers: Record; + contentType: string; + body: string | Buffer; + }): Promise { + // Mocking responses for dataURL requests is not currently supported. + if (this._url.startsWith('data:')) return; + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + + const responseBody: Buffer | null = + response.body && helper.isString(response.body) + ? Buffer.from(response.body) + : (response.body as Buffer) || null; + + const responseHeaders: Record = {}; + if (response.headers) { + for (const header of Object.keys(response.headers)) + responseHeaders[header.toLowerCase()] = response.headers[header]; + } + if (response.contentType) + responseHeaders['content-type'] = response.contentType; + if (responseBody && !('content-length' in responseHeaders)) + responseHeaders['content-length'] = String( + Buffer.byteLength(responseBody) + ); + + await this._client + .send('Fetch.fulfillRequest', { + requestId: this._interceptionId, + responseCode: response.status || 200, + responsePhrase: STATUS_TEXTS[response.status || 200], + responseHeaders: headersArray(responseHeaders), + body: responseBody ? responseBody.toString('base64') : undefined, + }) + .catch((error) => { + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors. + debugError(error); + }); + } + + async abort(errorCode: ErrorCode = 'failed'): Promise { + // Request interception is not supported for data: urls. + if (this._url.startsWith('data:')) return; + const errorReason = errorReasons[errorCode]; + assert(errorReason, 'Unknown error code: ' + errorCode); + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + await this._client + .send('Fetch.failRequest', { + requestId: this._interceptionId, + errorReason, + }) + .catch((error) => { + // In certain cases, protocol will return error if the request was already canceled + // or the page was closed. We should tolerate these errors. + debugError(error); + }); + } +} + +type ErrorCode = + | 'aborted' + | 'accessdenied' + | 'addressunreachable' + | 'blockedbyclient' + | 'blockedbyresponse' + | 'connectionaborted' + | 'connectionclosed' + | 'connectionfailed' + | 'connectionrefused' + | 'connectionreset' + | 'internetdisconnected' + | 'namenotresolved' + | 'timedout' + | 'failed'; + +const errorReasons: Record = { + aborted: 'Aborted', + accessdenied: 'AccessDenied', + addressunreachable: 'AddressUnreachable', + blockedbyclient: 'BlockedByClient', + blockedbyresponse: 'BlockedByResponse', + connectionaborted: 'ConnectionAborted', + connectionclosed: 'ConnectionClosed', + connectionfailed: 'ConnectionFailed', + connectionrefused: 'ConnectionRefused', + connectionreset: 'ConnectionReset', + internetdisconnected: 'InternetDisconnected', + namenotresolved: 'NameNotResolved', + timedout: 'TimedOut', + failed: 'Failed', +} as const; + +function headersArray( + headers: Record +): Array<{ name: string; value: string }> { + const result = []; + for (const name in headers) { + if (!Object.is(headers[name], undefined)) + result.push({ name, value: headers[name] + '' }); + } + return result; +} + +// List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes. +const STATUS_TEXTS = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '103': 'Early Hints', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '226': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Switch Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': "I'm a teapot", + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '425': 'Too Early', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '510': 'Not Extended', + '511': 'Network Authentication Required', +} as const; diff --git a/src/Response.ts b/src/Response.ts new file mode 100644 index 0000000000000..c76bc3f6d504e --- /dev/null +++ b/src/Response.ts @@ -0,0 +1,142 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CDPSession } from './Connection'; +import { Frame } from './FrameManager'; +import { Request } from './Request'; +import { SecurityDetails } from './SecurityDetails'; + +interface RemoteAddress { + ip: string; + port: number; +} + +export class Response { + private _client: CDPSession; + private _request: Request; + private _contentPromise: Promise | null = null; + private _bodyLoadedPromise: Promise; + private _bodyLoadedPromiseFulfill: (err: Error | void) => void; + private _remoteAddress: RemoteAddress; + private _status: number; + private _statusText: string; + private _url: string; + private _fromDiskCache: boolean; + private _fromServiceWorker: boolean; + private _headers: Record = {}; + private _securityDetails: SecurityDetails | null; + + constructor( + client: CDPSession, + request: Request, + responsePayload: Protocol.Network.Response + ) { + this._client = client; + this._request = request; + + this._bodyLoadedPromise = new Promise((fulfill) => { + this._bodyLoadedPromiseFulfill = fulfill; + }); + + this._remoteAddress = { + ip: responsePayload.remoteIPAddress, + port: responsePayload.remotePort, + }; + this._status = responsePayload.status; + this._statusText = responsePayload.statusText; + this._url = request.url(); + this._fromDiskCache = !!responsePayload.fromDiskCache; + this._fromServiceWorker = !!responsePayload.fromServiceWorker; + for (const key of Object.keys(responsePayload.headers)) + this._headers[key.toLowerCase()] = responsePayload.headers[key]; + this._securityDetails = responsePayload.securityDetails + ? new SecurityDetails(responsePayload.securityDetails) + : null; + } + + _resolveBody(err: Error | null): void { + return this._bodyLoadedPromiseFulfill(err); + } + + remoteAddress(): RemoteAddress { + return this._remoteAddress; + } + + url(): string { + return this._url; + } + + ok(): boolean { + return this._status === 0 || (this._status >= 200 && this._status <= 299); + } + + status(): number { + return this._status; + } + + statusText(): string { + return this._statusText; + } + + headers(): Record { + return this._headers; + } + + securityDetails(): SecurityDetails | null { + return this._securityDetails; + } + + buffer(): Promise { + if (!this._contentPromise) { + this._contentPromise = this._bodyLoadedPromise.then(async (error) => { + if (error) throw error; + const response = await this._client.send('Network.getResponseBody', { + requestId: this._request._requestId, + }); + return Buffer.from( + response.body, + response.base64Encoded ? 'base64' : 'utf8' + ); + }); + } + return this._contentPromise; + } + + async text(): Promise { + const content = await this.buffer(); + return content.toString('utf8'); + } + + async json(): Promise { + const content = await this.text(); + return JSON.parse(content); + } + + request(): Request { + return this._request; + } + + fromCache(): boolean { + return this._fromDiskCache || this._request._fromMemoryCache; + } + + fromServiceWorker(): boolean { + return this._fromServiceWorker; + } + + frame(): Frame | null { + return this._request.frame(); + } +} diff --git a/src/api.ts b/src/api.ts index 637f2f67c58fe..d8d974f897144 100644 --- a/src/api.ts +++ b/src/api.ts @@ -36,8 +36,8 @@ module.exports = { Mouse: require('./Input').Mouse, Page: require('./Page').Page, Puppeteer: require('./Puppeteer').Puppeteer, - Request: require('./NetworkManager').Request, - Response: require('./NetworkManager').Response, + Request: require('./Request').Request, + Response: require('./Response').Response, SecurityDetails: require('./SecurityDetails').SecurityDetails, Target: require('./Target').Target, TimeoutError: require('./Errors').TimeoutError,