DEV Community

Cover image for Creating a Browser Extension using SolidJS + WXT II
Michael Essiet
Michael Essiet

Posted on

Creating a Browser Extension using SolidJS + WXT II

It's no secret that browser extensions are a powerful tool for enhancing the browsing experience. They can be used to add new features, modify existing ones, or even create entirely new applications. In this article, we'll be talking about the process of creating a browser extension and things you should keep in mind using SolidJS and WXT.

Lessons learnt

The first time I tried to create a browser extension I was under the impression that it required some kind of special programming language, compilation method or something. Turns out they're just web apps bundled to run in the browser runtime. This means that you can use most of the same technologies that you use to build web apps to build browser extensions. You can use HTML, CSS, and JavaScript to build the user interface, and you can use JavaScript to interact with the browser's APIs.

WXT takes it to the next level though. It's a tool that allows you to scaffold a browser extension project with a single command. It also provides a way to manage your manifest file, pages, scripts and service workers. Did I forget to mention that it also offers framework support? Yup you can use WXT with React, Vue, Svelte, and SolidJS. It really helped in managing and creating the manifest file, background scripts, content scripts, browser actions, page actions, and permissions. Which are all important parts of a browser extension.

In my previous article about creating a browser extension using SolidJS, I had initially thought that all you needed was Javascript, HTML, CSS, and a manifest file and you were good to go. Oh, how wrong I was. I had to learn about the limitations of browser extensions, the importance of messaging, and the different APIs that are available to browser extensions. I also had to learn about the different parts of the manifest file and how to manage them. For instance, the permissions, the background scripts, the content scripts, the browser actions, the page actions, and the storage. And did you know that the window size is limited to 800x600? I didn't. This is why I decided to write this article. To share what I've learnt and to help others who might be interested in creating a browser extension.

Difference between browser extensions and web apps

Here's a table of the differences between browser extensions and web apps:

Browser Extensions Web Apps
can be used to add new features, modify existing ones, or even create entirely new applications is a client-server software application in which the client (or user interface) runs in a web browser
can be used to modify the behavior of the browser itself are accessed through the internet and are designed to be used on a web browser
can be used to interact with the browser runtime's APIs and it's window APIs can be used to interact with the server's APIs
can be used to interact with the browser's tabs, windows, and bookmarks can only interact with their tab and the server
advised to use the browser's storage APIs e.g chrome.storage web storage APIs a pretty much all you need e.g localStorage and sessionStorage

Concerning the last point, the browser's storage APIs are advised to be used in browser extensions because they can be used in service workers. Service workers are a type of web worker that runs in the background and can be used to intercept and handle network requests, cache resources, carry out messaging, and handle push notifications. They are also used to manage the browser's storage APIs.

API limitations

There are some limitations to the APIs that are available to browser extensions. For instance, the window object is not available to background scripts. This is because background scripts are compiled ahead of time to a single file and can't use the window or document object. They can only use the chrome object and the chrome.runtime object. The chrome object is used to interact with the browser's APIs and the chrome.runtime object is used to interact with the browser's runtime. The chrome.runtime object is used to manage the extension's lifecycle, to manage the extension's manifest file, to manage the extension's storage, to manage the extension's messaging, and to manage the extension's permissions.

Storage

