Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

kittens-everywhere – how to build a browser extension

I recently had an honor to teach a class on the basics of how to build a browser extension for Firefox. This is an article form of that class (with a few added pieces that we didn't have time to cover).

The goals of the class

I had a few goals in mind when going into the class:

  1. I wanted to inspire students to think about what browser extensions are and what they could build with them. The imagination (and some technical constraints) are the limit for what you can do with them and getting new people into thinking about what they could build can make a big difference.
  2. To see how the extensions are made. I decided to go with a "let's build one and see what happens" route rather than a "let's go step-by-step and learn every step completely before moving on". Hence, the content and the example extension were not exhaustive in their depth but rather a taste of what the process looks like.

With these goals in mind, let's start with a quick intro and then jump into the code.

Prerequisites

To be able to fully follow along this tutorial, basic level of Javascript knowledge is expected, as well as familiarity with developer tools like code editor, terminal (basically how to open, navigate to folder and run commands) and browser.

If you know frontend Javascript but are not familiar with terminal use, Josh W. Comeau has a great guide for you.

If you're new to Javascript and web development, I can recommend Brian Holt's great Complete Intro to Web Development in Frontend Masters.

What are browser extensions?

Extensions are fantastic things. They let the developers extended the functionality of the browser and the functionality of websites and web apps. Without browser extension capabilities, the users would be left with what the browser vendors and website/app creators decide for them.

By building extensions, individual developers can add or remove functionality, hide unwanted or distracting content, make browsing more private and secure and make life a bit more fun – just to name a few vague example use cases.

Extension examples by use case

Let's take a look at a few different categories of extensions that are available.

Adblockers

One of the most popular categories for extensions are adblockers. Extensions like uBlock Origin, Adblock for Firefox and Ghostery give power back to the users to interact with the web in a nicer, more performant and more private way. Not only can they block and hide ads from the website (making many sites usable in the first place) but they can help with blocking third party tracking. It's no wonder people love these extensions – I for sure can't see myself using the Internet without them.

Password managers

Another category of extensions lets their users worry less about passwords. Password managers like Bitwarden, 1Password and LastPass generate new passwords when you sign up for new services, stores them securely and lets you autofill the login pages. You only need to remember the master password which helps you use stronger passwords on all the services you use.

Privacy

Privacy seems to be the battleground of the internet these days. Extensions like Facebook Container and Privacy Badger fight the good fight on users' behalf. Limiting what data gets sent from your browsing to trackers and companies is a fantastic feature.

Website enhancers/modifiers

So many sites, especially social media sites add a lot of stuff that aims to keep you engaged on the platform but can often become a distraction. And sometimes redesigns of websites can make users miss old features. Extensions like Minimal Theme for Twitter and Reddit Enhancement Suite are built to solve that exact problem.

Displaying data

Some extensions are just stand-alone data displays to make it easier for you to stay up to date with what's happening in your favorite thing. I have recently been enjoying Footy Mate that shows standings, results and schedules for English football leagues in a tidy extension pop-up with a nice and clean design.

User customization

Extensions like Greasemonkey and Stylus give the customization power to the users. Instead of predefining what should happen, Greasemonkey lets you write custom Javascript and Stylus custom CSS to run on websites.

I use Styles all the time: I use it to add movie titles as text to Netflix so I can CTRL+F search them or to hide signatures from forums where people tend to use large images or animations in them.

DevTools

Browsers come with a built-in Developer Tools panel that gives access to tools relevant to developers like DOM inspector, Network tab, Debugger and so on. And when the browser's generic tools are not enough, developers can build more. React Dev Tools and Web Components Dev Tools are good examples of these – and there are more for different Javascript frameworks.

Adding functionality to websites

A quick shameless plug of my own most recent extension: Pokemon TCG Card Viewer adds the ability to hover over Pokemon TCG card codes (like BRS 120) on the website and see the image of the card instead of having to try to remember what card the code refers to.

Different user interfaces for extensions

Just like there are many categories of function for extensions, there are many ways the user can interact with them.

There's a great documentation page in MDN that lists different user interface options for extensions. Extensions can run on page load, when activated with toolbar button, through a toolbar popup, as a context menu item, as a sidebar panel or as a devtools panel. They can also provide users a way to set their preferences through options pages, send notifications and provide address bar suggestions. That's quite a lot of options.

Let's build kittens-everywhere

You can find the full code from github.com/hamatti/kittens-everywhere.

Kittens Everywhere is an example extension that when activated, replaces the images on a website with cute pictures of kittens – and down the line we'll add an option for cute pictures of dogs too because we're kind like that.

But before we get there, we need to learn a few basic concepts and tools first. And build a kind of "hello world" of extensions to double check that our setup works.

The tooling

Extensions are built with Javascript so you'll need a code editor. You also need a browser so you can test your extension in. We'll use Firefox.

Last tool that you want to have is web-ext. It's not exactly necessary but it makes your developer experience so much more enjoyable with building the extension, loading it to browsers and hot reloading. It can be installed with npm install -g web-ext and then ran with web-ext run in the folder of the extension.

Part 1: The very minimum required

Technically all a browser extension needs to be valid and able to load into a browser is a Manifest file with three mandatory fields: name, version and manifest_version:

{
  "name": "Kittens Everywhere",
  "version": "0.1.0",
  "manifest_version": 2
}
manifest.json

With this file, you can run web-ext run and the browser will happily load your extension. The extension doesn't do anything but it exists.

Manifest file is kind of a blueprint of your extension. It tells the browser basic information about the extension, which manifest version it's using (Firefox currently supports version 2 and will have version 3 coming soon; Chrome supports only version 3) and what assets and scripts it should use and where.

Let's add a few things to make it more functional.

{
  "name": "Kittens Everywhere",
  "version": "0.1.0",
  "manifest_version": 2,
  "icons": {
    "48": "kittens-everywhere-icon-48.png",
    "96": "kittens-everywhere-icon-96.png",
  },
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["kittens_everywhere.js"]
  }]
}
manifest.json

