Skip to content

Commit

Permalink
fix(bypass): support sendBeacon() requests
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito committed Nov 10, 2024
1 parent 8cb7b01 commit 8be9ad9
Show file tree
Hide file tree
Showing 7 changed files with 98 additions and 13 deletions.
12 changes: 12 additions & 0 deletions src/core/bypass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,15 @@ it('allows modifying the bypassed request instance', async () => {
expect(await request.text()).toBe('hello world')
expect(original.bodyUsed).toBe(false)
})

it('supports bypassing "keepalive: true" requests', async () => {
const original = new Request('http://localhost/resource', {
method: 'POST',
keepalive: true,
})
const request = bypass(original)

expect(request.method).toBe('POST')
expect(request.url).toBe('http://localhost/resource')
expect(request.body).toBeNull()
})
3 changes: 2 additions & 1 deletion src/core/bypass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function bypass(input: BypassRequestInput, init?: RequestInit): Request {
// to bypass this request from any further request matching.
// Unlike "passthrough()", bypass is meant for performing
// additional requests within pending request resolution.
requestClone.headers.set('x-msw-intention', 'bypass')

requestClone.headers.append('accept', 'msw/passthrough')

return requestClone
}
8 changes: 4 additions & 4 deletions src/core/utils/handleRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ afterEach(() => {
vi.resetAllMocks()
})

test('returns undefined for a request with the "x-msw-intention" header equal to "bypass"', async () => {
test('returns undefined for a request with the "accept: msw/passthrough" header equal to "bypass"', async () => {
const { emitter, events } = setup()

const requestId = createRequestId()
const request = new Request(new URL('http://localhost/user'), {
headers: new Headers({
'x-msw-intention': 'bypass',
accept: 'msw/passthrough',
}),
})
const handlers: Array<RequestHandler> = []
Expand All @@ -79,12 +79,12 @@ test('returns undefined for a request with the "x-msw-intention" header equal to
expect(handleRequestOptions.onMockedResponse).not.toHaveBeenCalled()
})

test('does not bypass a request with "x-msw-intention" header set to arbitrary value', async () => {
test('does not bypass a request with "accept: msw/*" header set to arbitrary value', async () => {
const { emitter } = setup()

const request = new Request(new URL('http://localhost/user'), {
headers: new Headers({
'x-msw-intention': 'invalid',
acceot: 'msw/invalid',
}),
})
const handlers: Array<RequestHandler> = [
Expand Down
4 changes: 2 additions & 2 deletions src/core/utils/handleRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export async function handleRequest(
): Promise<Response | undefined> {
emitter.emit('request:start', { request, requestId })

// Perform bypassed requests (i.e. wrapped in "bypass()") as-is.
if (request.headers.get('x-msw-intention') === 'bypass') {
// Perform requests wrapped in "bypass()" as-is.
if (request.headers.get('accept')?.includes('msw/passthrough')) {
emitter.emit('request:end', { request, requestId })
handleRequestOptions?.onPassthroughResponse?.(request)
return
Expand Down
14 changes: 8 additions & 6 deletions src/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,14 @@ async function getResponse(event, client, requestId) {
const requestClone = request.clone()

function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries())

// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention']
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)

// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')

return fetch(requestClone, { headers })
}
Expand Down
14 changes: 14 additions & 0 deletions test/browser/rest-api/send-beacon.mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { http, bypass } from 'msw'
import { setupWorker } from 'msw/browser'

const worker = setupWorker(
http.post('/analytics', ({ request }) => {
return new Response(request.body)
}),
http.post('*/analytics-bypass', ({ request }) => {
const nextRequest = bypass(request)
return fetch(nextRequest)
}),
)

worker.start()
56 changes: 56 additions & 0 deletions test/browser/rest-api/send-beacon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect } from '../playwright.extend'

test('supports mocking a response to a "sendBeacon" request', async ({
loadExample,
page,
}) => {
await loadExample(require.resolve('./send-beacon.mocks.ts'))

const isQueuedPromise = page.evaluate(() => {
return navigator.sendBeacon(
'/analytics',
JSON.stringify({ event: 'pageview' }),
)
})

const response = await page.waitForResponse((response) => {
return response.url().endsWith('/analytics')
})

expect(response.status()).toBe(200)
// Technically, "sendBeacon" responses don't send any body back.
// We use this body only to verify that the request body was accessible
// in the request handlers.
await expect(response.text()).resolves.toBe('{"event":"pageview"}')

// Must return true, indicating that the server has queued the sent data.
await expect(isQueuedPromise).resolves.toBe(true)
})

test.only('supports bypassing "sendBeacon" requests', async ({
loadExample,
page,
}) => {
const { compilation } = await loadExample(
require.resolve('./send-beacon.mocks.ts'),
{
beforeNavigation(compilation) {
compilation.use((router) => {
router.post('/analytics-bypass', (_req, res) => {
res.status(200).end()
})
})
},
},
)

const url = new URL('./analytics-bypass', compilation.previewUrl).href
const isQueuedPromise = page.evaluate((url) => {
return navigator.sendBeacon(url, JSON.stringify({ event: 'pageview' }))
}, url)

const response = await page.waitForResponse(url)
expect(response.status()).toBe(200)

await expect(isQueuedPromise).resolves.toBe(true)
})

0 comments on commit 8be9ad9

Please sign in to comment.