Skip to content

Commit

Permalink
fix: transfer mocked response as ArrayBuffer to the worker (#1337)
Browse files Browse the repository at this point in the history
* feat: transfer mocked response to the worker

* feat: mark readablestream mock responses not supported

* chore(createMessageChannel): fix type annotations

* fix: keep mocked response null body

* test(internal-error): assert msw error on regular console

* chore: represent worker message event payload as array
  • Loading branch information
kettanaito committed Jul 18, 2022
1 parent 6c6e119 commit 95be5f8
Show file tree
Hide file tree
Showing 5 changed files with 88 additions and 149 deletions.
68 changes: 2 additions & 66 deletions src/mockServiceWorker.js
Expand Up @@ -231,13 +231,6 @@ async function getResponse(event, client, requestId) {
return passthrough()
}

// Create a communication channel scoped to the current request.
// This way events can be exchanged outside of the worker's global
// "message" event listener (i.e. abstracted into functions).
const operationChannel = new BroadcastChannel(
`msw-response-stream-${requestId}`,
)

// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
Expand All @@ -262,43 +255,21 @@ async function getResponse(event, client, requestId) {

switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.payload)
}

case 'MOCK_RESPONSE_START': {
return respondWithMockStream(operationChannel, clientMessage.payload)
return respondWithMock(clientMessage.data)
}

case 'MOCK_NOT_FOUND': {
return passthrough()
}

case 'NETWORK_ERROR': {
const { name, message } = clientMessage.payload
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name

// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}

case 'INTERNAL_ERROR': {
const parsedBody = JSON.parse(clientMessage.payload.body)

console.error(
`\
[MSW] Uncaught exception in the request handler for "%s %s":
${parsedBody.location}
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
`,
request.method,
request.url,
)

return respondWithMock(clientMessage.payload)
}
}

return passthrough()
Expand Down Expand Up @@ -330,38 +301,3 @@ async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}

function respondWithMockStream(operationChannel, mockResponse) {
let streamCtrl
const stream = new ReadableStream({
start: (controller) => (streamCtrl = controller),
})

return new Promise(async (resolve, reject) => {
operationChannel.onmessageerror = (event) => {
operationChannel.close()
return reject(event.data.error)
}

operationChannel.onmessage = (event) => {
if (!event.data) {
return
}

switch (event.data.type) {
case 'MOCK_RESPONSE_CHUNK': {
streamCtrl.enqueue(event.data.payload)
break
}

case 'MOCK_RESPONSE_END': {
streamCtrl.close()
operationChannel.close()
}
}
}

await sleep(mockResponse.delay)
return resolve(new Response(stream, mockResponse))
})
}
99 changes: 58 additions & 41 deletions src/setupWorker/start/createRequestListener.ts
Expand Up @@ -3,20 +3,18 @@ import {
SerializedResponse,
SetupWorkerInternalContext,
ServiceWorkerIncomingEventsMap,
ServiceWorkerBroadcastChannelMessageMap,
} from '../glossary'
import {
ServiceWorkerMessage,
createMessageChannel,
WorkerChannel,
} from './utils/createMessageChannel'
import { NetworkError } from '../../utils/NetworkError'
import { parseWorkerRequest } from '../../utils/request/parseWorkerRequest'
import { handleRequest } from '../../utils/handleRequest'
import { RequestHandler } from '../../handlers/RequestHandler'
import { RequiredDeep } from '../../typeUtils'
import { MockedResponse } from '../../response'
import { streamResponse } from './utils/streamResponse'
import { StrictBroadcastChannel } from '../../utils/internal/StrictBroadcastChannel'
import { devUtils } from '../../utils/internal/devUtils'

