Files
sousa-gecko/remote/webdriver-bidi/modules/root/network.sys.mjs
T

3290 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { RootBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/RootBiDiModule.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
NetworkHelper:
"resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",
assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
CacheBehavior: "chrome://remote/content/shared/NetworkCacheManager.sys.mjs",
ContextDescriptorType:
"chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
Log: "chrome://remote/content/shared/Log.sys.mjs",
matchURLPattern:
"chrome://remote/content/shared/webdriver/URLPattern.sys.mjs",
NavigableManager: "chrome://remote/content/shared/NavigableManager.sys.mjs",
NetworkDataBytes: "chrome://remote/content/shared/NetworkDataBytes.sys.mjs",
NetworkDecodedBodySizeMap:
"chrome://remote/content/shared/NetworkDecodedBodySizeMap.sys.mjs",
NetworkListener:
"chrome://remote/content/shared/listeners/NetworkListener.sys.mjs",
NetworkResponse: "chrome://remote/content/shared/NetworkResponse.sys.mjs",
parseChallengeHeader:
"chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs",
parseURLPattern:
"chrome://remote/content/shared/webdriver/URLPattern.sys.mjs",
pprint: "chrome://remote/content/shared/Format.sys.mjs",
SessionDataCategory:
"chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
SessionDataMethod:
"chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
truncate: "chrome://remote/content/shared/Format.sys.mjs",
updateCacheBehavior:
"chrome://remote/content/shared/NetworkCacheManager.sys.mjs",
UserContextManager:
"chrome://remote/content/shared/UserContextManager.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "logger", () =>
lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI)
);
// WIP: See Bug 1994983, the invalid tests for network.setExtraHeaders expect
// the optional arguments not to accept null.
const NULL = Symbol("NULL");
/**
* Defines the maximum total size as expected by the specification at
* https://w3c.github.io/webdriver-bidi/#max-total-data-size
*/
const DEFAULT_MAX_TOTAL_SIZE = 200 * 1000 * 1000;
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"maxTotalDataSize",
"remote.network.maxTotalDataSize",
DEFAULT_MAX_TOTAL_SIZE
);
/**
* @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 network data collector types.
*
* @readonly
* @enum {CollectorType}
*/
const CollectorType = {
Blob: "blob",
};
/**
* @typedef {object} Collector
* @property {number} maxEncodedDataSize
* @property {Set<DataType>} dataTypes
* @property {string} collector
* @property {CollectorType} collectorType
* @property {Set<string>} userContexts
*/
/**
* 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
*/
/**
* Enum of possible network data types.
*
* @readonly
* @enum {DataType}
*/
const DataType = {
Request: "request",
Response: "response",
};
/**
* @typedef {object} Data
* @property {BytesValue|null} bytes
* @property {Array<Collector>} collectors
* @property {boolean} pending
* @property {string} request
* @property {number} size
* @property {DataType} type
*/
/**
* @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
*/
/**
* Mapping from nsICookie sameSite constants to network.SameSite values.
*
* @readonly
* @enum {SameSite}
*/
const NetworkCookieSameSiteType = {
[Ci.nsICookie.SAMESITE_NONE]: "none",
[Ci.nsICookie.SAMESITE_LAX]: "lax",
[Ci.nsICookie.SAMESITE_STRICT]: "strict",
[Ci.nsICookie.SAMESITE_UNSET]: "default",
};
/**
* Enum of possible sameSite values.
*
* @readonly
* @enum {SameSite}
*/
const SameSite = {
Default: "default",
Lax: "lax",
None: "none",
Strict: "strict",
};
/**
* @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 */
// @see https://searchfox.org/mozilla-central/rev/527d691a542ccc0f333e36689bd665cb000360b2/netwerk/protocol/http/HttpBaseChannel.cpp#2083-2088
const IMMUTABLE_RESPONSE_HEADERS = [
"content-encoding",
"content-length",
"content-type",
"trailer",
"transfer-encoding",
];
const UNAVAILABLE_DATA_ERROR_REASON = {
Aborted: "aborted",
Evicted: "evicted",
};
class NetworkModule extends RootBiDiModule {
#blockedRequests;
#collectedNetworkData;
#decodedBodySizeMap;
#extraHeaders;
#hasExtraHeaders;
#hasNetworkConditionsOffline;
#interceptMap;
#networkCollectors;
#networkListener;
#redirectedRequests;
#subscribedEvents;
constructor(messageHandler) {
super(messageHandler);
// Map of request id to BlockedRequest
this.#blockedRequests = new Map();
// Map of collected network Data, from a composite key `${requestId}-${dataType}`
// to a network Data struct.
// https://w3c.github.io/webdriver-bidi/#collected-network-data
// TODO: This is a property of the remote end per spec, not of the session.
// At the moment, each network module starts its own network observer. This
// makes it impossible to have a session agnostic step when receiving a new
// network event.
// Note: Implemented as a Map. A Map is guaranteed to iterate in the order
// of insertion, but still provides fast lookup.
this.#collectedNetworkData = new Map();
// Implements the BiDi Session extra headers.
// https://w3c.github.io/webdriver-bidi/#session-extra-headers
this.#extraHeaders = {
// Array of Header objects, initially empty.
defaultHeaders: [],
// WeakMap between navigables and arrays of Header objects.
// Due to technical limitations, navigables are represented via the
// BrowsingContextWebProgress of the top level browsing context.
navigableHeaders: new WeakMap(),
// Map between user context ids and arrays of Header objects.
userContextHeaders: new Map(),
};
// Flags used to check if the internal listener should remain enabled even
// when no public events are subscribed.
this.#hasExtraHeaders = false;
this.#hasNetworkConditionsOffline = false;
// Map of intercept id to InterceptProperties
this.#interceptMap = new Map();
// Map of collector id to Collector
this.#networkCollectors = new Map();
// Set of request ids which are being redirected using continueRequest with
// a url parameter. Those requests will lead to an additional beforeRequestSent
// event which needs to be filtered out.
this.#redirectedRequests = new Set();
// Set of event names which have active subscriptions
this.#subscribedEvents = new Set();
this.#decodedBodySizeMap = new lazy.NetworkDecodedBodySizeMap();
this.#networkListener = new lazy.NetworkListener(
this.messageHandler.navigationManager,
this.#decodedBodySizeMap
);
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);
lazy.UserContextManager.on(
"user-context-deleted",
this.#onUserContextDeleted
);
}
destroy() {
lazy.UserContextManager.off(
"user-context-deleted",
this.#onUserContextDeleted
);
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.#decodedBodySizeMap.destroy();
// Network related session cleanup steps
// https://w3c.github.io/webdriver-bidi/#cleanup-the-session
// Resume blocked requests
for (const [, { request }] of this.#blockedRequests) {
try {
request.wrappedChannel.resume();
} catch {
lazy.logger.warn(
`Failed to resume request "${request.requestId}" when ending the session`
);
}
}
// Remove collectors from collected data.
// TODO: This step is unnecessary until we support multiple sessions, because
// the collectedNetworkData is attached to the session and is cleaned up
// afterwards.
this.#blockedRequests = null;
this.#collectedNetworkData = null;
this.#decodedBodySizeMap = null;
this.#extraHeaders = null;
this.#interceptMap = null;
this.#networkCollectors = null;
this.#subscribedEvents = null;
}
/**
* Adds a data collector to collect network data.
*
* @param {object=} options
* @param {Array<DataType>} options.dataTypes
* Maximum size of data to collect in bytes.
* @param {number} options.maxEncodedDataSize
* Maximum size of data to collect in bytes.
* @param {CollectorType=} options.collectorType
* The type of data to collect. Optional, defaults to "blob".
* @param {Array<string>=} options.contexts
* Optional list of browsing context ids.
* @param {Array<string>=} options.userContexts
* Optional list of user context ids.
*
* @returns {object}
* An object with the following property:
* - collector {string} The unique id of the data collector.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
*/
async addDataCollector(options = {}) {
const {
dataTypes,
maxEncodedDataSize,
collectorType = CollectorType.Blob,
contexts: contextIds = null,
userContexts: userContextIds = null,
} = options;
lazy.assert.positiveInteger(
maxEncodedDataSize,
lazy.pprint`Expected "maxEncodedDataSize" to be a positive integer, got ${maxEncodedDataSize}`
);
if (maxEncodedDataSize === 0) {
throw new lazy.error.InvalidArgumentError(
`Expected "maxEncodedDataSize" to be greater than 0, got ${maxEncodedDataSize}`
);
}
if (maxEncodedDataSize > lazy.maxTotalDataSize) {
throw new lazy.error.InvalidArgumentError(
`Expected "maxEncodedDataSize" to be less than the max total data size available (${lazy.maxTotalDataSize}), got ${maxEncodedDataSize}`
);
}
lazy.assert.isNonEmptyArray(
dataTypes,
`Expected "dataTypes" to be a non-empty array, got ${dataTypes}`
);
const supportedDataTypes = Object.values(DataType);
for (const dataType of dataTypes) {
if (!supportedDataTypes.includes(dataType)) {
throw new lazy.error.InvalidArgumentError(
`Expected "dataTypes" values to be one of ${supportedDataTypes},` +
lazy.pprint` got ${dataType}`
);
}
}
const supportedCollectorTypes = Object.values(CollectorType);
if (!supportedCollectorTypes.includes(collectorType)) {
throw new lazy.error.InvalidArgumentError(
`Expected "collectorType" to be one of ${supportedCollectorTypes},` +
lazy.pprint` got ${collectorType}`
);
}
const navigables = new Set();
const userContexts = new Set();
if (contextIds !== null) {
lazy.assert.isNonEmptyArray(
contextIds,
lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}`
);
for (const contextId of contextIds) {
lazy.assert.string(
contextId,
lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}`
);
const context = this._getNavigable(contextId);
lazy.assert.topLevel(
context,
lazy.pprint`Browsing context with id ${contextId} is not top-level`
);
navigables.add(contextId);
}
}
if (userContextIds !== null) {
lazy.assert.isNonEmptyArray(
userContextIds,
lazy.pprint`Expected "userContexts" to be a non-empty array, got ${userContextIds}`
);
for (const userContextId of userContextIds) {
lazy.assert.string(
userContextId,
lazy.pprint`Expected elements of "userContexts" to be a string, got ${userContextId}`
);
const internalId =
lazy.UserContextManager.getInternalIdById(userContextId);
if (internalId === null) {
throw new lazy.error.NoSuchUserContextError(
`User context with id: ${userContextId} doesn't exist`
);
}
userContexts.add(userContextId);
}
}
if (contextIds !== null && userContextIds !== null) {
throw new lazy.error.InvalidArgumentError(
`Providing both "contexts" and "userContexts" arguments is not supported`
);
}
// Generate a unique collector ID
const collectorId = lazy.generateUUID();
const collector = {
collector: collectorId,
collectorType,
contexts: navigables,
dataTypes,
maxEncodedDataSize,
userContexts,
};
this.#networkCollectors.set(collectorId, collector);
await this.#updateCollectorSessionData(
collectorId,
collector,
lazy.SessionDataMethod.Add
);
return {
collector: collectorId,
};
}
/**
* 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.isNonEmptyArray(
contexts,
`Expected "contexts" to be a non-empty array, got ${contexts}`
);
for (const contextId of contexts) {
lazy.assert.string(
contextId,
`Expected elements of "contexts" to be a string, got ${contextId}`
);
const context = this._getNavigable(contextId);
lazy.assert.topLevel(
context,
lazy.pprint`Browsing context with id ${contextId} is not top-level`
);
}
}
lazy.assert.isNonEmptyArray(
phases,
`Expected "phases" to be a non-empty array, got ${phases}`
);
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
* Optional BytesValue to replace the body of the request.
* @param {Array<CookieHeader>=} options.cookies
* Optional array of cookie header values to replace the cookie header of
* the request.
* @param {Array<Header>=} options.headers
* Optional array of headers to replace the headers of the request.
* request.
* @param {string=} options.method
* Optional string to replace the method of the request.
* @param {string=} options.url
* 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,
lazy.truncate`Expected "body" to be a network.BytesValue, got ${body}`
);
}
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}`
);
}
}
let deserializedHeaders = [];
if (headers !== null) {
deserializedHeaders = this.#deserializeHeaders(headers);
}
if (method !== null) {
lazy.assert.string(
method,
`Expected "method" to be a string, got ${method}`
);
lazy.assert.that(
value => this.#isValidHttpToken(value),
`Expected "method" to be a valid HTTP token, got ${method}`
)(method);
}
if (url !== null) {
lazy.assert.string(url, `Expected "url" to be a string, got ${url}`);
if (!URL.canParse(url)) {
throw new lazy.error.InvalidArgumentError(
`Expected "url" to be a valid URL, got ${url}`
);
}
}
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}`
);
}
if (method !== null) {
request.setRequestMethod(method);
}
if (headers !== null) {
// Delete all existing request headers.
request.headers.forEach(([name]) => {
request.clearRequestHeader(name);
});
// Set all headers specified in the headers parameter.
for (const [name, value] of deserializedHeaders) {
request.setRequestHeader(name, value, { merge: true });
}
}
if (cookies !== null) {
let cookieHeader = "";
for (const cookie of cookies) {
if (cookieHeader != "") {
cookieHeader += ";";
}
cookieHeader += this.#serializeCookieHeader(cookie);
}
let foundCookieHeader = false;
for (const [name] of request.headers) {
if (name.toLowerCase() == "cookie") {
// If there is already a cookie header, use merge: false to override
// the value.
request.setRequestHeader(name, cookieHeader, { merge: false });
foundCookieHeader = true;
break;
}
}
if (!foundCookieHeader) {
request.setRequestHeader("Cookie", cookieHeader, { merge: false });
}
}
if (body !== null) {
const value = deserializeBytesValue(body);
request.setRequestBody(value);
}
if (url !== null) {
// Store the requestId in the redirectedRequests set to skip the extra
// beforeRequestSent event.
this.#redirectedRequests.add(requestId);
request.redirectTo(url);
}
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
* 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
* Optional array of header values to replace the headers of the response.
* @param {string=} options.reasonPhrase
* Optional string to replace the status message of the response.
* @param {number=} options.statusCode
* 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);
}
}
if (credentials !== null) {
this.#assertAuthCredentials(credentials);
}
let deserializedHeaders = [];
if (headers !== null) {
// For existing responses, are unable to update some response headers,
// so we skip them for the time being and log a warning.
// Bug 1914351 should remove this limitation.
deserializedHeaders = this.#deserializeHeaders(headers).filter(
([name]) => {
if (IMMUTABLE_RESPONSE_HEADERS.includes(name.toLowerCase())) {
lazy.logger.warn(
`network.continueResponse cannot currently modify the header "${name}", skipping (see Bug 1914351).`
);
return false;
}
return true;
}
);
}
if (reasonPhrase !== null) {
lazy.assert.string(
reasonPhrase,
`Expected "reasonPhrase" to be a string, got ${reasonPhrase}`
);
}
if (statusCode !== null) {
lazy.assert.positiveInteger(
statusCode,
`Expected "statusCode" to be a positive integer, got ${statusCode}`
);
}
if (!this.#blockedRequests.has(requestId)) {
throw new lazy.error.NoSuchRequestError(
`Blocked request with id ${requestId} not found`
);
}
const { authCallbacks, phase, request, resolveBlockedEvent, response } =
this.#blockedRequests.get(requestId);
if (headers !== null) {
// Delete all existing response headers.
response.headers
.filter(
([name]) =>
// All headers in IMMUTABLE_RESPONSE_HEADERS cannot be changed and
// will lead to a NS_ERROR_ILLEGAL_VALUE error.
// Bug 1914351 should remove this limitation.
!IMMUTABLE_RESPONSE_HEADERS.includes(name.toLowerCase())
)
.forEach(([name]) => response.clearResponseHeader(name));
for (const [name, value] of deserializedHeaders) {
response.setResponseHeader(name, value, { merge: true });
}
}
if (cookies !== null) {
for (const cookie of cookies) {
const headerValue = this.#serializeSetCookieHeader(cookie);
response.setResponseHeader("Set-Cookie", headerValue, { merge: true });
}
}
if (statusCode !== null || reasonPhrase !== null) {
response.setResponseStatus({
status: statusCode,
statusText: reasonPhrase,
});
}
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();
}
/**
* Releases a collected network data for a given collector and data type.
*
* @param {object=} options
* @param {string} options.collector
* The collector from which the data should be disowned.
* @param {string} options.dataType
* The data type of the data to disown.
* @param {string} options.request
* The id of the request for which data should be disowned.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchNetworkCollectorError}
* Raised if the collector id could not be found in the internal collectors
* map.
* @throws {NoSuchNetworkDataError}
* If the network data could not be found for the provided parameters.
*/
async disownData(options = {}) {
const { collector, dataType, request: requestId } = options;
lazy.assert.string(
requestId,
lazy.pprint`Expected "request" to be a string, got ${requestId}`
);
const supportedDataTypes = Object.values(DataType);
if (!supportedDataTypes.includes(dataType)) {
throw new lazy.error.InvalidArgumentError(
`Expected "dataType" to be one of ${supportedDataTypes},` +
lazy.pprint` got ${dataType}`
);
}
lazy.assert.string(
collector,
lazy.pprint`Expected "collector" to be a string, got ${collector}`
);
if (!this.#networkCollectors.has(collector)) {
throw new lazy.error.NoSuchNetworkCollectorError(
`Network data collector with id ${collector} not found`
);
}
const collectedData = this.#getCollectedData(requestId, dataType);
if (!collectedData) {
throw new lazy.error.NoSuchNetworkDataError(
`Network data for request id ${requestId} and DataType ${dataType} not found`
);
}
if (!collectedData.collectors.has(collector)) {
throw new lazy.error.NoSuchNetworkDataError(
`Network data for request id ${requestId} and DataType ${dataType} does not match collector ${collector}`
);
}
this.#removeCollectorFromData(collectedData, collector);
}
/**
* An object that holds information about a network data content.
*
* @typedef NetworkGetDataResult
*
* @property {BytesValue} bytes
* The network data content as BytesValue.
*/
/**
* Retrieve a network data if available.
*
* @param {object} options
* @param {string=} options.collector
* Optional id of a collector. If provided, data will only be returned if
* the collector is in the network data collectors.
* @param {DataType} options.dataType
* The type of the data to retrieve.
* @param {boolean=} options.disown
* Optional. If set to true, the collector parameter is mandatory and the
* collector will be removed from the network data collectors. Defaults to
* false.
* @param {string} options.request
* The id of the request of the data to retrieve.
*
* @returns {NetworkGetDataResult}
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchNetworkCollectorError}
* Raised if the collector id could not be found in the internal collectors
* map.
* @throws {NoSuchNetworkDataError}
* If the network data could not be found for the provided parameters.
* @throws {UnavailableNetworkDataError}
* If the network data content is no longer available because it was
* evicted.
*/
async getData(options = {}) {
const {
collector = null,
dataType,
disown = null,
request: requestId,
} = options;
lazy.assert.string(
requestId,
lazy.pprint`Expected "request" to be a string, got ${requestId}`
);
const supportedDataTypes = Object.values(DataType);
if (!supportedDataTypes.includes(dataType)) {
throw new lazy.error.InvalidArgumentError(
`Expected "dataType" to be one of ${supportedDataTypes},` +
lazy.pprint` got ${dataType}`
);
}
if (collector !== null) {
lazy.assert.string(
collector,
lazy.pprint`Expected "collector" to be a string, got ${collector}`
);
if (!this.#networkCollectors.has(collector)) {
throw new lazy.error.NoSuchNetworkCollectorError(
`Network data collector with id ${collector} not found`
);
}
}
if (disown !== null) {
lazy.assert.boolean(
disown,
lazy.pprint`Expected "disown" to be a boolean, got ${disown}`
);
if (disown && collector === null) {
throw new lazy.error.InvalidArgumentError(
`Expected "collector" to be provided when using "disown"=true`
);
}
}
const collectedData = this.#getCollectedData(requestId, dataType);
if (!collectedData) {
throw new lazy.error.NoSuchNetworkDataError(
`Network data for request id ${requestId} and DataType ${dataType} not found`
);
}
if (collectedData.pending) {
await collectedData.networkDataCollected.promise;
}
if (collector !== null && !collectedData.collectors.has(collector)) {
throw new lazy.error.NoSuchNetworkDataError(
`Network data for request id ${requestId} and DataType ${dataType} does not match collector ${collector}`
);
}
if (collectedData.bytes === null) {
const reason = collectedData.unavailableReason;
throw new lazy.error.UnavailableNetworkDataError(
`Network data content for request id ${requestId} and DataType ${dataType} is unavailable (reason: ${reason})`
);
}
const value = await collectedData.bytes.getBytesValue();
if (disown) {
this.#removeCollectorFromData(collectedData, collector);
}
const type = collectedData.bytes.isBase64
? BytesValueType.Base64
: BytesValueType.String;
// The data retrieved here is already either a UTF8-decoded string or a
// base64 encoded binary. No need to re-apply the serializeAsBytesValue
// algorithm.
return {
bytes: {
type,
value,
},
};
}
/**
* 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
* Optional BytesValue to replace the body of the response.
* For now, only supported for requests blocked in beforeRequestSent.
* @param {Array<SetCookieHeader>=} options.cookies
* Optional array of set-cookie header values to use for the provided
* response.
* For now, only supported for requests blocked in beforeRequestSent.
* @param {Array<Header>=} options.headers
* Optional array of header values to use for the provided
* response.
* For now, only supported for requests blocked in beforeRequestSent.
* @param {string=} options.reasonPhrase
* Optional string to use as the status message for the provided response.
* For now, only supported for requests blocked in beforeRequestSent.
* @param {number=} options.statusCode
* Optional number to use as the status code for the provided response.
* For now, only supported for requests blocked in beforeRequestSent.
*
* @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}`
);
}
if (cookies !== null) {
lazy.assert.array(
cookies,
`Expected "cookies" to be an array got ${cookies}`
);
for (const cookie of cookies) {
this.#assertSetCookieHeader(cookie);
}
}
let deserializedHeaders = [];
if (headers !== null) {
deserializedHeaders = this.#deserializeHeaders(headers);
}
if (reasonPhrase !== null) {
lazy.assert.string(
reasonPhrase,
`Expected "reasonPhrase" to be a string, got ${reasonPhrase}`
);
}
if (statusCode !== null) {
lazy.assert.positiveInteger(
statusCode,
`Expected "statusCode" to be a positive integer, got ${statusCode}`
);
}
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);
// Handle optional arguments for the beforeRequestSent phase.
// TODO: Support optional arguments in all phases, see Bug 1901055.
if (phase === InterceptPhase.BeforeRequestSent) {
// Create a new response.
const replacedHttpResponse = Cc[
"@mozilla.org/network/replaced-http-response;1"
].createInstance(Ci.nsIReplacedHttpResponse);
if (statusCode !== null) {
replacedHttpResponse.responseStatus = statusCode;
}
if (reasonPhrase !== null) {
replacedHttpResponse.responseStatusText = reasonPhrase;
}
if (body !== null) {
replacedHttpResponse.responseBody = deserializeBytesValue(body);
}
if (headers !== null) {
for (const [name, value] of deserializedHeaders) {
replacedHttpResponse.setResponseHeader(name, value, true);
}
}
if (cookies !== null) {
for (const cookie of cookies) {
const headerValue = this.#serializeSetCookieHeader(cookie);
replacedHttpResponse.setResponseHeader(
"Set-Cookie",
headerValue,
true
);
}
}
request.setResponseOverride(replacedHttpResponse);
request.wrappedChannel.resume();
} else {
if (body !== null) {
throw new lazy.error.UnsupportedOperationError(
`The "body" parameter is only supported for the beforeRequestSent phase at the moment`
);
}
if (cookies !== null) {
throw new lazy.error.UnsupportedOperationError(
`The "cookies" parameter is only supported for the beforeRequestSent phase at the moment`
);
}
if (headers !== null) {
throw new lazy.error.UnsupportedOperationError(
`The "headers" parameter is only supported for the beforeRequestSent phase at the moment`
);
}
if (reasonPhrase !== null) {
throw new lazy.error.UnsupportedOperationError(
`The "reasonPhrase" parameter is only supported for the beforeRequestSent phase at the moment`
);
}
if (statusCode !== null) {
throw new lazy.error.UnsupportedOperationError(
`The "statusCode" parameter is only supported for the beforeRequestSent phase at the moment`
);
}
if (phase === InterceptPhase.AuthRequired) {
// AuthRequired with no optional argument, resume the authentication.
await authCallbacks.provideAuthCredentials();
} else {
// Any phase other than AuthRequired with no optional argument, resume the
// request.
request.wrappedChannel.resume();
}
}
resolveBlockedEvent();
}
/**
* Removes a data collector.
*
* @param {object=} options
* @param {string} options.collector
* The id of the collector to remove.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchNetworkCollectorError}
* Raised if the collector id could not be found in the internal collectors
* map.
*/
async removeDataCollector(options = {}) {
const { collector } = options;
lazy.assert.string(
collector,
lazy.pprint`Expected "collector" to be a string, got ${collector}`
);
if (!this.#networkCollectors.has(collector)) {
throw new lazy.error.NoSuchNetworkCollectorError(
`Network data collector with id ${collector} not found`
);
}
const collectorData = this.#networkCollectors.get(collector);
this.#networkCollectors.delete(collector);
for (const [, collectedData] of this.#collectedNetworkData) {
this.#removeCollectorFromData(collectedData, collector);
}
await this.#updateCollectorSessionData(
collector,
collectorData,
lazy.SessionDataMethod.Remove
);
}
/**
* 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);
}
/**
* Configures the network cache behavior for certain requests.
*
* @param {object=} options
* @param {CacheBehavior} options.cacheBehavior
* An enum value to set the network cache behavior.
* @param {Array<string>=} options.contexts
* The list of browsing context ids where the network cache
* behavior should be updated.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchFrameError}
* If the browsing context cannot be found.
*/
setCacheBehavior(options = {}) {
const { cacheBehavior: behavior, contexts: contextIds = null } = options;
if (!Object.values(lazy.CacheBehavior).includes(behavior)) {
throw new lazy.error.InvalidArgumentError(
`Expected "cacheBehavior" to be one of ${Object.values(
lazy.CacheBehavior
)}` + lazy.pprint` got ${behavior}`
);
}
if (contextIds === null) {
// Set the default behavior if no specific context is specified.
lazy.updateCacheBehavior(behavior);
return;
}
lazy.assert.isNonEmptyArray(
contextIds,
lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}`
);
const contexts = new Set();
for (const contextId of contextIds) {
lazy.assert.string(
contextId,
lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}`
);
const context = this._getNavigable(contextId);
lazy.assert.topLevel(
context,
lazy.pprint`Browsing context with id ${contextId} is not top-level`
);
contexts.add(context);
}
lazy.updateCacheBehavior(behavior, contexts);
}
/**
* Allows to specify headers that will extend, or overwrite, existing request
* headers.
*
* @param {object=} options
* @param {Array<Header>} options.headers
* Array of header values to replace the headers of the response.
* @param {Array<string>=} options.contexts
* Optional list of browsing context ids.
* @param {Array<string>=} options.userContexts
* Optional list of user context ids.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {NoSuchFrameError}
* If the browsing context cannot be found.
*/
setExtraHeaders(options = {}) {
const {
headers,
contexts: contextIds = NULL,
userContexts: userContextIds = NULL,
} = options;
lazy.assert.array(
headers,
lazy.pprint`Expected "headers" to be an array, got ${headers}`
);
const deserializedHeaders = this.#deserializeHeaders(headers);
if (contextIds !== NULL && userContextIds !== NULL) {
throw new lazy.error.InvalidArgumentError(
`Providing both "contexts" and "userContexts" arguments is not supported`
);
}
const navigables = new Set();
const userContexts = new Set();
if (userContextIds !== NULL) {
lazy.assert.isNonEmptyArray(
userContextIds,
lazy.pprint`Expected "userContexts" to be a non-empty array, got ${userContextIds}`
);
for (const userContextId of userContextIds) {
lazy.assert.string(
userContextId,
lazy.pprint`Expected elements of "userContexts" to be a string, got ${userContextId}`
);
const internalId =
lazy.UserContextManager.getInternalIdById(userContextId);
if (internalId === null) {
throw new lazy.error.NoSuchUserContextError(
`User context with id: ${userContextId} doesn't exist`
);
}
userContexts.add(userContextId);
}
}
if (contextIds !== NULL) {
lazy.assert.isNonEmptyArray(
contextIds,
lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}`
);
for (const contextId of contextIds) {
lazy.assert.string(
contextId,
lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}`
);
const context = this._getNavigable(contextId);
lazy.assert.topLevel(
context,
lazy.pprint`Browsing context with id ${contextId} is not top-level`
);
navigables.add(contextId);
}
}
if (userContextIds !== NULL) {
for (const userContextId of userContexts) {
this.#extraHeaders.userContextHeaders.set(
userContextId,
deserializedHeaders
);
}
} else if (contextIds !== NULL) {
for (const contextId of navigables) {
const context = this._getNavigable(contextId);
this.#extraHeaders.navigableHeaders.set(
context.webProgress,
deserializedHeaders
);
}
} else {
this.#extraHeaders.defaultHeaders = deserializedHeaders;
}
if (!this.#hasExtraHeaders && headers.length) {
this.#hasExtraHeaders = true;
this.#networkListener.startListening();
}
}
/**
* 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);
});
}
/**
* Implements https://w3c.github.io/webdriver-bidi/#allocate-size-to-record-data
*
* @param {number} size
* The size to allocate in bytes.
*/
#allocateSizeToRecordData(size) {
let availableSize = lazy.maxTotalDataSize;
const alreadyCollectedData = [];
for (const [, collectedData] of this.#collectedNetworkData) {
if (collectedData.bytes !== null) {
availableSize = availableSize - collectedData.size;
alreadyCollectedData.push(collectedData);
}
}
if (size > availableSize) {
for (const collectedData of alreadyCollectedData) {
availableSize = availableSize + collectedData.size;
collectedData.bytes = null;
collectedData.unavailableReason = UNAVAILABLE_DATA_ERROR_REASON.Evicted;
collectedData.size = null;
if (size <= availableSize) {
return;
}
}
}
}
#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}`
);
}
}
#cloneNetworkRequestBody(request) {
if (!this.#networkCollectors.size) {
return;
}
// If request body is missing or null, do not store any collected data.
if (!request.postData || request.postData === null) {
return;
}
const key = `${request.requestId}-${DataType.Request}`;
if (this.#collectedNetworkData.has(key)) {
// For redirected requests we might already be tracking the body.
return;
}
const collectedData = {
bytes: null,
collectors: new Set(),
pending: true,
// This allows to implement the await/resume on "network data collected"
// described in the specification.
networkDataCollected: Promise.withResolvers(),
request: request.requestId,
size: null,
type: DataType.Request,
};
// The actual cloning is already handled by the DevTools
// NetworkResponseListener, here we just have to prepare the networkData and
// add it to the array.
this.#collectedNetworkData.set(key, collectedData);
}
#cloneNetworkResponseBody(request) {
if (!this.#networkCollectors.size) {
return;
}
const key = `${request.requestId}-${DataType.Response}`;
if (this.#collectedNetworkData.has(key)) {
// For redirected requests we might already be tracking the body.
return;
}
const collectedData = {
bytes: null,
// The cloned body is fully handled by DevTools' NetworkResponseListener
// so it will not explicitly be stored here.
collectors: new Set(),
pending: true,
// This allows to implement the await/resume on "network data collected"
// described in the specification.
networkDataCollected: Promise.withResolvers(),
request: request.requestId,
size: null,
type: DataType.Response,
// Internal string used in the UnavailableNetworkData error message.
unavailableReason: null,
};
// The actual cloning is already handled by the DevTools
// NetworkResponseListener, here we just have to prepare the networkData and
// add it to the array.
this.#collectedNetworkData.set(key, collectedData);
}
#deserializeHeader(protocolHeader) {
const name = protocolHeader.name;
const value = deserializeBytesValue(protocolHeader.value);
return [name, value];
}
#deserializeHeaders(headers) {
const deserializedHeaders = [];
lazy.assert.array(
headers,
lazy.pprint`Expected "headers" to be an array got ${headers}`
);
for (const header of headers) {
this.#assertHeader(
header,
lazy.pprint`Expected values in "headers" to be network.Header, got ${header}`
);
// Deserialize headers immediately to validate the value
const deserializedHeader = this.#deserializeHeader(header);
lazy.assert.that(
value => this.#isValidHttpToken(value),
lazy.pprint`Expected "header" name to be a valid HTTP token, got ${deserializedHeader[0]}`
)(deserializedHeader[0]);
lazy.assert.that(
value => this.#isValidHeaderValue(value),
lazy.pprint`Expected "header" value to be a valid header value, got ${deserializedHeader[1]}`
)(deserializedHeader[1]);
deserializedHeaders.push(deserializedHeader);
}
return deserializedHeaders;
}
#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.headers) {
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;
}
#getCollectedData(requestId, dataType) {
return this.#collectedNetworkData.get(`${requestId}-${dataType}`) || null;
}
#getNetworkIntercepts(event, request, topContextId) {
if (!request.supportsInterception) {
// For requests which do not support interception (such as data URIs or
// cached resources), do not attempt to match intercepts.
return [];
}
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;
}
#getCookiesForRequest(request) {
if (!request.host) {
return [];
}
const storeCookies = Services.cookies.getCookiesWithOriginAttributes(
request.originAttributesString,
request.host
);
const headerCookieNames = new Set();
for (const [name, value] of request.headers) {
if (name.toLowerCase() === "cookie") {
for (const part of value.split(";")) {
const eq = part.indexOf("=");
if (eq !== -1) {
headerCookieNames.add(unescape(part.substr(0, eq).trim()));
}
}
}
}
const cookies = [];
for (const cookie of storeCookies) {
if (headerCookieNames.has(cookie.name)) {
cookies.push(this.#serializeNetworkCookie(cookie));
}
}
return cookies;
}
#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 = [];
for (const [name, value] of request.headers) {
headers.push(this.#serializeHeader(name, value));
}
const cookies = this.#getCookiesForRequest(request);
const destination = request.destination;
const initiatorType = request.initiatorType;
const timings = request.timings;
return {
request: requestId,
url,
method,
bodySize,
headersSize,
headers,
cookies,
destination,
initiatorType,
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.mimeType;
const headers = [];
for (const [name, value] of response.headers) {
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`;
}
#isValidHeaderValue(value) {
if (!value.length) {
return true;
}
// For non-empty strings check against:
// - leading or trailing tabs & spaces
// - new lines and null bytes
const chars = value.split("");
const tabOrSpace = [" ", "\t"];
const forbiddenChars = ["\r", "\n", "\0"];
return (
!tabOrSpace.includes(chars.at(0)) &&
!tabOrSpace.includes(chars.at(-1)) &&
forbiddenChars.every(c => !chars.includes(c))
);
}
/**
* This helper is adapted from a C++ validation helper in nsHttp.cpp.
*
* @see https://searchfox.org/mozilla-central/rev/445a6e86233c733c5557ef44e1d33444adaddefc/netwerk/protocol/http/nsHttp.cpp#169
*/
#isValidHttpToken(token) {
// prettier-ignore
// This array corresponds to all char codes between 0 and 127, which is the
// range of supported char codes for HTTP tokens. Within this range,
// accepted char codes are marked with a 1, forbidden char codes with a 0.
const validTokenMap = [
0, 0, 0, 0, 0, 0, 0, 0, // 0
0, 0, 0, 0, 0, 0, 0, 0, // 8
0, 0, 0, 0, 0, 0, 0, 0, // 16
0, 0, 0, 0, 0, 0, 0, 0, // 24
0, 1, 0, 1, 1, 1, 1, 1, // 32
0, 0, 1, 1, 0, 1, 1, 0, // 40
1, 1, 1, 1, 1, 1, 1, 1, // 48
1, 1, 0, 0, 0, 0, 0, 0, // 56
0, 1, 1, 1, 1, 1, 1, 1, // 64
1, 1, 1, 1, 1, 1, 1, 1, // 72
1, 1, 1, 1, 1, 1, 1, 1, // 80
1, 1, 1, 0, 0, 0, 1, 1, // 88
1, 1, 1, 1, 1, 1, 1, 1, // 96
1, 1, 1, 1, 1, 1, 1, 1, // 104
1, 1, 1, 1, 1, 1, 1, 1, // 112
1, 1, 1, 0, 1, 0, 1, 0 // 120
];
if (!token.length) {
return false;
}
return token
.split("")
.map(s => s.charCodeAt(0))
.every(c => validTokenMap[c]);
}
/**
* Implements https://w3c.github.io/webdriver-bidi/#match-collector-for-navigable
*
* @param {Collector} collector
* The collector to match
* @param {BrowsingContext} navigable
* The navigable (BrowsingContext) to match
* @returns {boolean}
* True if the collector corresponds to the provided navigable. False
* otherwise.
*/
#matchCollectorForNavigable(collector, navigable) {
if (collector.contexts.size) {
const navigableId =
lazy.NavigableManager.getIdForBrowsingContext(navigable);
return collector.contexts.has(navigableId);
}
if (collector.userContexts.size) {
const userContext =
lazy.UserContextManager.getIdByBrowsingContext(navigable);
return collector.userContexts.has(userContext);
}
// Return true.
return true;
}
/**
* Implements https://w3c.github.io/webdriver-bidi/#maybe-abort-network-response-body-collection
*
* @param {NetworkRequest} request
* The request object for which we want to abort the body collection.
*/
#maybeAbortNetworkResponseBodyCollection(request) {
const collectedData = this.#getCollectedData(
request.requestId,
DataType.Response
);
if (collectedData === null) {
return;
}
lazy.logger.trace(
`Network data not collected for request "${request.requestId}" and data type "${DataType.Response}"` +
`: fetch error`
);
collectedData.pending = false;
collectedData.unavailableReason = UNAVAILABLE_DATA_ERROR_REASON.Aborted;
collectedData.networkDataCollected.resolve();
}
/**
* Implements https://w3c.github.io/webdriver-bidi/#maybe-collect-network-request-body
*
* @param {NetworkRequest} request
* The request object for which we want to collect the body.
*/
async #maybeCollectNetworkRequestBody(request) {
const collectedData = this.#getCollectedData(
request.requestId,
DataType.Request
);
if (collectedData === null) {
return;
}
this.#maybeCollectNetworkData({
collectedData,
dataType: DataType.Request,
request,
readAndProcessBodyFn: request.readAndProcessRequestBody,
size: request.postDataSize,
});
}
/**
* Implements https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-response-body
*
* @param {NetworkRequest} request
* The request object for which we want to collect the body.
* @param {NetworkResponse} response
* The response object for which we want to collect the body.
*/
async #maybeCollectNetworkResponseBody(request, response) {
if (response.willRedirect) {
return;
}
const collectedData = this.#getCollectedData(
request.requestId,
DataType.Response
);
if (collectedData === null) {
return;
}
if (
!(response instanceof lazy.NetworkResponse) &&
!response.isDataURL &&
!response.hasCachedResponseBody
) {
lazy.logger.trace(
`Network data not collected for request "${request.requestId}" and data type "${DataType.Response}"` +
`: unsupported response (read from memory cache)`
);
// Cached stencils do not return any response body.
collectedData.pending = false;
collectedData.networkDataCollected.resolve();
this.#collectedNetworkData.delete(
`${collectedData.request}-${collectedData.type}`
);
return;
}
let readAndProcessBodyFn, size;
if (response.isDataURL) {
// Handle data URLs as a special case since the response is not provided
// by the DevTools ResponseListener in this case.
const url = request.serializedURL;
const body = url.substring(url.indexOf(",") + 1);
const isText =
response.mimeType &&
lazy.NetworkHelper.isTextMimeType(response.mimeType);
readAndProcessBodyFn = () =>
new lazy.NetworkDataBytes({
getBytesValue: () => body,
isBase64: !isText,
});
size = body.length;
} else if (response.hasCachedResponseBody) {
readAndProcessBodyFn = () =>
new lazy.NetworkDataBytes({
getBytesValue: () => response.cachedResponseBody,
isBase64: false,
});
size = response.cachedResponseBody.length;
} else {
readAndProcessBodyFn = response.readAndProcessResponseBody;
size = response.encodedBodySize;
}
this.#maybeCollectNetworkData({
collectedData,
dataType: DataType.Response,
request,
readAndProcessBodyFn,
size,
});
}
/**
* Implements https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-data
*
* @param {object} options
* @param {Data} options.collectedData
* @param {DataType} options.dataType
* @param {NetworkRequest} options.request
* @param {Function} options.readAndProcessBodyFn
* @param {number} options.size
*/
async #maybeCollectNetworkData(options) {
const {
collectedData,
dataType,
request,
// Note: this parameter is not present in
// https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-data
// Each caller is responsible for providing a callable which will return
// a NetworkDataBytes instance corresponding to the collected data.
readAndProcessBodyFn,
// Note: the spec assumes that in some cases the size can be computed
// dynamically. But in practice we might be storing encoding data in a
// format which makes it hard to get the size. So here we always expect
// callers to provide a size.
size,
} = options;
const browsingContext = lazy.NavigableManager.getBrowsingContextById(
request.contextId
);
if (!browsingContext) {
lazy.logger.trace(
`Network data not collected for request "${request.requestId}" and data type "${dataType}"` +
`: navigable no longer available`
);
collectedData.pending = false;
this.#collectedNetworkData.delete(
`${collectedData.request}-${collectedData.type}`
);
collectedData.networkDataCollected.resolve();
return;
}
const topNavigable = browsingContext.top;
let collectors = [];
for (const [, collector] of this.#networkCollectors) {
if (
collector.dataTypes.includes(dataType) &&
this.#matchCollectorForNavigable(collector, topNavigable)
) {
collectors.push(collector);
}
}
if (!collectors.length) {
lazy.logger.trace(
`Network data not collected for request "${request.requestId}" and data type "${dataType}"` +
`: no matching collector`
);
collectedData.pending = false;
this.#collectedNetworkData.delete(
`${collectedData.request}-${collectedData.type}`
);
collectedData.networkDataCollected.resolve();
return;
}
let bytes = null;
// At this point, the specification expects to processBody for the cloned
// body. Here we do not explicitly clone the bodies.
// For responses, DevTools' NetworkResponseListener clones the stream.
// For requests, NetworkHelper.readPostTextFromRequest clones the stream on
// the fly to read it as text.
try {
const bytesOrNull = await readAndProcessBodyFn();
if (bytesOrNull !== null) {
bytes = bytesOrNull;
}
} catch {
// Let processBodyError be this step: Do nothing.
}
// If the network module was destroyed while waiting to read the response
// body, the session has been destroyed. Resolve the promise and bail out.
if (!this.#collectedNetworkData) {
collectedData.networkDataCollected.resolve();
return;
}
if (bytes !== null) {
for (const collector of collectors) {
if (size <= collector.maxEncodedDataSize) {
collectedData.collectors.add(collector.collector);
}
}
if (collectedData.collectors.size) {
this.#allocateSizeToRecordData(size);
collectedData.bytes = bytes;
collectedData.size = size;
}
}
// Note: specification flips `collectedData.pending` to false earlier, but
// the implementation is async with `await response.readResponseBody()`.
// `collectedData.pending` is only flipped before returning here - and in
// early returns above.
collectedData.pending = false;
if (!collectedData.collectors.size) {
this.#collectedNetworkData.delete(
`${collectedData.request}-${collectedData.type}`
);
}
collectedData.networkDataCollected.resolve();
}
#onAuthRequired = (name, data) => {
const { authCallbacks, request, response } = data;
let isBlocked = false;
try {
const browsingContext = lazy.NavigableManager.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._hasListener(protocolEventName, {
contextId: browsingContext.id,
});
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._emitEventForBrowsingContext(
browsingContext.id,
protocolEventName,
authRequiredEvent
);
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;
if (this.#redirectedRequests.has(request.requestId)) {
// If this beforeRequestSent event corresponds to a request that has
// just been redirected using continueRequest, skip the event and remove
// it from the redirectedRequests set.
this.#redirectedRequests.delete(request.requestId);
return;
}
const browsingContext = lazy.NavigableManager.getBrowsingContextById(
request.contextId
);
if (!browsingContext) {
// Do not emit events if the context id does not match any existing
// browsing context.
return;
}
// Make sure a collected data is created for the request.
// Note: this is supposed to be triggered from fetch and doesn't depend on
// whether network events are used or not.
this.#cloneNetworkRequestBody(request);
const relatedNavigables = [browsingContext];
this.#updateRequestHeaders(request, relatedNavigables);
const protocolEventName = "network.beforeRequestSent";
const isListening = this._hasListener(protocolEventName, {
contextId: browsingContext.id,
});
if (isListening) {
this.#maybeCollectNetworkRequestBody(request);
const baseParameters = this.#processNetworkEvent(
protocolEventName,
request
);
// Bug 1805479: Handle the initiator, including stacktrace details.
const initiator = {
type: InitiatorType.Other,
};
const beforeRequestSentEvent = {
...baseParameters,
initiator,
};
this._emitEventForBrowsingContext(
browsingContext.id,
protocolEventName,
beforeRequestSentEvent
);
if (beforeRequestSentEvent.isBlocked) {
request.wrappedChannel.suspend(
this.#getSuspendMarkerText(request, "beforeRequestSent")
);
this.#addBlockedRequest(
beforeRequestSentEvent.request.request,
InterceptPhase.BeforeRequestSent,
{
request,
}
);
}
}
// If network conditions are set to "offline", most requests should be
// prevented, but some are still sent (e.g. keep-alive).
// Per https://w3c.github.io/webdriver-bidi/#webdriver-bidi-before-request-sent
// this should be handled after emitting the beforeRequestSent event.
if (browsingContext.top?.forceOffline) {
request.wrappedChannel.cancel(
Cr.NS_ERROR_OFFLINE,
Ci.nsILoadInfo.BLOCKING_REASON_WEBDRIVER_BIDI
);
}
};
#onFetchError = (name, data) => {
const { request } = data;
const browsingContext = lazy.NavigableManager.getBrowsingContextById(
request.contextId
);
if (!browsingContext) {
// Do not emit events if the context id does not match any existing
// browsing context.
return;
}
const protocolEventName = "network.fetchError";
const isListening = this._hasListener(protocolEventName, {
contextId: browsingContext.id,
});
if (!isListening) {
// If there are no listeners subscribed to this event and this context,
// bail out.
return;
}
this.#maybeAbortNetworkResponseBodyCollection(request);
const baseParameters = this.#processNetworkEvent(
protocolEventName,
request
);
const fetchErrorEvent = {
...baseParameters,
errorText: request.errorText,
};
this._emitEventForBrowsingContext(
browsingContext.id,
protocolEventName,
fetchErrorEvent
);
};
#onResponseEvent = async (name, data) => {
const { request, response } = data;
const browsingContext = lazy.NavigableManager.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";
if (protocolEventName === "network.responseStarted") {
this.#cloneNetworkResponseBody(request);
}
const isListening = this._hasListener(protocolEventName, {
contextId: browsingContext.id,
});
if (!isListening) {
// If there are no listeners subscribed to this event and this context,
// bail out.
return;
}
if (protocolEventName === "network.responseCompleted") {
this.#maybeCollectNetworkResponseBody(request, response);
}
const baseParameters = this.#processNetworkEvent(
protocolEventName,
request
);
const responseData = this.#getResponseData(response);
const responseEvent = {
...baseParameters,
response: responseData,
};
this._emitEventForBrowsingContext(
browsingContext.id,
protocolEventName,
responseEvent
);
if (
protocolEventName === "network.responseStarted" &&
responseEvent.isBlocked &&
request.supportsInterception
) {
request.wrappedChannel.suspend(
this.#getSuspendMarkerText(request, "responseStarted")
);
this.#addBlockedRequest(
responseEvent.request.request,
InterceptPhase.ResponseStarted,
{
request,
response,
}
);
}
};
#onUserContextDeleted = (name, data) => {
const userContextId = data.userContextId;
if (this.#extraHeaders.userContextHeaders.has(userContextId)) {
this.#extraHeaders.userContextHeaders.delete(userContextId);
}
};
#processNetworkEvent(event, request) {
const requestData = this.#getRequestData(request);
const navigation = request.navigationId;
let contextId = null;
let topContextId = null;
let userContextId = null;
if (request.contextId) {
// Retrieve the top browsing context id for this network event.
contextId = request.contextId;
const browsingContext =
lazy.NavigableManager.getBrowsingContextById(contextId);
topContextId = lazy.NavigableManager.getIdForBrowsingContext(
browsingContext.top
);
userContextId =
lazy.UserContextManager.getIdByBrowsingContext(browsingContext);
}
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,
userContext: userContextId,
};
if (isBlocked) {
params.intercepts = intercepts;
}
return params;
}
/**
* Implements https://w3c.github.io/webdriver-bidi/#remove-collector-from-data
*
* @param {Data} collectedData
* The Data from which the collector should be removed.
* @param {string} collectorId
* The collector id to remove.
*/
#removeCollectorFromData(collectedData, collectorId) {
if (collectedData.collectors.has(collectorId)) {
collectedData.collectors.delete(collectorId);
if (!collectedData.collectors.size) {
this.#collectedNetworkData.delete(
`${collectedData.request}-${collectedData.type}`
);
}
}
}
#serializeCookieHeader(cookieHeader) {
const name = cookieHeader.name;
const value = deserializeBytesValue(cookieHeader.value);
return `${name}=${value}`;
}
#serializeHeader(name, value) {
return {
name,
value: serializeAsBytesValue(value),
};
}
#serializeNetworkCookie(cookie) {
const serialized = {
domain: cookie.host,
httpOnly: cookie.isHttpOnly,
name: cookie.name,
path: cookie.path,
sameSite: NetworkCookieSameSiteType[cookie.sameSite],
secure: cookie.isSecure,
size: cookie.name.length + cookie.value.length,
value: serializeAsBytesValue(cookie.value),
};
if (!cookie.isSession) {
// expiry is in milliseconds, the spec expects seconds.
serialized.expiry = Math.round(cookie.expiry / 1000);
}
return serialized;
}
#serializeSetCookieHeader(setCookieHeader) {
const {
name,
value,
domain = null,
httpOnly = null,
expiry = null,
maxAge = null,
path = null,
sameSite = null,
secure = null,
} = setCookieHeader;
let headerValue = `${name}=${deserializeBytesValue(value)}`;
if (expiry !== null) {
headerValue += `;Expires=${expiry}`;
}
if (maxAge !== null) {
headerValue += `;Max-Age=${maxAge}`;
}
if (domain !== null) {
headerValue += `;Domain=${domain}`;
}
if (path !== null) {
headerValue += `;Path=${path}`;
}
if (secure === true) {
headerValue += `;Secure`;
}
if (httpOnly === true) {
headerValue += `;HttpOnly`;
}
if (sameSite !== null) {
headerValue += `;SameSite=${sameSite}`;
}
return headerValue;
}
#startListening(event) {
if (!this.#subscribedEvents.size) {
this.#networkListener.startListening();
}
this.#subscribedEvents.add(event);
}
#stopListening(event) {
this.#subscribedEvents.delete(event);
if (this.#hasNetworkConditionsOffline || this.#hasExtraHeaders) {
// If networkConditions or extraHeaders are set, the listener should
// remain enabled even if no public events are emitted.
return;
}
if (!this.#subscribedEvents.size) {
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);
}
}
/**
* Update SessionData when a response data collector is added or removed.
*
* @param {string} collectorId
* The id of the collector.
* @param {Collector} collector
* The collector object.
* @param {SessionDataMethod} method
* Whether to add or remove the item.
*/
async #updateCollectorSessionData(collectorId, collector, method) {
const sessionDataItems = [];
if (!collector.dataTypes.includes(DataType.Response)) {
// windowglobal modules only need to know whether responses are collected
// in order to capture cached content held in the content process.
return;
}
const sessionDataItem = {
category: lazy.SessionDataCategory.ResponseCollector,
method,
moduleName: "network",
values: [collectorId],
};
if (collector.contexts.size) {
for (const contextId of collector.contexts) {
sessionDataItems.push({
...sessionDataItem,
contextDescriptor: {
type: lazy.ContextDescriptorType.TopBrowsingContext,
id: contextId,
},
});
}
} else if (collector.userContexts.size) {
for (const userContextId of collector.userContexts) {
sessionDataItems.push({
...sessionDataItem,
contextDescriptor: {
type: lazy.ContextDescriptorType.UserContext,
id: userContextId,
},
});
}
} else {
sessionDataItems.push({
...sessionDataItem,
contextDescriptor: {
type: lazy.ContextDescriptorType.All,
},
});
}
await this.messageHandler.updateSessionData(sessionDataItems);
}
/**
* Implements https://w3c.github.io/webdriver-bidi/#update-headers
*/
#updateHeaders(request, headers) {
for (const [name, value] of headers) {
// Use merge: false to always override the value
request.setRequestHeader(name, value, { merge: false });
}
}
/**
* Implements https://w3c.github.io/webdriver-bidi/#update-request-headers
*/
#updateRequestHeaders(request, navigables) {
for (const browsingContext of navigables) {
this.#updateHeaders(request, this.#extraHeaders.defaultHeaders);
const userContextHeaders = this.#extraHeaders.userContextHeaders;
const userContext =
lazy.UserContextManager.getIdByBrowsingContext(browsingContext);
if (userContextHeaders.has(userContext)) {
this.#updateHeaders(request, userContextHeaders.get(userContext));
}
const navigableHeaders = this.#extraHeaders.navigableHeaders;
const topNavigableWebProgress = browsingContext.top.webProgress;
if (navigableHeaders.has(topNavigableWebProgress)) {
this.#updateHeaders(
request,
navigableHeaders.get(topNavigableWebProgress)
);
}
}
}
/**
* 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);
}
}
}
_sendEventsForWindowGlobalNetworkResource(params) {
this.#onBeforeRequestSent("before-request-sent", params);
this.#onResponseEvent("response-started", params);
this.#onResponseEvent("response-completed", params);
}
_setDecodedBodySize(params) {
const { channelId, decodedBodySize } = params;
this.#decodedBodySizeMap.setDecodedBodySize(channelId, decodedBodySize);
}
_startListeningForNetworkConditionsOffline() {
if (!this.#hasNetworkConditionsOffline) {
this.#hasNetworkConditionsOffline = true;
this.#networkListener.startListening();
}
}
static get supportedEvents() {
return [
"network.authRequired",
"network.beforeRequestSent",
"network.fetchError",
"network.responseCompleted",
"network.responseStarted",
];
}
}
/**
* Deserialize a network BytesValue.
*
* @see https://w3c.github.io/webdriver-bidi/#deserialize-protocol-bytes
*
* @param {BytesValue} protocolBytes
* The BytesValue to deserialize.
* @returns {string}
* The deserialized value.
*/
export function deserializeBytesValue(protocolBytes) {
const { type, value } = protocolBytes;
let bytes;
if (type === BytesValueType.String) {
// If protocol bytes matches the network.StringValue production
// Encode values as UTF-8
bytes = encodeAsUTF8(value);
} else {
// Otherwise if protocol bytes matches the network.Base64Value production
// Let bytes be forgiving-base64 decode protocol bytes["value"].
bytes = atob(value);
}
return bytes;
}
/**
* Encode the provided value as UTF-8, working around argument limits in JS.
*
* @param {string} value
* The value to encode.
* @return {string}
* The UTF-8 encoded string.
*/
function encodeAsUTF8(value) {
const CHUNK_SIZE = 65536;
let result = "";
const encoder = new TextEncoder();
const utf8Bytes = encoder.encode(value);
for (let i = 0; i < utf8Bytes.length; i += CHUNK_SIZE) {
const chunk = utf8Bytes.slice(i, i + CHUNK_SIZE);
result += String.fromCharCode.apply(null, chunk);
}
return result;
}
/**
* Serialize a value as BytesValue.
*
* @see https://w3c.github.io/webdriver-bidi/#serialize-protocol-bytes
*
* @param {string} bytes
* The value to serialize.
* @return {BytesValue}
* The serialized value.
*/
function serializeAsBytesValue(bytes) {
let text, type;
try {
type = BytesValueType.String;
// Let text be UTF-8 decode without BOM or fail bytes.
const decoder = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
});
text = decoder.decode(Uint8Array.from(bytes, c => c.charCodeAt(0)));
} catch (e) {
if (e instanceof TypeError) {
// If text is failure, return a map matching the network.Base64Value production,
type = BytesValueType.Base64;
// Set value to forgiving-base64 encode bytes.
text = btoa(bytes);
} else {
// Errors other than TypeError are unexpected and should bubble up.
throw e;
}
}
return {
type,
value: text,
};
}
export const network = NetworkModule;