Skip to content

Commit

Permalink
fix: update "headers-polyfill" and "@mswjs/interceptors" to fix "/lib…
Browse files Browse the repository at this point in the history
…" issue
  • Loading branch information
kettanaito committed Sep 15, 2022
1 parent a9b8126 commit 184a7d6
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 14 deletions.
121 changes: 121 additions & 0 deletions RESEARCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Research

Mock Service Worker utilizes the Service Worker API to implement the interception of requests. As you may have known, Service Workers aren't exactly designed for that. Over the years of project development there have been numerous challenges to bent workers to behave as one would expect in the context of API mocking. In this paper, I would like to write down my findings and the outcomes of those findings.

## Challenge: Affecting unrelated projects

A Service Worker controls a client based on its address. So different clients like `http://localhost:3000` and `https://mswjs.io` can have their own workers installed in your browser, affecting the traffic of respective pages whenever you visit them.

MSW is a development tool, and during development we often run different projects at the same address like `http://localhost:3000`. This means that by default, our worker will activate for all those projects regardless if they're using the library. While the worker won't do much on its own, and needs a client counterpart that would communicate instructions to it, it will still intercept the traffic, which is unnecessary in the case when you're not mocking anything.

### Resolution

To prevent the worker from affecting unrelated projects at the same address, we've introduced a _self-destructive_ worker. The worker is shared between clients, so we can keep the map of client IDs internally in the worker, and whenever that map reaches 0 (read: the last controlled client has been closed), we can unregister the worker from within itself:

```ts
if (remainingClients.length === 0) {
self.registration.unregister()
}
```

> Note that even when you unregister the worker, it will still receive events like "fetch" until the page is reloaded. We're also accounting for that in the "fetch" listener, checking whether there are any `activeClientIds` when handling intercepted requests.
Each client signals about being closed in the "beforeunload" event by sending a message to the `controller.postMessage()`. It is in the "message" event listener of the worker when the self-unregistration happens.

---

## Discovery: Controller, Message and Broadcast channels