We added two things here: icons and content_scripts.

Icons define what images you want to use for icons: these are used on the toolbar, in listing of installed extensions and so on. The guidelines suggest submitting one for 48x48 pixels and another for 96x96 and the browser will then use the one that matches best to the need. You can pick up these icons from the repository as you build along – or make your own!

Content scripts are scripts that run in the context of a particular website. The matches property is a list of patterns that tell the browser where the extension should run. js property is a list of Javascript files that the extension will load as content scripts.

Now, let's write our first extension code to make sure everything works:

document.body.style = "border: 5px solid green";
kittens_everywhere.js

Now run web-ext run and a new Firefox browser window should open up with your extension loaded. To make sure it's loaded, you can head over to about:addons and click Extensions on the left. You should now see Kittens Everywhere as an enabled extension.

If you navigate to any other website, for example hamatti.org, you should now see a thick green border at the edge of the site. We have successfully built and loaded an extension that does something 🎉.

Part 2: Make it run when the icon is clicked

We don't want our extension to run on a page load: we only want to see kittens when the other content is too serious and boring. So we need to add a few things to make the extension clickable and running our code.

In manifest.json, let's add:

{
  "name": "Kittens Everywhere",
  "version": "0.1.0",
  "manifest_version": 2,
  "icons": {
    "48": "kittens-everywhere-icon-48.png",
    "96": "kittens-everywhere-icon-96.png"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["kittens_everywhere.js"]
    }
  ],
  "background": {
    "scripts": ["background.js"]
  },
  "browser_action": {
    "default_title": "Kittens Everywhere",
  }
}
manifest.json

We added two new properties, background and browser_action.

Background scripts are friends of content scripts that have access to all WebExtensions APIs but they cannot access the content of the website. We'll get back to that a bit later. Inside background, we give an array of Javascript files to scripts.

Browser action adds a button to the toolbar. Here, we only define the title which is displayed when hovering over the icon. From the documentation, you can find how to define different icons for different cases and how to add a popup page. We won't do any of those today to keep the code simpler to follow.

Another thing we need to do is add background.js to our extension:

browser.browserAction.onClicked.addListener(async function () {
  const tabs = await browser.tabs.query({ active: true, currentWindow: true });
  browser.tabs.sendMessage(tabs[0].id, { action: "kittenify" });
});
background.js

