From 3fc3649261474c2d4c309c555d3c3430f43855aa Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 12 Jun 2022 00:11:23 +0200 Subject: [PATCH 1/5] feat(worker): narrow "matchAll" scope, remove unnecessary headers serialization --- src/mockServiceWorker.js | 232 +++++++++++++++++++-------------------- 1 file changed, 110 insertions(+), 122 deletions(-) diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js index 2298cd2a8..6883704a9 100644 --- a/src/mockServiceWorker.js +++ b/src/mockServiceWorker.js @@ -9,15 +9,14 @@ */ const INTEGRITY_CHECKSUM = '' -const bypassHeaderName = 'x-msw-bypass' const activeClientIds = new Set() self.addEventListener('install', function () { - return self.skipWaiting() + self.skipWaiting() }) -self.addEventListener('activate', async function (event) { - return self.clients.claim() +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) }) self.addEventListener('message', async function (event) { @@ -33,7 +32,9 @@ self.addEventListener('message', async function (event) { return } - const allClients = await self.clients.matchAll() + const allClients = await self.clients.matchAll({ + type: 'window', + }) switch (event.data) { case 'KEEPALIVE_REQUEST': { @@ -83,30 +84,58 @@ self.addEventListener('message', async function (event) { } }) -// Resolve the "main" client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' - if (client.frameType === 'top-level') { - return client + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return } - const allClients = await self.clients.matchAll() + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) async function handleRequest(event, requestId) { const client = await resolveMainClient(event) @@ -128,7 +157,7 @@ async function handleRequest(event, requestId) { statusText: clonedResponse.statusText, body: clonedResponse.body === null ? null : await clonedResponse.text(), - headers: serializeHeaders(clonedResponse.headers), + headers: Object.fromEntries(clonedResponse.headers.entries()), redirected: clonedResponse.redirected, }, }) @@ -138,14 +167,54 @@ async function handleRequest(event, requestId) { return response } +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + async function getResponse(event, client, requestId) { const { request } = event - const requestClone = request.clone() - const getOriginalResponse = () => fetch(requestClone) + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the cilent). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } - // Bypass mocking when the request client is not active. + // Bypass mocking when the client is not active. if (!client) { - return getOriginalResponse() + return passthrough() } // Bypass initial page load requests (i.e. static assets). @@ -153,34 +222,23 @@ async function getResponse(event, client, requestId) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return await getOriginalResponse() + return passthrough() } - // Bypass requests with the explicit bypass header - if (requestClone.headers.get(bypassHeaderName) === 'true') { - const cleanRequestHeaders = serializeHeaders(requestClone.headers) - - // Remove the bypass header to comply with the CORS preflight check. - delete cleanRequestHeaders[bypassHeaderName] - - const originalRequest = new Request(requestClone, { - headers: new Headers(cleanRequestHeaders), - }) - - return fetch(originalRequest) + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() } - // Send the request to the client-side MSW. - const reqHeaders = serializeHeaders(request.headers) - const body = await request.text() - + // Notify the client that a request has been intercepted. const clientMessage = await sendToClient(client, { type: 'REQUEST', payload: { id: requestId, url: request.url, method: request.method, - headers: reqHeaders, + headers: Object.fromEntries(request.headers.entries()), cache: request.cache, mode: request.mode, credentials: request.credentials, @@ -189,7 +247,7 @@ async function getResponse(event, client, requestId) { redirect: request.redirect, referrer: request.referrer, referrerPolicy: request.referrerPolicy, - body, + body: await request.text(), bodyUsed: request.bodyUsed, keepalive: request.keepalive, }, @@ -204,7 +262,7 @@ async function getResponse(event, client, requestId) { } case 'MOCK_NOT_FOUND': { - return getOriginalResponse() + return passthrough() } case 'NETWORK_ERROR': { @@ -212,7 +270,7 @@ async function getResponse(event, client, requestId) { const networkError = new Error(message) networkError.name = name - // Rejecting a request Promise emulates a network error. + // Rejecting a "respondWith" promise emulates a network error. throw networkError } @@ -235,69 +293,7 @@ This exception has been gracefully handled as a 500 response, however, it's stro } } - return getOriginalResponse() -} - -self.addEventListener('fetch', function (event) { - const { request } = event - const accept = request.headers.get('accept') || '' - - // Bypass server-sent events. - if (accept.includes('text/event-stream')) { - return - } - - // Bypass navigation requests. - if (request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - const requestId = uuidv4() - - return event.respondWith( - handleRequest(event, requestId).catch((error) => { - if (error.name === 'NetworkError') { - console.warn( - '[MSW] Successfully emulated a network error for the "%s %s" request.', - request.method, - request.url, - ) - return - } - - // At this point, any exception indicates an issue with the original request/response. - console.error( - `\ -[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, - request.method, - request.url, - `${error.name}: ${error.message}`, - ) - }), - ) -}) - -function serializeHeaders(headers) { - const reqHeaders = {} - headers.forEach((value, name) => { - reqHeaders[name] = reqHeaders[name] - ? [].concat(reqHeaders[name]).concat(value) - : value - }) - return reqHeaders + return passthrough() } function sendToClient(client, message) { @@ -328,11 +324,3 @@ function respondWithMock(clientMessage) { headers: clientMessage.payload.headers, }) } - -function uuidv4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0 - const v = c == 'x' ? r : (r & 0x3) | 0x8 - return v.toString(16) - }) -} From c9cb42af183aa8de15c263fc2055725ed7fe1f47 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 12 Jun 2022 00:36:38 +0200 Subject: [PATCH 2/5] chore: move worker listeners closer to the worker --- .../start}/createFallbackRequestListener.ts | 6 +++--- src/setupWorker/start/createFallbackStart.ts | 2 +- .../worker => setupWorker/start}/createRequestListener.ts | 8 ++++---- .../start}/createResponseListener.ts | 2 +- src/setupWorker/start/createStartHandler.ts | 5 +++-- 5 files changed, 12 insertions(+), 11 deletions(-) rename src/{utils/worker => setupWorker/start}/createFallbackRequestListener.ts (91%) rename src/{utils/worker => setupWorker/start}/createRequestListener.ts (92%) rename src/{utils/worker => setupWorker/start}/createResponseListener.ts (93%) diff --git a/src/utils/worker/createFallbackRequestListener.ts b/src/setupWorker/start/createFallbackRequestListener.ts similarity index 91% rename from src/utils/worker/createFallbackRequestListener.ts rename to src/setupWorker/start/createFallbackRequestListener.ts index da7838e80..5d47cf1f1 100644 --- a/src/utils/worker/createFallbackRequestListener.ts +++ b/src/setupWorker/start/createFallbackRequestListener.ts @@ -10,10 +10,10 @@ import { SerializedResponse, SetupWorkerInternalContext, StartOptions, -} from '../../setupWorker/glossary' +} from '../glossary' import type { RequiredDeep } from '../../typeUtils' -import { handleRequest } from '../handleRequest' -import { parseIsomorphicRequest } from '../request/parseIsomorphicRequest' +import { handleRequest } from '../../utils/handleRequest' +import { parseIsomorphicRequest } from '../../utils/request/parseIsomorphicRequest' export function createFallbackRequestListener( context: SetupWorkerInternalContext, diff --git a/src/setupWorker/start/createFallbackStart.ts b/src/setupWorker/start/createFallbackStart.ts index f387f147c..cdc88bd61 100644 --- a/src/setupWorker/start/createFallbackStart.ts +++ b/src/setupWorker/start/createFallbackStart.ts @@ -1,4 +1,4 @@ -import { createFallbackRequestListener } from '../../utils/worker/createFallbackRequestListener' +import { createFallbackRequestListener } from './createFallbackRequestListener' import { SetupWorkerInternalContext, StartHandler } from '../glossary' import { printStartMessage } from './utils/printStartMessage' diff --git a/src/utils/worker/createRequestListener.ts b/src/setupWorker/start/createRequestListener.ts similarity index 92% rename from src/utils/worker/createRequestListener.ts rename to src/setupWorker/start/createRequestListener.ts index eaf356e77..563314da8 100644 --- a/src/utils/worker/createRequestListener.ts +++ b/src/setupWorker/start/createRequestListener.ts @@ -7,10 +7,10 @@ import { import { ServiceWorkerMessage, createBroadcastChannel, -} from '../createBroadcastChannel' -import { NetworkError } from '../NetworkError' -import { parseWorkerRequest } from '../request/parseWorkerRequest' -import { handleRequest } from '../handleRequest' +} from '../../utils/createBroadcastChannel' +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' diff --git a/src/utils/worker/createResponseListener.ts b/src/setupWorker/start/createResponseListener.ts similarity index 93% rename from src/utils/worker/createResponseListener.ts rename to src/setupWorker/start/createResponseListener.ts index 24dfd7034..4b751d3cd 100644 --- a/src/utils/worker/createResponseListener.ts +++ b/src/setupWorker/start/createResponseListener.ts @@ -2,7 +2,7 @@ import { ServiceWorkerIncomingEventsMap, SetupWorkerInternalContext, } from '../../setupWorker/glossary' -import { ServiceWorkerMessage } from '../createBroadcastChannel' +import { ServiceWorkerMessage } from '../../utils/createBroadcastChannel' export function createResponseListener(context: SetupWorkerInternalContext) { return ( diff --git a/src/setupWorker/start/createStartHandler.ts b/src/setupWorker/start/createStartHandler.ts index c0e3022e3..c7a127593 100644 --- a/src/setupWorker/start/createStartHandler.ts +++ b/src/setupWorker/start/createStartHandler.ts @@ -2,10 +2,10 @@ import { until } from '@open-draft/until' import { getWorkerInstance } from './utils/getWorkerInstance' import { enableMocking } from './utils/enableMocking' import { SetupWorkerInternalContext, StartHandler } from '../glossary' -import { createRequestListener } from '../../utils/worker/createRequestListener' +import { createRequestListener } from './createRequestListener' import { requestIntegrityCheck } from '../../utils/internal/requestIntegrityCheck' import { deferNetworkRequestsUntil } from '../../utils/deferNetworkRequestsUntil' -import { createResponseListener } from '../../utils/worker/createResponseListener' +import { createResponseListener } from './createResponseListener' import { validateWorkerScope } from './utils/validateWorkerScope' import { devUtils } from '../../utils/internal/devUtils' @@ -25,6 +25,7 @@ export const createStartHandler = ( createRequestListener(context, options), ) + // Handle responses signaled by the worker. context.workerChannel.on('RESPONSE', createResponseListener(context)) const instance = await getWorkerInstance( From 6006f2c43f028044dd346eff3d07c9b525837a62 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 12 Jun 2022 18:29:45 +0200 Subject: [PATCH 3/5] feat: stream mocked responses --- src/mockServiceWorker.js | 65 +++++++++++++++---- src/setupWorker/glossary.ts | 20 ++++-- src/setupWorker/setupWorker.ts | 2 +- .../start/createRequestListener.ts | 37 +++++++---- .../start/createResponseListener.ts | 2 +- .../start/utils/createMessageChannel.ts | 46 +++++++++++++ src/setupWorker/start/utils/streamResponse.ts | 48 ++++++++++++++ src/utils/createBroadcastChannel.ts | 34 ---------- src/utils/internal/StrictBroadcastChannel.ts | 27 ++++++++ test/msw-api/context/delay.node.test.ts | 1 - 10 files changed, 216 insertions(+), 66 deletions(-) create mode 100644 src/setupWorker/start/utils/createMessageChannel.ts create mode 100644 src/setupWorker/start/utils/streamResponse.ts delete mode 100644 src/utils/createBroadcastChannel.ts create mode 100644 src/utils/internal/StrictBroadcastChannel.ts diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js index 6883704a9..b19774bd8 100644 --- a/src/mockServiceWorker.js +++ b/src/mockServiceWorker.js @@ -231,6 +231,13 @@ 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', @@ -254,11 +261,12 @@ async function getResponse(event, client, requestId) { }) switch (clientMessage.type) { - case 'MOCK_SUCCESS': { - return delayPromise( - () => respondWithMock(clientMessage), - clientMessage.payload.delay, - ) + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.payload) + } + + case 'MOCK_RESPONSE_START': { + return respondWithMockStream(operationChannel, clientMessage.payload) } case 'MOCK_NOT_FOUND': { @@ -289,7 +297,7 @@ This exception has been gracefully handled as a 500 response, however, it's stro request.url, ) - return respondWithMock(clientMessage) + return respondWithMock(clientMessage.payload) } } @@ -312,15 +320,48 @@ function sendToClient(client, message) { }) } -function delayPromise(cb, duration) { +function sleep(timeMs) { return new Promise((resolve) => { - setTimeout(() => resolve(cb()), duration) + setTimeout(resolve, timeMs) }) } -function respondWithMock(clientMessage) { - return new Response(clientMessage.payload.body, { - ...clientMessage.payload, - headers: clientMessage.payload.headers, +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((resolve, reject) => { + operationChannel.onmessage = async (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)) + } + } + } + + operationChannel.onmessageerror = (event) => { + operationChannel.close() + return reject(event.data.error) + } }) } diff --git a/src/setupWorker/glossary.ts b/src/setupWorker/glossary.ts index 848b3ca24..417f1dbed 100644 --- a/src/setupWorker/glossary.ts +++ b/src/setupWorker/glossary.ts @@ -5,7 +5,7 @@ import { LifeCycleEventsMap, SharedOptions, } from '../sharedOptions' -import { ServiceWorkerMessage } from '../utils/createBroadcastChannel' +import { ServiceWorkerMessage } from './start/utils/createMessageChannel' import { DefaultBodyType, RequestHandler } from '../handlers/RequestHandler' import type { HttpRequestEventMap, Interceptor } from '@mswjs/interceptors' import { Path } from '../utils/matching/matchRequestUrl' @@ -76,11 +76,19 @@ export type ServiceWorkerOutgoingEventTypes = * Map of the events that can be sent to the Service Worker * only as a part of a single `fetch` event handler. */ -export type ServiceWorkerFetchEventTypes = - | 'MOCK_SUCCESS' - | 'MOCK_NOT_FOUND' - | 'NETWORK_ERROR' - | 'INTERNAL_ERROR' +export interface ServiceWorkerFetchEventMap { + MOCK_RESPONSE(payload: SerializedResponse): void + MOCK_RESPONSE_START(payload: SerializedResponse): void + + MOCK_NOT_FOUND(): void + NETWORK_ERROR(payload: { name: string; message: string }): void + INTERNAL_ERROR(payload: { status: number; body: string }): void +} + +export interface ServiceWorkerBroadcastChannelMessageMap { + MOCK_RESPONSE_CHUNK(payload: Uint8Array): void + MOCK_RESPONSE_END(): void +} export type WorkerLifecycleEventsMap = LifeCycleEventsMap diff --git a/src/setupWorker/setupWorker.ts b/src/setupWorker/setupWorker.ts index fb1e7323f..0ede80180 100644 --- a/src/setupWorker/setupWorker.ts +++ b/src/setupWorker/setupWorker.ts @@ -9,7 +9,7 @@ import { import { createStartHandler } from './start/createStartHandler' import { createStop } from './stop/createStop' import * as requestHandlerUtils from '../utils/internal/requestHandlerUtils' -import { ServiceWorkerMessage } from '../utils/createBroadcastChannel' +import { ServiceWorkerMessage } from './start/utils/createMessageChannel' import { jsonParse } from '../utils/internal/jsonParse' import { RequestHandler } from '../handlers/RequestHandler' import { RestHandler } from '../handlers/RestHandler' diff --git a/src/setupWorker/start/createRequestListener.ts b/src/setupWorker/start/createRequestListener.ts index 563314da8..4e3a32bdd 100644 --- a/src/setupWorker/start/createRequestListener.ts +++ b/src/setupWorker/start/createRequestListener.ts @@ -3,17 +3,20 @@ import { SerializedResponse, SetupWorkerInternalContext, ServiceWorkerIncomingEventsMap, -} from '../../setupWorker/glossary' + ServiceWorkerBroadcastChannelMessageMap, +} from '../glossary' import { ServiceWorkerMessage, - createBroadcastChannel, -} from '../../utils/createBroadcastChannel' + createMessageChannel, +} 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' export const createRequestListener = ( context: SetupWorkerInternalContext, @@ -26,10 +29,15 @@ export const createRequestListener = ( ServiceWorkerIncomingEventsMap['REQUEST'] >, ) => { - const channel = createBroadcastChannel(event) + const messageChannel = createMessageChannel(event) try { const request = parseWorkerRequest(message.payload) + const operationChannel = + new StrictBroadcastChannel( + `msw-response-stream-${request.id}`, + ) + await handleRequest( request, context.requestHandlers, @@ -38,15 +46,22 @@ export const createRequestListener = ( { transformResponse, onPassthroughResponse() { - return channel.send({ + return messageChannel.send({ type: 'MOCK_NOT_FOUND', }) }, onMockedResponse(response) { - channel.send({ - type: 'MOCK_SUCCESS', - payload: 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, + }) + } + + // If the mocked response has a body, stream it to the worker. + streamResponse(operationChannel, messageChannel, response) }, onMockedResponseSent( response, @@ -69,7 +84,7 @@ export const createRequestListener = ( if (error instanceof NetworkError) { // Treat emulated network error differently, // as it is an intended exception in a request handler. - return channel.send({ + return messageChannel.send({ type: 'NETWORK_ERROR', payload: { name: error.name, @@ -81,7 +96,7 @@ export const createRequestListener = ( if (error instanceof Error) { // Treat all the other exceptions in a request handler // as unintended, alerting that there is a problem needs fixing. - channel.send({ + messageChannel.send({ type: 'INTERNAL_ERROR', payload: { status: 500, diff --git a/src/setupWorker/start/createResponseListener.ts b/src/setupWorker/start/createResponseListener.ts index 4b751d3cd..6fa37ad89 100644 --- a/src/setupWorker/start/createResponseListener.ts +++ b/src/setupWorker/start/createResponseListener.ts @@ -2,7 +2,7 @@ import { ServiceWorkerIncomingEventsMap, SetupWorkerInternalContext, } from '../../setupWorker/glossary' -import { ServiceWorkerMessage } from '../../utils/createBroadcastChannel' +import { ServiceWorkerMessage } from './utils/createMessageChannel' export function createResponseListener(context: SetupWorkerInternalContext) { return ( diff --git a/src/setupWorker/start/utils/createMessageChannel.ts b/src/setupWorker/start/utils/createMessageChannel.ts new file mode 100644 index 000000000..912cac54d --- /dev/null +++ b/src/setupWorker/start/utils/createMessageChannel.ts @@ -0,0 +1,46 @@ +import { + ServiceWorkerFetchEventMap, + ServiceWorkerIncomingEventsMap, +} from '../../glossary' + +export interface ServiceWorkerMessage< + EventType extends keyof ServiceWorkerIncomingEventsMap, + EventPayload, +> { + type: EventType + payload: EventPayload +} + +export interface WorkerMessageChannel { + send( + message: Parameters[0] extends undefined + ? { type: Event } + : { + type: Event + payload: Parameters[0] + }, + ): void +} + +/** + * 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] + + return { + /** + * Send a text message to the connected Service Worker. + */ + send(message) { + if (!port) { + return + } + + port.postMessage(message) + }, + } +} diff --git a/src/setupWorker/start/utils/streamResponse.ts b/src/setupWorker/start/utils/streamResponse.ts new file mode 100644 index 000000000..f60568a49 --- /dev/null +++ b/src/setupWorker/start/utils/streamResponse.ts @@ -0,0 +1,48 @@ +import { invariant } from 'outvariant' +import { StrictBroadcastChannel } from '../../../utils/internal/StrictBroadcastChannel' +import { + SerializedResponse, + ServiceWorkerBroadcastChannelMessageMap, +} from '../../glossary' +import { WorkerMessageChannel } from './createMessageChannel' + +export async function streamResponse( + operationChannel: StrictBroadcastChannel, + messageChannel: WorkerMessageChannel, + mockedResponse: SerializedResponse, +): Promise { + const response = new Response(mockedResponse.body, mockedResponse) + + // Signal the mock response stream start event on the global + // message channel because the worker expects an event in response + // to the sent "REQUEST" global event. + messageChannel.send({ + type: 'MOCK_RESPONSE_START', + payload: mockedResponse, + }) + + invariant(response.body, 'Failed to stream mocked response with no body') + + // Read the mocked response body as stream + // and pipe it to the worker. + const reader = response.body.getReader() + + while (true) { + const { done, value } = await reader.read() + + if (!done) { + operationChannel.postMessage({ + type: 'MOCK_RESPONSE_CHUNK', + payload: value, + }) + continue + } + + operationChannel.postMessage({ + type: 'MOCK_RESPONSE_END', + }) + operationChannel.close() + reader.releaseLock() + break + } +} diff --git a/src/utils/createBroadcastChannel.ts b/src/utils/createBroadcastChannel.ts deleted file mode 100644 index a11d67e78..000000000 --- a/src/utils/createBroadcastChannel.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - ServiceWorkerFetchEventTypes, - ServiceWorkerIncomingEventsMap, -} from '../setupWorker/glossary' - -export interface ServiceWorkerMessage< - EventType extends keyof ServiceWorkerIncomingEventsMap, - EventPayload, -> { - type: EventType - payload: EventPayload -} - -/** - * Creates a communication channel between the client - * and the Service Worker associated with the given event. - */ -export const createBroadcastChannel = (event: MessageEvent) => { - const port = event.ports[0] - - return { - /** - * Sends a text message to the connected Service Worker. - */ - send(message: { - type: ServiceWorkerFetchEventTypes - payload?: Record | string - }) { - if (port) { - port.postMessage(message) - } - }, - } -} diff --git a/src/utils/internal/StrictBroadcastChannel.ts b/src/utils/internal/StrictBroadcastChannel.ts new file mode 100644 index 000000000..15b7237e4 --- /dev/null +++ b/src/utils/internal/StrictBroadcastChannel.ts @@ -0,0 +1,27 @@ +const ParentClass = + typeof BroadcastChannel == 'undefined' + ? class UnsupportedEnvironment { + constructor() { + throw new Error( + 'Cannot construct BroadcastChannel in a non-browser environment', + ) + } + } + : BroadcastChannel + +export class StrictBroadcastChannel< + MessageMap extends Record, +> extends (ParentClass as unknown as { new (name: string): BroadcastChannel }) { + public postMessage( + message: Parameters[0] extends undefined + ? { + type: MessageType + } + : { + type: MessageType + payload: Parameters[0] + }, + ): void { + return super.postMessage(message) + } +} diff --git a/test/msw-api/context/delay.node.test.ts b/test/msw-api/context/delay.node.test.ts index 279e8fbc2..adc685ae1 100644 --- a/test/msw-api/context/delay.node.test.ts +++ b/test/msw-api/context/delay.node.test.ts @@ -53,7 +53,6 @@ test('uses realistic server response time when no duration is provided', async ( // Realistic server response time in Node.js is set to 5ms. expect(responseTime).toBeGreaterThan(5) - expect(responseTime).toBeLessThan(100) expect(await res.text()).toBe('john') }) From 5a7c3c660f832d25e7cc0aa604413afdafe89490 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 13 Jun 2022 00:55:47 +0200 Subject: [PATCH 4/5] feat: add "ctx.stream" to respond with a ReadableStream --- src/context/stream.ts | 16 +++++++ src/handlers/RestHandler.ts | 3 ++ src/mockServiceWorker.js | 18 ++++---- src/setupWorker/start/utils/streamResponse.ts | 8 ++++ test/msw-api/context/stream.mocks.ts | 43 +++++++++++++++++++ test/msw-api/context/stream.test.ts | 31 +++++++++++++ 6 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 src/context/stream.ts create mode 100644 test/msw-api/context/stream.mocks.ts create mode 100644 test/msw-api/context/stream.test.ts diff --git a/src/context/stream.ts b/src/context/stream.ts new file mode 100644 index 000000000..10e6d8a90 --- /dev/null +++ b/src/context/stream.ts @@ -0,0 +1,16 @@ +import { ResponseTransformer } from '../response' + +/** + * Sets a `ReadableStream` as the mocked response body. + * @example + * const stream = ctx.fetch('/resource').then(res => res.body) + * return res(ctx.stream(stream)) + */ +export function stream( + readableStream: ReadableStream, +): ResponseTransformer> { + return (response) => { + response.body = readableStream + return response + } +} diff --git a/src/handlers/RestHandler.ts b/src/handlers/RestHandler.ts index d1249729b..9a6c3d709 100644 --- a/src/handlers/RestHandler.ts +++ b/src/handlers/RestHandler.ts @@ -1,4 +1,5 @@ import { body, cookie, json, text, xml } from '../context' +import { stream } from '../context/stream' import { SerializedResponse } from '../setupWorker/glossary' import { ResponseResolutionContext } from '../utils/getResponse' import { devUtils } from '../utils/internal/devUtils' @@ -50,6 +51,7 @@ export type RestContext = DefaultContext & { body: typeof body json: typeof json xml: typeof xml + stream: typeof stream } export const restContext: RestContext = { @@ -59,6 +61,7 @@ export const restContext: RestContext = { text, json, xml, + stream, } export type RequestQuery = { diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js index b19774bd8..c564eeaf0 100644 --- a/src/mockServiceWorker.js +++ b/src/mockServiceWorker.js @@ -337,8 +337,13 @@ function respondWithMockStream(operationChannel, mockResponse) { start: (controller) => (streamCtrl = controller), }) - return new Promise((resolve, reject) => { - operationChannel.onmessage = async (event) => { + return new Promise(async (resolve, reject) => { + operationChannel.onmessageerror = (event) => { + operationChannel.close() + return reject(event.data.error) + } + + operationChannel.onmessage = (event) => { if (!event.data) { return } @@ -352,16 +357,11 @@ function respondWithMockStream(operationChannel, mockResponse) { case 'MOCK_RESPONSE_END': { streamCtrl.close() operationChannel.close() - - await sleep(mockResponse.delay) - return resolve(new Response(stream, mockResponse)) } } } - operationChannel.onmessageerror = (event) => { - operationChannel.close() - return reject(event.data.error) - } + await sleep(mockResponse.delay) + return resolve(new Response(stream, mockResponse)) }) } diff --git a/src/setupWorker/start/utils/streamResponse.ts b/src/setupWorker/start/utils/streamResponse.ts index f60568a49..8f8b46a5a 100644 --- a/src/setupWorker/start/utils/streamResponse.ts +++ b/src/setupWorker/start/utils/streamResponse.ts @@ -13,6 +13,14 @@ export async function streamResponse( ): Promise { const response = new Response(mockedResponse.body, mockedResponse) + /** + * Delete the ReadableStream response body + * so it doesn't get sent via the message channel. + * @note Otherwise, an error: cannot clone a ReadableStream if + * it hasn't been transformed yet. + */ + delete mockedResponse.body + // Signal the mock response stream start event on the global // message channel because the worker expects an event in response // to the sent "REQUEST" global event. diff --git a/test/msw-api/context/stream.mocks.ts b/test/msw-api/context/stream.mocks.ts new file mode 100644 index 000000000..0ef5e33e5 --- /dev/null +++ b/test/msw-api/context/stream.mocks.ts @@ -0,0 +1,43 @@ +import { setupWorker, rest } from 'msw' + +function sleep(timeMs: number) { + return new Promise((resolve) => setTimeout(resolve, timeMs)) +} + +const worker = setupWorker( + rest.get('/video', (req, res, ctx) => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('hello')) + await sleep(500) + controller.enqueue(encoder.encode('world')) + await sleep(500) + controller.close() + }, + }) + + return res(ctx.set('Content-Type', 'text/plain'), ctx.stream(stream)) + }), +) + +worker.start() + +document.body.addEventListener('click', () => { + fetch('/video').then(async (res) => { + const decoder = new TextDecoder() + const reader = res.body.getReader() + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + console.log(`stream chunk: ${decoder.decode(value)}`) + } + + return + }) +}) diff --git a/test/msw-api/context/stream.test.ts b/test/msw-api/context/stream.test.ts new file mode 100644 index 000000000..ddf155d2a --- /dev/null +++ b/test/msw-api/context/stream.test.ts @@ -0,0 +1,31 @@ +import * as path from 'path' +import { pageWith } from 'page-with' +import { waitFor } from '../../support/waitFor' + +test('supports a ReadableStream as the mocked response body', async () => { + const runtime = await pageWith({ + example: path.resolve(__dirname, 'stream.mocks.ts'), + }) + + runtime.page.evaluate(() => { + document.body.click() + }) + + const req = await runtime.page.waitForRequest(/\/video$/) + const res = await runtime.page.waitForResponse(/\/video$/) + const timing = req.timing() + + await waitFor(() => { + expect(runtime.consoleSpy.get('log')).toEqual( + expect.arrayContaining([expect.stringMatching('stream chunk')]), + ) + }) + + expect(await res.text()).toBe('helloworld') + + // Ensure the data was streamed from the worker + // and not responded instantly. + expect(runtime.consoleSpy.get('log')).toContain('stream chunk: hello') + expect(runtime.consoleSpy.get('log')).toContain('stream chunk: world') + expect(timing.responseEnd).toBeGreaterThan(600) +}) From 65752d00026f8942dd90b01e2b9e05146ccc2850 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 2 Jul 2022 18:11:28 +0200 Subject: [PATCH 5/5] chore: remove "ctx.stream" due to the lack of node support --- src/context/stream.ts | 16 ----------- src/handlers/RestHandler.ts | 3 -- test/msw-api/context/stream.mocks.ts | 43 ---------------------------- test/msw-api/context/stream.test.ts | 31 -------------------- 4 files changed, 93 deletions(-) delete mode 100644 src/context/stream.ts delete mode 100644 test/msw-api/context/stream.mocks.ts delete mode 100644 test/msw-api/context/stream.test.ts diff --git a/src/context/stream.ts b/src/context/stream.ts deleted file mode 100644 index 10e6d8a90..000000000 --- a/src/context/stream.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ResponseTransformer } from '../response' - -/** - * Sets a `ReadableStream` as the mocked response body. - * @example - * const stream = ctx.fetch('/resource').then(res => res.body) - * return res(ctx.stream(stream)) - */ -export function stream( - readableStream: ReadableStream, -): ResponseTransformer> { - return (response) => { - response.body = readableStream - return response - } -} diff --git a/src/handlers/RestHandler.ts b/src/handlers/RestHandler.ts index 9a6c3d709..d1249729b 100644 --- a/src/handlers/RestHandler.ts +++ b/src/handlers/RestHandler.ts @@ -1,5 +1,4 @@ import { body, cookie, json, text, xml } from '../context' -import { stream } from '../context/stream' import { SerializedResponse } from '../setupWorker/glossary' import { ResponseResolutionContext } from '../utils/getResponse' import { devUtils } from '../utils/internal/devUtils' @@ -51,7 +50,6 @@ export type RestContext = DefaultContext & { body: typeof body json: typeof json xml: typeof xml - stream: typeof stream } export const restContext: RestContext = { @@ -61,7 +59,6 @@ export const restContext: RestContext = { text, json, xml, - stream, } export type RequestQuery = { diff --git a/test/msw-api/context/stream.mocks.ts b/test/msw-api/context/stream.mocks.ts deleted file mode 100644 index 0ef5e33e5..000000000 --- a/test/msw-api/context/stream.mocks.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { setupWorker, rest } from 'msw' - -function sleep(timeMs: number) { - return new Promise((resolve) => setTimeout(resolve, timeMs)) -} - -const worker = setupWorker( - rest.get('/video', (req, res, ctx) => { - const encoder = new TextEncoder() - const stream = new ReadableStream({ - async start(controller) { - controller.enqueue(encoder.encode('hello')) - await sleep(500) - controller.enqueue(encoder.encode('world')) - await sleep(500) - controller.close() - }, - }) - - return res(ctx.set('Content-Type', 'text/plain'), ctx.stream(stream)) - }), -) - -worker.start() - -document.body.addEventListener('click', () => { - fetch('/video').then(async (res) => { - const decoder = new TextDecoder() - const reader = res.body.getReader() - - while (true) { - const { done, value } = await reader.read() - - if (done) { - break - } - - console.log(`stream chunk: ${decoder.decode(value)}`) - } - - return - }) -}) diff --git a/test/msw-api/context/stream.test.ts b/test/msw-api/context/stream.test.ts deleted file mode 100644 index ddf155d2a..000000000 --- a/test/msw-api/context/stream.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as path from 'path' -import { pageWith } from 'page-with' -import { waitFor } from '../../support/waitFor' - -test('supports a ReadableStream as the mocked response body', async () => { - const runtime = await pageWith({ - example: path.resolve(__dirname, 'stream.mocks.ts'), - }) - - runtime.page.evaluate(() => { - document.body.click() - }) - - const req = await runtime.page.waitForRequest(/\/video$/) - const res = await runtime.page.waitForResponse(/\/video$/) - const timing = req.timing() - - await waitFor(() => { - expect(runtime.consoleSpy.get('log')).toEqual( - expect.arrayContaining([expect.stringMatching('stream chunk')]), - ) - }) - - expect(await res.text()).toBe('helloworld') - - // Ensure the data was streamed from the worker - // and not responded instantly. - expect(runtime.consoleSpy.get('log')).toContain('stream chunk: hello') - expect(runtime.consoleSpy.get('log')).toContain('stream chunk: world') - expect(timing.responseEnd).toBeGreaterThan(600) -})