From dc23b7535cb958c00d1eecfe85b4ee26e52e2e39 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 6 Dec 2021 23:48:42 -0800 Subject: [PATCH] feat: expose HTTPRequest intercept resolution state and clarify docs (#7796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Fernández --- docs/api.md | 348 ++++++++++++++---- src/common/HTTPRequest.ts | 105 ++++-- test/requestinterception-experimental.spec.ts | 16 + 3 files changed, 368 insertions(+), 101 deletions(-) diff --git a/docs/api.md b/docs/api.md index 4cc342f2a8562..98a21d9e6b42e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -182,8 +182,10 @@ * [page.setJavaScriptEnabled(enabled)](#pagesetjavascriptenabledenabled) * [page.setOfflineMode(enabled)](#pagesetofflinemodeenabled) * [page.setRequestInterception(value)](#pagesetrequestinterceptionvalue) - - [Cooperative Intercept Mode and Legacy Intercept Mode](#cooperative-intercept-mode-and-legacy-intercept-mode) - - [Upgrading to Cooperative Mode for package maintainers](#upgrading-to-cooperative-mode-for-package-maintainers) + - [Multiple Intercept Handlers and Asynchronous Resolutions](#multiple-intercept-handlers-and-asynchronous-resolutions) + - [Cooperative Intercept Mode](#cooperative-intercept-mode) + - [Cooperative Request Continuation](#cooperative-request-continuation) + - [Upgrading to Cooperative Intercept Mode for package maintainers](#upgrading-to-cooperative-intercept-mode-for-package-maintainers) * [page.setUserAgent(userAgent[, userAgentMetadata])](#pagesetuseragentuseragent-useragentmetadata) * [page.setViewport(viewport)](#pagesetviewportviewport) * [page.tap(selector)](#pagetapselector) @@ -345,6 +347,8 @@ * [httpRequest.frame()](#httprequestframe) * [httpRequest.headers()](#httprequestheaders) * [httpRequest.initiator()](#httprequestinitiator) + * [httpRequest.interceptResolutionState()](#httprequestinterceptresolutionstate) + * [httpRequest.isInterceptResolutionHandled()](#httprequestisinterceptresolutionhandled) * [httpRequest.isNavigationRequest()](#httprequestisnavigationrequest) * [httpRequest.method()](#httprequestmethod) * [httpRequest.postData()](#httprequestpostdata) @@ -2364,6 +2368,7 @@ const puppeteer = require('puppeteer'); const page = await browser.newPage(); await page.setRequestInterception(true); page.on('request', (interceptedRequest) => { + if (interceptedRequest.isInterceptResolutionHandled()) return; if ( interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg') @@ -2376,17 +2381,134 @@ const puppeteer = require('puppeteer'); })(); ``` -##### Cooperative Intercept Mode and Legacy Intercept Mode +##### Multiple Intercept Handlers and Asynchronous Resolutions -`request.respond`, `request.abort`, and `request.continue` can accept an optional `priority` to activate Cooperative Intercept Mode. In Cooperative Mode, all intercept handlers are guaranteed to run and all async handlers are awaited. The interception is resolved to the highest-priority resolution. Here are the rules of Cooperative Mode: +By default Puppeteer will raise a `Request is already handled!` exception if `request.abort`, `request.continue`, or `request.respond` are called after any of them have already been called. +Always assume that an unknown handler may have already called `abort/continue/respond`. Even if your handler is the only one you registered, +3rd party packages may register their own handlers. It is therefore +important to always check the resolution status using [request.isInterceptResolutionHandled](#httprequestisinterceptresolutionhandled) +before calling `abort/continue/respond`. + +Importantly, the intercept resolution may get handled by another listener while your handler is awaiting an asynchronous operation. Therefore, the return value of `request.isInterceptResolutionHandled` is only safe in a synchronous code block. Always execute `request.isInterceptResolutionHandled` and `abort/continue/respond` **synchronously** together. + +This example demonstrates two synchronous handlers working together: + +```js +/* +This first handler will succeed in calling request.continue because the request interception has never been resolved. +*/ +page.on('request', (interceptedRequest) => { + if (interceptedRequest.isInterceptResolutionHandled()) return; + interceptedRequest.continue(); +}); + +/* +This second handler will return before calling request.abort because request.continue was already +called by the first handler. +*/ +page.on('request', (interceptedRequest) => { + if (interceptedRequest.isInterceptResolutionHandled()) return; + interceptedRequest.abort(); +}); +``` + +This example demonstrates asynchronous handlers working together: + +```js +/* +This first handler will succeed in calling request.continue because the request interception has never been resolved. +*/ +page.on('request', (interceptedRequest) => { + // The interception has not been handled yet. Control will pass through this guard. + if (interceptedRequest.isInterceptResolutionHandled()) return; + + // It is not strictly necessary to return a promise, but doing so will allow Puppeteer to await this handler. + return new Promise(resolve => { + // Continue after 500ms + setTimeout(() => { + // Inside, check synchronously to verify that the intercept wasn't handled already. + // It might have been handled during the 500ms while the other handler awaited an async op of its own. + if (interceptedRequest.isInterceptResolutionHandled()) { + resolve(); + return; + } + interceptedRequest.continue(); + resolve(); + }, 500); + }) +}); +page.on('request', async (interceptedRequest) => { + // The interception has not been handled yet. Control will pass through this guard. + if (interceptedRequest.isInterceptResolutionHandled()) return; + + await someLongAsyncOperation() + // The interception *MIGHT* have been handled by the first handler, we can't be sure. + // Therefore, we must check again before calling continue() or we risk Puppeteer raising an exception. + if (interceptedRequest.isInterceptResolutionHandled()) return; + interceptedRequest.continue(); +}); +``` + +For finer-grained introspection (see Cooperative Intercept Mode below), you may also call [request.interceptResolutionState](#httprequestinterceptresolutionstate) synchronously before using `abort/continue/respond`. + +Here is the example above rewritten using `request.interceptResolutionState` + +```js +/* +This first handler will succeed in calling request.continue because the request interception has never been resolved. + +Note: `alreay-handled` is misspelled but likely won't be fixed until v13. https://github.com/puppeteer/puppeteer/pull/7780 +*/ +page.on('request', (interceptedRequest) => { + // The interception has not been handled yet. Control will pass through this guard. + const { action } = interceptedRequest.interceptResolutionState(); + if (action === 'alreay-handled') return; + + // It is not strictly necessary to return a promise, but doing so will allow Puppeteer to await this handler. + return new Promise(resolve => { + // Continue after 500ms + setTimeout(() => { + // Inside, check synchronously to verify that the intercept wasn't handled already. + // It might have been handled during the 500ms while the other handler awaited an async op of its own. + const { action } = interceptedRequest.interceptResolutionState(); + if (action === 'alreay-handled') { + resolve(); + return; + }; + interceptedRequest.continue(); + resolve(); + }, 500); + }) +}); +page.on('request', async (interceptedRequest) => { + // The interception has not been handled yet. Control will pass through this guard. + if (interceptedRequest.interceptResolutionState().action === 'alreay-handled') return; + + await someLongAsyncOperation() + // The interception *MIGHT* have been handled by the first handler, we can't be sure. + // Therefore, we must check again before calling continue() or we risk Puppeteer raising an exception. + if (interceptedRequest.interceptResolutionState().action === 'alreay-handled') return; + interceptedRequest.continue(); +}); +``` + +##### Cooperative Intercept Mode + +`request.abort`, `request.continue`, and `request.respond` can accept an optional `priority` to work in Cooperative Intercept Mode. When all +handlers are using Cooperative Intercept Mode, Puppeteer guarantees that all intercept handlers will run and be awaited in order of registration. The interception is resolved to the highest-priority resolution. Here are the rules of Cooperative Intercept Mode: + +- All resolutions must supply a numeric `priority` argument to `abort/continue/respond`. +- If any resolution does not supply a numeric `priority`, Legacy Mode is active and Cooperative Intercept Mode is inactive. - Async handlers finish before intercept resolution is finalized. - The highest priority interception resolution "wins", i.e. the interception is ultimately aborted/responded/continued according to which resolution was given the highest priority. - In the event of a tie, `abort` > `respond` > `continue`. -For standardization, when specifying a Cooperative Mode priority use `0` unless you have a clear reason to use a higher priority. This gracefully prefers `respond` over `continue` and `abort` over `respond`. If you do intentionally want to use a different priority, higher priorities win over lower priorities. Negative priorities are allowed. For example, `continue({}, 4)` would win over `continue({}, -2)`. +For standardization, when specifying a Cooperative Intercept Mode priority use `0` or `DEFAULT_INTERCEPT_RESOLUTION_PRIORITY` (exported from `HTTPRequest`) unless you have a clear reason to use a higher priority. This gracefully prefers `respond` over `continue` and `abort` over `respond` and allows other handlers to work cooperatively. If you do intentionally want to use a different priority, higher priorities win over lower priorities. Negative priorities are allowed. For example, `continue({}, 4)` would win over `continue({}, -2)`. -To preserve backward compatibility, any handler resolving the intercept without specifying `priority` (Legacy Mode) causes immediate resolution. For Cooperative Mode to work, all resolutions must use a `priority`. +To preserve backward compatibility, any handler resolving the intercept without specifying `priority` (Legacy Mode) causes immediate resolution. For Cooperative Intercept Mode to work, all resolutions must use a `priority`. In practice, this means you must still test for +`request.isInterceptResolutionHandled` because a handler beyond your control may have called `abort/continue/respond` without a +priority (Legacy Mode). In this example, Legacy Mode prevails and the request is aborted immediately because at least one handler omits `priority` when resolving the intercept: @@ -2394,17 +2516,16 @@ In this example, Legacy Mode prevails and the request is aborted immediately bec // Final outcome: immediate abort() page.setRequestInterception(true); page.on('request', (request) => { + if (request.isInterceptResolutionHandled()) return + // Legacy Mode: interception is aborted immediately. request.abort('failed'); }); page.on('request', (request) => { - // ['already-handled'], meaning a legacy resolution has taken place - console.log(request.interceptResolution()); + if (request.isInterceptResolutionHandled()) return + // Control will never reach this point because the request was already aborted in Legacy Mode - // Cooperative Mode: votes for continue at priority 0. - // Ultimately throws an exception after all handlers have finished - // running and Cooperative Mode resolutions are evaluated becasue - // abort() was called using Legacy Mode. + // Cooperative Intercept Mode: votes for continue at priority 0. request.continue({}, 0); }); ``` @@ -2415,73 +2536,110 @@ In this example, Legacy Mode prevails and the request is continued because at le // Final outcome: immediate continue() page.setRequestInterception(true); page.on('request', (request) => { - // Cooperative Mode: votes to abort at priority 0. - // Ultimately throws an exception after all handlers have finished - // running and Cooperative Mode resolutions are evaluated becasue - // continue() was called using Legacy Mode. + if (request.isInterceptResolutionHandled()) return + + // Cooperative Intercept Mode: votes to abort at priority 0. request.abort('failed', 0); }); page.on('request', (request) => { - // ['abort', 0], meaning an abort @ 0 is the current winning resolution - console.log(request.interceptResolution()); + if (request.isInterceptResolutionHandled()) return + + // Control reaches this point because the request was cooperatively aborted which postpones resolution. + + // { action: 'abort', priority: 0 }, because abort @ 0 is the current winning resolution + console.log(request.interceptResolutionState()); // Legacy Mode: intercept continues immediately. request.continue({}); }); +page.on('request', (request) => { + // { action: 'alreay-handled' }, because continue in Legacy Mode was called + console.log(request.interceptResolutionState()); +}); + ``` -In this example, Cooperative Mode is active because all handlers specify a `priority`. `continue()` wins because it has a higher priority than `abort()`. +In this example, Cooperative Intercept Mode is active because all handlers specify a `priority`. `continue()` wins because it has a higher priority than `abort()`. ```ts // Final outcome: cooperative continue() @ 5 page.setRequestInterception(true); page.on('request', (request) => { - // Cooperative Mode: votes to abort at priority 10 + if (request.isInterceptResolutionHandled()) return + + // Cooperative Intercept Mode: votes to abort at priority 10 request.abort('failed', 0); }); page.on('request', (request) => { - // Cooperative Mode: votes to continue at priority 5 + if (request.isInterceptResolutionHandled()) return + + // Cooperative Intercept Mode: votes to continue at priority 5 request.continue(request.continueRequestOverrides(), 5); }); page.on('request', (request) => { - // ['continue', 5], because continue @ 5 > abort @ 0 - console.log(request.interceptResolution()); + // { action: 'continue', priority: 5 }, because continue @ 5 > abort @ 0 + console.log(request.interceptResolutionState()); }); ``` -In this example, Cooperative Mode is active because all handlers specify `priority`. `respond()` wins because its priority ties with `continue()`, but `respond()` beats `continue()`. +In this example, Cooperative Intercept Mode is active because all handlers specify `priority`. `respond()` wins because its priority ties with `continue()`, but `respond()` beats `continue()`. ```ts // Final outcome: cooperative respond() @ 15 page.setRequestInterception(true); page.on('request', (request) => { - // Cooperative Mode: votes to abort at priority 10 + if (request.isInterceptResolutionHandled()) return + + // Cooperative Intercept Mode: votes to abort at priority 10 request.abort('failed', 10); }); page.on('request', (request) => { - // Cooperative Mode: votes to continue at priority 15 + if (request.isInterceptResolutionHandled()) return + + // Cooperative Intercept Mode: votes to continue at priority 15 request.continue(request.continueRequestOverrides(), 15); }); page.on('request', (request) => { - // Cooperative Mode: votes to respond at priority 15 + if (request.isInterceptResolutionHandled()) return + + // Cooperative Intercept Mode: votes to respond at priority 15 request.respond(request.responseForRequest(), 15); }); page.on('request', (request) => { - // Cooperative Mode: votes to respond at priority 12 + if (request.isInterceptResolutionHandled()) return + + // Cooperative Intercept Mode: votes to respond at priority 12 request.respond(request.responseForRequest(), 12); }); page.on('request', (request) => { - // ['respond', 15], because respond @ 15 > continue @ 15 > respond @ 12 > abort @ 10 - console.log(request.interceptResolution()); + // { action: 'respond', priority: 15 }, because respond @ 15 > continue @ 15 > respond @ 12 > abort @ 10 + console.log(request.interceptResolutionState()); }); ``` -##### Upgrading to Cooperative Mode for package maintainers +##### Cooperative Request Continuation + +Puppeteer requires `request.continue` to be called explicitly or the request will hang. Even if +your handler means to take no special action, or 'opt out', `request.continue` must still be called. + +With the introduction of Cooperative Intercept Mode, two use cases arise for cooperative request continuations: +Unopinionated and Opinionated. + +The first case (common) is that your handler means to opt out of doing anything special the request. It has no opinion on further action and simply intends to continue by default and/or defer to other handlers that might have an opinion. But in case there are no other handlers, we must call `request.continue` to ensure that the request doesn't hang. + +We call this an **Unopinionated continuation** because the intent is to continue the request if nobody else has a better idea. Use `request.continue({...}, DEFAULT_INTERCEPT_RESOLUTION_PRIORITY)` (or `0`) for this type of continuation. -If you are package maintainer and your package uses intercept handlers, you can update your intercept handlers to use Cooperative Mode. Suppose you have the following existing handler: +The second case (uncommon) is that your handler actually does have an opinion and means to force continuation by overriding a lower-priority `abort` or `respond` issued elsewhere. We call this an **Opinionated continuation**. In these rare cases where you mean to specify an overriding continuation priority, use a custom priority. + +To summarize, reason through whether your use of `request.continue` is just meant to be default/bypass behavior vs falling within the intended use case of your handler. Consider using a custom priority for in-scope use cases, and a default priority otherwise. Be aware that your handler may have both Opinionated and Unopinionated cases. + +##### Upgrading to Cooperative Intercept Mode for package maintainers + +If you are package maintainer and your package uses intercept handlers, you can update your intercept handlers to use Cooperative Intercept Mode. Suppose you have the following existing handler: ```ts page.on('request', (interceptedRequest) => { + if (request.isInterceptResolutionHandled()) return if ( interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg') @@ -2491,10 +2649,11 @@ page.on('request', (interceptedRequest) => { }); ``` -To use Cooperative Mode, upgrade `continue()` and `abort()`: +To use Cooperative Intercept Mode, upgrade `continue()` and `abort()`: ```ts page.on('request', (interceptedRequest) => { + if (request.isInterceptResolutionHandled()) return if ( interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg') @@ -2508,24 +2667,29 @@ page.on('request', (interceptedRequest) => { }); ``` -With those simple upgrades, your handler now uses Cooperative Mode instead. +With those simple upgrades, your handler now uses Cooperative Intercept Mode instead. -However, we recommend a slightly more robust solution because the above introduces two subtle issues: +However, we recommend a slightly more robust solution because the above introduces several subtle issues: -1. **Backward compatibility.** Cooperative Mode resolves interceptions only if no Legacy Mode resolution has taken place. If any handler uses a Legacy Mode resolution (ie, does not specify a priority), that handler will resolve the interception immediately even if your handler runs first. This could cause disconcerting behavior for your users because suddenly your handler is not resolving the interception and a different handler is taking priority when all they did was upgrade your package. +1. **Backward compatibility.** If any handler still uses a Legacy Mode resolution (ie, does not specify a priority), that handler will resolve the interception immediately even if your handler runs first. This could cause disconcerting behavior for your users because suddenly your handler is not resolving the interception and a different handler is taking priority when all the user did was upgrade your package. 2. **Hard-coded priority.** Your package user has no ability to specify the default resolution priority for your handlers. This can become important when the user wishes to manipulate the priorities based on use case. For example, one user might want your package to take a high priority while another user might want it to take a low priority. -To resolve both of these issues, our recommended approach is to export a `setInterceptResolutionStrategy()` from your package. The user can then call `setInterceptResolutionStrategy()` to explicitly activate Cooperative Mode in your package so they aren't surprised by changes in how the interception is resolved. They can also optionally specify a custom priority using `setInterceptResolutionStrategy(priority)` that works for their use case: +To resolve both of these issues, our recommended approach is to export a `setInterceptResolutionConfig()` from your package. The user can then call `setInterceptResolutionConfig()` to explicitly activate Cooperative Intercept Mode in your package so they aren't surprised by changes in how the interception is resolved. They can also optionally specify a custom priority using `setInterceptResolutionConfig(priority)` that works for their use case: ```ts // Defaults to undefined which preserves Legacy Mode behavior let _priority = undefined; // Export a module configuration function -export const setInterceptResolutionStrategy = (defaultPriority = 0) => - (_priority = defaultPriority); +export const setInterceptResolutionConfig = (priority = 0) => + (_priority = priority); +/** + * Note that this handler uses `DEFAULT_INTERCEPT_RESOLUTION_PRIORITY` to "pass" on this request. It is important to use + * the default priority when your handler has no opinion on the request and the intent is to continue() by default. + */ page.on('request', (interceptedRequest) => { + if (request.isInterceptResolutionHandled()) return if ( interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg') @@ -2534,48 +2698,56 @@ page.on('request', (interceptedRequest) => { else interceptedRequest.continue( interceptedRequest.continueRequestOverrides(), - _priority + DEFAULT_INTERCEPT_RESOLUTION_PRIORITY // Unopinionated continuation ); }); ``` -If your package calls for more fine-grained control resolution priorities, use a config pattern like this: +If your package calls for more fine-grained control over resolution priorities, use a config pattern like this: ```ts -interface ResolutionStrategy { - abortPriority: number; - continuePriority: number; +interface InterceptResolutionConfig { + abortPriority?: number; + continuePriority?: number; } -// This strategy supports multiple priorities based on situational -// differences. You could, for example, create a strategy that +// This approach supports multiple priorities based on situational +// differences. You could, for example, create a config that // allowed separate priorities for PNG vs JPG. -const DEFAULT_STRATEGY: ResolutionStrategy = { - abortPriority: 0, - continuePriority: 0, +const DEFAULT_CONFIG: InterceptResolutionConfig = { + abortPriority: undefined, // Default to Legacy Mode + continuePriority: undefined, // Default to Legacy Mode }; // Defaults to undefined which preserves Legacy Mode behavior -let _strategy: Partial = {}; +let _config: Partial = {}; -export const setInterceptResolutionStrategy = (strategy: ResolutionStrategy) => - (_strategy = { ...DEFAULT_STRATEGY, ...strategy }); +export const setInterceptResolutionConfig = (config: InterceptResolutionConfig) => + (_config = { ...DEFAULT_CONFIG, ...config }); page.on('request', (interceptedRequest) => { + if (request.isInterceptResolutionHandled()) return if ( interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg') - ) - interceptedRequest.abort('failed', _strategy.abortPriority); + ) { + interceptedRequest.abort('failed', _config.abortPriority); + } else - interceptedRequest.continue( - interceptedRequest.continueRequestOverrides(), - _strategy.continuePriority - ); + { + // Here we use a custom-configured priority to allow for Opinionated + // continuation. + // We would only want to allow this if we had a very clear reason why + // some use cases required Opinionated continuation. + interceptedRequest.continue( + interceptedRequest.continueRequestOverrides(), + _config.continuePriority // Why would we ever want priority!==0 here? + ); + } }); ``` -The above solution ensures backward compatibility while also allowing the user to adjust the importance of your package in the resolution chain when Cooperative Mode is being used. Your package continues to work as expected until the user has fully upgraded their code and all third party packages to use Cooperative Mode. If any handler or package still uses Legacy Mode, your package can still operate in Legacy Mode too. +The above solutions ensure backward compatibility while also allowing the user to adjust the importance of your package in the resolution chain when Cooperative Intercept Mode is being used. Your package continues to work as expected until the user has fully upgraded their code and all third party packages to use Cooperative Intercept Mode. If any handler or package still uses Legacy Mode, your package can still operate in Legacy Mode too. #### page.setUserAgent(userAgent[, userAgentMetadata]) @@ -4743,7 +4915,7 @@ Exception is immediately thrown if the request interception is not enabled. - returns: <[string]> of type [Protocol.Network.ErrorReason](https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ErrorReason). -Returns the most recent reason for aborting set by the previous call to abort() in Cooperative Mode. +Returns the most recent reason for aborting set by the previous call to abort() in Cooperative Intercept Mode. #### httpRequest.continue([overrides], [priority]) @@ -4758,10 +4930,15 @@ Returns the most recent reason for aborting set by the previous call to abort() Continues request with optional request overrides. To use this, request interception should be enabled with `page.setRequestInterception`. Exception is immediately thrown if the request interception is not enabled. +Note: Pass `DEFAULT_INTERCEPT_RESOLUTION_PRIORITY` to continue a request if your intent is to bypass/defer the request because +your handler has no opinion about it. + ```js await page.setRequestInterception(true); page.on('request', (request) => { - // Override headers + if (request.isInterceptResolutionHandled()) return; + +// Override headers const headers = Object.assign({}, request.headers(), { foo: 'bar', // set "foo" header origin: undefined, // remove "origin" header @@ -4778,7 +4955,7 @@ page.on('request', (request) => { - `postData` <[string]> If set changes the post data of request. - `headers` <[Object]> If set changes the request HTTP headers. Header values will be converted to a string. -Returns the most recent set of request overrides set with a previous call to continue() in Cooperative Mode. +Returns the most recent set of request overrides set with a previous call to continue() in Cooperative Intercept Mode. #### httpRequest.enqueueInterceptAction(pendingHandler) @@ -4806,7 +4983,7 @@ page.on('requestfailed', (request) => { - returns: <[Promise]> -When in Cooperative Mode, awaits pending interception handlers and then decides how to fulfill the request interception. +When in Cooperative Intercept Mode, awaits pending interception handlers and then decides how to fulfill the request interception. #### httpRequest.frame() @@ -4824,6 +5001,47 @@ When in Cooperative Mode, awaits pending interception handlers and then decides - `url` Initiator URL, set for `parser`, `script` and `SignedExchange` type. - `lineNumber` 0 based initiator line number, set for `parser` and `script`. +#### httpRequest.interceptResolutionState() + +- returns: <[InterceptResolutionState]> + - `action` <[InterceptResolutionAction]> Current resolution action. Possible values: `abort`, `respond`, `continue`, + `disabled`, `none`, and `alreay-handled` + - `priority` The current priority of the winning action. + +`InterceptResolutionAction` is one of: + +- `abort` - The request will be aborted if no higher priority arises. +- `respond` - The request will be responded if no higher priority arises. +- `continue` - The request will be continued if no higher priority arises. +- `disabled` - Request interception is not currently enabled (see `page.setRequestInterception`). +- `none` - `abort/continue/respond` have not been called yet. +- `alreay-handled` - The interception has already been handled in Legacy Mode by a call to `abort/continue/respond` with + a `priority` of `undefined`. Subsequent calls to `abort/continue/respond` will throw an exception. + +This example will `continue` a request at a slightly higher priority than the current action if the interception has not +already handled and is not already being continued. + +```js +page.on('request', (interceptedRequest) => { + const { action, priority } = interceptedRequest.interceptResolutionState(); + if (action === 'alreay-handled') return; + if (action === 'continue') return; + + // Change the action to `continue` and bump the priority so `continue` becomes the new winner + interceptedRequest.continue( + interceptedRequest.continueRequestOverrides(), + priority + 1 + ); +}); +``` + +#### httpRequest.isInterceptResolutionHandled() + +- returns: <[boolean]> + +Whether this request's interception has been handled (i.e., `abort`, `continue`, or `respond` has already been called +with a `priority` of `undefined`). + #### httpRequest.isNavigationRequest() - returns: <[boolean]> @@ -4894,6 +5112,8 @@ An example of fulfilling all requests with 404 responses: ```js await page.setRequestInterception(true); page.on('request', (request) => { + if (request.isInterceptResolutionHandled()) return; + request.respond({ status: 404, contentType: 'text/plain', @@ -4913,7 +5133,7 @@ page.on('request', (request) => { - returns: A matching [HTTPResponse] object, or `null` if the response has not been received yet. -Returns the current response object set by the previous call to respond() in Cooperative Mode. +Returns the current response object set by the previous call to respond() in Cooperative Intercept Mode. #### httpRequest.url() diff --git a/src/common/HTTPRequest.ts b/src/common/HTTPRequest.ts index 0d451594e8def..6fa8928506623 100644 --- a/src/common/HTTPRequest.ts +++ b/src/common/HTTPRequest.ts @@ -36,6 +36,14 @@ export interface ContinueRequestOverrides { headers?: Record; } +/** + * @public + */ +export interface InterceptResolutionState { + action: InterceptResolutionAction; + priority?: number; +} + /** * Required response data to fulfill a request with. * @@ -58,6 +66,13 @@ export interface ResponseForRequest { */ export type ResourceType = Lowercase; +/** + * The default cooperative request interception resolution priority + * + * @public + */ +export const DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0; + interface CDPSession extends EventEmitter { send( method: T, @@ -137,9 +152,8 @@ export class HTTPRequest { private _continueRequestOverrides: ContinueRequestOverrides; private _responseForRequest: Partial; private _abortErrorReason: Protocol.Network.ErrorReason; - private _currentStrategy: InterceptResolutionStrategy; - private _currentPriority: number | undefined; - private _interceptActions: Array<() => void | PromiseLike>; + private _interceptResolutionState: InterceptResolutionState; + private _interceptHandlers: Array<() => void | PromiseLike>; private _initiator: Protocol.Network.Initiator; /** @@ -166,9 +180,8 @@ export class HTTPRequest { this._frame = frame; this._redirectChain = redirectChain; this._continueRequestOverrides = {}; - this._currentStrategy = 'none'; - this._currentPriority = undefined; - this._interceptActions = []; + this._interceptResolutionState = { action: 'none' }; + this._interceptHandlers = []; this._initiator = event.initiator; for (const key of Object.keys(event.request.headers)) @@ -210,14 +223,28 @@ export class HTTPRequest { } /** - * @returns An array of the current intercept resolution strategy and priority - * `[strategy,priority]`. Strategy is one of: `abort`, `respond`, `continue`, - * `disabled`, `none`, or `already-handled`. + * @returns An InterceptResolutionState object describing the current resolution + * action and priority. + * + * InterceptResolutionState contains: + * action: InterceptResolutionAction + * priority?: number + * + * InterceptResolutionAction is one of: `abort`, `respond`, `continue`, + * `disabled`, `none`, or `alreay-handled`. + */ + interceptResolutionState(): InterceptResolutionState { + if (!this._allowInterception) return { action: 'disabled' }; + if (this._interceptionHandled) return { action: 'alreay-handled' }; + return { ...this._interceptResolutionState }; + } + + /** + * @returns `true` if the intercept resolution has already been handled, + * `false` otherwise. */ - private interceptResolution(): [InterceptResolutionStrategy, number?] { - if (!this._allowInterception) return ['disabled']; - if (this._interceptionHandled) return ['alreay-handled']; - return [this._currentStrategy, this._currentPriority]; + isInterceptResolutionHandled(): boolean { + return this._interceptionHandled; } /** @@ -229,7 +256,7 @@ export class HTTPRequest { enqueueInterceptAction( pendingHandler: () => void | PromiseLike ): void { - this._interceptActions.push(pendingHandler); + this._interceptHandlers.push(pendingHandler); } /** @@ -237,12 +264,12 @@ export class HTTPRequest { * the request interception. */ async finalizeInterceptions(): Promise { - await this._interceptActions.reduce( + await this._interceptHandlers.reduce( (promiseChain, interceptAction) => promiseChain.then(interceptAction), Promise.resolve() ); - const [resolution] = this.interceptResolution(); - switch (resolution) { + const { action } = this.interceptResolutionState(); + switch (action) { case 'abort': return this._abort(this._abortErrorReason); case 'respond': @@ -411,21 +438,20 @@ export class HTTPRequest { } this._continueRequestOverrides = overrides; if ( - priority > this._currentPriority || - this._currentPriority === undefined + priority > this._interceptResolutionState.priority || + this._interceptResolutionState.priority === undefined ) { - this._currentStrategy = 'continue'; - this._currentPriority = priority; + this._interceptResolutionState = { action: 'continue', priority }; return; } - if (priority === this._currentPriority) { + if (priority === this._interceptResolutionState.priority) { if ( - this._currentStrategy === 'abort' || - this._currentStrategy === 'respond' + this._interceptResolutionState.action === 'abort' || + this._interceptResolutionState.action === 'respond' ) { return; } - this._currentStrategy = 'continue'; + this._interceptResolutionState.action = 'continue'; } return; } @@ -498,18 +524,17 @@ export class HTTPRequest { } this._responseForRequest = response; if ( - priority > this._currentPriority || - this._currentPriority === undefined + priority > this._interceptResolutionState.priority || + this._interceptResolutionState.priority === undefined ) { - this._currentStrategy = 'respond'; - this._currentPriority = priority; + this._interceptResolutionState = { action: 'respond', priority }; return; } - if (priority === this._currentPriority) { - if (this._currentStrategy === 'abort') { + if (priority === this._interceptResolutionState.priority) { + if (this._interceptResolutionState.action === 'abort') { return; } - this._currentStrategy = 'respond'; + this._interceptResolutionState.action = 'respond'; } } @@ -577,11 +602,10 @@ export class HTTPRequest { } this._abortErrorReason = errorReason; if ( - priority >= this._currentPriority || - this._currentPriority === undefined + priority >= this._interceptResolutionState.priority || + this._interceptResolutionState.priority === undefined ) { - this._currentStrategy = 'abort'; - this._currentPriority = priority; + this._interceptResolutionState = { action: 'abort', priority }; return; } } @@ -602,7 +626,7 @@ export class HTTPRequest { /** * @public */ -export type InterceptResolutionStrategy = +export type InterceptResolutionAction = | 'abort' | 'respond' | 'continue' @@ -610,6 +634,13 @@ export type InterceptResolutionStrategy = | 'none' | 'alreay-handled'; +/** + * @public + * + * Deprecate ASAP + */ +export type InterceptResolutionStrategy = InterceptResolutionAction; + /** * @public */ diff --git a/test/requestinterception-experimental.spec.ts b/test/requestinterception-experimental.spec.ts index e73d1102f55ca..9f0203ee301d9 100644 --- a/test/requestinterception-experimental.spec.ts +++ b/test/requestinterception-experimental.spec.ts @@ -845,6 +845,22 @@ describe('request interception', function () { 'Yo, page!' ); }); + it('should indicate alreay-handled if an intercept has been handled', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.continue(); + }); + page.on('request', (request) => { + expect(request.isInterceptResolutionHandled()).toBeTruthy(); + }); + page.on('request', (request) => { + const { action } = request.interceptResolutionState(); + expect(action).toBe('alreay-handled'); + }); + await page.goto(server.EMPTY_PAGE); + }); }); });