Andrey Luiz Software Engineer

A word about Chrome Extension architecture

Some thoughts on a recent project.

Hello again. I’ve been playing with a Chrome Extension for a while now, and I wanted to share some thoughts about it, and some ideas of how to architect it.

How a Chrome Extension works

Chrome extensions are pretty simple. You have a popup (that window that appears when you click on the extension icon), and you have a background script. The background script is the one that does the heavy lifting. It can be a simple script that runs in the background, or it can be a service worker. The service worker is a script that runs in the background, but it can also intercept network requests and modify them. It can also cache requests and responses, and it can also send push notifications. Extensions can also have a configuration page, which is a simple HTML page that can be used to configure the extension.

The architecture of the extension

My extension leverages these three elements. The popup shows the main interface of the extension, and where the users interact the most. The configuration page is only used to configure where the API calls should go to. The background script does all the API calls, caching, and processing for the extension.

Sending messages

When the user interacts with something, for example, making a login, the popup has rely on API call to our Strapi server to authenticate the user. I could have done this in the popup itself. It has all the fetch aparatus that a regular browser has. But I decided to leave this only to the background script. Instead, the popup sends a message to the background script.

For example, here my login function in the popup:

const handleSubmit = useCallback(
  async (e: SyntheticEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const { data, error } = await chrome.runtime.sendMessage<
      ChromeMessage<LoginInfo>,
      ChromeResponse<boolean>
    >({
      type: "authenticate",
      payload: {
        identifier: formData.get("email").toString(),
        password: formData.get("password").toString(),
      },
    });
    if (data) {
      onLoginSuccess();
    } else if (error) {
      setError(true);
    }
  },
  [onLoginSuccess]
);

All this ChromeMessage, and ChromeResponse typings is on me. I decided to setup the types in a way where I always know which type I’m dealing with.

So, I pass a type and a payload to the background script. The payload can contain anything that’s serializable. The other calls on the extension all follow this pattern.

Receiving messages

The background script, then, receives the message, and does the API call. Here’s the code for the login:

async function handleAuthenticate(
  request: ChromeMessage<{ identifier: string; password: string }>
) {
  try {
    const result = await api.authenticate(
      request.payload.identifier,
      request.payload.password
    );
    return getFormattedData(result, null);
  } catch (e) {
    getFormattedData(null, e);
  }
}

chrome.runtime.onMessage.addListener((request, _, sendResponse) => {
  try {
    (async () => {
      switch (request.type) {
        case "authenticate": {
          sendResponse(await handleAuthenticate(request));
          break;
        }
      }
    })();
  } catch (e) {
    console.error("error", e);
  }

  return true;
});

Some important aspects here:

  • The chrome.runtime.onMessage.addListener is the listener for the messages sent by the popup;
  • The sendResponse callback sends data back to the popup;
  • The return true is important. It tells Chrome that the listener is asynchronous, and that it should wait for the response. If you don’t return true, the popup will not receive the response;
  • The getFormattedData is a function that I created to format the response. It’s just a helper function;
  • The api.authenticate is a function that I created to do the API call. Think here as whatever method you use to do API calls.

Caching

The background script also caches stuff. Like JWT tokens, and some user preferences as well. I use the chrome.storage API to do that. The way I do it is exactly the same as the API calls. I send a message to the background script, and it does the caching. Or I call the background script to get something that is cached.

Important considerations

  • Make sure you have one, and one only, chrome.runtime.onMessage.addListener. If you have more than one, things go wild and your callbacks start to behave in unexpected ways;
  • Make sure you return true in your listener if you are doing async calls. If you don’t, the popup will not receive the response;
  • Make sure you have a try/catch in your listener. If you don’t, and an error happens, the popup will not receive the response;

Conclusion

I’m learning a lot with this extension. I might post more stuff here as the project goes by.

Cheers.