There are two types of storage that are available to browser extensions:

  • Web Storage APIs (can't be used in service worker)
  • Browser storage APIs e.g chrome.storage (can be used in service worker)

The web storage APIs are a set of APIs that are used to store web app user data in the browser. The most common example of a web storage API is localStorage, which offers a storage limit up to 5MB and is semi persistent. The browser storage APIs are a set of APIs that are used to store extension and service worker user data, with the unlimitedStorage permission they can even store data large than 5MB. The most common example is chrome.storage.local.

One major thing to keep in mind though is the difference in the way the two storage APIs are used. The web storage APIs are used to store data in the browser's window object and can only be used in the window object. The browser storage APIs are used to store data in the browser's storage object and can be used in the window object and the service worker object. Browser storage APIs are asynchronous as opposed to web storage APIs which are synchronous.

// web storage APIs
localStorage.setItem("key", "value");
localStorage.getItem("key");

// browser storage APIs
chrome.storage.local.set({ key: "value" }, () => {
  console.log("Value is set to " + value);
});
chrome.storage.local.get(["key"], (result) => {
  console.log("Value currently is " + result.key);
});
Enter fullscreen mode Exit fullscreen mode

WXT provides a wrapper for the different browser storage APIs. As you may have noticed chrome.storage.local is only usable on chromium browsers. The wrapper allows you to use single code to interact with different browsers. Making your extension compatible on Chrome, Safari, and Firefox.

await storage.setItem("local:key", "value");
await storage.getItem("local:key");
Enter fullscreen mode Exit fullscreen mode

Permissions

Permissions are something that you should get very acquainted with if you plan to build an extension that functions more than just some simple todo app. This part my be a bit tricky because you have to be very specific about the permissions you request. You can't just request all permissions and hope for the best, because, when submitting your extension to the web store, the permissions you request are reviewed by the Chrome Web Store team. They will only approve the permissions that are necessary for your extension to function. If you request permissions that are not necessary for your extension to function, your extension will be rejected and asked to resubmit with the unnecessary permissions removed.

Here's a comprehensive list of the permissions that are available to browser extensions using Manifest V3 as of the time of writing this article:

Why Messaging is Important

Chrome extensions are sandboxed and can't access the DOM a page is running on. So in order to manipulate or identify things in the DOM there needs to be a messaging bridge created between the contentScript, backgroundScript, and popup. Anything from strings to booleans can be sent over this bridge, however, I recommend serializing objects as complex objects with methods and classes will not be able to be sent over the bridge.

Here's a quick example of how to create a messaging bridge between the contentScript, backgroundScript, and popup:

// contentScript.js
chrome.runtime.sendMessage({
  text: "hello from the content script",
  to: "background",
  from: "content",
});

// backgroundScript.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  console.log(message.text);
  if (message.to === "background" && message.from === "content") {
    sendResponse({ text: "hello content, this is from the background script" });
  }
  if (message.to === "background" && message.from === "popup") {
    console.log("hello popup, this is from the background script");
  }
});

// popup.js
chrome.runtime.sendMessage({
  text: "hello from the popup",
  to: "background",
  from: "popup",
});
Enter fullscreen mode Exit fullscreen mode

We're using the chrome.runtime API to send and receive messages between the contentScript, backgroundScript, and popup. The chrome.runtime.onMessage method is used to listen for messages and the chrome.runtime.sendMessage method is used to send messages.

Covering all basis in the manifest file

An extensions manifest file is a JSON file that contains metadata about the extension. It's used to configure the extension and to specify the extension's properties. Here's a quick example of a manifest file:

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0",
  "description": "This is my first extension",
  "permissions": ["storage", "activeTab", "scripting"],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "images/icon16.png",
      "48": "images/icon48.png",
      "128": "images/icon128.png"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ],
  "web_accessible_resources": ["images/*"],
  "options_ui": {
    "page": "options.html",
    "open_in_tab": true
  },
  "icons": {
    "16": "images/icon16.png",
    "48": "images/icon48.png",
    "128": "images/icon128.png"
  }
}
Enter fullscreen mode Exit fullscreen mode