export const createRequestListener = (
context: SetupWorkerInternalContext,
Expand All @@ -29,15 +27,10 @@ export const createRequestListener = (
ServiceWorkerIncomingEventsMap['REQUEST']
>,
) => {
const messageChannel = createMessageChannel(event)
const messageChannel = new WorkerChannel(event.ports[0])
const request = parseWorkerRequest(message.payload)

try {
const request = parseWorkerRequest(message.payload)
const operationChannel =
new StrictBroadcastChannel<ServiceWorkerBroadcastChannelMessageMap>(
`msw-response-stream-${request.id}`,
)

await handleRequest<SerializedResponse>(
request,
context.requestHandlers,
Expand All @@ -46,22 +39,35 @@ export const createRequestListener = (
{
transformResponse,
onPassthroughResponse() {
return messageChannel.send({
type: 'MOCK_NOT_FOUND',
})
messageChannel.postMessage('NOT_FOUND')
},
onMockedResponse(response) {
// Signal the mocked responses without bodies immediately.
// There is nothing to stream, so no need to initiate streaming.
if (response.body == null) {
return messageChannel.send({
type: 'MOCK_RESPONSE',
payload: response,
})
async onMockedResponse(response) {
if (response.body instanceof ReadableStream) {
throw new Error(
devUtils.formatMessage(
'Failed to construct a mocked response with a "ReadableStream" body: mocked streams are not supported. Follow https://github.com/mswjs/msw/issues/1336 for more details.',
),
)
}

// If the mocked response has a body, stream it to the worker.
streamResponse(operationChannel, messageChannel, response)
const responseInstance = new Response(response.body, response)
const responseBodyBuffer = await responseInstance.arrayBuffer()

// If the mocked response has no body, keep it that way.
// Sending an empty ArrayBuffer to the worker will cause
// the worker constructing "new Response(new ArrayBuffer(0))"
// which will throw on responses that must have no body (i.e. 204).
const responseBody =
response.body == null ? null : responseBodyBuffer

messageChannel.postMessage(
'MOCK_RESPONSE',
{
...response,
body: responseBody,
},
[responseBodyBuffer],
)
},
onMockedResponseSent(
response,
Expand All @@ -84,28 +90,39 @@ export const createRequestListener = (
if (error instanceof NetworkError) {
// Treat emulated network error differently,
// as it is an intended exception in a request handler.
return messageChannel.send({
type: 'NETWORK_ERROR',
payload: {
name: error.name,
message: error.message,
},
messageChannel.postMessage('NETWORK_ERROR', {
name: error.name,
message: error.message,
})

return
}

if (error instanceof Error) {
// Treat all the other exceptions in a request handler
// as unintended, alerting that there is a problem needs fixing.
messageChannel.send({
type: 'INTERNAL_ERROR',
payload: {
status: 500,
body: JSON.stringify({
errorType: error.constructor.name,
message: error.message,
location: error.stack,
}),
devUtils.error(
`Uncaught exception in the request handler for "%s %s":
%s
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses`,
request.method,
request.url,
error,
)

// Treat all other exceptions in a request handler as unintended,
// alerting that there is a problem that needs fixing.
messageChannel.postMessage('MOCK_RESPONSE', {
status: 500,
statusText: 'Request Handler Error',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: error.name,
message: error.message,
stack: error.stack,
}),
})
}
}
Expand Down
42 changes: 13 additions & 29 deletions src/setupWorker/start/utils/createMessageChannel.ts
@@ -1,5 +1,5 @@
import {
ServiceWorkerFetchEventMap,
SerializedResponse,
ServiceWorkerIncomingEventsMap,
} from '../../glossary'

Expand All @@ -11,36 +11,20 @@ export interface ServiceWorkerMessage<
payload: EventPayload
}

export interface WorkerMessageChannel {
send<Event extends keyof ServiceWorkerFetchEventMap>(
message: Parameters<ServiceWorkerFetchEventMap[Event]>[0] extends undefined
? { type: Event }
: {
type: Event
payload: Parameters<ServiceWorkerFetchEventMap[Event]>[0]
},
): void
interface WorkerChannelEventsMap {
MOCK_RESPONSE: [data: SerializedResponse<any>, body?: [ArrayBuffer]]
NOT_FOUND: []
NETWORK_ERROR: [data: { name: string; message: string }]
}

/**
* Creates a communication channel between the client
* and the Service Worker associated with the given event.
*/
export function createMessageChannel(
event: MessageEvent,
): WorkerMessageChannel {
const port = event.ports[0]
export class WorkerChannel {
constructor(private readonly port: MessagePort) {}

return {
/**
* Send a text message to the connected Service Worker.
*/
send(message) {
if (!port) {
return
}

port.postMessage(message)
},
public postMessage<Event extends keyof WorkerChannelEventsMap>(
event: Event,
...rest: WorkerChannelEventsMap[Event]
): void {
const [data, transfer] = rest
this.port.postMessage({ type: event, data }, { transfer })
}
}
14 changes: 9 additions & 5 deletions test/msw-api/exception-handling.test.ts
Expand Up @@ -16,12 +16,16 @@ test('transforms uncaught exceptions into a 500 response', async () => {
const runtime = await createRuntime()

const res = await runtime.request('https://api.github.com/users/octocat')
const status = res.status()
const headers = await res.allHeaders()
const body = await res.json()

expect(status).toEqual(500)
expect(res.status()).toBe(500)
expect(res.statusText()).toBe('Request Handler Error')
expect(headers).not.toHaveProperty('x-powered-by', 'msw')
expect(body).toHaveProperty('errorType', 'ReferenceError')
expect(body).toHaveProperty('message', 'nonExisting is not defined')
expect(await res.json()).toEqual({
name: 'ReferenceError',
message: 'nonExisting is not defined',
stack: expect.stringContaining(
'ReferenceError: nonExisting is not defined',
),
})
})
@@ -1,7 +1,6 @@
import * as path from 'path'
import { pageWith } from 'page-with'
import { waitFor } from '../../../../support/waitFor'
import { workerConsoleSpy } from '../../../../support/workerConsole'

test('propagates the exception originating from a handled request', async () => {
const runtime = await pageWith({
Expand All @@ -10,14 +9,14 @@ test('propagates the exception originating from a handled request', async () =>

const endpointUrl = runtime.makeUrl('/user')
const res = await runtime.request(endpointUrl)
const json = await res.json()

// Expect the exception to be handled as a 500 error response.
expect(res.status()).toEqual(500)
expect(json).toEqual({
errorType: 'Error',
expect(res.status()).toBe(500)
expect(res.statusText()).toBe('Request Handler Error')
expect(await res.json()).toEqual({
name: 'Error',
message: 'Custom error message',
location: expect.stringContaining('Error: Custom error message'),
stack: expect.stringContaining('Error: Custom error message'),
})

// Expect standard request failure message from the browser.
Expand All @@ -31,8 +30,7 @@ test('propagates the exception originating from a handled request', async () =>
)
})

//
expect(workerConsoleSpy.get('error')).toEqual(
expect(runtime.consoleSpy.get('error')).toEqual(
expect.arrayContaining([
expect.stringContaining(`\
[MSW] Uncaught exception in the request handler for "GET ${endpointUrl}":
Expand Down

0 comments on commit 95be5f8

Please sign in to comment.