diff --git a/README.md b/README.md index be087caa..4c50a006 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Low-level HTTP/HTTPS/XHR/fetch request interception library. - `https.get`/`https.request` - `XMLHttpRequest` - `window.fetch` +- `navigator.sendBeacon` - Any third-party libraries that use the modules above (i.e. `axios`, `request`, `node-fetch`, `supertest`, etc.) ## Motivation @@ -80,6 +81,7 @@ This library extends (or patches, where applicable) the following native modules - `https.get`/`https.request` - `XMLHttpRequest` - `fetch` +- `navigator.sendBeacon` Once extended, it intercepts and normalizes all requests to the _isomorphic request instances_. The isomorphic request is an abstract representation of the request coming from different sources (`ClientRequest`, `XMLHttpRequest`, `window.Request`, etc.) that allows us to handle such requests in the same, unified manner. @@ -103,6 +105,7 @@ To use this library you need to choose one or multiple interceptors to apply. Th - `ClientRequestInterceptor` to spy on `http.ClientRequest` (`http.get`/`http.request`); - `XMLHttpRequestInterceptor` to spy on `XMLHttpRequest`; - `FetchInterceptor` to spy on `fetch`. +- `SendBeaconInterceptor` to spy on `navigator.sendBeacon`. Use an interceptor by constructing it and attaching request/response listeners: @@ -187,7 +190,7 @@ interceptor.on('request', listener) ### Browser preset -This preset combines `XMLHttpRequestInterceptor` and `FetchInterceptor` and is meant to be used in a browser. +This preset combines `XMLHttpRequestInterceptor`, `FetchInterceptor` and `SendBeaconInterceptor` and is meant to be used in a browser. ```js import { BatchInterceptor } from '@mswjs/interceptors' @@ -364,6 +367,30 @@ resolver.on('request', (request) => { }) ``` +### `SendBeaconInterceptor` + +Intercepts requests to `navigator.sendBeacon`. Once the interceptor is applied, `sendBeacon` always returns `true`. +This is necessary, because it is impossible to check if the requirements for the user-agent to queue the send beacon call from JavaScript. + +Another difference to other interceptors like the `FetchInterceptor` is the response handling. `sendBeacon` run synchronously and does not provide a way to access the response. That means the response to the `sendBeacon` call does not matter, but you can still use `request.respondWith()` to prevent calling the original `sendBeacon`. + +```js +import { SendBeaconInterceptor } from '@mswjs/interceptors/lib/SendBeaconInterceptor' + +const interceptor = new SendBeaconInterceptor() + +interceptor.on('request', (request) => { + if (request.url.pathname === '/blocked') { + // The `respondWith()` call will prevent the request from being + // passed to the original `sendBeacon`. The response itself does + // not matter, since it can not be accessed. + request.respondWith({ status: 204 }) + return + } + // Call to other paths will be passed on to the original `sendBeacon` +}) +``` + ## Special mention The following libraries were used as an inspiration to write this low-level API: diff --git a/src/interceptors/sendBeacon/index.ts b/src/interceptors/sendBeacon/index.ts new file mode 100644 index 00000000..9408ef2a --- /dev/null +++ b/src/interceptors/sendBeacon/index.ts @@ -0,0 +1,140 @@ +import { Headers } from 'headers-polyfill' +import { invariant } from 'outvariant' +import { IsomorphicRequest } from '../../IsomorphicRequest' +import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' +import { Interceptor } from '../../Interceptor' +import { InteractiveIsomorphicRequest } from '../../InteractiveIsomorphicRequest' + +export class SendBeaconInterceptor extends Interceptor { + static symbol = Symbol('sendBeacon') + + constructor() { + super(SendBeaconInterceptor.symbol) + } + + protected checkEnvironment() { + return typeof navigator.sendBeacon === 'function' + } + + protected setup() { + const pureSendBeacon = navigator.sendBeacon + + invariant( + !(pureSendBeacon as any)[IS_PATCHED_MODULE], + 'Failed to patch the "sendBeacon" module: already patched.' + ) + + navigator.sendBeacon = (url, data) => { + this.log('[%s] %s', 'POST', url) + + // Perform asynchronous part of sendBeacon. + this.handleSendBeacon(pureSendBeacon, url, data) + + // We can not find out if a `sendBeacon` call would be rejected + // by the user-agent, because it is not only dependent on the + // payload size, but also other criteria like how many sendBeacon + // calls are scheduled to be processed, which we can not know. + // - https://github.com/whatwg/fetch/issues/679 + // - https://fetch.spec.whatwg.org/#concept-http-network-or-cache-fetch + // + // We also do not have access to the return value of `pureSendBeacon`, + // because we need to check for mocked responses asynchronously to + // decide if we need to call `pureSendBeacon`. + return true + } + + Object.defineProperty(navigator.sendBeacon, IS_PATCHED_MODULE, { + enumerable: true, + configurable: true, + value: true, + }) + + this.subscriptions.push(() => { + Object.defineProperty(navigator.sendBeacon, IS_PATCHED_MODULE, { + value: undefined, + }) + + navigator.sendBeacon = pureSendBeacon + + this.log( + 'restored native "navigator.sendBeacon"!', + navigator.sendBeacon.name + ) + }) + } + + /** + * Handles the asynchronous part of the `sendBeacon` call. + */ + protected async handleSendBeacon( + pureSendBeacon: typeof navigator.sendBeacon, + url: string, + data?: BodyInit | null + ) { + const request = new Request(url, { body: data, method: 'POST' }) + const body = await request.clone().arrayBuffer() + const contentType = getContentType(data) + const headers = new Headers() + if (contentType) headers.append('Content-Type', contentType) + + const isomorphicRequest = new IsomorphicRequest( + new URL(url, location.origin), + { + method: 'POST', + headers, + body, + credentials: 'include', + } + ) + + const interactiveIsomorphicRequest = new InteractiveIsomorphicRequest( + isomorphicRequest + ) + + this.log('isomorphic request', interactiveIsomorphicRequest) + + this.log( + 'emitting the "request" event for %d listener(s)...', + this.emitter.listenerCount('request') + ) + this.emitter.emit('request', interactiveIsomorphicRequest) + + this.log('awaiting for the mocked response...') + + await this.emitter.untilIdle('request', ({ args: [request] }) => { + return request.id === interactiveIsomorphicRequest.id + }) + this.log('all request listeners have been resolved!') + + const [mockedResponse] = + await interactiveIsomorphicRequest.respondWith.invoked() + this.log('event.respondWith called with:', mockedResponse) + + if (mockedResponse) { + this.log('received mocked response:', mockedResponse) + + this.log('original sendBeacon not performed') + + return + } + + this.log('no mocked response received!') + + pureSendBeacon(url, data) + + this.log('original sendBeacon performed') + } +} + +/** + * Parses the content type the same way `sendBeacon` is doing. + * See: https://fetch.spec.whatwg.org/#concept-bodyinit-extract + */ +function getContentType(body: BodyInit | null | undefined) { + if (typeof body === 'string') return 'text/plain;charset=UTF-8' + if (body instanceof Blob) return body.type === '' ? undefined : body.type + if (body instanceof URLSearchParams) + return 'application/x-www-form-urlencoded;charset=UTF-8' + if (body instanceof FormData) return 'multipart/form-data' + return undefined +} diff --git a/src/presets/browser.ts b/src/presets/browser.ts index 038ba808..9430aaf1 100644 --- a/src/presets/browser.ts +++ b/src/presets/browser.ts @@ -1,8 +1,13 @@ import { FetchInterceptor } from '../interceptors/fetch' import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest' +import { SendBeaconInterceptor } from '../interceptors/sendBeacon' /** * The default preset provisions the interception of requests * regardless of their type (fetch/XMLHttpRequest). */ -export default [new FetchInterceptor(), new XMLHttpRequestInterceptor()] +export default [ + new FetchInterceptor(), + new XMLHttpRequestInterceptor(), + new SendBeaconInterceptor(), +] diff --git a/test/helpers.ts b/test/helpers.ts index 29fea09d..6239d4c5 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -202,6 +202,27 @@ interface BrowserXMLHttpRequestInit { withCredentials?: boolean } +export async function extractPureBeaconEventDetails( + page: Page, + timeout = 5000, +) { + return page.evaluate((timeout) => { + return new Promise((resolve) => { + const timeoutTimer = setTimeout(() => { + return resolve(null) + }, timeout) + + window.addEventListener( + 'pure-beacon' as any, + (event: CustomEvent<{url: string, data?: BodyInit | null}>) => { + clearTimeout(timeoutTimer) + resolve(event.detail) + } + ) + }) + }, timeout) +} + export async function extractRequestFromPage( page: Page ): Promise { diff --git a/test/modules/send-beacon/intercept/send-beacon.browser.runtime.js b/test/modules/send-beacon/intercept/send-beacon.browser.runtime.js new file mode 100644 index 00000000..98749252 --- /dev/null +++ b/test/modules/send-beacon/intercept/send-beacon.browser.runtime.js @@ -0,0 +1,19 @@ +import { SendBeaconInterceptor } from '@mswjs/interceptors/lib/interceptors/sendBeacon' + +const interceptor = new SendBeaconInterceptor() +interceptor.on('request', async (request) => { + window.dispatchEvent( + new CustomEvent('resolver', { + detail: { + id: request.id, + method: request.method, + url: request.url.href, + headers: request.headers.all(), + credentials: request.credentials, + body: await request.text(), + }, + }) + ) +}) + +interceptor.apply() diff --git a/test/modules/send-beacon/intercept/send-beacon.browser.test.ts b/test/modules/send-beacon/intercept/send-beacon.browser.test.ts new file mode 100644 index 00000000..cc6f5bce --- /dev/null +++ b/test/modules/send-beacon/intercept/send-beacon.browser.test.ts @@ -0,0 +1,162 @@ +/** + * @jest-environment node + */ +import * as path from 'path' +import { HttpServer } from '@open-draft/test-server/http' +import { pageWith, ScenarioApi } from 'page-with' +import { extractRequestFromPage } from '../../../helpers' +import { IsomorphicRequest } from '../../../../src' +import { PageFunction } from 'playwright-core/types/structs' + +const httpServer = new HttpServer((app) => { + app.get('/ping', (_req, res) => { + res.sendStatus(204) + }) +}) + +function prepareRuntime() { + return pageWith({ + example: path.resolve(__dirname, 'send-beacon.browser.runtime.js'), + }) +} + +async function callSendBeacon( + context: ScenarioApi, + url: string, + data?: BodyInit | null +): Promise<[IsomorphicRequest, boolean]> { + return Promise.all([ + extractRequestFromPage(context.page), + context.page.evaluate( + ({ url, data }) => { + return navigator.sendBeacon(url, data) + }, + { url, data } + ), + ]) +} + +/** + * `FormData`, `Blob` are not natively supported by Node <18 + * Use this instead of `callSendBeacon` for those cases to + * create the payload inside the browser context. + */ +async function evalAndWaitForRequest( + context: ScenarioApi, + evalFunc: PageFunction, + args: Arg +): Promise<[IsomorphicRequest, boolean]> { + return Promise.all([ + extractRequestFromPage(context.page), + context.page.evaluate(evalFunc, args), + ]) +} + +beforeAll(async () => { + await httpServer.listen() +}) + +afterAll(async () => { + await httpServer.close() +}) + +test('intercepts a sendBeacon call', async () => { + const context = await prepareRuntime() + const url = httpServer.http.url('/ping') + const [request, returnValue] = await callSendBeacon(context, url, 'test') + + expect(request.method).toEqual('POST') + expect(request.url.href).toEqual(url) + expect(request.headers.get('content-type')).toEqual( + 'text/plain;charset=UTF-8' + ) + expect(request.credentials).toEqual('include') + expect(await request.text()).toEqual('test') + + expect(returnValue).toBe(true) +}) + +describe('sets the correct request mime type', () => { + test('for blobs with defined type', async () => { + const context = await prepareRuntime() + const url = httpServer.http.url('/ping') + + const [request] = await evalAndWaitForRequest( + context, + ({ url }: { url: string }) => { + const encodedText = new TextEncoder().encode('test') + const blob = new Blob([encodedText], { + type: 'text/plain;charset=UTF-8', + }) + return navigator.sendBeacon(url, blob) + }, + { url } + ) + + expect(request.headers.get('content-type')).toBe('text/plain;charset=utf-8') + expect(await request.text()).toBe('test') + }) + + test('for blobs with undefined type', async () => { + const context = await prepareRuntime() + const url = httpServer.http.url('/ping') + + const [request] = await evalAndWaitForRequest( + context, + ({ url }: { url: string }) => { + const encodedText = new TextEncoder().encode('test') + const blob = new Blob([encodedText]) + return navigator.sendBeacon(url, blob) + }, + { url } + ) + + expect(request.headers.get('content-type')).toBe(null) + expect(await request.text()).toBe('test') + }) + + test('for strings', async () => { + const context = await prepareRuntime() + const url = httpServer.http.url('/ping') + + const [request] = await callSendBeacon(context, url, 'test') + + expect(request.headers.get('content-type')).toBe('text/plain;charset=UTF-8') + expect(await request.text()).toBe('test') + }) + + test('for URLSearchParams', async () => { + const context = await prepareRuntime() + const url = httpServer.http.url('/ping') + + const [request] = await evalAndWaitForRequest( + context, + ({ url }: { url: string }) => { + const searchParams = new URLSearchParams('test=test') + return navigator.sendBeacon(url, searchParams) + }, + { url } + ) + + expect(request.headers.get('content-type')).toBe( + 'application/x-www-form-urlencoded;charset=UTF-8' + ) + expect(await request.text()).toBe('test=test') + }) + + test('for FormData', async () => { + const context = await prepareRuntime() + const url = httpServer.http.url('/ping') + + const [request] = await evalAndWaitForRequest( + context, + ({ url }: { url: string }) => { + const formData = new FormData() + return navigator.sendBeacon(url, formData) + }, + { url } + ) + + expect(request.headers.get('content-type')).toMatch(/^multipart\/form-data/) + }) +}) diff --git a/test/modules/send-beacon/response/send-beacon-response-patching.browser.runtime.js b/test/modules/send-beacon/response/send-beacon-response-patching.browser.runtime.js new file mode 100644 index 00000000..20edd954 --- /dev/null +++ b/test/modules/send-beacon/response/send-beacon-response-patching.browser.runtime.js @@ -0,0 +1,31 @@ +import { SendBeaconInterceptor } from '@mswjs/interceptors/lib/interceptors/sendBeacon' + +// Dispatch a `pure-beacon` event before calling `sendBeacon` +// to make sure we are calling the original sendBeacon. +// This needs to be done before we apply the interceptor +// to ensure it will be used instead of the original version. +const pureSendBeacon = navigator.sendBeacon +navigator.sendBeacon = (url, data) => { + window.dispatchEvent( + new CustomEvent('pure-beacon', { + detail: { + url, + data, + }, + }) + ) + pureSendBeacon(url, data) +} + +const interceptor = new SendBeaconInterceptor() +interceptor.on('request', async (request) => { + if (request.url.pathname === '/mocked') { + await new Promise((resolve) => setTimeout(resolve, 0)) + + request.respondWith({ status: 204 }) + } +}) + +interceptor.apply() + +window.interceptor = interceptor diff --git a/test/modules/send-beacon/response/send-beacon-response-patching.browser.test.ts b/test/modules/send-beacon/response/send-beacon-response-patching.browser.test.ts new file mode 100644 index 00000000..37d759b0 --- /dev/null +++ b/test/modules/send-beacon/response/send-beacon-response-patching.browser.test.ts @@ -0,0 +1,64 @@ +/** + * @jest-environment node + */ +import * as path from 'path' +import { HttpServer } from '@open-draft/test-server/http' +import { pageWith, ScenarioApi } from 'page-with' +import { extractPureBeaconEventDetails } from '../../../helpers' + +const httpServer = new HttpServer((app) => { + app.get('/original', (_req, res) => { + res.sendStatus(204) + }) +}) + +function prepareRuntime() { + return pageWith({ + example: path.resolve( + __dirname, + 'send-beacon-response-patching.browser.runtime.js' + ), + }) +} + +async function callSendBeacon( + context: ScenarioApi, + url: string, + data?: BodyInit | null +): Promise<[null | { url: string; data?: BodyInit | null }, boolean]> { + return Promise.all([ + extractPureBeaconEventDetails(context.page), + context.page.evaluate( + ({ url, data }) => { + return navigator.sendBeacon(url, data) + }, + { url, data } + ), + ]) +} + +beforeAll(async () => { + await httpServer.listen() +}) + +afterAll(async () => { + await httpServer.close() +}) + +test('forwards call to the original sendBeacon without response patching', async () => { + const context = await prepareRuntime() + const url = httpServer.http.url('/original') + const [eventDetail] = await callSendBeacon(context, url, 'test') + + expect(eventDetail).not.toBe(null) + expect(eventDetail?.url).toBe(url) + expect(eventDetail?.data).toBe('test') +}) + +test('does not forward the call to the original sendBeacon with response patching', async () => { + const context = await prepareRuntime() + const url = httpServer.http.url('/mocked') + const [eventDetail] = await callSendBeacon(context, url, 'test') + + expect(eventDetail).toBe(null) +})