blob: 402e29f7ec7c92ba985a2a0d06af454549e75d08 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// This file defines a ControlledFrameWebRequest class that wraps WebView's
// WebRequest implementation and provides a more web-friendly API that uses
// EventTarget and Web naming conventions for enums. ControlledFrameWebRequest
// doesn't provide any new functionality; it translates its API to the
// WebView API.
const $Headers = require('safeMethods').SafeMethods.$Headers;
function convertUrlPatternsToMatchPatterns(urlPatterns) {
// TODO(crbug.com/419101630): Implement this.
return urlPatterns;
}
function convertExtensionHeadersToWeb(httpHeaders) {
const headers = new $Headers.self();
for (const header of httpHeaders) {
const value = (header.value !== undefined)
? header.value
: $String.fromCharCode(...header.binaryValue);
$Headers.append(headers, header.name, value);
}
return headers;
}
function convertWebHeadersToExtension(headersInit) {
const headers = new $Headers.self(headersInit);
const httpHeaders = $Array.self();
$Headers.forEach(headers, (value, key) => {
$Array.push(httpHeaders, {
__proto__: null,
name: key,
value,
});
});
return httpHeaders;
}
function identity(value) {
return value;
}
function mapString(mapping, value) {
if (value in mapping) {
return mapping[value];
}
return value;
}
function extractAndMapValues(obj, mapping) {
const mapped = { __proto__: null };
for (const [key, value] of $Object.entries(obj)) {
if (key in mapping) {
$Object.defineProperty(mapped, key, {
__proto__: null,
value: mapping[key](value),
enumerable: true,
configurable: true,
});
}
}
return mapped;
}
function renameObjectKeys(obj, mapping) {
for (const [oldKey, newKey] of $Object.entries(mapping)) {
if (oldKey in obj) {
$Object.defineProperty(obj, newKey, {
__proto__: null,
value: obj[oldKey],
enumerable: true,
configurable: true,
});
delete obj[oldKey];
}
}
}
function emptyObjectToUndefined(obj) {
if ($Object.keys(obj).length === 0) {
return undefined;
}
return obj;
}
// Converts the extensions WebRequest details object to the format used by
// Controlled Frame, which is more grouped and follows web naming conventions.
function webifyRequestDetails(details) {
const webDetails = extractAndMapValues(details, {
documentId: identity,
documentLifecycle: $Function.bind(
mapString, null,
{__proto__: null, pending_deletion: 'pending-deletion'}),
error: identity,
frameId: identity,
frameType: $Function.bind(mapString, null, {
__proto__: null,
outermost_frame: 'outermost-frame',
fenced_frame: 'fenced-frame',
sub_frame: 'sub-frame',
}),
parentDocumentId: identity,
parentFrameId: identity,
});
const request = extractAndMapValues(details, {
initiator: identity,
method: identity,
requestBody: identity,
requestHeaders: convertExtensionHeadersToWeb,
requestId: identity,
type: $Function.bind(mapString, null, {
__proto__: null,
main_frame: 'main-frame',
sub_frame: 'sub-frame',
csp_report: 'csp-report',
}),
url: identity,
});
renameObjectKeys(request, {
__proto__: null,
requestBody: 'body',
requestHeaders: 'headers',
requestId: 'id',
});
$Object.freeze(request);
const response = emptyObjectToUndefined(extractAndMapValues(details, {
__proto__: null,
fromCache: identity,
ip: identity,
redirectUrl: identity,
responseHeaders: convertExtensionHeadersToWeb,
statusCode: identity,
statusLine: identity,
}));
if (response) {
$Object.defineProperty(response, 'auth', {
__proto__: null,
value: emptyObjectToUndefined(extractAndMapValues(details, {
__proto__: null,
challenger: identity,
isProxy: identity,
realm: identity,
scheme: identity,
})),
enumerable: true,
});
renameObjectKeys(response, {
__proto__: null,
redirectUrl: 'redirectURL',
responseHeaders: 'headers',
});
}
$Object.freeze(response);
return $Object.assign(webDetails, {__proto__: null, request, response});
}
class ControlledFrameWebRequest {
#webRequest;
constructor(webRequest) {
this.#webRequest = webRequest;
}
createWebRequestInterceptor(options) {
if (!options.urlPatterns || options.urlPatterns.length === 0) {
throw new TypeError('"urlPatterns" must contain at least one value');
}
if (options.includeHeaders !== undefined &&
!$Array.includes(
$Array.self('none', 'same-origin', 'cross-origin'),
options.includeHeaders)) {
throw new TypeError(
'If defined, "includeHeaders" must equal the string ' +
'"none", "same-origin", or "cross-origin".');
}
return new WebRequestInterceptor(this.#webRequest, options);
}
interceptorBehaviorChanged() {
return new $Promise.self((resolve) => {
// TODO(crbug.com/421986167): handlerBehaviorChanged is undefined.
this.#webRequest.handlerBehaviorChanged(resolve);
});
}
}
function createEventInfo(webRequestEventName) {
return {
webRequestEventName,
registeredListeners: $Object.create(null),
};
}
class WebRequestInterceptor extends EventTarget {
#webRequest;
#filter;
#extraInfoSpec;
#events = {
authrequired: createEventInfo('onAuthRequired'),
beforeredirect: createEventInfo('onBeforeRedirect'),
beforerequest: createEventInfo('onBeforeRequest'),
beforesendheaders: createEventInfo('onBeforeSendHeaders'),
completed: createEventInfo('onCompleted'),
erroroccurred: createEventInfo('onErrorOccurred'),
headersreceived: createEventInfo('onHeadersReceived'),
responsestarted: createEventInfo('onResponseStarted'),
sendheaders: createEventInfo('onSendHeaders'),
};
constructor(webRequest, options) {
super();
this.#webRequest = webRequest;
this.#filter = {
__proto__: null,
urls: convertUrlPatternsToMatchPatterns(options.urlPatterns),
};
if (options.resourceTypes !== undefined) {
this.#filter.types =
$Array.map(options.resourceTypes, $Function.bind(mapString, null, {
__proto__: null,
'main-frame': 'main_frame',
'sub-frame': 'sub_frame',
'csp-report': 'csp_report',
}));
}
// All possibly valid extraInfoSpec values are added here. When registering
// handlers, this list will be filtered to those supported by the specific
// event type.
this.#extraInfoSpec = $Array.self();
if (options.blocking === true) {
$Array.push(this.#extraInfoSpec, 'blocking');
$Array.push(this.#extraInfoSpec, 'asyncBlocking');
}
if (options.includeRequestBody === true) {
$Array.push(this.#extraInfoSpec, 'requestBody');
}
if (options.includeHeaders === 'same-origin') {
$Array.push(this.#extraInfoSpec, 'requestHeaders');
$Array.push(this.#extraInfoSpec, 'responseHeaders');
} else if (options.includeHeaders === 'cross-origin') {
$Array.push(this.#extraInfoSpec, 'requestHeaders');
$Array.push(this.#extraInfoSpec, 'responseHeaders');
$Array.push(this.#extraInfoSpec, 'extraHeaders');
}
}
addEventListener(type, webListener, options) {
const eventInfo = this.#events[type];
if (eventInfo === undefined) {
$Function.apply(super.addEventListener, this, arguments);
return;
}
const supportedExtraInfoSpec = {
__proto__: null,
// authrequired always uses 'asyncBlocking' instead of 'blocking'.
authrequired:
$Array.self('asyncBlocking', 'responseHeaders', 'extraHeaders'),
beforeredirect: $Array.self('responseHeaders', 'extraHeaders'),
beforerequest: $Array.self('blocking', 'requestBody'),
beforesendheaders:
$Array.self('blocking', 'requestHeaders', 'extraHeaders'),
completed: $Array.self('responseHeaders', 'extraHeaders'),
erroroccurred: $Array.self(),
headersreceived:
$Array.self('blocking', 'responseHeaders', 'extraHeaders'),
responsestarted: $Array.self('responseHeaders', 'extraHeaders'),
sendheaders: $Array.self('requestHeaders', 'extraHeaders'),
}[type];
const filteredExtraInfoSpec = $Array.filter(
this.#extraInfoSpec,
(flag) => $Array.includes(supportedExtraInfoSpec, flag));
const blocking = $Array.includes(filteredExtraInfoSpec, 'blocking') ||
$Array.includes(filteredExtraInfoSpec, 'asyncBlocking');
const webRequestListener =
$Function.bind(this.#onEvent, this, type, webListener, blocking);
eventInfo.registeredListeners[webListener] = webRequestListener;
this.#webRequest[eventInfo.webRequestEventName].addListener(
webRequestListener, this.#filter, filteredExtraInfoSpec);
}
removeEventListener(type, webListener, options) {
const eventInfo = this.#events[type];
if (eventInfo === undefined) {
$Function.apply(super.removeEventListener, this, arguments);
return;
}
if (webListener in eventInfo.registeredListeners) {
this.#webRequest[eventInfo.webRequestEventName].removeListener(
eventInfo.registeredListeners[webListener]);
delete eventInfo.registeredListeners[webListener];
}
}
#onEvent(type, webListener, blocking, details, asyncCallback) {
let webEvent;
const webDetails = webifyRequestDetails(details);
const result = blocking ? {__proto__: null} : undefined;
switch (type) {
case 'authrequired':
if (blocking && asyncCallback) {
this.#handleAsyncAuthRequiredEvent(
webListener, webDetails, asyncCallback);
return;
}
webEvent = new AuthRequiredEvent(webDetails, result, {__proto__: null});
break;
case 'beforeredirect':
webEvent = new BeforeRedirectEvent(webDetails);
break;
case 'beforerequest':
webEvent = new BeforeRequestEvent(webDetails, result);
break;
case 'beforesendheaders':
webEvent = new BeforeSendHeadersEvent(webDetails, result);
break;
case 'completed':
webEvent = new CompletedEvent(webDetails);
break;
case 'erroroccurred':
webEvent = new ErrorOccurredEvent(webDetails);
break;
case 'headersreceived':
webEvent = new HeadersReceivedEvent(webDetails, result);
break;
case 'responsestarted':
webEvent = new ResponseStartedEvent(webDetails);
break;
case 'sendheaders':
webEvent = new SendHeadersEvent(webDetails);
break;
}
const listenerReturnValue = webListener(webEvent);
if (listenerReturnValue instanceof Promise) {
console.error(`WebRequest ${type} handlers must be synchronous`);
}
return result;
}
#handleAsyncAuthRequiredEvent(webListener, webDetails, asyncCallback) {
const result = {__proto__: null};
const options = {__proto__: null};
const webEvent = new AuthRequiredEvent(webDetails, result, options);
const listenerReturnValue = webListener(webEvent);
if (listenerReturnValue instanceof $Promise.self) {
console.error(`authrequired handlers must be synchronous`);
}
if (result.cancel || (options.signal && options.signal.aborted)) {
asyncCallback({__proto__: null, cancel: true});
return;
}
if (!result.authCredentials) {
asyncCallback();
return;
}
const resultPromises = $Array.self(
$Promise.resolve(result.authCredentials));
if (options.signal) {
$Array.push(resultPromises, new $Promise.self((resolve) => {
options.signal.addEventListener('abort', resolve);
}));
}
const promise = $Promise.race(resultPromises);
$Promise.then(promise, (authCredentials) => {
const response = {__proto__: null};
if (options.signal && options.signal.aborted) {
response.cancel = true;
} else {
response.authCredentials = authCredentials;
}
asyncCallback(response);
});
$Promise.catch(promise, (e) => {
console.error('authrequired Promise rejected:', e);
asyncCallback();
});
}
}
class AuthRequiredEvent extends Event {
#result;
#options;
constructor(details, result, options) {
super('authrequired');
$Object.assign(this, details);
this.#result = result;
this.#options = options;
$Object.freeze(this);
}
preventDefault() {
if (this.#result !== undefined) {
this.#result.cancel = true;
}
super.preventDefault();
}
setCredentials(credentials, options) {
if (this.#result === undefined) {
console.error(
'AuthRequiredEvent.setCredentials is only supported ' +
'in blocking event handlers');
return;
}
this.#result.authCredentials = credentials;
if (options && options.signal) {
if (options.signal instanceof AbortSignal) {
this.#options.signal = options.signal;
} else {
console.error(
'options.signal argument to setCredentials ' +
'must be an AbortSignal');
}
}
}
}
class BeforeRedirectEvent extends Event {
constructor(details) {
super('beforeredirect');
$Object.assign(this, details);
$Object.freeze(this);
}
}
class BeforeRequestEvent extends Event {
#result;
constructor(details, result) {
super('beforerequest');
$Object.assign(this, details);
this.#result = result;
$Object.freeze(this);
}
preventDefault() {
if (this.#result !== undefined) {
this.#result.cancel = true;
}
super.preventDefault();
}
redirect(url) {
if (this.#result === undefined) {
console.error(
'BeforeRequestEvent.redirect is only supported ' +
'in blocking event handlers');
return;
}
this.#result.redirectUrl = url;
}
}
class BeforeSendHeadersEvent extends Event {
#result;
constructor(details, result) {
super('authrequired');
$Object.assign(this, details);
this.#result = result;
$Object.freeze(this);
}
preventDefault() {
if (this.#result !== undefined) {
this.#result.cancel = true;
}
super.preventDefault();
}
setRequestHeaders(headersInit) {
if (this.#result === undefined) {
console.error(
'BeforeSendHeadersEvent.setRequestHeaders is only supported ' +
'in blocking event handlers');
return;
}
this.#result.requestHeaders = convertWebHeadersToExtension(headersInit);
}
}
class CompletedEvent extends Event {
constructor(details) {
super('completed');
$Object.assign(this, details);
$Object.freeze(this);
}
}
class ErrorOccurredEvent extends Event {
constructor(details) {
super('erroroccurred');
$Object.assign(this, details);
$Object.freeze(this);
}
}
class HeadersReceivedEvent extends Event {
#result;
constructor(details, result) {
super('headersreceived');
$Object.assign(this, details);
this.#result = result;
$Object.freeze(this);
}
preventDefault() {
if (this.#result !== undefined) {
this.#result.cancel = true;
}
super.preventDefault();
}
redirect(url) {
if (this.#result === undefined) {
console.error(
'HeadersReceivedEvent.redirect is only supported ' +
'in blocking event handlers');
return;
}
this.#result.redirectUrl = url;
}
setResponseHeaders(headersInit) {
if (this.#result === undefined) {
console.error(
'HeadersReceivedEvent.setResponseHeaders is only supported ' +
'in blocking event handlers');
return;
}
this.#result.responseHeaders = convertWebHeadersToExtension(headersInit);
}
}
class ResponseStartedEvent extends Event {
constructor(details) {
super('responsestarted');
$Object.assign(this, details);
$Object.freeze(this);
}
}
class SendHeadersEvent extends Event {
constructor(details) {
super('sendheaders');
$Object.assign(this, details);
$Object.freeze(this);
}
}
// Exports.
exports.$set('ControlledFrameWebRequest', ControlledFrameWebRequest);