lib/network/Request.js
const { STATUS_CODES } = require('http')
const { URL } = require('url')
const util = require('util')
const { assert, debugError, helper } = require('../helper')
const {
NonHTTP2Protocols,
CRLF,
HTTP11,
SpaceChar,
stringifyRequestHeaders,
headersToLowerCase
} = require('./_shared')
const errorReasons = {
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'
}
/**
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#type-Request
*/
class Request {
/**
* @param {Object} client
* @param {Object} event
* @param {?Frame} frame
* @param {Array<Request>} redirectChain
* @param {string} interceptionId
* @param {boolean} allowInterception
*/
constructor (
client,
event,
frame,
redirectChain,
interceptionId,
allowInterception
) {
/**
* @type {Object}
* @protected
*/
this._client = client
/**
* @type {?Frame}
*/
this._frame = frame
/**
* @type {string}
*/
this._interceptionId = interceptionId
/**
* @type {boolean}
*/
this._allowInterception = allowInterception
/**
* @type {?Response}
*/
this._response = null
/**
* @type {string}
*/
this._requestId = event.requestId
/**
* @type {string}
*/
this._loaderId = event.loaderId
/**
* @type {string}
*/
this._documentURL = event.documentURL
/**
* @type {number}
*/
this._timestamp = event.timestamp
/**
* @type {number}
*/
this._wallTime = event.wallTime
/**
* @type {string}
*/
this._initiator = event.initiator
/**
* @type {string}
*/
this._type = event.type
/**
* @type {string}
*/
this._frameId = event.frameId
/**
* @type {boolean}
*/
this._hasUserGesture = event.hasUserGesture
/** @type {Object} **/
const rinfo = event.request
/**
* @type {string}
*/
this._url = rinfo.url
/**
* @type {?URL}
* @private
*/
this._purl = null
/**
* @type {Object}
*/
this._headers = rinfo.headers
/**
* @type {Object}
*/
this._fullHeaders = null
/**
* @type {?string}
*/
this._headersText = null
/**
* @type {?string}
*/
this._urlFragment = rinfo.urlFragment
/**
* @type {string}
*/
this._method = rinfo.method
/**
* @type {?string}
*/
this._postData = rinfo.postData
/**
* @type {?boolean}
*/
this._hasPostData = rinfo.hasPostData
/**
* @type {?string}
*/
this._mixedContentType = rinfo.mixedContentType
/**
* @type {string}
*/
this._initialPriority = rinfo.initialPriority
/**
* @type {string}
*/
this._referrerPolicy = rinfo.referrerPolicy
/**
* @type {boolean}
*/
this._isLinkPreload = rinfo.isLinkPreload
/**
* @type {boolean}
*/
this._isNavigationRequest =
this._requestId === this._loaderId && this._type === 'Document'
/**
* @type {?string}
*/
this._protocol = null
/**
* @type {Array<Request>}
*/
this._redirectChain = redirectChain
/**
* @type {boolean}
*/
this._fromMemoryCache = false
/**
* @type {?string}
*/
this._failureText = null
/** @type {?Object} */
this._headersLower = null
this._checkRedoNormalization = true
}
/**
* @param {boolean} [noHTTP2Plus] - When true if the request was made via HTTP2/HTTP3 the protocol is forced to HTTP/1.1
* @return {string}
*/
requestLine (noHTTP2Plus) {
const url = this.parsedURL()
const path = `${url.pathname}${
url.search ? `?${url.searchParams.toString()}` : ''
}${url.hash ? url.hash : ''}`
let proto = this._protocol ? this._protocol : HTTP11
if (noHTTP2Plus && !NonHTTP2Protocols.has(proto)) {
proto = HTTP11
}
return `${this._method} ${path} ${proto}`
}
/**
* @param {boolean} [noHTTP2Plus] - When true if the request was made via HTTP2/HTTP3 the protocol is forced to HTTP/1.1
* @return {string}
*/
requestLineAndHeaders (noHTTP2Plus) {
if (!noHTTP2Plus) {
if (this._headersText) return this._headersText
return `${this.requestLine()}${CRLF}${stringifyRequestHeaders(
this.headers(),
this.parsedURL().host
)}`
}
if (this._headersText) {
const fcrlfidx = this._headersText.indexOf(CRLF)
const fline = this._headersText.substring(0, fcrlfidx)
const protocol = fline.substring(fline.lastIndexOf(SpaceChar) + 1)
if (NonHTTP2Protocols.has(protocol)) {
return this._headersText
}
return `${fline.replace(protocol, HTTP11)}${this._headersText.substring(
fcrlfidx
)}`
}
return `${this.requestLine(noHTTP2Plus)}${CRLF}${stringifyRequestHeaders(
this.headers(),
this.parsedURL().host
)}`
}
/**
* @return {string}
*/
url () {
return this._urlFragment != null ? this._url + this._urlFragment : this._url
}
/**
* @return {URL}
*/
parsedURL () {
if (!this._purl) {
this._purl = new URL(this.url())
}
return this._purl
}
/**
* Returns the HTTP headers as sent by the browser.
* If the full request HTTP headers are available (CDPResponse.requestHeaders) they are
* returned otherwise the value of CDPRequest.headers are returned.
* @return {Object}
*/
headers () {
return this._fullHeaders != null ? this._fullHeaders : this._headers
}
/**
* Returns the normalized (header keys in lowercase) HTTP headers as sent by the browser.
* See {@link Request#headers} for more details
* @return {Object}
*/
normalizedHeaders () {
if (
!this._headersLower ||
(this._checkRedoNormalization && this._fullHeaders)
) {
if (this._fullHeaders) this._checkRedoNormalization = false
this._headersLower = headersToLowerCase(this.headers())
}
return this._headersLower
}
/**
* Returns the value for the supplied header (case insensitive) if it exists.
* See {@link Request#normalizedHeaders} and {@link Request#headers} for more details
* @param {string} header
* @return {?string}
*/
header (header) {
const headers = this.normalizedHeaders()
if (headers) return headers[header.toLowerCase()]
return null
}
/**
* @des Returns this requests (request line and headers with CRLF) as sent by the browser if they were included with this requests response
* @return {?string}
*/
headersText () {
return this._headersText
}
/**
*
* @return {string}
*/
method () {
return this._method
}
/**
*
* @return {?string}
*/
postData () {
return this._postData
}
/**
* @return {?Response}
*/
response () {
return this._response
}
/**
*
* @return {?string}
*/
protocol () {
return this._protocol
}
/**
*
* @return {string}
*/
requestId () {
return this._requestId
}
/**
*
* @return {string}
*/
loaderId () {
return this._loaderId
}
/**
*
* @return {string}
*/
documentURL () {
return this._documentURL
}
/**
*
* @return {number}
*/
timestamp () {
return this._timestamp
}
/**
*
* @return {number}
*/
wallTime () {
return this._wallTime
}
/**
*
* @return {string}
*/
initiator () {
return this._initiator
}
/**
*
* @return {string}
*/
type () {
return this._type
}
resourceType () {
return this._type.toLowerCase()
}
/**
*
* @return {string}
*/
frameId () {
return this._frameId
}
/**
*
* @return {?string}
*/
urlFragment () {
return this._urlFragment
}
/**
*
* @return {?boolean}
*/
hasPostData () {
return this._hasPostData
}
/**
*
* @return {?string}
*/
mixedContentType () {
return this._mixedContentType
}
/**
*
* @return {string}
*/
initialPriority () {
return this._initialPriority
}
/**
*
* @return {string}
*/
referrerPolicy () {
return this._referrerPolicy
}
/**
*
* @return {boolean}
*/
isNavigationRequest () {
return this._isNavigationRequest
}
/**
*
* @return {boolean}
*/
hasUserGesture () {
return this._hasUserGesture
}
/**
*
* @return {?Frame}
*/
frame () {
return this._frame
}
/**
*
* @return {boolean}
*/
isLinkPreload () {
return this._isLinkPreload
}
/**
* @return {Array<Request>}
*/
redirectChain () {
return this._redirectChain.slice()
}
/**
* @return {boolean}
*/
fromMemoryCache () {
return this._fromMemoryCache
}
/**
* @return {?{errorText: string}}
*/
failure () {
if (!this._failureText) return null
return {
errorText: this._failureText
}
}
/**
* Returns post data sent with the request. Returns an error when no data was sent with the request
* @return {Promise<Buffer>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-getRequestPostData
*/
async getPostData () {
const data = await this._client.send('Network.getRequestPostData', {
requestId: this._requestId
})
return Buffer.from(data.postData, data.base64Encoded ? 'base64' : 'utf8')
}
/**
* @param {!{url?: string, method?:string, postData?: string, headers?: !Object}} overrides
*/
async continue (overrides = {}) {
if (this._url.startsWith('data:')) return
assert(this._allowInterception, 'Request Interception is not enabled!')
assert(!this._interceptionHandled, 'Request is already handled!')
this._interceptionHandled = true
await this._client
.send(
'Network.continueInterceptedRequest',
Object.assign({}, overrides, { interceptionId: this._interceptionId })
)
.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)
})
}
/**
* @param {!{status: number, headers: Object, contentType: string, body: (string|Buffer)}} response
*/
async respond (response) {
// 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 =
response.body && helper.isString(response.body)
? Buffer.from(/** @type {string} */ (response.body))
: /** @type {?Buffer} */ (response.body || null)
const responseHeaders = {}
if (response.headers) {
const headerKeys = Object.keys(response.headers)
for (let i = 0; i < headerKeys.length; i++) {
responseHeaders[headerKeys[i].toLowerCase()] =
response.headers[headerKeys[i]]
}
}
if (response.contentType) {
responseHeaders['content-type'] = response.contentType
}
if (responseBody && !('content-length' in responseHeaders)) {
responseHeaders['content-length'] = Buffer.byteLength(responseBody)
}
const statusCode = response.status || 200
const statusText = STATUS_CODES[statusCode] || ''
const statusLine = `HTTP/1.1 ${statusCode} ${statusText}`
const CRLF = '\r\n'
const text = [statusLine, CRLF]
const responseHeaderKeys = Object.keys(responseHeaders)
for (let i = 0; i < responseHeaderKeys.length; i++) {
text.push(
responseHeaderKeys[i],
': ',
responseHeaders[responseHeaderKeys[i]],
CRLF
)
}
text.push(CRLF)
let responseBuffer = Buffer.from(text.join(''), 'utf8')
if (responseBody) {
responseBuffer = Buffer.concat([responseBuffer, responseBody])
}
await this._client
.send('Network.continueInterceptedRequest', {
interceptionId: this._interceptionId,
rawResponse: responseBuffer.toString('base64')
})
.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)
})
}
/**
* @param {string=} errorCode
*/
async abort (errorCode = 'failed') {
// 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('Network.continueInterceptedRequest', {
interceptionId: 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)
})
}
/**
* @return {{headers: Object, initialPriority: string, method: string, referrerPolicy: string, frameId: string, mixedContentType: ?string, documentURL: string, initiator: string, loaderId: string, hasPostData: ?boolean, urlFragment: ?string, type: string, url: string, isLinkPreload: boolean, requestId: string, response: ?Response, hasUserGesture: boolean, wallTime: number, fromMemoryCache: boolean, postData: ?string, timestamp: number}}
*/
toJSON () {
return {
documentURL: this._documentURL,
frameId: this._frameId,
fromMemoryCache: this._fromMemoryCache,
hasPostData: this._hasPostData,
hasUserGesture: this._hasUserGesture,
headers: this.headers(),
initialPriority: this._initialPriority,
initiator: this._initiator,
isLinkPreload: this._isLinkPreload,
loaderId: this._loaderId,
method: this._method,
mixedContentType: this._mixedContentType,
postData: this._postData,
referrerPolicy: this._referrerPolicy,
requestId: this._requestId,
response: this._response,
timestamp: this._timestamp,
type: this._type,
url: this._url,
urlFragment: this._urlFragment,
wallTime: this._wallTime
}
}
/** @ignore */
// eslint-disable-next-line space-before-function-paren
[util.inspect.custom](depth, options) {
if (depth < 0) {
return options.stylize('[Request]', 'special')
}
const newOptions = Object.assign({}, options, {
depth: options.depth == null ? null : options.depth - 1
})
const inner = util.inspect(
{
url: this.url(),
method: this._method,
headers: this.headers(),
requestId: this._requestId,
type: this._type
},
newOptions
)
return `${options.stylize('Request', 'special')} ${inner}`
}
}
/**
* @type {Request}
*/
module.exports = Request