DEV Community

Jennie
Jennie

Posted on

Mock API in a Chrome extension

Mock is critical in the development nowadays. Web developer like us often use tools like Mockon, Node server, API platform, Service Worker(such as msw), 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 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:

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 sendMessagestorage. 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:

// 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))
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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({}));
Enter fullscreen mode Exit fullscreen mode

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, which has significantly simplified this process. Here is the code with ajax-hook:

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)
  },
})
Enter fullscreen mode Exit fullscreen mode

Injecting the hijacking code

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

intercept.js
content_script.js
manifest.json
Enter fullscreen mode Exit fullscreen mode

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

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))
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

const interceptScript = document.createElement('script')
interceptScript.src = chrome.runtime.getURL('intercept.js')
document.head.prepend(interceptScript)
Enter fullscreen mode Exit fullscreen mode

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

{
  "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"]
}
Enter fullscreen mode Exit fullscreen mode
  • 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:

chrome.storage.local.set({ key: value }, callback)
Enter fullscreen mode Exit fullscreen mode

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:

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"}'
       }
     }
  */
});
Enter fullscreen mode Exit fullscreen mode

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:

chrome.runtime.sendMessage(
  EXTENSION_ID,
  message,
  options,
  (response) => {
    // Handling response if there is any
  }
)
Enter fullscreen mode Exit fullscreen mode

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

chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  // Handling the message
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

+ 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)
Enter fullscreen mode Exit fullscreen mode

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

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))
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

{
  "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"]
}

Enter fullscreen mode Exit fullscreen mode

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

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);
    }); 
   }
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)