Repository: mswjs/msw (ts)

Issue:
'Cannot read properties of undefined (reading 'url')' when multiple browser tabs are open
### Prerequisites

- [X] I confirm my issue is not in the [opened issues](https://github.com/mswjs/msw/issues)
- [X] I confirm the [Frequently Asked Questions](https://mswjs.io/docs/faq) didn't contain the answer to my issue

### Environment check

- [X] I'm using the [latest](https://github.com/mswjs/msw/releases/latest) `msw` version
- [X] I'm using Node.js version 18 or higher

### Browsers

Chromium (Chrome, Brave, etc.)

### Reproduction repository

https://github.com/hal-shin/msw-bug

### Reproduction steps

1. install nodejs v20.12.2
2. npm i
3. npm start
4. open 2 tabs 'http://localhost:3000/'
5. after opening the second tab, you'll see the following error:
<img width="1363" alt="image" src="https://github.com/mswjs/msw/assets/5569078/30189258-ca02-43cf-8abe-4c1cd0cd6c26">


### Current behavior

when the second tab opened, I see 'Cannot read properties of undefined (reading 'url')' error.

Affected versions: >2.1.7
Versions that work correctly: <= 2.1.7

### Expected behavior

application should continue working when it is opened in 2 tabs.

Pull request:
feat: send `request` reference within the `RESPONSE` event
> [!WARNING]
> This change will require you to upgrade your worker script via `npm init`. If you've saved the worker script location in the past using the `--save` flag, the worker script will be updated automatically. 

- **Follow up to #2455**
- Related to #2193
- Fixes #2146
- Closes #2240

Test attempt at https://github.com/mswjs/msw/pull/2473. Highly flaky due to how the client registrations are resolved across tabs. I don't believe it's worth it to pursue that test. 

Patch:
diff --git a/package.json b/package.json
index 192cb0f3..878e9782 100644
--- a/package.json
+++ b/package.json
@@ -266,6 +266,7 @@
     "@types/express": "^4.17.21",
     "@types/json-bigint": "^1.0.4",
     "@types/node": "~18.19.28",
+    "@types/serviceworker": "^0.0.136",
     "@typescript-eslint/eslint-plugin": "^8.8.1",
     "@typescript-eslint/parser": "^8.8.1",
     "@web/dev-server": "^0.4.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4198649f..bf1c2ec3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -90,6 +90,9 @@ importers:
       '@types/node':
         specifier: ~18.19.28
         version: 18.19.28
+      '@types/serviceworker':
+        specifier: ^0.0.136
+        version: 0.0.136
       '@typescript-eslint/eslint-plugin':
         specifier: ^8.8.1
         version: 8.16.0(@typescript-eslint/parser@8.16.0(eslint@8.57.0)(typescript@5.5.2))(eslint@8.57.0)(typescript@5.5.2)
@@ -1463,6 +1466,9 @@ packages:
   '@types/serve-static@1.15.5':
     resolution: {integrity: sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==}
 
+  '@types/serviceworker@0.0.136':
+    resolution: {integrity: sha512-5lqryjGIegGNpkOcCfVg7j+v+aB128pEUpffB9mqUAX3sFq38mjtKTTRtAq2oLtt5Wg3Ex0G9EB52ha0qJfbsQ==}
+
   '@types/statuses@2.0.5':
     resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==}
 
@@ -6432,6 +6438,8 @@ snapshots:
       '@types/mime': 4.0.0
       '@types/node': 18.19.28
 
+  '@types/serviceworker@0.0.136': {}
+
   '@types/statuses@2.0.5': {}
 
   '@types/tough-cookie@4.0.5': {}
diff --git a/src/browser/setupWorker/glossary.ts b/src/browser/setupWorker/glossary.ts
index dc7e3c24..38ad656b 100644
--- a/src/browser/setupWorker/glossary.ts
+++ b/src/browser/setupWorker/glossary.ts
@@ -36,12 +36,13 @@ export interface ServiceWorkerIncomingRequest extends RequestWithoutMethods {
   body?: ArrayBuffer | null
 }
 
-type ServiceWorkerIncomingResponse = Pick<
-  Response,
-  'type' | 'ok' | 'status' | 'statusText' | 'body' | 'headers' | 'redirected'
-> & {
-  requestId: string
+type ServiceWorkerIncomingResponse = {
   isMockedResponse: boolean
+  request: ServiceWorkerIncomingRequest
+  response: Pick<
+    Response,
+    'type' | 'ok' | 'status' | 'statusText' | 'body' | 'headers' | 'redirected'
+  >
 }
 
 /**
@@ -87,8 +88,7 @@ export interface SetupWorkerInternalContext {
   startOptions: RequiredDeep<StartOptions>
   worker: ServiceWorker | null
   registration: ServiceWorkerRegistration | null
-  getRequestHandlers(): Array<RequestHandler | WebSocketHandler>
-  requests: Map<string, Request>
+  getRequestHandlers: () => Array<RequestHandler | WebSocketHandler>
   emitter: Emitter<LifeCycleEventsMap>
   keepAliveInterval?: number
   workerChannel: {
diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts
index f83c67e0..5899d7af 100644
--- a/src/browser/setupWorker/setupWorker.ts
+++ b/src/browser/setupWorker/setupWorker.ts
@@ -66,7 +66,6 @@ export class SetupWorkerApi
         return this.handlersController.currentHandlers()
       },
       registration: null,
-      requests: new Map(),
       emitter: this.emitter,
       workerChannel: {
         on: (eventType, callback) => {
diff --git a/src/browser/setupWorker/start/createRequestListener.ts b/src/browser/setupWorker/start/createRequestListener.ts
index c43974c1..f29f10ac 100644
--- a/src/browser/setupWorker/start/createRequestListener.ts
+++ b/src/browser/setupWorker/start/createRequestListener.ts
@@ -7,7 +7,7 @@ import {
   ServiceWorkerMessage,
   WorkerChannel,
 } from './utils/createMessageChannel'
-import { parseWorkerRequest } from '../../utils/parseWorkerRequest'
+import { deserializeRequest } from '../../utils/deserializeRequest'
 import { RequestHandler } from '~/core/handlers/RequestHandler'
 import { handleRequest } from '~/core/utils/handleRequest'
 import { RequiredDeep } from '~/core/typeUtils'
@@ -29,16 +29,15 @@ export const createRequestListener = (
     const messageChannel = new WorkerChannel(event.ports[0])
 
     const requestId = message.payload.id
-    const request = parseWorkerRequest(message.payload)
+    const request = deserializeRequest(message.payload)
     const requestCloneForLogs = request.clone()
 
-    // Make this the first requets clone before the
+    // Make this the first request clone before the
     // request resolution pipeline even starts.
     // Store the clone in cache so the first matching
     // request handler would skip the cloning phase.
     const requestClone = request.clone()
     RequestHandler.cache.set(request, requestClone)
-    context.requests.set(requestId, requestClone)
 
     try {
       await handleRequest(
diff --git a/src/browser/setupWorker/start/createResponseListener.ts b/src/browser/setupWorker/start/createResponseListener.ts
index 81a70bab..4e8ec408 100644
--- a/src/browser/setupWorker/start/createResponseListener.ts
+++ b/src/browser/setupWorker/start/createResponseListener.ts
@@ -4,6 +4,7 @@ import type {
   SetupWorkerInternalContext,
 } from '../glossary'
 import type { ServiceWorkerMessage } from './utils/createMessageChannel'
+import { deserializeRequest } from '../../utils/deserializeRequest'
 
 export function createResponseListener(context: SetupWorkerInternalContext) {
   return (
@@ -14,12 +15,7 @@ export function createResponseListener(context: SetupWorkerInternalContext) {
     >,
   ) => {
     const { payload: responseJson } = message
-
-    // Get the Request instance reference stored in the
-    // request listener.
-    const { requestId } = responseJson
-    const request = context.requests.get(requestId)!
-    context.requests.delete(requestId)
+    const request = deserializeRequest(responseJson.request)
 
     /**
      * CORS requests with `mode: "no-cors"` result in "opaque" responses.
@@ -28,12 +24,12 @@ export function createResponseListener(context: SetupWorkerInternalContext) {
      * @see https://fetch.spec.whatwg.org/#concept-filtered-response-opaque
      * @see https://github.com/mswjs/msw/issues/529
      */
-    if (responseJson.type?.includes('opaque')) {
+    if (responseJson.response.type?.includes('opaque')) {
       return
     }
 
     const response =
-      responseJson.status === 0
+      responseJson.response.status === 0
         ? Response.error()
         : new FetchResponse(
             /**
@@ -42,8 +38,8 @@ export function createResponseListener(context: SetupWorkerInternalContext) {
              * throw when passed a non-null body, so ensure it's null here
              * for those codes
              */
-            FetchResponse.isResponseWithBody(responseJson.status)
-              ? responseJson.body
+            FetchResponse.isResponseWithBody(responseJson.response.status)
+              ? responseJson.response.body
               : null,
             {
               ...responseJson,
@@ -59,9 +55,9 @@ export function createResponseListener(context: SetupWorkerInternalContext) {
     context.emitter.emit(
       responseJson.isMockedResponse ? 'response:mocked' : 'response:bypass',
       {
-        response,
+        requestId: responseJson.request.id,
         request,
-        requestId: responseJson.requestId,
+        response,
       },
     )
   }
diff --git a/src/browser/utils/parseWorkerRequest.ts b/src/browser/utils/deserializeRequest.ts
similarity index 55%
rename from src/browser/utils/parseWorkerRequest.ts
rename to src/browser/utils/deserializeRequest.ts
index 4160efcb..aae0c5c7 100644
--- a/src/browser/utils/parseWorkerRequest.ts
+++ b/src/browser/utils/deserializeRequest.ts
@@ -5,11 +5,11 @@ import type { ServiceWorkerIncomingRequest } from '../setupWorker/glossary'
  * Converts a given request received from the Service Worker
  * into a Fetch `Request` instance.
  */
-export function parseWorkerRequest(
-  incomingRequest: ServiceWorkerIncomingRequest,
+export function deserializeRequest(
+  serializedRequest: ServiceWorkerIncomingRequest,
 ): Request {
-  return new Request(incomingRequest.url, {
-    ...incomingRequest,
-    body: pruneGetRequestBody(incomingRequest),
+  return new Request(serializedRequest.url, {
+    ...serializedRequest,
+    body: pruneGetRequestBody(serializedRequest),
   })
 }
diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js
index 7d768456..4ca51953 100644
--- a/src/mockServiceWorker.js
+++ b/src/mockServiceWorker.js
@@ -5,7 +5,6 @@
  * Mock Service Worker.
  * @see https://github.com/mswjs/msw
  * - Please do NOT modify this file.
- * - Please do NOT serve this file on production.
  */
 
 const PACKAGE_VERSION = '<PACKAGE_VERSION>'
@@ -13,16 +12,16 @@ const INTEGRITY_CHECKSUM = '<INTEGRITY_CHECKSUM>'
 const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
 const activeClientIds = new Set()
 
-self.addEventListener('install', function () {
+addEventListener('install', function () {
   self.skipWaiting()
 })
 
-self.addEventListener('activate', function (event) {
+addEventListener('activate', function (event) {
   event.waitUntil(self.clients.claim())
 })
 
-self.addEventListener('message', async function (event) {
-  const clientId = event.source.id
+addEventListener('message', async function (event) {
+  const clientId = Reflect.get(event.source || {}, 'id')
 
   if (!clientId || !self.clients) {
     return
@@ -94,17 +93,18 @@ self.addEventListener('message', async function (event) {
   }
 })
 
-self.addEventListener('fetch', function (event) {
-  const { request } = event
-
+addEventListener('fetch', function (event) {
   // Bypass navigation requests.
-  if (request.mode === 'navigate') {
+  if (event.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') {
+  if (
+    event.request.cache === 'only-if-cached' &&
+    event.request.mode !== 'same-origin'
+  ) {
     return
   }
 
@@ -115,48 +115,62 @@ self.addEventListener('fetch', function (event) {
     return
   }
 
-  // Generate unique request ID.
   const requestId = crypto.randomUUID()
   event.respondWith(handleRequest(event, requestId))
 })
 
+/**
+ * @param {FetchEvent} event
+ * @param {string} requestId
+ */
 async function handleRequest(event, requestId) {
   const client = await resolveMainClient(event)
+  const requestCloneForEvents = event.request.clone()
   const response = await getResponse(event, client, requestId)
 
   // Send back the response clone for the "response:*" life-cycle events.
   // Ensure MSW is active and ready to handle the message, otherwise
   // this message will pend indefinitely.
   if (client && activeClientIds.has(client.id)) {
-    ;(async function () {
-      const responseClone = response.clone()
-
-      sendToClient(
-        client,
-        {
-          type: 'RESPONSE',
-          payload: {
-            requestId,
-            isMockedResponse: IS_MOCKED_RESPONSE in response,
+    const serializedRequest = await serializeRequest(requestCloneForEvents)
+
+    // Clone the response so both the client and the library could consume it.
+    const responseClone = response.clone()
+
+    sendToClient(
+      client,
+      {
+        type: 'RESPONSE',
+        payload: {
+          isMockedResponse: IS_MOCKED_RESPONSE in response,
+          request: {
+            id: requestId,
+            ...serializedRequest,
+          },
+          response: {
             type: responseClone.type,
             status: responseClone.status,
             statusText: responseClone.statusText,
-            body: responseClone.body,
             headers: Object.fromEntries(responseClone.headers.entries()),
+            body: responseClone.body,
           },
         },
-        [responseClone.body],
-      )
-    })()
+      },
+      responseClone.body ? [serializedRequest.body, responseClone.body] : [],
+    )
   }
 
   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.
+/**
+ * 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.
+ * @param {FetchEvent} event
+ * @returns {Promise<Client | undefined>}
+ */
 async function resolveMainClient(event) {
   const client = await self.clients.get(event.clientId)
 
@@ -184,12 +198,16 @@ async function resolveMainClient(event) {
     })
 }
 
+/**
+ * @param {FetchEvent} event
+ * @param {Client | undefined} client
+ * @param {string} requestId
+ * @returns {Promise<Response>}
+ */
 async function getResponse(event, client, requestId) {
-  const { request } = event
-
   // Clone the request because it might've been already used
   // (i.e. its body has been read and sent to the client).
-  const requestClone = request.clone()
+  const requestClone = event.request.clone()
 
   function passthrough() {
     // Cast the request headers to a new Headers instance
@@ -230,29 +248,17 @@ async function getResponse(event, client, requestId) {
   }
 
   // Notify the client that a request has been intercepted.
-  const requestBuffer = await request.arrayBuffer()
+  const serializedRequest = await serializeRequest(event.request)
   const clientMessage = await sendToClient(
     client,
     {
       type: 'REQUEST',
       payload: {
         id: requestId,
-        url: request.url,
-        mode: request.mode,
-        method: request.method,
-        headers: Object.fromEntries(request.headers.entries()),
-        cache: request.cache,
-        credentials: request.credentials,
-        destination: request.destination,
-        integrity: request.integrity,
-        redirect: request.redirect,
-        referrer: request.referrer,
-        referrerPolicy: request.referrerPolicy,
-        body: requestBuffer,
-        keepalive: request.keepalive,
+        ...serializedRequest,
       },
     },
-    [requestBuffer],
+    [serializedRequest.body],
   )
 
   switch (clientMessage.type) {
@@ -268,6 +274,12 @@ async function getResponse(event, client, requestId) {
   return passthrough()
 }
 
+/**
+ * @param {Client} client
+ * @param {any} message
+ * @param {Array<Transferable>} transferrables
+ * @returns {Promise<any>}
+ */
 function sendToClient(client, message, transferrables = []) {
   return new Promise((resolve, reject) => {
     const channel = new MessageChannel()
@@ -280,14 +292,18 @@ function sendToClient(client, message, transferrables = []) {
       resolve(event.data)
     }
 
-    client.postMessage(
-      message,
-      [channel.port2].concat(transferrables.filter(Boolean)),
-    )
+    client.postMessage(message, [
+      channel.port2,
+      ...transferrables.filter(Boolean),
+    ])
   })
 }
 
-async function respondWithMock(response) {
+/**
+ * @param {Response} response
+ * @returns {Response}
+ */
+function respondWithMock(response) {
   // Setting response status code to 0 is a no-op.
   // However, when responding with a "Response.error()", the produced Response
   // instance will have status code set to 0. Since it's not possible to create
@@ -305,3 +321,24 @@ async function respondWithMock(response) {
 
   return mockedResponse
 }
+
+/**
+ * @param {Request} request
+ */
+async function serializeRequest(request) {
+  return {
+    url: request.url,
+    mode: request.mode,
+    method: request.method,
+    headers: Object.fromEntries(request.headers.entries()),
+    cache: request.cache,
+    credentials: request.credentials,
+    destination: request.destination,
+    integrity: request.integrity,
+    redirect: request.redirect,
+    referrer: request.referrer,
+    referrerPolicy: request.referrerPolicy,
+    body: await request.arrayBuffer(),
+    keepalive: request.keepalive,
+  }
+}
diff --git a/src/tsconfig.worker.json b/src/tsconfig.worker.json
new file mode 100644
index 00000000..26b178eb
--- /dev/null
+++ b/src/tsconfig.worker.json
@@ -0,0 +1,13 @@
+{
+  "include": ["./mockServiceWorker.js"],
+  "compilerOptions": {
+    "strict": true,
+    "allowJs": true,
+    "checkJs": true,
+    "noEmit": true,
+    "target": "esnext",
+    "module": "esnext",
+    "lib": ["esnext"],
+    "types": ["@types/serviceworker"]
+  }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 57a15aab..f7ea7d4b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,6 +8,9 @@
     {
       "path": "./src/tsconfig.node.json"
     },
+    {
+      "path": "./src/tsconfig.worker.json"
+    },
 
     // Tests.
     {


Test patch:
diff --git a/test/node/msw-api/setup-server/resetHandlers.node.test.ts b/test/node/msw-api/setup-server/resetHandlers.node.test.ts
index 8ec9593e..e0f0446d 100644
--- a/test/node/msw-api/setup-server/resetHandlers.node.test.ts
+++ b/test/node/msw-api/setup-server/resetHandlers.node.test.ts
@@ -57,8 +57,8 @@ test('replaces all handlers with the explicit next runtime handlers upon reset',
     }),
   )
 
-  // Once reset with explicit next requets handlers,
-  // replaces all present requets handlers with those.
+  // Once reset with explicit next request handlers,
+  // replaces all present request handlers with those.
   server.resetHandlers(
     http.get('https://test.mswjs.io/products', () => {
       return HttpResponse.json([1, 2, 3])
diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts
index 135d641a..fa5aedd5 100644
--- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts
+++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts
@@ -107,8 +107,12 @@ test('does not error on request which handler implicitly returns no mocked respo
   expect(console.error).not.toHaveBeenCalled()
 })
 
-test('ignores common static assets when using the "error" strategy', async () => {
-  await fetch('https://example.com/styles/main.css')
-
-  expect(console.error).not.toHaveBeenCalled()
-})
+test(
+  'ignores common static assets when using the "error" strategy',
+  { timeout: 8000 },
+  async () => {
+    await fetch('https://example.com/styles/main.css')
+
+    expect(console.error).not.toHaveBeenCalled()
+  },
+)