diff --git a/README.md b/README.md index f9ab040..9a6f713 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ In summary, the `puppeteer-intercept-and-modify-requests` library offers a more npm install puppeteer-intercept-and-modify-requests ``` -## Usage +## Example Usage To modify intercepted requests: @@ -40,12 +40,18 @@ import { RequestInterceptionManager } from 'puppeteer-intercept-and-modify-reque // assuming 'page' is your Puppeteer page object const client = await page.target().createCDPSession() +// note: if you want to intercept requests on ALL tabs, instead use: +// const client = await browser.target().createCDPSession() + const interceptManager = new RequestInterceptionManager(client) await interceptManager.intercept( { + // specify the URL pattern to intercept: urlPattern: `https://example.com/*`, + // optionally filter by resource type: resourceType: 'Document', + // specify how you want to modify the response (may be async): modifyResponse({ body }) { return { // replace break lines with horizontal lines: @@ -55,6 +61,7 @@ await interceptManager.intercept( }, { urlPattern: '*/api/v4/user.json', + // specify how you want to modify the response (may be async): modifyResponse({ body }) { const parsed = JSON.parse(body) // set role property to 'admin' @@ -98,7 +105,7 @@ await interceptManager.intercept( ## Usage Examples -Here's an example of how to use the `RequestInterceptionManager` to intercept and modify a request: +Here's an example of how to use the `RequestInterceptionManager` to intercept and modify a request and a response: ```ts import puppeteer from 'puppeteer' @@ -141,9 +148,9 @@ This example modifies the request by adding a custom header and modifies the res ## Advanced Usage -### Streaming and Modifying Response Chunks +### Applying Delay -You can also stream and modify response chunks using the `streamResponse` and `modifyResponseChunk` options. Here's an example of how to do this: +You can apply a delay to a request or response using the `delay` property in the `modifyRequest` or `modifyResponse` functions. Here's an example of how to add a delay to a request: ```ts import puppeteer from 'puppeteer' @@ -161,11 +168,11 @@ async function main() { const interceptionConfig: Interception = { urlPattern: 'https://example.com/*', - streamResponse: true, - modifyResponseChunk: async ({ event, data }) => { - // Modify response chunk - const modifiedData = data.replace(/example/gi, 'intercepted') - return { ...event, data: modifiedData } + modifyRequest: async ({ event }) => { + // Add a 500 ms delay to the request + return { + delay: 500, + } }, } @@ -177,11 +184,11 @@ async function main() { main() ``` -In this example, the response is streamed and each response chunk has all occurrences of the word "example" replaced with "intercepted". +In this example, a 500 ms delay is added to the request for the specified URL pattern. -### Applying Delay +### Handling Errors -You can apply a delay to a request or response using the `delay` property in the `modifyRequest` or `modifyResponse` functions. Here's an example of how to add a delay to a request: +You can handle errors using the `onError` option when creating a new `RequestInterceptionManager` instance. Here's an example of how to handle errors: ```ts import puppeteer from 'puppeteer' @@ -195,14 +202,18 @@ async function main() { const page = await browser.newPage() const client = await page.target().createCDPSession() - const requestInterceptionManager = new RequestInterceptionManager(client) + const requestInterceptionManager = new RequestInterceptionManager(client, { + onError: (error) => { + console.error('Request interception error:', error) + }, + }) const interceptionConfig: Interception = { urlPattern: 'https://example.com/*', modifyRequest: async ({ event }) => { - // Add a 500 ms delay to the request + // Modify request headers return { - delay: 500, + headers: [{ name: 'X-Custom-Header', value: 'CustomValue' }], } }, } @@ -215,11 +226,11 @@ async function main() { main() ``` -In this example, a 500 ms delay is added to the request for the specified URL pattern. +In this example, any errors that occur during request interception are logged to the console with the message "Request interception error:". -### Handling Errors +### Failing a Request -You can handle errors using the `onError` option when creating a new `RequestInterceptionManager` instance. Here's an example of how to handle errors: +To fail a request, return an object containing an `errorReason` property in the `modifyRequest` function. Here's an example of how to fail a request: ```ts import puppeteer from 'puppeteer' @@ -233,18 +244,14 @@ async function main() { const page = await browser.newPage() const client = await page.target().createCDPSession() - const requestInterceptionManager = new RequestInterceptionManager(client, { - onError: (error) => { - console.error('Request interception error:', error) - }, - }) + const requestInterceptionManager = new RequestInterceptionManager(client) const interceptionConfig: Interception = { urlPattern: 'https://example.com/*', modifyRequest: async ({ event }) => { - // Modify request headers + // Fail the request with the error reason "BlockedByClient" return { - headers: [{ name: 'X-Custom-Header', value: 'CustomValue' }], + errorReason: 'BlockedByClient', } }, } @@ -257,11 +264,23 @@ async function main() { main() ``` -In this example, any errors that occur during request interception are logged to the console with the message "Request interception error:". +In this example, the request for the specified URL pattern is blocked with the error reason "BlockedByClient". -### Failing a Request +### Intercepting network requests from all Pages (rather than just the one) -To fail a request, return an object containing an `errorReason` property in the `modifyRequest` function. Here's an example of how to fail a request: +When creating the `RequestInterceptionManager` instance, you can pass in the `client` object from the `CDPSession` of the `Browser` object. This will allow you to intercept requests from all the pages rather than just the one. Here's an example of how to do this: + +```ts +// intercept requests on ALL tabs, instead use: +const client = await browser.target().createCDPSession() +const interceptManager = new RequestInterceptionManager(client) + +// ... +``` + +### Streaming and Modifying Response Chunks + +You can also stream and modify response chunks using the `streamResponse` and `modifyResponseChunk` options. Here's an example of how to do this: ```ts import puppeteer from 'puppeteer' @@ -279,11 +298,11 @@ async function main() { const interceptionConfig: Interception = { urlPattern: 'https://example.com/*', - modifyRequest: async ({ event }) => { - // Fail the request with the error reason "BlockedByClient" - return { - errorReason: 'BlockedByClient', - } + streamResponse: true, + modifyResponseChunk: async ({ event, data }) => { + // Modify response chunk + const modifiedData = data.replace(/example/gi, 'intercepted') + return { ...event, data: modifiedData } }, } @@ -295,4 +314,4 @@ async function main() { main() ``` -In this example, the request for the specified URL pattern is blocked with the error reason "BlockedByClient". +In this example, the response is streamed and each response chunk has all occurrences of the word "example" replaced with "intercepted". diff --git a/src/main.test.ts b/src/main.test.ts index 879425c..b2696e5 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -12,6 +12,8 @@ let server: Server let browser: puppeteerType.Browser let page: puppeteerType.Page let client: puppeteerType.CDPSession +let browserClient: puppeteerType.CDPSession +let manager: RequestInterceptionManager const host = 'localhost' let port = 3_000 @@ -37,6 +39,7 @@ describe('RequestInterceptionManager', () => { beforeAll(async () => { browser = await puppeteer.launch() + browserClient = await browser.target().createCDPSession() }) afterAll(async () => { @@ -51,6 +54,7 @@ describe('RequestInterceptionManager', () => { afterEach(async () => { await page.close() await stopServer() + await manager.clear() }) describe.each` @@ -66,7 +70,7 @@ describe('RequestInterceptionManager', () => { res.end('Hello, world!') }) - const manager = new RequestInterceptionManager(client) + manager = new RequestInterceptionManager(client) await manager.intercept({ urlPattern: '*', modifyResponse: ({ body }) => ({ @@ -101,7 +105,7 @@ describe('RequestInterceptionManager', () => { } }) - const manager = new RequestInterceptionManager(client) + manager = new RequestInterceptionManager(client) await manager.intercept({ urlPattern: '*/original', modifyRequest: ({ event }) => ({ @@ -128,7 +132,7 @@ describe('RequestInterceptionManager', () => { res.end('Hello, world!') }) - const manager = new RequestInterceptionManager(client) + manager = new RequestInterceptionManager(client) await manager.intercept({ urlPattern: 'non-existent-url/*', modifyResponse: ({ body }) => ({ @@ -155,7 +159,7 @@ describe('RequestInterceptionManager', () => { res.end() }) - const manager = new RequestInterceptionManager(client) + manager = new RequestInterceptionManager(client) await manager.intercept({ urlPattern: '*', modifyResponse: () => ({ @@ -190,7 +194,7 @@ describe('RequestInterceptionManager', () => { } }) - const manager = new RequestInterceptionManager(client) + manager = new RequestInterceptionManager(client) await manager.intercept({ urlPattern: '*/redirected', modifyResponse: ({ body, event: { responseStatusCode } }) => ({ @@ -238,7 +242,7 @@ describe('RequestInterceptionManager', () => { res.end('It is forbidden') }) - const manager = new RequestInterceptionManager(client) + manager = new RequestInterceptionManager(client) await manager.intercept({ urlPattern: '*', modifyResponse: ({ body }) => ({ @@ -278,7 +282,7 @@ describe('RequestInterceptionManager', () => { } }) - const manager = new RequestInterceptionManager(client) + manager = new RequestInterceptionManager(client) await manager.intercept( { urlPattern: '*/first', @@ -313,6 +317,63 @@ describe('RequestInterceptionManager', () => { expect(firstResponse).toBe('First intercepted') expect(secondResponse).toBe('Second intercepted') }) + + it('should intercept and modify requests on new tabs', async () => { + await startServer((req, res) => { + if (req.url === '/') { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end('Open new tab') + } else if (req.url === '/new-tab') { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('Hello, new tab!') + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not found') + } + }) + + // Set up request interception for the initial page + manager = new RequestInterceptionManager(browserClient) + await manager.intercept({ + urlPattern: '*', + modifyResponse: ({ body }) => + body + ? { + body: body.replace('new tab', 'new tab intercepted'), + } + : undefined, + ...options, + }) + + await page.goto(`http://localhost:${port}`) + + // Listen for a new page to be opened + const newPagePromise = browser + .waitForTarget( + (target) => + target.type() === 'page' && + target.url() === `http://localhost:${port}/new-tab`, + ) + .then((target) => target.page()) + .then((newPage) => newPage!) + + // Click the link to open a new tab + await page.click('a') + + // Wait for the new page to be opened + const newPage = await newPagePromise + + // Check if the request on the new tab was intercepted and modified + const newText = await newPage.evaluate(() => document.body.textContent) + expect(newText).toBe('Hello, new tab intercepted!') + + const newResponse = await newPage.evaluate(async (url) => { + const response = await fetch(url) + return response.text() + }, `http://localhost:${port}/new-tab`) + + expect(newResponse).toBe('Hello, new tab intercepted!') + }) }, ) @@ -349,7 +410,7 @@ describe('RequestInterceptionManager', () => { sendNextMessage() }) - const manager = new RequestInterceptionManager(client) + manager = new RequestInterceptionManager(client) await manager.intercept({ urlPattern: '*/stream', // Replace "world" with "Jest" in the response chunk diff --git a/src/main.ts b/src/main.ts index bc0fdd9..2f9f932 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,14 +62,14 @@ const wait = promisify(setTimeout) export class RequestInterceptionManager { interceptions: Map = new Map() #client: CDPSession + #requestPausedHandler: (event: Protocol.Fetch.RequestPausedEvent) => void + #isInstalled = false + // eslint-disable-next-line no-console constructor(client: CDPSession, { onError = console.error } = {}) { this.#client = client - client.on( - 'Fetch.requestPaused', - (event: Protocol.Fetch.RequestPausedEvent) => - void this.onRequestPausedEvent(event).catch(onError), - ) + this.#requestPausedHandler = (event: Protocol.Fetch.RequestPausedEvent) => + void this.onRequestPausedEvent(event).catch(onError) } async intercept(...interceptions: Interception[]) { @@ -90,6 +90,7 @@ export class RequestInterceptionManager { } async enable(): Promise { + this.#install() return this.#client.send('Fetch.enable', { handleAuthRequests: false, patterns: [...this.interceptions.values()].map( @@ -103,7 +104,12 @@ export class RequestInterceptionManager { } async disable(): Promise { - return this.#client.send('Fetch.disable') + this.#uninstall() + try { + await this.#client.send('Fetch.disable') + } catch { + // ignore (most likely session closed) + } } async clear() { @@ -203,6 +209,20 @@ export class RequestInterceptionManager { } } + #install() { + if (this.#isInstalled) return + + this.#client.on('Fetch.requestPaused', this.#requestPausedHandler) + this.#isInstalled = true + } + + #uninstall() { + if (!this.#isInstalled) return + + this.#client.off('Fetch.requestPaused', this.#requestPausedHandler) + this.#isInstalled = false + } + async #getResponseBody( event: Protocol.Fetch.RequestPausedEvent, ): Promise<{ body: string | undefined; base64Encoded?: boolean }> {