Here we do three things:

  1. We listen to when user clicks on the icon (with browserAction.onClicked) and add a function to run when that happens (with addListener).
  2. We query for the current tab.
  3. We send a message to that tab with a Javascript object { action: 'kittenify' }

The contents of the message are up to you as a developer. Any Javascript object works. I like to use property action to define what I want to be done with it but nothing requires you to use action specifically.

Messaging between background and content scripts

Both of our script types, content and background, have access to some things but not all. Mainly, the content scripts can access the content of the website they are run on but cannot access all WebExtension APIs. The background scripts have access to those but not the content.

So to get full access to everything, these two need to work together and we do that by passing messages from one to another.

One last thing before we run the new version: we need to tell the content script to listen to the message sent by background script and activate our code only when that happens:

function kittenify() {
  document.body.style = "border: 5px solid green";
}

browser.runtime.onMessage.addListener(function (message) {
  if (message.action === "kittenify") {
    kittenify();
  }
});
kittens_everywhere.js

We did two things here:

  1. We wrapped the original code into a function called kittenify
  2. We listen to messages from the background and when we get one with action === 'kittenify',  we run our function.

If you have kept web-ext run running, you should be able to reload the page, click the kitten button in the toolbar and see the border appear.

Part 3: We want them kittens already!

Okay, okay, I got you. Now we'll add the kittens!

We'll use the fantastic Placekitten.com service. It's designed  to be used as placeholder images when developing a website layout when you are lacking the real pictures.

To use it, we need to request a picture from http://placekitten.com/{width}/{height}. We can do that request in our background.js. To know what images to request for, we need to find all the images in the website with kittens_everywhere.js and send messages between the two.

Let's start from our content script:

function kittenify() {
  const images = document.querySelectorAll("img");
  images.forEach(async function (image) {
    const newImage = await browser.runtime.sendMessage({
      action: "fetch",
      size: {
        width: image.width,
        height: image.height,
      },
    });

    image.src = newImage.imageUrl;
  });
}

// rest of the code stays the same
kittens_everywhere.js

First we find all the img elements in the page with document.querySelectorAll and loop over each one of those.

For each image, we take its width and height and pass those to the background script so it knows what sizes we want. When we get the answer from the background, we replace the image source with our new URL.

Let's see how the counterpart in background.js looks like:

// all the other code stays the same

browser.runtime.onMessage.addListener(async function (message) {
  if (message.action === "fetch") {
    const { width, height } = message.size;

    const resp = await fetch(`https://placekitten.com/${width}/${height}`);
    const blob = await resp.blob();

    const imageObjectURL = URL.createObjectURL(blob);
    return Promise.resolve({ imageUrl: imageObjectURL });
  }
});
background.js

This time, our background script is the one listening to messages and when we receive one, we make a GET request to the placekitten.com URL with width and height parameters from our message. The API returns a blob that we convert into an ObjectURL with URL.createObjectURL.

We then return a Promise that resolves into an object that holds our imageObjectURL. The reason we must return a promise is that we are inside an async function. If you're not familiar them, I recommend reading the documentation for async/await, you'll use them a lot with extensions.

Now reload the page (and make sure web-ext run is still running; if not, restart it), navigate to a page with images (like hamatti.org/) and click on the icon.

Images on the page should now be replaced with cute pictures of kittens. We have now built a functional extension that does something great 🎉!

Part 4: Let's refactor and improve our code a bit

As we've reached a nice milestone, it's a good idea to step back a bit and look how we can improve our code.

First thing I like to do is extract the URL into a global constant in background.js:

// at the top of the file
const KITTEN_URL = "https://placekitten.com"

// ... rest of the code ...

const resp = await fetch(`${KITTEN_URL}/${width}/${height}`);
background.js

Next, I like to add a check so that we only replace images that are larger than some threshold. Often tiny images are used for icons or in-site graphics and it doesn't make sense for us to replace those. This happens in kittens_everywhere.js:

// at the top of the file
const MIN_IMAGE_DIMENSION = 150;

// ... rest of the code ...

