Developing Chrome Extensions: a Cautionary Tale

ยท

6 min read

If you're a React dev thinking of developing a chrome extension, this is for you.

A Chrome extension is basically a webpage. However, it comes with a bunch of extra restrictions.

The extension I made was a cryptocurrency wallet. It has login functionality, as well as send and swap functionality. In this article, I'm going to show you a few roadblocks I ran into developing it, and how I got around them.

n.b I used Manifest V3, so this guide is tailored towards that.

Persistence

Storage

The biggest roadblock of a Chrome extension is that opening it creates a new instance of the extension app, and clicking out destroys that instance. This means figuring out how to make its state persistent so that it can at least remember input you put into it before.

A Chrome extension has no access to the browser local storage API. However, it can access chrome.storage in two flavours: local and sync, the latter being something users can access on any chrome browser they sign in to.

To use storage, you need add this to the permissions property in your manifest:

  "permissions": ["storage"]

chrome.storage is async, so be sure to account for it.

Here are two helper functions that can store and retrieve things from sync storage:

export function setChromeStorage(keyName: string, payload: any) {
  chrome.storage.sync.set({ [keyName]: payload });
}

export async function getChromeStorage(keyName: string) {
  return new Promise((resolve, reject) => {
    try {
        chrome.storage.sync.get(keyName, function (value) {
            resolve(value[keyName]);
        })
    }
    catch (ex) {
        reject(ex);
    }
});
}

Background processes

Another tool in your ability to manage persistent data is the background process. In Manifest 3, you can have one background process that can run whilst the extension is closed.

You can rely on the background process' memory rather than using storage. I had a system that used both runtime memory as well as storage as a fallback. You might set up your background process to do the bulk of the programming work for some tasks, but be warned, having a complex background process file is not easy. As of yet, I was unable to get mine to accept imported files, and therefore I can't use any libraries with it.

Communicating with the background process is a little bit awkward. The background process can receive messages from the main app, and even websites, but it doesn't emit messages itself.

In the app

 chrome.runtime.sendMessage(
        { message: "EXT_FETCH_DATA" },
        function (response) {
          resolve(response.payload);
        }
      )

And in the background process:

chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  if (request.message === "EXT_FETCH_DATA") {
    if (data) {
      sendResponse({
        success: true,
        message: "BKG_PROVIDE DATA,
        payload: data,
        extra: "From runtime",
      });
    }
  })
}

I think the more you can get your background process to do, the better, but this may not be straightforward.

The main extension app can be a React app, complete with package.json, and therefore NPM modules, but without some serious webpack trickery, you'll struggle to have that package.json provide node modules to the background file.

For that reason, it might be better to set up a separate project for your background process, build this as a single file, and place this in the public folder before building the main app.

With that said, even without that, it is definitely possible for your background.js file to import other files:

In the manifest:

  "background": {
    "service_worker": "background.js",
    "type": "module"
  },

And then, when importing:

import { myFunc } from "./myModules/filename.mjs";

N.B I'm not sure if naming the file .mjs is required, but you must include the extension.

Communicating with a web page

In my extension, logging in happens on a separate web page. This means that I can avoid some of the extension limitations. How can you pass data from a web page to the extension?

Firstly, you need to specify your app as externally connectable in the manifest:

  "externally_connectable": {
    "ids": ["*"],
    "matches": ["http://localhost:3333/*"]
  },

Only these pages will be able to send messages.

In the background process:

chrome.runtime.onMessageExternal.addListener(async function (
  request,
  _,
  sendResponse
) {
  handleExternalMessages(request, sendResponse);
});

This will allow the background process to receive messages from the web page. The background process can respond to messages, but can't initiate the message.

In the web page:

    chrome.runtime.sendMessage(
      extensionID,
      { message: extMsg.WEBAPP_SEND_LOGIN, payload: loginPayload },
      (response: any) => {
        if (response.success) {
          console.log("Extension is now logged in");
        } 
      }
    );

Notice: your web page will need to know the extension's ID. You might set this in ENV variables. You could also pass this over in a url query - but potentially risky.

You're able to send over a message, meaning you can have different types of messages for different behaviour. payload is a variable I added myself. You might use a variable like payload or data to pass over the data you need.

Note again: the background process can't initiate these messages, so if you need data to go from the background to the web app:

  • Pass it as a url query parameter
  • Use chrome.storage as a common data store
  • Refactor to allow the web page to initiate somehow.

Sending messages between the background and the extension

These are separate apps! Therefore, they don't share their data by default. However, you have two main options for sharing data from background to extension.

  • Use chrome.storage as a common data store
  • Use the internal messaging system

The Chrome API also has a method for messaging within the extension:

In the background:

chrome.runtime.onMessage.addListener(function (request, _, sendResponse) {
  handleExtensionMessage(request, sendResponse);
});

Since these messages are internal, you don't need to verify the extension ID.

In the extension:

chrome.runtime.sendMessage(
        { message: extMsg.EXT_LOGIN_REQUEST },
        function (response) {
          resolve(response.payload);
        }
      )

Notice the same message property, like the externalMessage method. You could also add a data property if you needed it.

Opening the extension into a tab

The main limitation with an extension is: when you click out of the extension, the app process is destroyed.

Is there a way to hold the extension popup open? No.

But careful inspection of the popup with the developer tools will reveal that the popup has its own url, one beginning with chrome-extension://your-extension-id-string (Check how the popup can stay open when the dev tools are open...)

You can navigate to that address in a normal browser tab. The Chrome API includes methods for opening urls in the browser, too:

      chrome.tabs.create({url: chrome.runtime.getURL("index.html")});

(where 'index.html' is the name of your app's main html file.) ((It's probably 'index.html' if you're using react))

Now... One issue that might arise is that your extension needs to know if it's running in a popup or in a tab. I never found anything in the Chrome API nor the javascript window object that reliably identifies whether or not the web 'page' that's open is an extension or in a tab.

My solution to this was to pass a query param into the new tab's address:

      chrome.tabs.create({
        url: chrome.runtime.getURL(
          "index.html#" + page + "?tab=true"
        ),
      });

And then setup a useEffect in the app, that would register that query and set a property in global state: isRunningInTab=true. The chrome.tabs.create function would then only be called if that property was false!

The End

Ultimately, every issue and solution I outlined here is an attempt to overcome a problem caused by the fact that the extension app is destroyed when you click outside of it!

I hope these ideas will help you overcome this limitation!

ย