This post explains the inner workings of a Chrome extension built with Rust and compiled into WASM.
Target audience: Rust programmers interested in building cross-browser extensions with WASM.
About the extension: Github, Chrome Webstore, Mozilla Webstore
What you will learn:
- toolchain
- architecture of the extension
- how WASM, background and content scripts communicate with each other
- intercepting session tokens to impersonate the user
- debugging
- making it work for Chrome and Firefox
- listing in Google and Mozilla addon stores
You will get the best value for your time if you look at the source code and read this post side by side.
Toolchain
The main tool to build, test and publish WASM: https://rustwasm.github.io/docs/wasm-pack/
cargo install wasm-pack
See build.sh for an example of build command.
cargo installs wasm-bindgen on the first compilation to facilitate high-level interactions between wasm modules and JavaScript.
References:
-
https://rustwasm.github.io/wasm-bindgen/ - Rust/WASM book with examples, explanations and details references. Read about:
- Hello, World!
- console.log
- Without a bundler
- https://rustwasm.github.io/wasm-bindgen/api/web_sys/index.html - a Rust wrapper around Web APIs. APIs of interest:
- https://rustwasm.github.io/wasm-bindgen/api/js_sys/index.html - a Rust wrapper around JS types and objects
Architecture of the extension
This extension consists of several logical modules:
- manifest - the definition of the extension for the browser
- background script - a collection of JS scripts and WASM code that run in a separate browser process regardless of the current web page
- content script - the part of the extension that runs inside the current web page, but it is not used in this extension
- popup - a window displayed by the browser when the user clicks on the extension button in the toolbar
Folders
- extension: the code of the extension as it is loaded into the browser, including JS and WASM
- media: various media files for publishing the extension, not essential
- wasm_mod: Rust code to be compiled into WASM
- wasm_mod/samples: Spotify request/response captures, not essential
Manifest V3
We have to use separate manifest V3 files for different browsers because of incompatible features:
- extension/manifest_cr.json: Chrome version
- extension/manifest_ff.json: Firefox version
Browser-specific manifests are renamed into manifest.json by build.sh as explained in Debugging and Packaging sections of this document.
List of manifest properties to pay attention to:
- action/show_matches - when the browser should make the extension button active
- action/default_popup - what should happen when the user clicks on the extension button
- background/service_worker - the name of the script to run as a background service worker
- content_security_policy - declares what the extension can do, e.g. load scripts or WASM
Other properties are either self-explanatory or not as important.
Manifest V3 docs: MDN / Chrome
Background script
extension/js/background.js acts as a Service Worker. The name "background script" is historical and can be used interchangeably with "service worker".
What background.js
does:
- loads and initializes WASM module
- listens to messages from WASM and the popup page (extension/js/popup.html)
- sends error messages to the popup page (extension/js/popup.js)
- captures session token (
captureSessionToken()
) - fetches user details (
fetchUserDetails()
) - extracts the current playlist ID from the active tab URL (
getPlaylistIdFromCurrentTabUrl()
) - calls WASM functions in response to user actions (
add_random_tracks()
) - controls the extension icon in the toolbar (
toggleToolbarBadge()
)
background.js
is loaded when the browser is started and performs:
- WASM module initialisation
- adding listeners for messaging and token capture
Once running, background.js
and its WASM part continue to run independently of the page it was started from. A service worker will continue running even if the user navigates elsewhere or closes the tab that started it because the Service Worker lifecycle is separate from that of the document.
More on service workers: MDN / Chrome
Popup page
The manifest file instructs the browser to open a popup window when the extension is activated by the user, e.g. with a click on the extension icon in the toolbar.
This line of the manifest defines what popup window to open when the user clicks on the toolbar button:
"default_popup": "js/popup.html"
The popup lives only while it is being displayed. It cannot host any long-running processes and it does not retain the state from one activation to the other. All DOMContentLoaded
events fire every time it is activated.
What popup.js
does:
- attaches event handlers to its buttons and links
- handles on-click for links because browsers do not open link URLs from popups (see
chrome.tabs.create()
) - listens to messages from
background.js
and WASM (seechrome.runtime.onMessage.addListener()
) - displays an activity log from the messages it receives from WASM and background.js
Any inline JS in popup.html is ignored. Any JS code or event handlers have to be loaded externally with <script type="module" src="popup.js">
because only src scripts are allowed by content_security_policy/extension_pages
entry of the manifest.
Content script
Content scripts interact with the UI and DOM. They run in the same context as the page and have access to Window object as their main browser context.
This extension does not interact with the Spotify tab and does not need a content script.
Messaging between the scripts
Communication between different scripts (background, popup, WASM) is done via asynchronous message passing. A script A sends a message to a shared pool hoping that the intended recipient is listening and can understand the message. This means that if there are multiple listeners, all of them get the message notification. The sender does not get notifications for the messages it sends out.
It is possible to invoke functions from a different script within the same extension, but the script will run in the context of the caller. For example, popup.js
can call a background.js
function when the user clicks on a button inside the popup. The invocation will work in the context of the popup and die as soon as the popup is closed.
On the other hand, background.js
is a long-running process. It listens to messages sent to it from other scripts. E.g. chrome.runtime.onMessage.addListener()
function checks the message payload and acts depending on the message contents.
This extension relies on messaging, not direct invocation.
Key messaging concepts:
- messages are objects
- the only metadata attached to the message is the sender's details
- structure the message to include any additional metadata, e.g. the type of the payload
- always catch
chrome.runtime.sendMessage()
errors because there is no guarantee of delivery and an unhandled error will fail your entire script - an error is always raised if you send a message and there is no active listener, e.g. if you expect a popup to listen, but the user closed it
- message senders cannot receive their own messages, so if you send and listen within the same script, there will be no message notification to self, only to other listeners
More on message passing between different scripts: https://developer.chrome.com/docs/extensions/develop/concepts/messaging.
Messaging examples
From background.js to popup.js
background.js
sends error messages out to anyone who listens. It handles non-delivery errors by ignoring them.
popup.js
listens and displays them if popup.html
is open. If the popup is not open, there is no listener and the sender gets an error. These errors should be handled for the rest of the sender's script to work.
chrome.runtime.sendMessage("Already running. Restart the browser if stuck on this message.").then(onSuccess, onError);
where both onSuccess
and onError
do nothing to prevent the error from bubbling up to the top.
From popup.js to background.js
popup.js
sends a message out when the user clicks Add tracks button.
background.js
listens and invokes WASM to handle the user request.
The popup script could call a function in the background script to invoke WASM or even call WASM directly, but the popup lives only if open. Also, it wouldn't have access to the token stored in the context of the long-running background.js
process.
So, we have a long-running background script that lives independently of the tabs or the popup. When a message from the popup arrives, background.js calls WASM and continues running even if the caller no longer exists.
Sending messages from WASM to JS scripts
WASM sends out messages via report_progress()
function located in wasm_mod/src/progress.js
script. That function is imported into wasm_mod/src/lib.rs
as
#[wasm_bindgen(module = "/src/progress.js")]
extern "C" {
pub fn report_progress(msg: &str);
}
and is called from other Rust functions as a native Rust function.
The progress reporting from WASM to the popup delivers messages in near-real-time while the WASM process continues running.
WASM
wasm_mod folder contains the WASM part of the extension.
Inside Cargo.toml
Cargo file: wasm_mod/Cargo.toml
crate-type = ["cdylib", "rlib"]
- cdylib: used to create a dynamic system library, e.g. .so or .dll, but for WebAssembly target it creates a *.wasm file without a start function
-
rlib: optional, used to create an intermediate "Rust library" for unit testing with
wasm-pack test
More on targets: https://doc.rust-lang.org/reference/linkage.html
Dependencies
-
wasm-bindgen: contains the runtime support for
#[wasm_bindgen]
attribute, JsValue interface and other JS bindings (crate docs) - js-sys: bindings to JS types, e.g. Array, Date and Promise (crate docs)
- web-sys: raw API bindings for Web APIs, 1:1, e.g. the browser Window object is web_sys::Window with all the methods, properties and events available from JS (crate docs)
Remember to add WebAPI classes and interfaces as web-sys features in your Cargo.toml. E.g. if you want to use Window class in Rust code it has to be added to web-sys features first.
More about how Rust binds to browser and JS APIs: https://rustwasm.github.io/wasm-pack/book/tutorials/npm-browser-packages/template-deep-dive/cargo-toml.html
Calling WASM from JS example
background.js makes a call to hello_wasm()
from lib.rs that logs a greeting in the browser console for demo purposes.
hello_wasm()
sequence diagram
How console.log()
is called from Rust
To log something into the browser console our Rust code has to access the logging function of WebAPI provided by the browser.
lib.rs imports WebAPI console.log()
function and makes it available in Rust:
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace=console)]
fn log(s: &str);
}
The low-level binding is done by web-sys and js-sys crates with the help of wasm-bindgen and wasm-pack.
How Rust's hello_wasm()
is called from JS
To call a Rust/WASM function from JS we need to export it with #[wasm_bindgen]
.
lib.rs exports hello_wasm()
Rust function and makes it available to background.js
:
#[wasm_bindgen]
pub fn hello_wasm() {
log("Hello from WASM!");
}
wasm-bindgen and wasm-pack generate wasm_mod.js file every time we run the build. That file is the glue between WASM and JS. Some of its high-level features:
- initializes the WASM module
- exports
wasm.hello_wasm()
function for background.js - type and param checking
How WASM is initialized
Every time the browser activates the extension, it runs the following code from background.js:
import initWasmModule, { hello_wasm } from './wasm/wasm_mod.js';
(async () => {
await initWasmModule();
hello_wasm();
})();
wasm-bindgen and wasm-pack generate the initialization routine and place it in wasm_mod.js file as the default export.
lib.rs
lib.rs is the top-level file for our WASM module. It exposes Rust functions and binds to JS interfaces.
Main entry point
The main entry point that does the work of adding tracks to the current playlist is pub async fn add_random_tracks(...)
.
Browser context
The code will need access to some global functions that are only available through an instance of a browser context or runtime. The two main classes that serve this purpose are WorkerGlobalScope
and Window
.
The important difference between the two is that:
-
WorkerGlobalScope
is available to Service Workers (background scripts running in their own process independent of a web page) -
Window
is available to content scripts running in the same process as the web page
Both classes are very similar, but have slightly different sets of properties and methods.
lib.rs obtains a reference to the right browser runtime inside async fn get_runtime()
and passes it to other functions.
get_runtime()
attempts to get a reference to WorkerGlobalScope
first. If that fails it tries to get a reference to Window
object. One works in Chrome and the other in Firefox.
Reporting progress back to the UI
Our WASM code may take minutes to run. It reports its progress and failures back to the UI via browser messaging API.
pub fn report_progress(msg: &str)
in lib.rs is a proxy for report_progress()
in progress.js.
It is called from various locations in our Rust code to send progress and error messages to popup.js
.
To make a custom JS function available to Rust code we created wasm_mod/src/progress.js with the following export:
export function report_progress(msg) {
chrome.runtime.sendMessage(msg).then(handleResponse, handleError);
}
and matched it by creating this import in lib.rs:
#[wasm_bindgen(module = "/src/progress.js")]
extern "C" {
pub fn report_progress(msg: &str);
}
wasm-bindgen and wasm-pack generate necessary bindings and copy progress.js to extension/js/wasm/snippets/wasm_mod-bc4ca452742d1bb1/src/progress.js.
other .rs files
The rest of .rs files contain vanilla Rust that can be compiled into WASM.
- client.rs - a single high-level function that brings everything together
- api_wrappers.rs - a collection of wrappers for Spotify API, called from client.rs
- constants.rs - shared constants, utility functions
- models.rs - Rust structures for Spotify requests and responses
Token capture
The extension needs some kind of credentials to communicate with Spotify on behalf of the user. One way of achieving this without asking the user is to capture the current session token. For that to work, the user has to navigate to a Spotify web page and log in there.
A frequent Spotify user would already be logged in, because Spotify maintains an active session on all *.spotify.com pages.
This extension captures all request headers sent to https://api-partner.spotify.com/pathfinder/v1/query
endpoint, including the tokens. The headers are stored in local variables and are copied into requests made by the extension to mimic the Spotify app.
captureSessionToken()
function from background.js does the header extraction when onBeforeSendHeaders event is triggered:
chrome.webRequest.onBeforeSednHeaders.addListener(captureSessionToken, { urls: ['https://api-partner.spotify.com/pathfinder/v1/query*'] }, ["requestHeaders"])
-
onBeforeSendHeaders listener reads, but does not modify the headers on
https://api-partner.spotify.com/pathfinder/v1/query*
. - The
*
at the end of the URL is necessary for the pattern to work. -
["requestHeaders"]
param instructs the browser to include all the headers in the request details object passed onto captureSessionToken handler. Only the URL and a few common headers are included If that param is omitted. -
host_permissions": ["*://*.spotify.com/*"]
in manifest.json is required for onBeforeSendHeaders to work.
All extracted headers are stored in headers
variable inside background.js. The tokens are stored in auth
and token
vars. None of the headers or tokens are persisted in storage.
The tokens are passed onto WASM as function parameters:
#[wasm_bindgen]
pub async fn add_random_tracks(
auth_header_value: &str,
token_header_value: &str,
playlist_id: &str,
user_uri: &str,
)
Debugging
Extensions can be loaded from source code in Firefox and Chrome for testing and debugging.
Firefox
- run
. build.sh
to build the WASM code - go to about:debugging#/runtime/this-firefox
- click on Load temporary Add-on button
- Firefox opens a file selector popup asking to select manifest.json file. Remember to rename manifest_ff.json into manifest.json.
If the extension was loaded correctly, its details should appear inside the Temporary Extensions section on the same page.
If you keep losing this obscure about:debugging#/runtime/this-firefox URL, there is an alternative way of getting to the debugging page in Firefox:
- click on extensions icon in the toolbar
- click on Manage extensions
- click on settings gear-like icon at the top right of the page
- click on Debug Add-ons
Chrome
The Chrome process is very similar to the one in Firefox.
- run
. build.sh
to build the WASM code - go to chrome://extensions/
- Turn on Developer mode toggle at the top-right corner of the page
- Cick on Load unpacked and select the folder with manifest.json. Remember to rename manifest_cr.json into manifest.json.
If the extension was loaded correctly, its details should appear in the list of extensions on the same page. See Chrome docs for more info.
If you forget what the direct URL is (chrome://extensions/), there is an alternative way of getting to the debugging page in Chrome:
- click on
...
icon in the Chrome toolbar to open the Chrome options menu - click on Extensions / Manage Extensions
- Chrome will open chrome://extensions/ page
Making changes and reloading
Changes to code located inside wasm_mod/src folder require running build.sh script to rebuild the WASM code.
Changes to JS, CSS or asset files inside extension folder do not require a new build and are picked up by the browser when you reload the extension.
Click on Reload icon for the extension to load the latest changes.
For example, changing lib.rs or progress.js requires a rebuild and reload. Changing popup.js only requires a reload.
Viewing logs
Different modules log messages into different consoles. If you are not seeing your log message, you are probably looking at the wrong console.
Content scripts output log messages (e.g. via console.log()
) to the same log as the web page. View them in DevTools / Console tab for the web page.
Background scripts, including WASM, send messages to a separate console log.
- in Chrome: click on Inspect views link in the extension details panel (e.g. Inspect views service worker (Inactive))
- in Firefox: click on Inspect button in the extension details panel
If you follow the steps above, Firefox should open a new DevTools window with Console, Network and other tabs.
Popup windows (e.g. popup.html) in Firefox log messages into the same console as the background scripts.
The popup window in Chrome also logs into the same console as the background script, but you have to enable it with a separate step.
Right-click on the open popup and select Inspect. It will open a new DevTools window with both background and popup logs. Closing the popup window kills the DevTools window as well.
Network requests and responses from background.js and WASM appear in the background DevTools window.
Cross-browser compatibility
Most of the extension code works in both, Firefox and Chrome. There are a few small differences that have to be kept separate: manifest, global context and host_permissions.
manifest.json
background.js
lives under different manifest property names:
- Firefox: background/scripts
- Chrome: background/service_worker
Firefox complains about minimum_chrome_version
, offline_enabled
and show_matches
.
Chrome rejects browser_specific_settings
required by Firefox.
See the full list of manifest differences between Firefox and Chrome with this CLI command:
git diff --no-index --word-diff extension/manifest_cr.json extension/manifest_ff.json
This project is configured to have two manifests in separate files: manifest_cr.json and manifest_ff.json. Rename one of them manually into manifest.json for local debugging and then revert to the browser-specific file when finished. build.sh script renames them to manifest.json on the fly during packaging.
If a package has no manifest.json it is not recognized as an extension by the browser.
Global context
Firefox and Chrome use different global context class names to access methods such as fetch()
:
WorkerGlobalScope
Window
Both classes are part of the standardized WebAPI. The difference is in how they are used in content and background scripts.
Chrome
-
web_sys::window()
function returns Some(Window) for content scripts and None for Service Workers -
js_sys::global().dyn_into::<WorkerGlobalScope>()
returns Ok(WorkerGlobalScope) for Service Workers and Err for content scripts
Firefox
-
web_sys::window()
function returns Some(Window) for both, context and Service Worker scripts -
js_sys::global().dyn_into::<WorkerGlobalScope>()
always returns None
It looks like Firefox is not compliant with the standard or even MDN, but maybe I missed something and got this part wrong.
See get_runtime()
function in lib.rs for more implementation details. Also, see Window and WorkerGlobalScope docs on MDN.
Host permissions
Both Chrome and Firefox have this entry in the manifest:
"host_permissions": [
"*://*.spotify.com/*"
]
The format is the same, but Chrome grants this permission on install and Firefox treats it as an optional permission that has to be requested at runtime.
The extension code handles this discrepancy gracefully at the cost of some complexity. More info:
- the implementation of
btn_add
click listener in popup.js has detailed comments - MDN docs: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/request
- Firefox's strange behavior: https://stackoverflow.com/questions/47723297/firefox-extension-api-permissions-request-may-only-be-called-from-a-user-input
Listing WASM extensions in Google and Mozilla addon stores
There are no special listing requirements for extensions containing WASM code.
Both Google and Mozilla may request the source code and build instructions for the WASM part, which may delay the review process. This extension is 100% open source and was always approved within 24hrs.
Packaging
build.sh script packages the extension into chrome.zip and firefox.zip files. The packaging steps include:
- building the WASM code with wasm-pack
- removing unnecessary files
- renaming browser-specific manifest files to manifest.json
- zipping up extension folder into chrome.zip and firefox.zip with the right manifest
wasm-pack copies .js files from wasm_mod/src folder to extension/js/wasm/snippets/wasm_mod/src if they are used for binding to Rust. E.g. wasm_mod/src/progress.js is copied to extension/js/wasm/snippets/wasm_mod-bc4ca452742d1bb1/src/progress.js.
See detailed packaging instructions for Firefox and Chrome for more info.
Listing
The listing form has no WASM-specific questions or options. Make sure you do not have any unused permissions in the manifest and give clear explanations why you need the permissions you request.
Useful links
Firefox
- Example: https://addons.mozilla.org/en-US/firefox/addon/spotify-playlist-builder-addon/
- Sign up here: https://addons.mozilla.org/en-US/developers/
- List your extensions: https://addons.mozilla.org/en-US/developers/addons
- Uploading listing images may only be available after the extension is approved.
Chrome
- Example: https://chromewebstore.google.com/detail/spotify-playlist-builder/kmbnbjbfpnchgmmkbeidpllpamcahljn
- Sign up and list your extensions: https://chrome.google.com/webstore/devconsole
Listing process
- Sign up, create a new listing, describe the extension and upload the .zip for review
- Check any errors and warnings generated by the store website after uploading the .zip
- Approval time for both stores is about 24 hours, from personal experience.
- Any change in the code or the listing requires a store review before it is published
Both stores allow listing extensions without them appearing in the public directory while users with the extension URL can still access it.
Top comments (2)
This looks like just what I was thinking of! - I have been using this for a long time to manage many windows with many tabs:
cxw42.github.io/TabFern/
It allows me to group tabs into projects and save / reload them nicely but it slows to a crawl when I overload it - so I was thinking that a WASM version of it might help? What do you think?
What happened? I posted a comment asking a question and now it is gone . .