// inside the forEach
if (image.width > MIN_IMAGE_DIMENSION && image.height > MIN_IMAGE_DIMENSION) {
  const newImage = await browser.runtime.sendMessage({
    action: "fetch",
    size: {
      width: image.width,
      height: image.height,
    },
  });

  image.src = newImage.imageUrl;
}
kittens_everywhere.js

Similar to the extraction to constant with the URL, I like to put these kind of numbers (also known as magic numbers) into constants and give them a descriptive name. Seeing two random 150 in the code always begs the question "Why?" later.

I don't know if 150 is the right size for the threshold. That's why while we develop the extension further, we are looking at if it seems like the right images are being replaced and if not, we can adjust this one number up or down until things look nice.

Finally at this stage, I wanna do a nice-to-have feature: if we click the icon again, I want us to bring back the old images. In kittens_everywhere.js where we originally had image.src = newImage.imageUrl;, we replace that with:

if (image.dataset.oldSrc) {
    image.src = image.dataset.oldSrc;
    delete image.dataset.oldSrc;
} else {
    image.dataset.oldSrc = image.src;
    image.src = newImage.imageUrl;
}
kittens_everywhere.js

What we do here is we store on the first run the old image source into a data attribute and if on subsequent runs that attribute exists, we use that as the image source and delete the data attribute.

You can test this out by making these changes, reloading the page and clicking the extension icon a couple of times. Pictures should first become kittens and then go back on the next click.

Debugging extensions

Things don't always go the way we want them to go when developing any software. We might make a typo, forget an async or await or forget to request for some permissions in our manifest.json file.

In those cases, we need to debug! For generic tips for debugging Javascript applications, I have written a Humane Guide to Debugging Web Apps that I recommend to read. But there are a couple of special cases for browser extensions.

Whenever I develop extensions, I keep two Firefox windows (of the instance opened by web-ext run) open side by side. One has a website open where I run the extension and for another, I open the debugging site. On Firefox, you can open that on about:debugging and selecting This Firefox, finding your extension and clicking Inspect.

This opens up a new developer tools instance that is connected to your background script. Now, if you do console.log in your background script, it will appear here. You'll also see some of the error messages in this and some in the developer tools console of the main website window.

Part 5: Hey Juhis, what if I like dogs more?

Our extension could be finished now: it does what it promises. But I want to extend it a bit more so I can show you how to build an options page where user can set their preferences for what kind of animals they want.

Let's modify the manifest.json to add an options page. Add this to the end of your JSON:

"options_ui": {
  "page": "options_page/options.html",
  "browser_style": true
}
manifest.json

Next, create a new folder called options_page and two new files inside: options.html and options.js. We'll build our UI for the preferences there.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Settings</title>

    <style>
        fieldset {
            margin-bottom: 1em;
        }
    </style>
</head>

<body>
    <p>
        Do you want to see cute pictures of kittens or doggos?
    </p>
    <form>
        <fieldset>
            <legend>Choose your animal</legend>
            <input type="radio" id="kittens" name="animalPreference" value="kittens" />
            <label for="kittens">Kittens</label>
            <br>
            <input type="radio" id="dogs" name="animalPreference" value="dogs" />
            <label for="dogs">Dogs</label>
        </fieldset>
        <button type="submit">Save preferences</button>

    </form>

    <script src="options.js"></script>
</body>

</html>
options.html

Our options.html is a HTML form with two options and a button. At the bottom, we load the options.js where we will add these two blocks:

async function restorePreferences() {
    let localStorage = await browser.storage.local.get(['animalPreference']);
    let preference = localStorage.animalPreference || "kittens" // We default to kittens
    if (preference === 'kittens') {
        document.querySelector('#kittens').checked = true
        document.querySelector('#dogs').checked = false
    } else {
        document.querySelector('#dogs').checked = true
        document.querySelector('#kittens').checked = false
    }
}

document.addEventListener('DOMContentLoaded', restorePreferences);
options.js

Our first part restores the preferences we save into storage.local with key animalPreference. We define it to be run when DOMContentLoaded event triggers, effectively meaning when the page has loaded and parsed.

