Mock API in a Chrome extension
Tags: chrome, extension, mock, debug
Published: 2021-05-01 04:10:39



Mock is critical in the development nowadays. Web developer like us often use tools like [Mockon](https://mockoon.com/), Node server, API platform, Service Worker(such as [msw](https://mswjs.io/)), etc. 

In fact, there is another way maybe more productive for a web developer - browser extension, as we can avoid switching between editor and browser frequently. 
And the basic idea could be concluded in single sentence: hijacking the API requests from the page, and if the request matches our requirement (e.g. URL exact match), response with mock data.

Initially, I thought it would be fairly easy with the support of Chrome APIs. However, I only found that Chrome team gradually banned most of the possibilities for protecting users. For instance, [webRequest](https://developer.chrome.com/docs/extensions/reference/webRequest/) is only able to block or proxy the request now. As a result, we have to try a way farther: inject a piece of code to proxy the request APIs like `fetch` or `XHR`.

Injecting code sounds like a dangerous move. Why shall we do this? Let us take a look at Chrome extension's architecture design:

![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ee1199c7c6c94797aa81c2d79f345999~tplv-k3u1fbpfcp-zoom-1.image)

Here are 6 parts in an extension we may need to consider: 
1. The opening pages (could be any tab, any window, but usually we only work with the active tab);
2. Content script running on each page;
3. Popup page opening from the extension icon on the top right of Chrome;
4. Dev tool panel;
5. Background script;
6. Extension option page.

Each of them are running independently in their own black box. To communicate with each other, they have to send message with the help of APIs like [sendMessage](https://developer.chrome.com/docs/extensions/mv3/messaging/)，[storage](https://developer.chrome.com/docs/extensions/reference/storage/). If we would like to obtain or operate content on the web page, content script is the right one to help.

Content script is the only part in the extension could retrieve page `document`, and only document. Although the `window` object exists in the content script, it is not the same one in the web page. Hence, to get, proxy the globals on the page we will require to sneak in a little piece of script.

## Hijacking fetch

To hijack a fetch request, we just need to swap it like this:
```javascript
// Save the original fetch
const f = window.fetch

// Replace with our own fetch
window.fetch = (req, config?) => {
  // Write a hijack method to help checking whether mock exists, and return a Response object with the mock data
  return hijack(req, config)
    // If the mock does not exist, throw it and hand it back to the original fetch
    .catch(() => f(req, config))
}
```

## Hijacking XHR

If the code were compiled into ES5, then we will require to consider XHR as well. Comparing to fetch, it is kind of troublesome.

Here is how to use XHR to request an API:
```javascript
// GET request
var get = new XMLHttpRequest();
get.open('GET', '/api', true);

get.onload = function () {
  // Request finished
};
get.send(null);

// POST request
var post = new XMLHttpRequest();
post.open("POST", '/api', true);
post.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
post.onreadystatechange = function() {
    if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
        // Request finished
    }
}
post.send(JSON.stringify({}));
```

The example shows us that initialising, send a request and handling the response are using different methods. And we have to take care of each of them in case of problems.

Luckily, I found an open-source package called [ajax-hook](https://github.com/wendux/Ajax-hook), which has significantly simplified this process. Here is the code with [ajax-hook](https://github.com/wendux/Ajax-hook):

```javascript
import { proxy } from 'ajax-hook'
proxy({
  onRequest: (config, handler) =>
    hijack(config)
      .then(({ response }) => {
        // If mock is found, let handler to continue processing
        return handler.resolve({
          config,
          status: 200,
          headers: [],
          response,
        })
      })
  		// If mock is not found, handler.next will hand it back to the original XHR handler
      .catch(() => handler.next(config)),
  // Somehow the onResponse hook is necessary to prevent an exception, this shall be a bug
  onResponse: (response, handler) => {
    handler.resolve(response)
  },
})
```

## Injecting the hijacking code

To inject the code, we will require at least 3 files:

```
intercept.js
content_script.js
manifest.json
```

Firstly, let us re-organize the hijacking code a bit and put into `intercept.js`:

```javascript
import { proxy } from 'ajax-hook'

function hijack(url, { method }) {
  return new Promise((resolve, reject) => {
    // We will replace this part later
    console.log(`Hijacking ${method} ${url}`)
    reject();
  })
}

proxy({
  onRequest: (config, handler) =>
    hijack(config.url, config)
      .then(({ response }) => {
        return handler.resolve({
          config,
          status: 200,
          headers: [],
          response,
        })
      })
      .catch(() => handler.next(config)),
  onResponse: (response, handler) => {
    handler.resolve(response)
  },
})

if (window.fetch) {
  const f = window.fetch
  window.fetch = (req, config?) => {
    return hijack(req, config)
      .then(({ response }) => {
        return new Response(response, {
          headers: new Headers([]),
          status: 200,
        })
      })
      .catch(() => f(req, config))
  }
}
```

**NOTE that since we have imported a 3rd party package, a bundle tool may be required here.**

Secondly, instructing Chrome to inject the script in the `content_script.js`:

```javascript
const interceptScript = document.createElement('script')
interceptScript.src = chrome.runtime.getURL('intercept.js')
document.head.prepend(interceptScript)
```

Lastly, we must remember to update the `manifest.json` with the right permissions：

```javascript
{
  "name": "My Chrome Extension",
  "description": "",
  "manifest_version": 2,
  "version": "1.0.0",
  "permissions": [],
  "content_scripts": [
    {
      "js": ["content_script.js"],
      "matches": ["*://*.example.com/*"],
      "all_frames": true
    }
  ],
  "content_security_policy": "script-src 'self' https://*.example.com/*; object-src 'self'",
  "web_accessible_resources": ["intercept.js"]
}
```

- `content_scripts` and `content_security_policy` ensure our `content_script.js` could run in the pages under the specified domain both http and https.
- In the meantime, `web_accessible_resources` to allow the web page access our `intercept.js`.

## Adding mock data

Now we need a UI to setup the mock data. The UI could be placed into either the popup page, the dev panel or an independent page. It is up to you, but I would like to skip this part for now and assume we will save the data into the Chrome storage with following API:

```javascript
chrome.storage.local.set({ key: value }, callback)
```

Chrome provides several types of storage. Mostly, we use `local` and `sync`. `local` works like `localStorage`, while `sync` will auto-sync the data with Google account. We shall prefer local for most of the time as `local` provides larger capacity and suit for most of the cases.

Suppose that we store the mock into key `mock`, and each mock contains the full URL and method to match with the request, as well as the mock response data:

```javascript
chrome.storage.local.get(['mock'], function(result) {
  console.log(result.mock);
  /* Prints：
     {
       'GET http://www.example.com/api/test': {
          response: '{"msg":"This is a mock"}'
       }
     }
  */
});
```

To retrieve these data from the injected code in the web page, we shall borrow the hands of Chrome's messaging system as mentioned above.

Here is how to send a message from a web page to an extension:

```javascript
chrome.runtime.sendMessage(
  EXTENSION_ID,
  message,
  options,
  (response) => {
    // Handling response if there is any
  }
)
```

And the extension can receive the message from web page in this way:

```JavaScript
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  // Handling the message
})
```

Here is an essential parameter `EXTENSION_ID`. If an extension has been published into the Chrome Web Store, it can be found in the extension URL. For example like the Google Translate extension, the last part of this URL is the ID of it:

```
https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb
```

If an extension is installed in our browser, then we could find the ID by entering `chrome://extensions` in the address bar. It is placed under the icon of each extension.

Soon, careful dev will find that the extension ID changes every time when we reload the extension if it is installed from the unpacked source in the development mode. In this case, we may ask the API `chrome.runtime.id` for help. 

We can inject a global and read it from the `intercept.js`. Modify our `content_script.js` like this：

```diff
+ const extensionGlobals = document.createElement('script')
+ extensionGlobals.innerText = `window.__EXTENTION_ID__ = "${chrome.runtime.id}";`
+ document.head.prepend(extensionGlobals)
const interceptScript = document.createElement('script')
interceptScript.src = chrome.runtime.getURL('intercept.js')
document.head.prepend(interceptScript)
```

And `intercept.js` now could send message to our extension with following code：

```diff
import { proxy } from 'ajax-hook'

function hijack(url, { method }) {
  return new Promise((resolve, reject) => {
    console.log(`Hijacking ${method} ${url}`)
    
+    chrome.runtime.sendMessage(
+      window.__EXTENSION_ID__,
+      {
+        type: 'request_mock',
+        url,
+        method
+      },
+      {},
+      (response) => {
+        if (response) resolve(response);
+        else reject();
+      }
    )
  })
}

proxy({
  onRequest: (config, handler) =>
    hijack(config.url, config)
      .then(({ response }) => {
        return handler.resolve({
          config,
          status: 200,
          headers: [],
          response,
        })
      })
      .catch(() => handler.next(config)),
  onResponse: (response, handler) => {
    handler.resolve(response)
  },
})

if (window.fetch) {
  const f = window.fetch
  window.fetch = (req, config?) => {
    return hijack(req, config)
      .then(({ response }) => {
        return new Response(response, {
          headers: new Headers([]),
          status: 200,
        })
      })
      .catch(() => f(req, config))
  }
}
```

Handling message shall be a job of the background script as it initialises and runs since the browser starts, while the others like popup and dev tool panels only initialises when user open them.

Turn on the background script in `manifest.json`, and specifies the message we would like to receive from:
```diff
{
  "name": "My Chrome Extension",
  "description": "",
  "manifest_version": 2,
  "version": "1.0.0",
  "permissions": [
+    "background",
+    "*://*.example.com/*"
  ],
+  "background": {
+    "scripts": ["background_script.js"],
+    "persistent": true
+  },
+  "externally_connectable": {
+    "matches": [
+      "*://*.example.com/*"
+    ]
+  },
  "content_scripts": [
    {
      "js": ["content_script.js"],
      "matches": ["*://*.example.com/*"],
      "all_frames": true
    }
  ],
  "content_security_policy": "script-src 'self' https://*.example.com/*; object-src 'self'",
  "web_accessible_resources": ["intercept.js"]
}

```

Create our `background_script.js` and handle our `request_mock` message accordingly:

```javascript
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
   if (message && message.type === 'request_mock') {
    const { method, url } = message;
    chrome.storage.local.get(['mock'], function({ mock }) {
      const matchedMock= mock[`${method.toUpperCase()} ${url}`];
			sendResponse(matchedMock);
    });	
   }
});
```

Congratulations! You have roughly made a mock extension! Now you can enjoy the seamless mocking experience except some flaws.

## The flaws of injecting code and hijacking

On the one hand, injecting code and hijacking requests are very powerful. It allows us to retrieve most of the information we require. On the other hand, it has a deadly flaw which is depending on the web page DOM. It means that we have to wait for the DOM loaded before injecting and surely would miss the requests fired before we can inject.

Well, I haven't found any perfect solution yet. 

A possible solution to cover all the requests is adding an external mock server and proxy the requests to it, which may also bring in new challenges.