Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add SendBeaconInterceptor #284

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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:

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Expand Down
140 changes: 140 additions & 0 deletions src/interceptors/sendBeacon/index.ts
Original file line number Diff line number Diff line change
@@ -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<HttpRequestEventMap> {
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
}
7 changes: 6 additions & 1 deletion src/presets/browser.ts
Original file line number Diff line number Diff line change
@@ -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(),
]
21 changes: 21 additions & 0 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,27 @@ interface BrowserXMLHttpRequestInit {
withCredentials?: boolean
}

export async function extractPureBeaconEventDetails(
page: Page,
timeout = 5000,
) {
return page.evaluate((timeout) => {
return new Promise<null | {url: string, data?: BodyInit | null}>((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<IsomorphicRequest> {
Expand Down
19 changes: 19 additions & 0 deletions test/modules/send-beacon/intercept/send-beacon.browser.runtime.js
Original file line number Diff line number Diff line change
@@ -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()
Loading