Creating a Browser Extension using SolidJS + WXT II
An in-depth guide on creating a Chrome extension using SolidJS and WXT - 17/03/2024
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);
});
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");
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:
- activeTab
- alarms
- bookmarks
- browsingData
- contentSettings
- contextMenus
- cookies
- debugger
- declarativeContent
- declarativeNetRequest
- declarativeNetRequestFeedback
- desktopCapture
- dns
- documentScan
- downloads
- enterprise.deviceAttributes
- enterprise.platformKeys
- fileBrowserHandler
- fileSystemProvider
- fontSettings
- gcm
- geolocation
- history
- identity
- idle
- management
- notifications
- pageCapture
- platformKeys
- power
- printerProvider
- processes
- proxy
- scripting
- sessions
- storage
- system.cpu
- system.display
- system.memory
- system.storage
- tabCapture
- tabs
- topSites
- tts
- ttsEngine
- unlimitedStorage
- vpnProvider
- webNavigation
- webRequest
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",
});
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"
}
}
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, thestorage
permission is required to use thechrome.storage
API.action
: The action property is used to define the browser action. Thedefault_popup
property is used to specify the HTML file that will be used as the popup. Thedefault_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. Theservice_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. Thematches
property is used to specify the URLs the content script will run on. Thejs
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, theimages/*
resource is accessible to the web.options_ui
: The options_ui property is used to define the options page. Thepage
property is used to specify the HTML file that will be used as the options page. Theopen_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'"
}
}
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'"
}
}
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"]
}
]
}
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");
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");
},
});
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");
});
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
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
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
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;
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>
);
}
// Home.tsx
export default function Home() {
return <h1>Home</h1>;
}
// About.tsx
export default function About() {
return <h1>About</h1>;
}
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",
},
},
});
Compile and build
bun zip
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.