Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
matchURLPattern:
NetworkListener:
parseChallengeHeader:
parseURLPattern:
WindowGlobalMessageHandler:
});
/**
* @typedef {object} AuthChallenge
* @property {string} scheme
* @property {string} realm
*/
/**
* @typedef {object} AuthCredentials
* @property {'password'} type
* @property {string} username
* @property {string} password
*/
/**
* @typedef {object} BaseParameters
* @property {string=} context
* @property {Array<string>?} intercepts
* @property {boolean} isBlocked
* @property {Navigation=} navigation
* @property {number} redirectCount
* @property {RequestData} request
* @property {number} timestamp
*/
/**
* @typedef {object} BlockedRequest
* @property {NetworkEventRecord} networkEventRecord
* @property {InterceptPhase} phase
*/
/**
* Enum of possible BytesValue types.
*
* @readonly
* @enum {BytesValueType}
*/
export const BytesValueType = {
Base64: "base64",
String: "string",
};
/**
* @typedef {object} BytesValue
* @property {BytesValueType} type
* @property {string} value
*/
/**
* Enum of possible continueWithAuth actions.
*
* @readonly
* @enum {ContinueWithAuthAction}
*/
const ContinueWithAuthAction = {
Cancel: "cancel",
Default: "default",
ProvideCredentials: "provideCredentials",
};
/**
* @typedef {object} Cookie
* @property {string} domain
* @property {number=} expires
* @property {boolean} httpOnly
* @property {string} name
* @property {string} path
* @property {SameSite} sameSite
* @property {boolean} secure
* @property {number} size
* @property {BytesValue} value
*/
/**
* @typedef {object} CookieHeader
* @property {string} name
* @property {BytesValue} value
*/
/**
* @typedef {object} FetchTimingInfo
* @property {number} timeOrigin
* @property {number} requestTime
* @property {number} redirectStart
* @property {number} redirectEnd
* @property {number} fetchStart
* @property {number} dnsStart
* @property {number} dnsEnd
* @property {number} connectStart
* @property {number} connectEnd
* @property {number} tlsStart
* @property {number} requestStart
* @property {number} responseStart
* @property {number} responseEnd
*/
/**
* @typedef {object} Header
* @property {string} name
* @property {BytesValue} value
*/
/**
* @typedef {string} InitiatorType
*/
/**
* Enum of possible initiator types.
*
* @readonly
* @enum {InitiatorType}
*/
const InitiatorType = {
Other: "other",
Parser: "parser",
Preflight: "preflight",
Script: "script",
};
/**
* @typedef {object} Initiator
* @property {InitiatorType} type
* @property {number=} columnNumber
* @property {number=} lineNumber
* @property {string=} request
* @property {StackTrace=} stackTrace
*/
/**
* Enum of intercept phases.
*
* @readonly
* @enum {InterceptPhase}
*/
const InterceptPhase = {
AuthRequired: "authRequired",
BeforeRequestSent: "beforeRequestSent",
ResponseStarted: "responseStarted",
};
/**
* @typedef {object} InterceptProperties
* @property {Array<InterceptPhase>} phases
* @property {Array<URLPattern>} urlPatterns
*/
/**
* @typedef {object} RequestData
* @property {number|null} bodySize
* Defaults to null.
* @property {Array<Cookie>} cookies
* @property {Array<Header>} headers
* @property {number} headersSize
* @property {string} method
* @property {string} request
* @property {FetchTimingInfo} timings
* @property {string} url
*/
/**
* @typedef {object} BeforeRequestSentParametersProperties
* @property {Initiator} initiator
*/
/* eslint-disable jsdoc/valid-types */
/**
* Parameters for the BeforeRequestSent event
*
* @typedef {BaseParameters & BeforeRequestSentParametersProperties} BeforeRequestSentParameters
*/
/* eslint-enable jsdoc/valid-types */
/**
* @typedef {object} ResponseContent
* @property {number|null} size
* Defaults to null.
*/
/**
* @typedef {object} ResponseData
* @property {string} url
* @property {string} protocol
* @property {number} status
* @property {string} statusText
* @property {boolean} fromCache
* @property {Array<Header>} headers
* @property {string} mimeType
* @property {number} bytesReceived
* @property {number|null} headersSize
* Defaults to null.
* @property {number|null} bodySize
* Defaults to null.
* @property {ResponseContent} content
* @property {Array<AuthChallenge>=} authChallenges
*/
/**
* @typedef {object} ResponseStartedParametersProperties
* @property {ResponseData} response
*/
/* eslint-disable jsdoc/valid-types */
/**
* Parameters for the ResponseStarted event
*
* @typedef {BaseParameters & ResponseStartedParametersProperties} ResponseStartedParameters
*/
/* eslint-enable jsdoc/valid-types */
/**
* @typedef {object} ResponseCompletedParametersProperties
* @property {ResponseData} response
*/
/**
* Enum of possible sameSite values.
*
* @readonly
* @enum {SameSite}
*/
const SameSite = {
Lax: "lax",
None: "none",
Script: "script",
};
/**
* @typedef {object} SetCookieHeader
* @property {string} name
* @property {BytesValue} value
* @property {string=} domain
* @property {boolean=} httpOnly
* @property {string=} expiry
* @property {number=} maxAge
* @property {string=} path
* @property {SameSite=} sameSite
* @property {boolean=} secure
*/
/**
* @typedef {object} URLPatternPattern
* @property {'pattern'} type
* @property {string=} protocol
* @property {string=} hostname
* @property {string=} port
* @property {string=} pathname
* @property {string=} search
*/
/**
* @typedef {object} URLPatternString
* @property {'string'} type
* @property {string} pattern
*/
/**
* @typedef {(URLPatternPattern|URLPatternString)} URLPattern
*/
/* eslint-disable jsdoc/valid-types */
/**
* Parameters for the ResponseCompleted event
*
* @typedef {BaseParameters & ResponseCompletedParametersProperties} ResponseCompletedParameters
*/
/* eslint-enable jsdoc/valid-types */
class NetworkModule extends Module {
#blockedRequests;
#interceptMap;
#networkListener;
#subscribedEvents;
constructor(messageHandler) {
super(messageHandler);
// Map of request id to BlockedRequest
this.#blockedRequests = new Map();
// Map of intercept id to InterceptProperties
this.#interceptMap = new Map();
// Set of event names which have active subscriptions
this.#subscribedEvents = new Set();
this.#networkListener = new lazy.NetworkListener(
this.messageHandler.navigationManager
);
this.#networkListener.on("auth-required", this.#onAuthRequired);
this.#networkListener.on("before-request-sent", this.#onBeforeRequestSent);
this.#networkListener.on("fetch-error", this.#onFetchError);
this.#networkListener.on("response-completed", this.#onResponseEvent);
this.#networkListener.on("response-started", this.#onResponseEvent);
}
destroy() {
this.#networkListener.off("auth-required", this.#onAuthRequired);
this.#networkListener.off("before-request-sent", this.#onBeforeRequestSent);
this.#networkListener.off("fetch-error", this.#onFetchError);
this.#networkListener.off("response-completed", this.#onResponseEvent);
this.#networkListener.off("response-started", this.#onResponseEvent);
this.#networkListener.destroy();
this.#blockedRequests = null;
this.#interceptMap = null;
this.#subscribedEvents = null;
}
/**
* Adds a network intercept, which allows to intercept and modify network
* requests and responses.
*
* The network intercept will be created for the provided phases
* (InterceptPhase) and for specific url patterns. When a network event
* corresponding to an intercept phase has a URL which matches any url pattern
* of any intercept, the request will be suspended.
*
* @param {object=} options
* @param {Array<string>=} options.contexts
* The list of browsing context ids where this intercept should be used.
* Optional, defaults to null.
* @param {Array<InterceptPhase>} options.phases
* The phases where this intercept should be checked.
* @param {Array<URLPattern>=} options.urlPatterns
* The URL patterns for this intercept. Optional, defaults to empty array.
*
* @returns {object}
* An object with the following property:
* - intercept {string} The unique id of the network intercept.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
*/
addIntercept(options = {}) {
const { contexts = null, phases, urlPatterns = [] } = options;
if (contexts !== null) {
lazy.assert.array(
contexts,
`Expected "contexts" to be an array, got ${contexts}`
);
if (!options.contexts.length) {
throw new lazy.error.InvalidArgumentError(
`Expected "contexts" to contain at least one item, got an empty array`
);
}
for (const contextId of contexts) {
lazy.assert.string(
contextId,
`Expected elements of "contexts" to be a string, got ${contextId}`
);
const context = this.#getBrowsingContext(contextId);
if (context.parent) {
throw new lazy.error.InvalidArgumentError(
`Context with id ${contextId} is not a top-level browsing context`
);
}
}
}
lazy.assert.array(
phases,
`Expected "phases" to be an array, got ${phases}`
);
if (!options.phases.length) {
throw new lazy.error.InvalidArgumentError(
`Expected "phases" to contain at least one phase, got an empty array`
);
}
const supportedInterceptPhases = Object.values(InterceptPhase);
for (const phase of phases) {
if (!supportedInterceptPhases.includes(phase)) {
throw new lazy.error.InvalidArgumentError(
`Expected "phases" values to be one of ${supportedInterceptPhases}, got ${phase}`
);
}
}
lazy.assert.array(
urlPatterns,
`Expected "urlPatterns" to be an array, got ${urlPatterns}`
);
const parsedPatterns = urlPatterns.map(urlPattern =>
lazy.parseURLPattern(urlPattern)
);
const interceptId = lazy.generateUUID();
this.#interceptMap.set(interceptId, {
contexts,
phases,
urlPatterns: parsedPatterns,
});
return {
intercept: interceptId,
};
}
/**
* Continues a request that is blocked by a network intercept at the
* beforeRequestSent phase.
*
* @param {object=} options
* @param {string} options.request
* The id of the blocked request that should be continued.
* @param {BytesValue=} options.body [unsupported]
* Optional BytesValue to replace the body of the request.
* @param {Array<CookieHeader>=} options.cookies [unsupported]
* Optional array of cookie header values to replace the cookie header of
* the request.
* @param {Array<Header>=} options.headers [unsupported]
* Optional array of headers to replace the headers of the request.
* request.
* @param {string=} options.method [unsupported]
* Optional string to replace the method of the request.
* @param {string=} options.url [unsupported]
* Optional string to replace the url of the request. If the provided url
* is not a valid URL, an InvalidArgumentError will be thrown.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchRequestError}
* Raised if the request id does not match any request in the blocked
* requests map.
*/
async continueRequest(options = {}) {
const {
body = null,
cookies = null,
headers = null,
method = null,
url = null,
request: requestId,
} = options;
lazy.assert.string(
requestId,
`Expected "request" to be a string, got ${requestId}`
);
if (body !== null) {
this.#assertBytesValue(
body,
`Expected "body" to be a network.BytesValue, got ${body}`
);
throw new lazy.error.UnsupportedOperationError(
`"body" not supported yet in network.continueRequest`
);
}
if (cookies !== null) {
lazy.assert.array(
cookies,
`Expected "cookies" to be an array got ${cookies}`
);
for (const cookie of cookies) {
this.#assertHeader(
cookie,
`Expected values in "cookies" to be network.CookieHeader, got ${cookie}`
);
}
throw new lazy.error.UnsupportedOperationError(
`"cookies" not supported yet in network.continueRequest`
);
}
if (headers !== null) {
lazy.assert.array(
headers,
`Expected "headers" to be an array got ${headers}`
);
for (const header of headers) {
this.#assertHeader(
header,
`Expected values in "headers" to be network.Header, got ${header}`
);
}
throw new lazy.error.UnsupportedOperationError(
`"headers" not supported yet in network.continueRequest`
);
}
if (method !== null) {
lazy.assert.string(
method,
`Expected "method" to be a string, got ${method}`
);
throw new lazy.error.UnsupportedOperationError(
`"method" not supported yet in network.continueRequest`
);
}
if (url !== null) {
lazy.assert.string(url, `Expected "url" to be a string, got ${url}`);
throw new lazy.error.UnsupportedOperationError(
`"url" not supported yet in network.continueRequest`
);
}
if (!this.#blockedRequests.has(requestId)) {
throw new lazy.error.NoSuchRequestError(
`Blocked request with id ${requestId} not found`
);
}
const { phase, request, resolveBlockedEvent } =
this.#blockedRequests.get(requestId);
if (phase !== InterceptPhase.BeforeRequestSent) {
throw new lazy.error.InvalidArgumentError(
`Expected blocked request to be in "beforeRequestSent" phase, got ${phase}`
);
}
request.wrappedChannel.resume();
resolveBlockedEvent();
}
/**
* Continues a response that is blocked by a network intercept at the
* responseStarted or authRequired phase.
*
* @param {object=} options
* @param {string} options.request
* The id of the blocked request that should be continued.
* @param {Array<SetCookieHeader>=} options.cookies [unsupported]
* Optional array of set-cookie header values to replace the set-cookie
* headers of the response.
* @param {AuthCredentials=} options.credentials
* Optional AuthCredentials to use.
* @param {Array<Header>=} options.headers [unsupported]
* Optional array of header values to replace the headers of the response.
* @param {string=} options.reasonPhrase [unsupported]
* Optional string to replace the status message of the response.
* @param {number=} options.statusCode [unsupported]
* Optional number to replace the status code of the response.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchRequestError}
* Raised if the request id does not match any request in the blocked
* requests map.
*/
async continueResponse(options = {}) {
const {
cookies = null,
credentials = null,
headers = null,
reasonPhrase = null,
request: requestId,
statusCode = null,
} = options;
lazy.assert.string(
requestId,
`Expected "request" to be a string, got ${requestId}`
);
if (cookies !== null) {
lazy.assert.array(
cookies,
`Expected "cookies" to be an array got ${cookies}`
);
for (const cookie of cookies) {
this.#assertSetCookieHeader(cookie);
}
throw new lazy.error.UnsupportedOperationError(
`"cookies" not supported yet in network.continueResponse`
);
}
if (credentials !== null) {
this.#assertAuthCredentials(credentials);
}
if (headers !== null) {
lazy.assert.array(
headers,
`Expected "headers" to be an array got ${headers}`
);
for (const header of headers) {
this.#assertHeader(
header,
`Expected values in "headers" to be network.Header, got ${header}`
);
}
throw new lazy.error.UnsupportedOperationError(
`"headers" not supported yet in network.continueResponse`
);
}
if (reasonPhrase !== null) {
lazy.assert.string(
reasonPhrase,
`Expected "reasonPhrase" to be a string, got ${reasonPhrase}`
);
throw new lazy.error.UnsupportedOperationError(
`"reasonPhrase" not supported yet in network.continueResponse`
);
}
if (statusCode !== null) {
lazy.assert.positiveInteger(
statusCode,
`Expected "statusCode" to be a positive integer, got ${statusCode}`
);
throw new lazy.error.UnsupportedOperationError(
`"statusCode" not supported yet in network.continueResponse`
);
}
if (!this.#blockedRequests.has(requestId)) {
throw new lazy.error.NoSuchRequestError(
`Blocked request with id ${requestId} not found`
);
}
const { authCallbacks, phase, request, resolveBlockedEvent } =
this.#blockedRequests.get(requestId);
if (
phase !== InterceptPhase.ResponseStarted &&
phase !== InterceptPhase.AuthRequired
) {
throw new lazy.error.InvalidArgumentError(
`Expected blocked request to be in "responseStarted" or "authRequired" phase, got ${phase}`
);
}
if (phase === InterceptPhase.AuthRequired) {
// Requests blocked in the AuthRequired phase should be resumed using
// authCallbacks.
if (credentials !== null) {
await authCallbacks.provideAuthCredentials(
credentials.username,
credentials.password
);
} else {
await authCallbacks.provideAuthCredentials();
}
} else {
request.wrappedChannel.resume();
}
resolveBlockedEvent();
}
/**
* Continues a response that is blocked by a network intercept at the
* authRequired phase.
*
* @param {object=} options
* @param {string} options.request
* The id of the blocked request that should be continued.
* @param {string} options.action
* The continueWithAuth action, one of ContinueWithAuthAction.
* @param {AuthCredentials=} options.credentials
* The credentials to use for the ContinueWithAuthAction.ProvideCredentials
* action.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchRequestError}
* Raised if the request id does not match any request in the blocked
* requests map.
*/
async continueWithAuth(options = {}) {
const { action, credentials, request: requestId } = options;
lazy.assert.string(
requestId,
`Expected "request" to be a string, got ${requestId}`
);
if (!Object.values(ContinueWithAuthAction).includes(action)) {
throw new lazy.error.InvalidArgumentError(
`Expected "action" to be one of ${Object.values(
ContinueWithAuthAction
)} got ${action}`
);
}
if (action == ContinueWithAuthAction.ProvideCredentials) {
this.#assertAuthCredentials(credentials);
}
if (!this.#blockedRequests.has(requestId)) {
throw new lazy.error.NoSuchRequestError(
`Blocked request with id ${requestId} not found`
);
}
const { authCallbacks, phase, resolveBlockedEvent } =
this.#blockedRequests.get(requestId);
if (phase !== InterceptPhase.AuthRequired) {
throw new lazy.error.InvalidArgumentError(
`Expected blocked request to be in "authRequired" phase, got ${phase}`
);
}
switch (action) {
case ContinueWithAuthAction.Cancel: {
authCallbacks.cancelAuthPrompt();
break;
}
case ContinueWithAuthAction.Default: {
authCallbacks.forwardAuthPrompt();
break;
}
case ContinueWithAuthAction.ProvideCredentials: {
await authCallbacks.provideAuthCredentials(
credentials.username,
credentials.password
);
break;
}
}
resolveBlockedEvent();
}
/**
* Fails a request that is blocked by a network intercept.
*
* @param {object=} options
* @param {string} options.request
* The id of the blocked request that should be continued.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchRequestError}
* Raised if the request id does not match any request in the blocked
* requests map.
*/
async failRequest(options = {}) {
const { request: requestId } = options;
lazy.assert.string(
requestId,
`Expected "request" to be a string, got ${requestId}`
);
if (!this.#blockedRequests.has(requestId)) {
throw new lazy.error.NoSuchRequestError(
`Blocked request with id ${requestId} not found`
);
}
const { phase, request, resolveBlockedEvent } =
this.#blockedRequests.get(requestId);
if (phase === InterceptPhase.AuthRequired) {
throw new lazy.error.InvalidArgumentError(
`Expected blocked request not to be in "authRequired" phase`
);
}
request.wrappedChannel.resume();
request.wrappedChannel.cancel(
Cr.NS_ERROR_ABORT,
Ci.nsILoadInfo.BLOCKING_REASON_WEBDRIVER_BIDI
);
resolveBlockedEvent();
}
/**
* Continues a request that’s blocked by a network intercept, by providing a
* complete response.
*
* @param {object=} options
* @param {string} options.request
* The id of the blocked request for which the response should be
* provided.
* @param {BytesValue=} options.body [unsupported]
* Optional BytesValue to replace the body of the response.
* @param {Array<SetCookieHeader>=} options.cookies [unsupported]
* Optional array of set-cookie header values to use for the provided
* response.
* @param {Array<Header>=} options.headers [unsupported]
* Optional array of header values to use for the provided
* response.
* @param {string=} options.reasonPhrase [unsupported]
* Optional string to use as the status message for the provided response.
* @param {number=} options.statusCode [unsupported]
* Optional number to use as the status code for the provided response.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchRequestError}
* Raised if the request id does not match any request in the blocked
* requests map.
*/
async provideResponse(options = {}) {
const {
body = null,
cookies = null,
headers = null,
reasonPhrase = null,
request: requestId,
statusCode = null,
} = options;
lazy.assert.string(
requestId,
`Expected "request" to be a string, got ${requestId}`
);
if (body !== null) {
this.#assertBytesValue(
body,
`Expected "body" to be a network.BytesValue, got ${body}`
);
throw new lazy.error.UnsupportedOperationError(
`"body" not supported yet in network.provideResponse`
);
}
if (cookies !== null) {
lazy.assert.array(
cookies,
`Expected "cookies" to be an array got ${cookies}`
);
for (const cookie of cookies) {
this.#assertSetCookieHeader(cookie);
}
throw new lazy.error.UnsupportedOperationError(
`"cookies" not supported yet in network.provideResponse`
);
}
if (headers !== null) {
lazy.assert.array(
headers,
`Expected "headers" to be an array got ${headers}`
);
for (const header of headers) {
this.#assertHeader(
header,
`Expected values in "headers" to be network.Header, got ${header}`
);
}
throw new lazy.error.UnsupportedOperationError(
`"headers" not supported yet in network.provideResponse`
);
}
if (reasonPhrase !== null) {
lazy.assert.string(
reasonPhrase,
`Expected "reasonPhrase" to be a string, got ${reasonPhrase}`
);
throw new lazy.error.UnsupportedOperationError(
`"reasonPhrase" not supported yet in network.provideResponse`
);
}
if (statusCode !== null) {
lazy.assert.positiveInteger(
statusCode,
`Expected "statusCode" to be a positive integer, got ${statusCode}`
);
throw new lazy.error.UnsupportedOperationError(
`"statusCode" not supported yet in network.provideResponse`
);
}
if (!this.#blockedRequests.has(requestId)) {
throw new lazy.error.NoSuchRequestError(
`Blocked request with id ${requestId} not found`
);
}
const { authCallbacks, phase, request, resolveBlockedEvent } =
this.#blockedRequests.get(requestId);
if (phase === InterceptPhase.AuthRequired) {
await authCallbacks.provideAuthCredentials();
} else {
request.wrappedChannel.resume();
}
resolveBlockedEvent();
}
/**
* Removes an existing network intercept.
*
* @param {object=} options
* @param {string} options.intercept
* The id of the intercept to remove.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchInterceptError}
* Raised if the intercept id could not be found in the internal intercept
* map.
*/
removeIntercept(options = {}) {
const { intercept } = options;
lazy.assert.string(
intercept,
`Expected "intercept" to be a string, got ${intercept}`
);
if (!this.#interceptMap.has(intercept)) {
throw new lazy.error.NoSuchInterceptError(
`Network intercept with id ${intercept} not found`
);
}
this.#interceptMap.delete(intercept);
}
/**
* Add a new request in the blockedRequests map.
*
* @param {string} requestId
* The request id.
* @param {InterceptPhase} phase
* The phase where the request is blocked.
* @param {object=} options
* @param {object=} options.authCallbacks
* Only defined for requests blocked in the authRequired phase.
* Provides callbacks to handle the authentication.
* @param {nsIChannel=} options.requestChannel
* The request channel.
* @param {nsIChannel=} options.responseChannel
* The response channel.
*/
#addBlockedRequest(requestId, phase, options = {}) {
const { authCallbacks, request, response } = options;
const { promise: blockedEventPromise, resolve: resolveBlockedEvent } =
Promise.withResolvers();
this.#blockedRequests.set(requestId, {
authCallbacks,
request,
response,
resolveBlockedEvent,
phase,
});
blockedEventPromise.finally(() => {
this.#blockedRequests.delete(requestId);
});
}
#assertAuthCredentials(credentials) {
lazy.assert.object(
credentials,
`Expected "credentials" to be an object, got ${credentials}`
);
if (credentials.type !== "password") {
throw new lazy.error.InvalidArgumentError(
`Expected credentials "type" to be "password" got ${credentials.type}`
);
}
lazy.assert.string(
credentials.username,
`Expected credentials "username" to be a string, got ${credentials.username}`
);
lazy.assert.string(
credentials.password,
`Expected credentials "password" to be a string, got ${credentials.password}`
);
}
#assertBytesValue(obj, msg) {
lazy.assert.object(obj, msg);
lazy.assert.string(obj.value, msg);
lazy.assert.in(obj.type, Object.values(BytesValueType), msg);
}
#assertHeader(value, msg) {
lazy.assert.object(value, msg);
lazy.assert.string(value.name, msg);
this.#assertBytesValue(value.value, msg);
}
#assertSetCookieHeader(setCookieHeader) {
lazy.assert.object(
setCookieHeader,
`Expected set-cookie header to be an object, got ${setCookieHeader}`
);
const {
name,
value,
domain = null,
httpOnly = null,
expiry = null,
maxAge = null,
path = null,
sameSite = null,
secure = null,
} = setCookieHeader;
lazy.assert.string(
name,
`Expected set-cookie header "name" to be a string, got ${name}`
);
this.#assertBytesValue(
value,
`Expected set-cookie header "value" to be a BytesValue, got ${name}`
);
if (domain !== null) {
lazy.assert.string(
domain,
`Expected set-cookie header "domain" to be a string, got ${domain}`
);
}
if (httpOnly !== null) {
lazy.assert.boolean(
httpOnly,
`Expected set-cookie header "httpOnly" to be a boolean, got ${httpOnly}`
);
}
if (expiry !== null) {
lazy.assert.string(
expiry,
`Expected set-cookie header "expiry" to be a string, got ${expiry}`
);
}
if (maxAge !== null) {
lazy.assert.integer(
maxAge,
`Expected set-cookie header "maxAge" to be an integer, got ${maxAge}`
);
}
if (path !== null) {
lazy.assert.string(
path,
`Expected set-cookie header "path" to be a string, got ${path}`
);
}
if (sameSite !== null) {
lazy.assert.in(
sameSite,
Object.values(SameSite),
`Expected set-cookie header "sameSite" to be one of ${Object.values(
SameSite
)}, got ${sameSite}`
);
}
if (secure !== null) {
lazy.assert.boolean(
secure,
`Expected set-cookie header "secure" to be a boolean, got ${secure}`
);
}
}
#extractChallenges(response) {
let headerName;
// Using case-insensitive match for header names, so we use the lowercase
// version of the "WWW-Authenticate" / "Proxy-Authenticate" strings.
if (response.status === 401) {
headerName = "www-authenticate";
} else if (response.status === 407) {
headerName = "proxy-authenticate";
} else {
return null;
}
const challenges = [];
for (const [name, value] of response.getHeadersList()) {
if (name.toLowerCase() === headerName) {
// A single header can contain several challenges.
const headerChallenges = lazy.parseChallengeHeader(value);
for (const headerChallenge of headerChallenges) {
const realmParam = headerChallenge.params.find(
param => param.name == "realm"
);
const realm = realmParam ? realmParam.value : undefined;
const challenge = {
scheme: headerChallenge.scheme,
realm,
};
challenges.push(challenge);
}
}
}
return challenges;
}
#getBrowsingContext(contextId) {
const context = lazy.TabManager.getBrowsingContextById(contextId);
if (context === null) {
throw new lazy.error.NoSuchFrameError(
`Browsing Context with id ${contextId} not found`
);
}
if (!context.currentWindowGlobal) {
throw new lazy.error.NoSuchFrameError(
`No window found for BrowsingContext with id ${contextId}`
);
}
return context;
}
#getContextInfo(browsingContext) {
return {
contextId: browsingContext.id,
type: lazy.WindowGlobalMessageHandler.type,
};
}
#getNetworkIntercepts(event, request, topContextId) {
const intercepts = [];
let phase;
switch (event) {
case "network.beforeRequestSent":
phase = InterceptPhase.BeforeRequestSent;
break;
case "network.responseStarted":
phase = InterceptPhase.ResponseStarted;
break;
case "network.authRequired":
phase = InterceptPhase.AuthRequired;
break;
case "network.responseCompleted":
// The network.responseCompleted event does not match any interception
// phase. Return immediately.
return intercepts;
}
const url = request.serializedURL;
for (const [interceptId, intercept] of this.#interceptMap) {
if (
intercept.contexts !== null &&
!intercept.contexts.includes(topContextId)
) {
// Skip this intercept if the event's context does not match the list
// of contexts for this intercept.
continue;
}
if (intercept.phases.includes(phase)) {
const urlPatterns = intercept.urlPatterns;
if (
!urlPatterns.length ||
urlPatterns.some(pattern => lazy.matchURLPattern(pattern, url))
) {
intercepts.push(interceptId);
}
}
}
return intercepts;
}
#getRequestData(request) {
const requestId = request.requestId;
// "Let url be the result of running the URL serializer with request’s URL"
// request.serializedURL is already serialized.
const url = request.serializedURL;
const method = request.method;
const bodySize = request.postDataSize;
const headersSize = request.headersSize;
const headers = [];
const cookies = [];
for (const [name, value] of request.getHeadersList()) {
headers.push(this.#serializeHeader(name, value));
if (name.toLowerCase() == "cookie") {
// TODO: Retrieve the actual cookies from the cookie store.
const headerCookies = value.split(";");
for (const cookie of headerCookies) {
const equal = cookie.indexOf("=");
const cookieName = cookie.substr(0, equal);
const cookieValue = cookie.substr(equal + 1);
const serializedCookie = this.#serializeHeader(
unescape(cookieName.trim()),
unescape(cookieValue.trim())
);
cookies.push(serializedCookie);
}
}
}
const timings = request.getFetchTimings();
return {
request: requestId,
url,
method,
bodySize,
headersSize,
headers,
cookies,
timings,
};
}
#getResponseContentInfo(response) {
return {
size: response.decodedBodySize,
};
}
#getResponseData(response) {
const url = response.serializedURL;
const protocol = response.protocol;
const status = response.status;
const statusText = response.statusMessage;
// TODO: Ideally we should have a `isCacheStateLocal` getter
// const fromCache = response.isCacheStateLocal();
const fromCache = response.fromCache;
const mimeType = response.getComputedMimeType();
const headers = [];
for (const [name, value] of response.getHeadersList()) {
headers.push(this.#serializeHeader(name, value));
}
const bytesReceived = response.totalTransmittedSize;
const headersSize = response.headersTransmittedSize;
const bodySize = response.encodedBodySize;
const content = this.#getResponseContentInfo(response);
const authChallenges = this.#extractChallenges(response);
const params = {
url,
protocol,
status,
statusText,
fromCache,
headers,
mimeType,
bytesReceived,
headersSize,
bodySize,
content,
};
if (authChallenges !== null) {
params.authChallenges = authChallenges;
}
return params;
}
#getSuspendMarkerText(requestData, phase) {
return `Request (id: ${requestData.request}) suspended by WebDriver BiDi in ${phase} phase`;
}
#onAuthRequired = (name, data) => {
const { authCallbacks, request, response } = data;
let isBlocked = false;
try {
const browsingContext = lazy.TabManager.getBrowsingContextById(
request.contextId
);
if (!browsingContext) {
// Do not emit events if the context id does not match any existing
// browsing context.
return;
}
const protocolEventName = "network.authRequired";
const isListening = this.messageHandler.eventsDispatcher.hasListener(
protocolEventName,
{ contextId: request.contextId }
);
if (!isListening) {
// If there are no listeners subscribed to this event and this context,
// bail out.
return;
}
const baseParameters = this.#processNetworkEvent(
protocolEventName,
request
);
const responseData = this.#getResponseData(response);
const authRequiredEvent = {
...baseParameters,
response: responseData,
};
this.emitEvent(
protocolEventName,
authRequiredEvent,
this.#getContextInfo(browsingContext)
);
if (authRequiredEvent.isBlocked) {
isBlocked = true;
// requestChannel.suspend() is not needed here because the request is
// already blocked on the authentication prompt notification until
// one of the authCallbacks is called.
this.#addBlockedRequest(
authRequiredEvent.request.request,
InterceptPhase.AuthRequired,
{
authCallbacks,
request,
response,
}
);
}
} finally {
if (!isBlocked) {
// If the request was not blocked, forward the auth prompt notification
// to the next consumer.
authCallbacks.forwardAuthPrompt();
}
}
};
#onBeforeRequestSent = (name, data) => {
const { request } = data;
const browsingContext = lazy.TabManager.getBrowsingContextById(
request.contextId
);
if (!browsingContext) {
// Do not emit events if the context id does not match any existing
// browsing context.
return;
}
const internalEventName = "network._beforeRequestSent";
const protocolEventName = "network.beforeRequestSent";
// Always emit internal events, they are used to support the browsingContext
// navigate command.
// Bug 1861922: Replace internal events with a Network listener helper
// directly using the NetworkObserver.
this.emitEvent(
internalEventName,
{
navigation: request.navigationId,
url: request.serializedURL,
},
this.#getContextInfo(browsingContext)
);
const isListening = this.messageHandler.eventsDispatcher.hasListener(
protocolEventName,
{ contextId: request.contextId }
);
if (!isListening) {
// If there are no listeners subscribed to this event and this context,
// bail out.
return;
}
const baseParameters = this.#processNetworkEvent(
protocolEventName,
request
);
// Bug 1805479: Handle the initiator, including stacktrace details.
const initiator = {
type: InitiatorType.Other,
};
const beforeRequestSentEvent = {
...baseParameters,
initiator,
};
this.emitEvent(
protocolEventName,
beforeRequestSentEvent,
this.#getContextInfo(browsingContext)
);
if (beforeRequestSentEvent.isBlocked) {
// TODO: Requests suspended in beforeRequestSent still reach the server at
request.wrappedChannel.suspend(
this.#getSuspendMarkerText(request, "beforeRequestSent")
);
this.#addBlockedRequest(
beforeRequestSentEvent.request.request,
InterceptPhase.BeforeRequestSent,
{
request,
}
);
}
};
#onFetchError = (name, data) => {
const { request } = data;
const browsingContext = lazy.TabManager.getBrowsingContextById(
request.contextId
);
if (!browsingContext) {
// Do not emit events if the context id does not match any existing
// browsing context.
return;
}
const internalEventName = "network._fetchError";
const protocolEventName = "network.fetchError";
// Always emit internal events, they are used to support the browsingContext
// navigate command.
// Bug 1861922: Replace internal events with a Network listener helper
// directly using the NetworkObserver.
this.emitEvent(
internalEventName,
{
navigation: request.navigationId,
url: request.serializedURL,
},
this.#getContextInfo(browsingContext)
);
const isListening = this.messageHandler.eventsDispatcher.hasListener(
protocolEventName,
{ contextId: request.contextId }
);
if (!isListening) {
// If there are no listeners subscribed to this event and this context,
// bail out.
return;
}
const baseParameters = this.#processNetworkEvent(
protocolEventName,
request
);
const fetchErrorEvent = {
...baseParameters,
errorText: request.errorText,
};
this.emitEvent(
protocolEventName,
fetchErrorEvent,
this.#getContextInfo(browsingContext)
);
};
#onResponseEvent = (name, data) => {
const { request, response } = data;
const browsingContext = lazy.TabManager.getBrowsingContextById(
request.contextId
);
if (!browsingContext) {
// Do not emit events if the context id does not match any existing
// browsing context.
return;
}
const protocolEventName =
name === "response-started"
? "network.responseStarted"
: "network.responseCompleted";
const internalEventName =
name === "response-started"
? "network._responseStarted"
: "network._responseCompleted";
// Always emit internal events, they are used to support the browsingContext
// navigate command.
// Bug 1861922: Replace internal events with a Network listener helper
// directly using the NetworkObserver.
this.emitEvent(
internalEventName,
{
navigation: request.navigationId,
url: request.serializedURL,
},
this.#getContextInfo(browsingContext)
);
const isListening = this.messageHandler.eventsDispatcher.hasListener(
protocolEventName,
{ contextId: request.contextId }
);
if (!isListening) {
// If there are no listeners subscribed to this event and this context,
// bail out.
return;
}
const baseParameters = this.#processNetworkEvent(
protocolEventName,
request
);
const responseData = this.#getResponseData(response);
const responseEvent = {
...baseParameters,
response: responseData,
};
this.emitEvent(
protocolEventName,
responseEvent,
this.#getContextInfo(browsingContext)
);
if (
protocolEventName === "network.responseStarted" &&
responseEvent.isBlocked
) {
request.wrappedChannel.suspend(
this.#getSuspendMarkerText(request, "responseStarted")
);
this.#addBlockedRequest(
responseEvent.request.request,
InterceptPhase.ResponseStarted,
{
request,
response,
}
);
}
};
#processNetworkEvent(event, request) {
const requestData = this.#getRequestData(request);
const navigation = request.navigationId;
let contextId = null;
let topContextId = null;
if (request.contextId) {
// Retrieve the top browsing context id for this network event.
contextId = request.contextId;
const browsingContext = lazy.TabManager.getBrowsingContextById(contextId);
topContextId = lazy.TabManager.getIdForBrowsingContext(
browsingContext.top
);
}
const intercepts = this.#getNetworkIntercepts(event, request, topContextId);
const redirectCount = request.redirectCount;
const timestamp = Date.now();
const isBlocked = !!intercepts.length;
const params = {
context: contextId,
isBlocked,
navigation,
redirectCount,
request: requestData,
timestamp,
};
if (isBlocked) {
params.intercepts = intercepts;
}
return params;
}
#serializeHeader(name, value) {
return {
name,
value: this.#serializeStringAsBytesValue(value),
};
}
/**
* Serialize a string value as BytesValue.
*
* Note: This does not attempt to fully implement serialize protocol bytes
* header values read from the Channel are already serialized as strings at
* the moment.
*
* @param {string} value
* The value to serialize.
*/
#serializeStringAsBytesValue(value) {
// TODO: For now, we handle all headers and cookies with the "string" type.
// See Bug 1835216 to add support for "base64" type and handle non-utf8
// values.
return {
type: BytesValueType.String,
value,
};
}
#startListening(event) {
if (this.#subscribedEvents.size == 0) {
this.#networkListener.startListening();
}
this.#subscribedEvents.add(event);
}
#stopListening(event) {
this.#subscribedEvents.delete(event);
if (this.#subscribedEvents.size == 0) {
this.#networkListener.stopListening();
}
}
#subscribeEvent(event) {
if (this.constructor.supportedEvents.includes(event)) {
this.#startListening(event);
}
}
#unsubscribeEvent(event) {
if (this.constructor.supportedEvents.includes(event)) {
this.#stopListening(event);
}
}
/**
* Internal commands
*/
_applySessionData(params) {
// TODO: Bug 1775231. Move this logic to a shared module or an abstract
// class.
const { category } = params;
if (category === "event") {
const filteredSessionData = params.sessionData.filter(item =>
this.messageHandler.matchesContext(item.contextDescriptor)
);
for (const event of this.#subscribedEvents.values()) {
const hasSessionItem = filteredSessionData.some(
item => item.value === event
);
// If there are no session items for this context, we should unsubscribe from the event.
if (!hasSessionItem) {
this.#unsubscribeEvent(event);
}
}
// Subscribe to all events, which have an item in SessionData.
for (const { value } of filteredSessionData) {
this.#subscribeEvent(value);
}
}
}
static get supportedEvents() {
return [
"network.authRequired",
"network.beforeRequestSent",
"network.fetchError",
"network.responseCompleted",
"network.responseStarted",
];
}
}
export const network = NetworkModule;