function savePreferences(ev) {
    ev.preventDefault();

    const formElement = document.querySelector('form');
    const formData = new FormData(formElement);
    const key = 'animalPreference';
    const value = formData.get(key);
    browser.storage.local.set({
        [key]: value
    })
}

document.querySelector("form").addEventListener("submit", savePreferences);
options.js

The second part defines how we save the values into the storage. We run it when the form submits, we load the form into FormData, find our value for animalPreference and store that into the storage.

One last thing is required to make this functional:

We need to request for a permission.

Permissions

Not everything is available to the extensions to do as they wish. Some functionality is gated behind permissions so that the user knows the extension is about to do these things. Storage is one of the functionalities for that.

When you build your extension, it's recommended to only ask for the minimum permissions your extension needs to work. This builds trust with your users as they don't have to wonder why your extension needs all these permissions.

Let me explain that through an example that might be more familiar to you. If you want to install a clock app to your mobile phone and it asks access to your contacts, your text messages and your location, you might be (for a good reason) suspicious about the app – even if it doesn't actually use those for anything.

So don't ask for things you don't need.

To get this permission for our storage use, we need to add a new property to our manifest.json:

{
  "permissions": [
    "storage"
  ]
}
manifest.json

At this point, the full manifest should look like this:

{
    "name": "Kittens everywhere",
    "description": "Change images on a website into cute pictures of kitty cats - or doggos.",
    "version": "0.1.0",
    "manifest_version": 2,
    "icons": {
      "48": "kittens-everywhere-icon-48.png"
      "96": "kittens-everywhere-icon-96.png"
    },
    "content_scripts": [
        {
            "matches": [
                "<all_urls>"
            ],
            "js": [
                "kittens_everywhere.js"
            ]
        }
    ],
    "background": {
        "scripts": ["background.js"]
    },
    "browser_action": {
        "default_title": "Kittens everywhere"
    },
    "options_ui": {
        "page": "options_page/options.html",
        "browser_style": true
    },
    "permissions": [
        "storage"
    ]
}
manifest.json

If you right click your extension icon in the toolbar and select Manage Extension and then head over to Preferences, you should see a form where you can select the kittens or dogs.

Finally, we need to make the selection affect something. Let's head over to our background.js:

// top of the file
const DOGGO_URL = 'https://placedog.net';

// inside the `message.action === 'fetch'` if block
// replace the first line with
const { animal, size } = message;
const { width, height } = size;

let fetchURL = animal === "kittens" ? KITTEN_URL : DOGGO_URL;
fetchURL = `${fetchURL}/${width}/${height}`;

// replace the fetch call with
const resp = await fetch(`${fetchURL}`);
background.js

To pass the animal attribute from our storage to background, we need to adjust the kittens_everywhere.js. We wrap our existing code inside kittenify with:

 let localStorage = browser.storage.local.get(['animalPreference'])
 localStorage.then(function(res) {
     const animal = res.animalPreference || "kittens";

     // rest of the code starting with const images = ...

     // change the object we send in sendMessage to
     {
         action: 'fetch',
         animal,
         size: {
           width: img.width,
           height: img.height
         }
     }

     // rest of the function

 })
kittens_everywhere.js

Now the preference selection saved in the options page should reflect the animals you see when clicking the extension icon. If it's not working, check out the full code at https://github.com/Hamatti/kittens-everywhere-example/.

Wrap up

That's a wrap!

In this blog post, we learned what extensions are and why they are awesome and how to build a browser extension that replaces images with cute pictures of kittens and doggos. To achieve that, we learned how to communicate between the content and background scripts, how to debug when things go awry and how to set up a preferences page and store the settings.

If you want to learn more about developing browser extensions, check out these links:

And if you're looking to get involved with our great community, come to

  • Mozilla Discourse for Add-ons is a discussion platform where you can discuss extensions and themes with other developers, ask and answer questions and help each other out.
  • #addons:mozilla.org in Matrix is our chat room for extension developers. Same stuff as in Discourse but in real-time chat.
  • Mozilla Add-ons Community Blog is where we talk about new things, what we are doing with the community and showcase the great people involved in the community.
Syntax Error

Sign up for Syntax Error, a monthly newsletter that helps developers turn a stressful debugging situation into a joyful exploration.