Writing a browser extension

Posted on 2025-02-16 in Programmation

I recently wrote a browser extension for Firefox and Chromium based browsers for my Legadilo (RSS feeds aggregator and articles saver) project. The goal is to make it easier and faster to save an article or to subscribe to a feed directly on a web page without forcing you to open the app. I never wrote a browser extension before and it didn’t go as I thought it would. So I’d like to share a bit of my experience.

At high level, an extension is just a bunch of HTML, JS and CSS files zipped together with a manifest.json file and some icons. I rely on Mozilla’s web-ext tool to do the packaging for me. The manifest.json is there to:

  • Define where the icons are.
  • Define what permissions the extension will need
  • The version (v2 or v3) of the extension.
  • Link your files to what the extension needs:
    • Point to the HTML file for options.
    • Point to the HTML file for each action you will define.
    • Point to the background script or service worker to use to handle network requests.

The other JS files are loaded directly in their respective HTML file. I load them with <script src="action.js" type="module"></script> to be able to use new style imports in them without transpilling.

To keep things simple, I decided not to use a framework and to code interactions myself. So I have all "screens" directly in the same HTML hidden by default with display: none. My JS file then hides/displays what is needed and fills the required content by updating and inserting relevant DOM nodes. Like in the old days. It works fine for me since my extension remains very simple, but I think that for more complex use cases, a lightweight framework like React, Vue or Svelte would come in handy. I also wander if you could use web components (eventually with a library like LitElement) to keeps things small and simple and rely on the new features of the browser instead.

I took me some time to figure out how to wire everything together. I didn’t find the doc that great and ended up mostly relying on existing extension to understand how to do what I wanted to do. With hours of tries and errors.

I developed my extension of Firefox because I prefer their dev tools (just like for normal web development). The most frustrating part was understanding how the dev tools work to have proper debugging and logging. On Firefox, it happens on a dedicated page debug: and then opening the proper dev tools. Sometimes, I had to manually refresh the extension in there, and sometimes it was automatic. With more experience, it may become more obvious when a manual action is needed.

Once I got everything working in Firefox, I tried the extension on Chromium. And it didn’t work. I decided to use manifest v3 right away: for this extension the new restrictions don’t apply and I thought both browsers support it correctly in the same way. It turns out, for the background script part (both how it runs and how it communicates with other JS scripts), Firefox still uses the manifest v2 way while Chromium is on the v3 way. I think it’s to still allow network based ad blockers to work with Firefox. This introduced lots of small differences even for my tiny extension.

What I ended up doing: instead of trying to solve them at once, I copied the FF extension and ported it to "full" v3 so it would work correctly on Chrome. I then diffed the two implementations and worked on merging them. It took some time, but I managed to do that by introducing glue functions:

  • In the background.js script, both browsers don’t subscribe to events the same way. Symmetrically, they don’t send responses the same way.

    // Check whether we are on FF.
    const isFirefox = typeof browser === "object";
    
    if (isFirefox) {
        browser.runtime.onConnect.addListener(function (port) {
            port.onMessage.addListener(
                (request) => onMessage(request, port.postMessage)
            );
        });
    } else {
        chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
            onMessage(request, sendResponse);
    
            // This is required to correctly get the response on the other side!
            // I lost lots of time because I forgot it because it seems so useless!
            return true;
        });
    }
    
    const onMessage = async (request, sendResponse) => {
        // request is on the format you need. Same for response.
        // I choose a very simple { name: "", payload {} } structure.
        try {
            switch (request.name) {
                case "save-article":
                    await processSaveArticleRequest(sendResponse, request.payload);
                    break;
                default:
                    console.warn(`Unknown action ${request.name}`);
                    break;
            }
        } catch (err) {
            sendResponse({ error: err.message });
        }
    };
    
  • On the action.js end, I must connect to the port on FF but not on Chromium. This gives a little wrapper like this:

    // Only used on FF.
    let port;
    const isFirefox = typeof browser === "object";
    
    document.addEventListener("DOMContentLoaded", async () => {
        connectToPort();
        // Do extension related stuff
    });
    
    const connectToPort = () => {
        // Chromium based browsers will receive the response directly
        // from the function call sending the message.
        if (!isFirefox) {
            return;
        }
    
        port = chrome.runtime.connect({ name: "legadilo-popup" });
        port.onMessage.addListener(onMessage);
    };
    
  • And then to send messages to the background script (still in action.js):

    const doStuff = () => {
    sendMessage({
        name: "update-feed",
            payload: {
                feedId: 1,
            },
        });
    }
    
    const sendMessage = async (message) => {
        if (isFirefox) {
            // On response will be called as part of the port behavior.
            port.postMessage(message);
            return;
        }
    
        // Manually call onMessage to process the response in the same way
        // on all browsers.
        const response = await chrome.runtime.sendMessage(message);
        onMessage(response);
    };
    
    const onMessage = (request) => {
        if (request.error) {
            displayErrorMessage(request.error);
            return;
        }
    
        hideErrorMessage();
        hideLoader();
        switch (request.name) {
            case "saved-article":
                savedArticleSuccess(request.article, request.tags);
                break;
            default:
                console.warn(`Unknown action ${request.name}`);
        }
    };
    

Once I had everything working on both browsers, the time to publish this extension on the web stores came:

  • On Firefox it was really easy: a small form to fill with the extension attached to it. An automated review later, the extension was online.
  • For Chrome, it took much longer:
    • First, you must pay a small fee (to protect against spam I suppose).
    • Then I had to fill a form, which took much longer than for Firefox: I needed extra icons, screenshots and I had to give much more details mostly about data collection and a privacy. I couldn’t help but ask myself: "like Google cared about privacy and data collection". That’s the only reason I have an almost empty privacy page on the app.
    • Only then and after one rejection because I was missing some infos, I managed to publish the extension.

Now my extension is online and I use it. I still have works to do to improve it, but the base is here and useful. I’m glad of the UX it gives. I didn’t expect cross browser compatibility to be that hard. I guess it comes from the fact we are still in the transition to manifest v3. I also wasn’t ready for how much infos the Chrome webstore asks to publish even this basic extension. On the bright side, it forced me to write a simple privacy policy that states I don’t collect nor sell anything (so you know that as well).

Web