As of the time of writing this article manifest V3 is the latest version of the manifest file. Let's do a quick rundown of the properties in the snippet above:

  • manifest_version: The version of the manifest file. As of the time of writing this article, the latest version is 3.
  • name: The name of the extension.
  • version: The version of the extension.
  • description: A description of the extension.
  • permissions: An array of permissions the extension requires. For example, the storage permission is required to use the chrome.storage API.
  • action: The action property is used to define the browser action. The default_popup property is used to specify the HTML file that will be used as the popup. The default_icon property is used to specify the icon that will be used for the extension.
  • background: The background property is used to define the background script. The service_worker property is used to specify the service worker that will be used as the background script.
  • content_scripts: The content_scripts property is used to define the content scripts. The matches property is used to specify the URLs the content script will run on. The js property is used to specify the JavaScript files that will be used as the content script.
  • web_accessible_resources: An array of resources that are accessible to the web. For example, the images/* resource is accessible to the web.
  • options_ui: The options_ui property is used to define the options page. The page property is used to specify the HTML file that will be used as the options page. The open_in_tab property is used to specify whether the options page will open in a tab.
  • icons: An object that contains the icons that will be used for the extension.

Content Security Policy

The content_security_policy property is used to define the content security policy for the extension. The content security policy is used to specify the rules for the resources the extension can load. For example, the script-src 'self'; object-src 'self' content security policy specifies that the extension can only load scripts and objects from the extension itself.

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'",
    "content_scripts": "script-src 'self'; object-src 'self'",
    "web_accessible_resources": "script-src 'self'; object-src 'self'"
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: If you intend to use WASM in your extension, you'll need to add wasm-eval or wasm-unsafe-eval to the script-src directive in the content_security_policy property. Like so:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self' 'wasm-eval'; object-src 'self'",
    "content_scripts": "script-src 'self' 'wasm-eval'; object-src 'self'",
    "web_accessible_resources": "script-src 'self' 'wasm-eval'; object-src 'self'"
  }
}
Enter fullscreen mode Exit fullscreen mode

Content scripts

Content scripts are JavaScript files that run in the context of web pages. They can read and modify the DOM of web pages and interact with the web page's JavaScript. Content scripts are used to modify the appearance and behavior of web pages.

To add a content script to your extension, you'll first need to add it to the manifest file.

{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Once it's added you can then use the chrome.tabs.executeScript method to inject the content script into the web page.

// in-page script
chrome.tabs.executeScript({
  file: "content.js",
});

// content.js
console.log("I'm the content script");
Enter fullscreen mode Exit fullscreen mode

However, if you plan on using WXT you won't need to do the above as WXT offers a method that can do this for you, eliminating the need for the chrome.tabs.executeScript method.

export default defineContentScript({
  matches: ["<all_urls>"],
  main() {
    console.log("I'm the content script");
  },
});
Enter fullscreen mode Exit fullscreen mode

Background scripts

These scrips can also be referred to as service workers. You might think that they are similar to the content script with the only differing feature being that they run in the background, but you couldn't be further from the truth. Background scripts are fundamentally different in a sense that they can not be used like normal JavaScript scripts that run on the web, because they don't have access to the DOM, web page or anything related to it(like localStorage, the document and window objects).

Now you might be wondering since they're so limited what can they even be used for? Well as their name implies they can be used to run background tasks such as notifications, tracking, analytics, event-handling and messaging. I hope you understand why they're also referred to as service workers now.

Setting up a background script is similar to setting up a content script in normal extension workflows, however, when using WXT it can be done even easier.

export default defineBackground(() => {
  console.log("I'm the background script");
});
Enter fullscreen mode Exit fullscreen mode

Something that you'll have to keep in mind is that the callback can not be made async as background scripts are synchronous. So if you're a fine of the async/await syntax you're out of luck, you'll have to use the good old then/finally/catch method chain.

Browser actions

Web extension browser actions are buttons that extensions add to the browser's toolbar, typically with an icon and optional popup functionality. These buttons allow users to interact with the extension directly from the browser interface. When a user clicks on a browser action button, it can trigger specific actions defined by the extension, such as opening a popup with HTML, CSS, and JavaScript content, or executing certain functions within the extension.

As of the time of writing this article I haven't been able to get these to work using WXT. It might be something with my implementation or WXT doesn't support it yet, however, here's a ChatGPT thread explaining how you can go about trying it out yourself using a none WXT managed code base.

Page actions

Page actions in Chrome extensions are specific actions that are relevant to particular pages rather than the browser as a whole. They are represented by icons added inside the browser's URL bar and can be associated with specific web pages. Page actions can be used for features that make sense for only a few pages, and they are hidden by default but can be shown for a particular tab using the pageAction.show() method.

Using WXT for Scaffolding

Now that we're past the basics, let's actually create a project. Run the following command to create a WXT project.

bunx wxt@latest init sample-project
Enter fullscreen mode Exit fullscreen mode

The command will prompt you to choose a framework you would like to use. I recommend SolidJS, but go with whichever you're proficient in.

Once that's done your project's file structure will look like this:

.
├── README.md
├── assets
│   └── solid.svg
├── entrypoints
│   ├── background.ts
│   ├── content.ts
│   └── popup
│       ├── App.css
│       ├── App.tsx
│       ├── index.html
│       ├── main.tsx
│       └── style.css
├── package.json
├── public
│   ├── icon
│   │   ├── 128.png
│   │   ├── 16.png
│   │   ├── 32.png
│   │   ├── 48.png
│   │   └── 96.png
│   └── wxt.svg
├── tsconfig.json
└── wxt.config.ts

6 directories, 18 files
Enter fullscreen mode Exit fullscreen mode

I used tree to print this. If you're using pkgx, just run pkgx tree to print the dirctory tree

Directory structure

  • assets: This houses your images, svgs, and any other asset formats you'll be using.
  • entrypoints: This houses your background scripts, content scripts, and popup.
  • wxt.config.ts: This manages WXTs behavior and your manifest file settings.
  • package.json, public, tsconfig.json: Same old, same old.

Running the extension

bun dev
Enter fullscreen mode Exit fullscreen mode

Building the extension

Using SolidJS for the popup

The popup directory is the UI of your extension. It's where you'll be using SolidJS to create the UI of your extension. The index.html file is the entry point of the popup, and the main.tsx file is the entry point of the SolidJS app. While the extension is running and open you can modify the App.tsx file and see it update in real time.

import { createSignal } from "solid-js";
import solidLogo from "@/assets/solid.svg";
import wxtLogo from "/wxt.svg";
import "./App.css";

function App() {
  const [count, setCount] = createSignal(0);

  return (
    <>
      <div>
        <a href="https://wxt.dev" target="_blank">
          <img src={wxtLogo} class="logo" alt="WXT logo" />
        </a>
        <a href="https://solidjs.com" target="_blank">
          <img src={solidLogo} class="logo solid" alt="Solid logo" />
        </a>
      </div>
      <h1>WXT + Solid</h1>
      <div class="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count()}
        </button>
        <p>
          Edit <code>popup/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p class="read-the-docs">
        Click on the WXT and Solid logos to learn more
      </p>
    </>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Note: WXT uses the @ symbol to refer to the root directory of your project. This is a feature of WXT and not SolidJS. You can get rid of it by changing the baseUrl in your tsconfig.json file and modifying the paths in your wxt.config.ts file. I recommend leaving it as is, because if you're not well versed in configs you might end up breaking the project.

Routing

You can add routing to your popup the exact same way you would any other SolidJS app. Here's an example of how you can do that:

// App.tsx
import { Router, useRoutes } from "@solidjs/router";

export default function App() {
  const Routes = useRoutes([
    { path: "/", component: <Home /> },
    { path: "/about", component: <About /> },
  ]);

  return (
    <Router>
      <Routes />
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode
// Home.tsx
export default function Home() {
  return <h1>Home</h1>;
}
Enter fullscreen mode Exit fullscreen mode
// About.tsx
export default function About() {
  return <h1>About</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Managing your manifest properties with WXT

WXT uses a wxt.config.ts file to manage your manifest properties. Manifest properties can be assigned through the manifest object in the defineConfig function. Here's an example of how you can do that:

import { defineConfig } from "wxt";
import Solid from "vite-plugin-solid";

// See https://wxt.dev/api/config.html
export default defineConfig({
  vite: () => ({
    build: {
      target: "esnext",
    },
    plugins: [Solid()],
  }),
  manifest: {
    name: "WXT + Solid",
    description: "A WXT extension with SolidJS",
    version: "0.0.1",
    permissions: ["storage"],
    action: "popup",
    icon: {
      16: "/icon/16.png",
      48: "/icon/48.png",
      128: "/icon/128.png",
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Compile and build

bun zip
Enter fullscreen mode Exit fullscreen mode

This builds and zips your extension into a .zip file. You can then upload this file to the Chrome Web Store or drag and drop it into the chrome://extensions page to install it.

Conclusion

That's pretty much all the info you'll need on how to build and package your first extension using WXT + SolidJS. As you may have noticed this article leans heavily towards building for the Chrome Web Store. The same principles apply to building for Firefox, Edge, and Opera. The only difference is the manifest properties and the way you package your extension. I recommend giving the Chrome browser extension api doc a read (https://developer.chrome.com/docs/extensions/reference) to get a better understanding of how to use the Chrome extension api, and the differences between the different browsers.

If you would like to learn more about how to publish and distribute your extension, let me know and I might write an article on that. If you have any questions or need help with anything, feel free to reach out to me on Twitter. I'm always happy to help.

Top comments (0)