lib/network/NetworkManager.js
const util = require('util')
const EventEmitter = require('eventemitter3')
const Multimap = require('../Multimap')
const Events = require('../Events')
const Cookie = require('./Cookie')
const Request = require('./Request')
const Response = require('./Response')
const NetIdleWatcher = require('./NetworkIdleWatcher')
const TimeoutSettings = require('../TimeoutSettings')
const { assert, debugError, helper } = require('../helper')
class NetworkManager extends EventEmitter {
/**
* @param {Chrome|CRIConnection|CDPSession|Object} client
* @param {TimeoutSettings} [timeoutSettings]
*/
constructor (client, timeoutSettings) {
super()
/**
* @type {Chrome|CRIConnection|CDPSession|Object}
* @private
*/
this._client = client
/**
* @type {?FrameManager}
*/
this._frameManager = null
/** @type {Map<string, Request>} */
this._requestIdToRequest = new Map()
/** @type {Map<string, Object>} */
this._requestIdToRequestWillBeSentEvent = new Map()
/** @type {Object<string, string>} */
this._extraHTTPHeaders = {}
/** @type {TimeoutSettings} */
this._timeoutSettings = timeoutSettings || new TimeoutSettings()
/**
* @type {boolean}
* @private
*/
this._offline = false
/**
* @type {boolean}
* @private
*/
this._cacheEnabledState = true
/** @type {?{username: string, password: string}} */
this._credentials = null
/** @type {!Set<string>} */
this._attemptedAuthentications = new Set()
this._userRequestInterceptionEnabled = false
this._protocolRequestInterceptionEnabled = false
/** @type {!Multimap<string, string>} */
this._requestHashToRequestIds = new Multimap()
/** @type {!Multimap<string, string>} */
this._requestHashToInterceptionIds = new Multimap()
this._client.on(
'Network.requestWillBeSent',
this._onRequestWillBeSent.bind(this)
)
this._client.on(
'Network.requestIntercepted',
this._onRequestIntercepted.bind(this)
)
this._client.on(
'Network.requestServedFromCache',
this._onRequestServedFromCache.bind(this)
)
this._client.on(
'Network.responseReceived',
this._onResponseReceived.bind(this)
)
this._client.on(
'Network.loadingFinished',
this._onLoadingFinished.bind(this)
)
this._client.on('Network.loadingFailed', this._onLoadingFailed.bind(this))
}
/**
* @param {TimeoutSettings} toSettings
* @since chrome-remote-interface-extra
*/
setTimeoutSettings (toSettings) {
if (toSettings) {
this._timeoutSettings = toSettings
}
}
/**
* @return {!Object<string, string>}
*/
extraHTTPHeaders () {
return Object.assign({}, this._extraHTTPHeaders)
}
/**
* Returns a promise that resolves once the network has become idle.
* Detection of network idle considers only the number of in-flight HTTP requests
* for the Page connected to
* @param {NetIdleOptions} [options]
* @return {Promise<void>}
* @since chrome-remote-interface-extra
*/
networkIdlePromise (options) {
return NetIdleWatcher.idlePromise(this, options)
}
/**
* @param {!FrameManager} frameManager
*/
setFrameManager (frameManager) {
this._frameManager = frameManager
}
/**
* @param {(string|Function)} urlOrPredicate
* @param {{timeout?: number}} [options]
* @return {Promise<Request>}
*/
waitForRequest (urlOrPredicate, options = {}) {
const { timeout = this._timeoutSettings.timeout() } = options
return helper.waitForEvent(
this,
Events.NetworkManager.Request,
request => {
if (helper.isString(urlOrPredicate)) {
return urlOrPredicate === request.url()
}
if (typeof urlOrPredicate === 'function') {
return !!urlOrPredicate(request)
}
return false
},
timeout
)
}
/**
* @param {(string|Function)} urlOrPredicate
* @param {{timeout?: number}} [options]
* @return {Promise<Response>}
*/
waitForResponse (urlOrPredicate, options = {}) {
const { timeout = this._timeoutSettings.timeout() } = options
return helper.waitForEvent(
this,
Events.NetworkManager.Response,
response => {
if (helper.isString(urlOrPredicate)) {
return urlOrPredicate === response.url()
}
if (typeof urlOrPredicate === 'function') {
return !!urlOrPredicate(response)
}
return false
},
timeout
)
}
/**
* Blocks URLs from loading. EXPERIMENTAL
* @param {...string} urls - URL patterns to block. Wildcards ('*') are allowed
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setBlockedURLs
* @since chrome-remote-interface-extra
*/
async setBlockedURLs (...urls) {
if (urls.length === 0) return
await this._client.send('Network.setBlockedURLs', { urls })
}
/**
* Returns the DER-encoded certificate. EXPERIMENTAL
* @param {string} origin - Origin to get certificate for
* @return {Promise<Array<string>>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-getCertificate
* @since chrome-remote-interface-extra
*/
async getCertificate (origin) {
if (!origin) return []
const { tableNames } = await this._client.send('Network.getCertificate', {
origin
})
return tableNames
}
/**
* Toggles ignoring of service worker for each request. EXPERIMENTAL
* @param {boolean} bypass - Bypass service worker and load from network
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setBypassServiceWorker
* @since chrome-remote-interface-extra
*/
async bypassServiceWorker (bypass) {
await this._client.send('Network.setBypassServiceWorker', { bypass })
}
/**
* @param {?{username: string, password: string}} credentials
*/
async authenticate (credentials) {
this._credentials = credentials
await this._updateProtocolRequestInterception()
}
/**
* @param {!Object<string, string>} extraHTTPHeaders
*/
async setExtraHTTPHeaders (extraHTTPHeaders) {
this._extraHTTPHeaders = {}
const extraHeadersKeys = Object.keys(extraHTTPHeaders)
for (let i = 0; i < extraHeadersKeys.length; i++) {
const key = extraHeadersKeys[i]
const value = extraHTTPHeaders[key]
assert(
helper.isString(value),
`Expected value of header "${key}" to be String, but "${typeof value}" is found.`
)
this._extraHTTPHeaders[key] = value
}
await this._client.send('Network.setExtraHTTPHeaders', {
headers: this._extraHTTPHeaders
})
}
/**
* @param {boolean} offline - T/F indicating offline status
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-emulateNetworkConditions
*/
async setOfflineMode (offline) {
if (this._offline === offline) return
this._offline = offline
await this._client.send('Network.emulateNetworkConditions', {
offline: this._offline,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1
})
}
/**
* Activates emulation of network conditions
* @param {NetworkConditions} networkConditions - The new network conditions
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-emulateNetworkConditions
* @since chrome-remote-interface-extra
*/
async emulateNetworkConditions (networkConditions) {
if (!networkConditions) return
if (typeof networkConditions.offline === 'boolean') {
this._offline = networkConditions.offline
}
await this._client.send(
'Network.emulateNetworkConditions',
networkConditions
)
}
/**
* @param {string} userAgent - User agent to use
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setUserAgentOverride
*/
async setUserAgent (userAgent) {
await this._client.send('Network.setUserAgentOverride', { userAgent })
}
/**
* @param {string} acceptLanguage - Browser langugage to emulate
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setUserAgentOverride
* @since chrome-remote-interface-extra
*/
async setAcceptLanguage (acceptLanguage) {
await this._client.send('Network.setUserAgentOverride', { acceptLanguage })
}
/**
* @param {string} platform - The platform navigator.platform should return
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setUserAgentOverride
* @since chrome-remote-interface-extra
*/
async setNavigatorPlatform (platform) {
await this._client.send('Network.setUserAgentOverride', { platform })
}
/**
* Allows overriding user agent with the given string.
* @param {UserAgentOverride} overrides
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setUserAgentOverride
*/
async setUserAgentOverride ({ userAgent, acceptLanguage, platform }) {
assert(
!(userAgent == null && acceptLanguage == null && platform == null),
'Must supply a value for at least one of "userAgent, acceptLanguage, platform"'
)
await this._client.send('Network.setUserAgentOverride', {
userAgent: userAgent || undefined,
acceptLanguage: acceptLanguage || undefined,
platform: platform || undefined
})
}
/**
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setCacheDisabled
* @since chrome-remote-interface-extra
*/
async disableCache () {
if (!this._cacheEnabledState) return
await this._setCacheDisabled(false)
}
/**
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setCacheDisabled
* @since chrome-remote-interface-extra
*/
async enableCache () {
if (this._cacheEnabledState) return
await this._setCacheDisabled(true)
}
/**
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-clearBrowserCache
* @since chrome-remote-interface-extra
*/
async clearBrowserCache () {
await this._client.send('Network.clearBrowserCache')
}
/**
* Clears browser cookies
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-clearBrowserCookies
* @since chrome-remote-interface-extra
*/
async clearBrowserCookies () {
await this._client.send('Network.clearBrowserCookies')
}
/**
* Deletes the specified browser cookies with matching name and url or domain/path pair.
* @param {CDPCookie|CookieToBeDeleted|string|Cookie} cookie - The cookie to be deleted
* @param {string} [forURL]
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-deleteCookies
* @since chrome-remote-interface-extra
*/
async deleteCookie (cookie, forURL) {
let deleteMe
if (typeof cookie === 'string') {
if (cookie.includes('=')) {
const nameValue = cookie.split('=')
deleteMe = { name: nameValue[0], url: forURL || undefined }
} else {
deleteMe = { name: cookie, url: forURL || undefined }
}
} else if (cookie instanceof Cookie) {
deleteMe = {
name: cookie.name() || undefined,
path: cookie.path() || undefined,
url: forURL || undefined,
domain: cookie.domain() || undefined
}
} else {
deleteMe =
typeof forURL === 'string'
? Object.assign(cookie, { url: forURL })
: cookie
}
await this._client.send('Network.deleteCookies', deleteMe)
}
/**
* Deletes browser cookies with matching name and url or domain/path pair.
* @param {...(CDPCookie|CookieToBeDeleted|string|Cookie)} cookies - The cookies to be deleted
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-deleteCookies
* @since chrome-remote-interface-extra
*/
async deleteCookies (...cookies) {
for (let i = 0; i < cookies.length; i++) {
await this.deleteCookie(cookies[i])
}
}
/**
* Returns all browser cookies.
* Depending on the backend support, will return detailed cookie information in the cookies field.
* @return {Promise<Array<Cookie>>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-getAllCookies
* @since chrome-remote-interface-extra
*/
async getAllCookies () {
const { cookies } = await this._client.send('Network.getAllCookies')
if (cookies.length === 0) return cookies
/** @type {Array<Cookie>} */
const browserCookies = []
const numCookies = cookies.length
for (let i = 0; i < numCookies; i++) {
browserCookies.push(new Cookie(cookies[i], this))
}
return browserCookies
}
/**
* Returns all browser cookies for the current URL.
* Depending on the backend support, will return detailed cookie information in the cookies field.
* @param {Array<string>} urls - The list of URLs for which applicable cookies will be fetched
* @return {Promise<Array<Cookie>>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-getCookies
* @since chrome-remote-interface-extra
*/
async getCookies (urls) {
const { cookies } = await this._client.send('Network.getCookies', { urls })
if (cookies.length === 0) return cookies
/** @type {Array<Cookie>} */
const cookiesForURLs = []
const numCookies = cookies.length
for (let i = 0; i < numCookies; i++) {
cookiesForURLs.push(new Cookie(cookies[i], this))
}
return cookiesForURLs
}
/**
* Sets a cookie with the given cookie data; may overwrite equivalent cookies if they exist
* @param {CDPCookie|CookieParam|string} cookie - The new cookie to be set
* @return {Promise<boolean>} - T/F indicating if the cookie was set
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setCookie
* @since chrome-remote-interface-extra
*/
async setCookie (cookie) {
let setCookie
if (typeof cookie === 'string') {
const nameValue = cookie.split('=')
setCookie = { name: nameValue[0], value: nameValue[1] }
} else {
setCookie = cookie
}
const results = await this._client.send('Network.setCookie', setCookie)
return results.success
}
/**
* Sets given cookies
* @param {...(CDPCookie|CookieParam|string)} cookies
* @return {Promise<void>}
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network#method-setCookies
* @since chrome-remote-interface-extra
*/
async setCookies (...cookies) {
const cookiesToBeSet = []
for (let i = 0; i < cookies.length; i++) {
if (typeof cookies[i] === 'string') {
const nameValue = cookies[i].split('=')
cookiesToBeSet.push({ name: nameValue[0], value: nameValue[1] })
} else {
cookiesToBeSet.push(cookies[i])
}
}
await this._client.send('Network.setCookies', { cookies: cookiesToBeSet })
}
/**
* @param {boolean} value
*/
async setRequestInterception (value) {
this._userRequestInterceptionEnabled = value
await this._updateProtocolRequestInterception()
}
async _updateProtocolRequestInterception () {
const enabled = this._userRequestInterceptionEnabled || !!this._credentials
if (enabled === this._protocolRequestInterceptionEnabled) {
return
}
this._protocolRequestInterceptionEnabled = enabled
const patterns = enabled ? [{ urlPattern: '*' }] : []
await Promise.all([
this._setCacheDisabled(enabled),
this._client.send('Network.setRequestInterception', { patterns })
])
}
/**
* @param {boolean} cacheDisabled
* @return {Promise<*>}
* @private
*/
_setCacheDisabled (cacheDisabled) {
this._cacheEnabledState = cacheDisabled
return this._client.send('Network.setCacheDisabled', {
cacheDisabled
})
}
/**
* @param {!Object} event
*/
_onRequestWillBeSent (event) {
// Request interception doesn't happen for data URLs with Network Service.
if (
this._protocolRequestInterceptionEnabled &&
!event.request.url.startsWith('data:')
) {
const requestHash = generateRequestHash(event.request)
const interceptionId = this._requestHashToInterceptionIds.firstValue(
requestHash
)
if (interceptionId) {
this._onRequest(event, interceptionId)
this._requestHashToInterceptionIds.delete(requestHash, interceptionId)
} else {
this._requestHashToRequestIds.set(requestHash, event.requestId)
this._requestIdToRequestWillBeSentEvent.set(event.requestId, event)
}
return
}
this._onRequest(event, null)
}
/**
* @param {!Object} event
*/
_onRequestIntercepted (event) {
if (event.authChallenge) {
/** @type {"Default"|"CancelAuth"|"ProvideCredentials"} */
let response = 'Default'
if (this._attemptedAuthentications.has(event.interceptionId)) {
response = 'CancelAuth'
} else if (this._credentials) {
response = 'ProvideCredentials'
this._attemptedAuthentications.add(event.interceptionId)
}
const { username, password } = this._credentials || {
username: undefined,
password: undefined
}
this._client
.send('Network.continueInterceptedRequest', {
interceptionId: event.interceptionId,
authChallengeResponse: { response, username, password }
})
.catch(debugError)
return
}
if (
!this._userRequestInterceptionEnabled &&
this._protocolRequestInterceptionEnabled
) {
this._client
.send('Network.continueInterceptedRequest', {
interceptionId: event.interceptionId
})
.catch(debugError)
}
const requestHash = generateRequestHash(event.request)
const requestId = this._requestHashToRequestIds.firstValue(requestHash)
if (requestId) {
const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(
requestId
)
this._onRequest(requestWillBeSentEvent, event.interceptionId)
this._requestHashToRequestIds.delete(requestHash, requestId)
this._requestIdToRequestWillBeSentEvent.delete(requestId)
} else {
this._requestHashToInterceptionIds.set(requestHash, event.interceptionId)
}
}
/**
* @param {!Object} event
* @param {?string} interceptionId
*/
_onRequest (event, interceptionId) {
let redirectChain = []
if (event.redirectResponse) {
const request = this._requestIdToRequest.get(event.requestId)
// If we connect late to the target, we could have missed the requestWillBeSent event.
if (request) {
this._handleRequestRedirect(request, event)
redirectChain = request._redirectChain
}
}
const frame =
event.frameId && this._frameManager
? this._frameManager.frame(event.frameId)
: null
const request = new Request(
this._client,
event,
frame,
redirectChain,
interceptionId,
this._userRequestInterceptionEnabled
)
this._requestIdToRequest.set(event.requestId, request)
this.emit(Events.NetworkManager.Request, request)
}
/**
* @param {!Object} event
*/
_onRequestServedFromCache (event) {
const request = this._requestIdToRequest.get(event.requestId)
if (request) request._fromMemoryCache = true
}
/**
* @param {!Request} request
* @param {!Object} event
*/
_handleRequestRedirect (request, event) {
const response = new Response(this._client, request, event)
request._response = response
request._redirectChain.push(request)
response._bodyLoadedPromiseFulfill.call(
null,
new Error('Response body is unavailable for redirect responses')
)
this._requestIdToRequest.delete(request._requestId)
this._attemptedAuthentications.delete(request._interceptionId)
this.emit(Events.NetworkManager.Response, response)
this.emit(Events.NetworkManager.RequestFinished, request)
}
/**
* @param {!Object} event
*/
_onResponseReceived (event) {
const request = this._requestIdToRequest.get(event.requestId)
// FileUpload sends a response without a matching request.
if (!request) return
const response = new Response(this._client, request, event)
request._response = response
this.emit(Events.NetworkManager.Response, response)
}
/**
* @param {!Object} event
*/
_onLoadingFinished (event) {
const request = this._requestIdToRequest.get(event.requestId)
// For certain requestIds we never receive requestWillBeSent event.
// @see https://crbug.com/750469
if (!request) return
// 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)
}
this._requestIdToRequest.delete(request._requestId)
this._attemptedAuthentications.delete(request._interceptionId)
this.emit(Events.NetworkManager.RequestFinished, request)
}
/**
* @param {!Object} event
*/
_onLoadingFailed (event) {
const request = this._requestIdToRequest.get(event.requestId)
// For certain requestIds we never receive requestWillBeSent event.
// @see https://crbug.com/750469
if (!request) return
request._failureText = event.errorText
const response = request.response()
if (response) {
response._bodyLoadedPromiseFulfill.call(null)
}
this._requestIdToRequest.delete(request._requestId)
this._attemptedAuthentications.delete(request._interceptionId)
this.emit(Events.NetworkManager.RequestFailed, request)
}
toJSON () {
return {
extraHTTPHeaders: this._extraHTTPHeaders,
ignoreHTTPSErrors: this._ignoreHTTPSErrors,
defaultViewport: this._defaultViewport,
offline: this._offline,
cacheEnabledState: this._cacheEnabledState,
credentials: this._credentials,
userRequestInterceptionEnabled: this._userRequestInterceptionEnabled,
protocolRequestInterceptionEnabled: this
._protocolRequestInterceptionEnabled
}
}
/** @ignore */
// eslint-disable-next-line space-before-function-paren
[util.inspect.custom](depth, options) {
if (depth < 0) {
return options.stylize('[NetworkManager]', 'special')
}
const newOptions = Object.assign({}, options, {
depth: options.depth == null ? null : options.depth - 1
})
const inner = util.inspect(
{
extraHTTPHeaders: this._extraHTTPHeaders,
ignoreHTTPSErrors: this._ignoreHTTPSErrors,
defaultViewport: this._defaultViewport,
offline: this._offline,
cacheEnabledState: this._cacheEnabledState,
credentials: this._credentials,
userRequestInterceptionEnabled: this._userRequestInterceptionEnabled,
protocolRequestInterceptionEnabled: this
._protocolRequestInterceptionEnabled
},
newOptions
)
return `${options.stylize('NetworkManager', 'special')} ${inner}`
}
}
const IGNORED_HEADERS = new Set([
'accept',
'referer',
'x-devtools-emulate-network-conditions-client-id',
'cookie',
'origin',
'content-type',
'intervention'
])
/**
* @param {!Object} request
* @return {string}
*/
function generateRequestHash (request) {
let normalizedURL = request.url
try {
// Decoding is necessary to normalize URLs. @see crbug.com/759388
// The method will throw if the URL is malformed. In this case,
// consider URL to be normalized as-is.
normalizedURL = decodeURI(request.url)
} catch (e) {}
const hash = {
url: normalizedURL,
method: request.method,
postData: request.postData,
headers: {}
}
if (!normalizedURL.startsWith('data:')) {
const headers = Object.keys(request.headers)
headers.sort()
for (let i = 0; i < headers.length; i++) {
const header = headers[i].toLowerCase()
if (IGNORED_HEADERS.has(header)) continue
hash.headers[header] = request.headers[header]
}
}
return JSON.stringify(hash)
}
/**
* @type {NetworkManager}
*/
module.exports = NetworkManager
/**
* @typedef {Object} CookieParam
* @property {!string} name
* @property {!string} value
* @property {?string} [url]
* @property {?string} [domain]
* @property {?string} [path]
* @property {?number} [expires]
* @property {?boolean} [httpOnly]
* @property {?boolean} [secure]
* @property {?string} [sameSite]
*/
/**
* @typedef {Object} ModifyCookieParam
* @property {?string} name
* @property {?string} value
* @property {?string} url
* @property {?string} domain
* @property {?string} path
* @property {?number} expires
* @property {?boolean} httpOnly
* @property {?boolean} secure
* @property {?string} sameSite
*/
/**
* @typedef {Object} CookieToBeDeleted
* @property {string} name
* @property {?string} [url]
* @property {?string} [domain]
* @property {?string} [path]
*/
/**
* @typedef {Object} NetworkConditions
* @property {boolean} offline
* @property {number} latency
* @property {number} downloadThroughput
* @property {number}uploadThroughput
* @property {?string} [connectionType]
*/
/**
* @typedef {Object} UserAgentOverride
* @property {string} userAgent
* @property {?string} [acceptLanguage]
* @property {?string} [platform]
*/