There are [multiple ways to communicate with the worker](https://felixgerschau.com/how-to-communicate-with-service-workers/) but they are quite different in nature.

### Controller messages

A client may send any data to the worker via the `navigator.serviceWorker.controller` object. That controlled will post messages to any workers controlling the page but cannot be used to receive messages from the worker.

```js
// Let the worker know that the current client is being closed.
// We don't need any confirmation from the worker for this event.
window.addEventListener('beforeunload', () => {
navigator.serviceWorker.controller.postMessage({
type: 'CLIENT_CLOSED',
})
})
```

Controller messages are great for one-way signaling of events that affect the entire worker. The worker, in turn, can handle these messages in its "message" event:

```ts
self.addEventListener('message', (event) => {
event.data.type // "CLIENT_CLOSED"
})
```

> Keep in mind that controller messages handling is global, and affect the entire worker. This makes controller messages rather unsuitable to handle request-based data signaling. You should prefer other means below to achieve that.
### `MessageChannel`

We are using a message channel to send a message to the client _and await_ its response.

```ts
// Get the client behind the intercepted request.
const client = await self.clients.get(event.clientId)

const channel = new MessageChannel()

// Listen to a message from the client.
// You can wrap this in a Promise to pause request
// handling until a certain message is received.
channel.port1.onmessage = (event) => {
console.log('from client:', event.data)
}

// Send a message to the client. For example,
// a message like "hey, a request has happened!".
client.postMessage('payload', [channel.port2])
```

### `BroadcastChannel`

Broadcast channel is much similar to the `MessageChannel` with one important distinction: it uses a unique _channel ID_ instead of message channel ports. Whenever two instances of a broadcast channel with the same ID are created, they can communicate with each other even being in different contexts (like two separate tags) or threads (like the client and the worker).

```ts
self.addEventListener('fetch', (event) => {
const requestId = generateRequestId(event.request)
const operationChannel = new BroadcastChannel(`msw-request-${requestId}`)

operationChannel.onmessage = (event) => {
console.log('from client:', event.data)
}

operationChannel.postMessage('to the client')
})
```

Since it's the worker who sends the information about an intercepted request, including the generated request ID, the client can also construct a `BroadcastChannel` with the same ID, and communicate in the context of a particular request.

> Broadcast channel is fantastic when you need to scope incoming/outgoing messages. In comparison with the controller messages, which are global, broadcasted messages can be abstracted into a function.
```ts
function respondWithStream(operationChannel) {
let streamCtrl
const stream = new ReadableStream({
start(controller) {
streamCtrl = controller
},
})

operationChannel.onmessage = (event) => {
if (event.data.type === 'RESPONSE_CHUNK') {
return streamCtrl.enqueue(event.data.chunk)
}

if (event.data.type === 'STREAM_END') {
streamCtl.close()
operationChannel.close()
return new Response(stream)
}
}
}
```

> Don't forget to close broadcast channels once they are no longer needed (i.e. request context has been destroyed).
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,15 @@
"sideEffects": false,
"dependencies": {
"@mswjs/cookies": "^0.2.2",
"@mswjs/interceptors": "^0.17.2",
"@mswjs/interceptors": "^0.17.5",
"@open-draft/until": "^1.0.3",
"@types/cookie": "^0.4.1",
"@types/js-levenshtein": "^1.1.1",
"chalk": "4.1.1",
"chokidar": "^3.4.2",
"cookie": "^0.4.2",
"graphql": "^15.0.0 || ^16.0.0",
"headers-polyfill": "^3.0.4",
"headers-polyfill": "^3.1.0",
"inquirer": "^8.2.0",
"is-node-process": "^1.0.1",
"js-levenshtein": "^1.1.6",
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/GraphQLHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { encodeBuffer } from '@mswjs/interceptors'
import { OperationTypeNode, parse } from 'graphql'
import { Headers } from 'headers-polyfill/lib'
import { Headers } from 'headers-polyfill'
import { context, MockedRequest, MockedRequestInit } from '..'
import { response } from '../response'
import {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/request/MockedRequest.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Headers } from 'headers-polyfill/lib'
import { Headers } from 'headers-polyfill'
import { clearCookies } from '../../../test/support/utils'
import { MockedRequest } from './MockedRequest'

Expand Down
2 changes: 1 addition & 1 deletion src/utils/request/MockedRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as cookieUtils from 'cookie'
import { store } from '@mswjs/cookies'
import { IsomorphicRequest, RequestInit } from '@mswjs/interceptors'
import { decodeBuffer } from '@mswjs/interceptors/lib/utils/bufferUtils'
import { Headers } from 'headers-polyfill/lib'
import { Headers } from 'headers-polyfill'
import { DefaultBodyType } from '../../handlers/RequestHandler'
import { MockedResponse } from '../../response'
import { getRequestCookies } from './getRequestCookies'
Expand Down
19 changes: 10 additions & 9 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1722,15 +1722,16 @@
"@types/set-cookie-parser" "^2.4.0"
set-cookie-parser "^2.4.6"

"@mswjs/interceptors@^0.17.2":
version "0.17.2"
resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.17.2.tgz#a1d1cd8ef98b944c91d9fe202f27a68ec3673b88"
integrity sha512-DbfVSteMnBbxTmtldNO2PRmEXl+f5ehxN6cxbbhp22xcQx4wldbpW6xk3zlxS0gTbyBe0hvdIR7CCUnm2OIDRw==
"@mswjs/interceptors@^0.17.5":
version "0.17.5"
resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.17.5.tgz#38a7502e5bf8b579901adece32693b1620ea7172"
integrity sha512-/uZkyPUZMRExZs+DZQVnc+uoDwLfs1gFNvcRY5S3Gu78U+uhovaSEUW3tuyld1e7Oke5Qphfseb8v66V+H1zWQ==
dependencies:
"@open-draft/until" "^1.0.3"
"@types/debug" "^4.1.7"
"@xmldom/xmldom" "^0.7.5"
debug "^4.3.3"
headers-polyfill "^3.0.4"
headers-polyfill "^3.1.0"
outvariant "^1.2.1"
strict-event-emitter "^0.2.4"
web-encoding "^1.1.5"
Expand Down Expand Up @@ -5492,10 +5493,10 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"

headers-polyfill@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.0.4.tgz#cd70c815a441dd882372fcd6eda212ce997c9b18"
integrity sha512-I1DOM1EdWYntdrnCvqQtcKwSSuiTzoqOExy4v1mdcFixFZABlWP4IPHdmoLtPda0abMHqDOY4H9svhQ10DFR4w==
headers-polyfill@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.1.0.tgz#22135c594feb4d5efd56e5c7f587552b9feac0e7"
integrity sha512-AVwgTAzeGpF7kwUCMc9HbAoCKFcHGEfmWkaI8g0jprrkh9VPRaofIsfV7Lw8UuR9pi4Rk7IIjJce8l0C+jSJNA==

headers-utils@^3.0.2:
version "3.0.2"
Expand Down

0 comments on commit 184a7d6

Please sign in to comment.