4 Key Points for Getting Started with Plasmo in Browser Extension Development
Introduction
To understand how Plasmo enhances the developer experience, explore "Why Plasmo is an Excellent Choice for Developing Browser Extensions".
This article integrates Plasmo with traditional browser development tools, emphasizing two critical issues to aid effective utilization and addressing peculiar behaviors to clarify developers' confusion.
All code examples in this article are from Furigana Maker and Don't translate code, which I developed.
Preparing for Coding
To broaden the extension's potential user base, it's essential to consider supporting multiple browsers. As I lack a Mac OS device, this article will only attempt to support Chrome, Edge, and Firefox, excluding Safari.
Scripts in package.json
Given the high compatibility between Edge and Chrome, most cases allow the use of the same scripts, often eliminating the need for separate testing in Edge.
pnpm dev
starts the development server with Live-reloading functionality. Both Chrome and Edge can share the generatedbuild/chrome-mv3-dev
directory.pnpm debug
, an extension ofpnpm dev
, provides additional logging information for error troubleshooting.pnpm build
generates the production-ready output without any development-specific elements (e.g.console.log
), occasionally differing from the behavior ofpnpm dev
.pnpm package
compresses the directory created bypnpm build
intochrome-mv3-prod.zip
, suitable for submission to the Chrome Web Store for review.
{
"scripts": {
"dev": "plasmo dev",
"dev:firefox": "plasmo dev --target=firefox-mv2",
"debug": "plasmo dev --verbose",
"debug:firefox": "plasmo dev --target=firefox-mv2 --verbose",
"build": "plasmo build",
"build:firefox": "plasmo build --target=firefox-mv2",
"package": "plasmo package",
"package:firefox": "plasmo package --target=firefox-mv2"
}
}
Developers also need to install the web-ext dependency, which allows them to quickly open a clean window in Firefox or Chromium to load the extension (similar to incognito mode). This is very useful during final testing.
Add the following scripts to package.json
:
{
"scripts": {
"start": "web-ext run --source-dir ./build/chrome-mv3-dev -t chromium --start-url chrome://newtab",
"start:firefox": "web-ext run --source-dir ./build/firefox-mv2-dev"
}
}
Without the --start-url chrome://newtab
parameter, web-ext defaults to opening the about:blank
page in Chromium, which can be too glaring.
The package manager issues warnings regarding peer dependencies and deprecated subdependencies. These warnings, introduced by Plasmo and web-ext upstream dependencies, persist until addressed at the upstream level. However, you can choose to ignore these warnings for now.
Additionally, you might receive GitHub vulnerability alerts related to web-ext, used solely in the development environment. Code with vulnerabilities doesn’t enter the production build, making it safe to disregard these alerts.
Chrome and Browser Namespace Compatibility
Despite Firefox and Edge having widespread support for the Chrome namespace by default, some APIs remain incompatible, like chrome.tabs.query
. To run the code seamlessly across different browsers without altering the code, introduce the webextension-polyfill dependency. Use the following code in places where chrome
namespace is required:
import Browser from 'webextension-polyfill'
//chrome.tabs.sendMessage(...)
Browser.tabs.sendMessage(...)
This adaptation allows a smoother transition across browsers without changing the underlying codebase.
Sending Messages Across Extension Components
Given the confusion resulting from Chrome Developer's unclear documentation, it's crucial to delve deeper into this topic.
Sending to Content Script
Though seemingly straightforward, the usage can be perplexing. Take the following example:
<script setup lang="ts">
...
const addFurigana = async () => {
const tabs = await Browser.tabs.query({ active: true, currentWindow: true })
sendMessage(tabs[0]!.id!, ExtensionEvent.AddFurigana)
}
...
</script>
Browser.runtime.onMessage.addListener((event: ExtensionEvent) => {
switch (event) {
...
}
})
The { active: true, currentWindow: true }
selection targets the current window with the open popup. However, calling it in chrome://newtab
raises a "Could not establish connection. Receiving end does not exist." runtime error.
The reason is that numerous pages in Chrome can't inject content scripts. These pages lack registered event listeners, leaving no method in the popup to verify if the page has event listeners. To mitigate this, the sendMessage
function can be encapsulated to capture this error.
export const sendMessage = async (id: number, event: ExtensionEvent) => {
try {
await Browser.tabs.sendMessage(id, event)
} catch (error) {
if (
!(error instanceof Error) ||
error.message !== 'Could not establish connection. Receiving end does not exist.'
) {
throw error
}
}
}
Avoid using tabs.query
with the url
parameter to determine if a page registers event listeners, as it need tab
or activeTab
permissions, potentially causing issues with Chrome's approval process.
Sending to Service Worker
Plasmo registers all files in the background
directory as a Service Worker, ensuring the extension doesn't consume resources when idle and simplifying Service Worker usage.
Basic functionalities in background/index.ts
using runtime.onInstalled
, commands.onCommand
, etc., can be employed for user settings initialization or shortcut registrations.
Beyond that, Plasmo offers more advanced capabilities through @plasmohq/messaging. This feature enables seamless messaging from popup or content scripts to the Service Worker, allowing message exchanges.
Prefer using the @plasmohq/messaging
API over runtime.onMessage.addListener
and runtime.sendMessage
.
Sending to CSUI
CSUI (Content Scripts UI) is a method provided by Plasmo to directly inject UIs written in Vue or React onto a page. Interaction often necessitates bidirectional communication between regular content scripts and CSUI.
As content scripts lack permission to use runtime.onMessage.addListener
, sending messages to CSUI via runtime.sendMessage
is impossible.
Utilizing Shadow DOM, CSUI permits message exchange using window.postMessage. For bidirectional communication, register listeners both in CSUI and content scripts:
<script setup lang="ts">
window.addEventListener("message", (event) => {
...
event.source.postMessage(
...
);
});
</script>
Accessing Extension File Assets
Plasmo generally recognizes file path references in the source code, bundling them into the build output automatically. However, at times, unconventional methods are needed to specify files for packaging.
Future Plasmo versions will support plasmo.config.ts
to alleviate the clutter in package.json
. But for now, add the following code:
"manifest": {
"web_accessible_resources": [
{
"resources": [
"assets/dicts/*.dat.gz"
],
"matches": [
"https://*/*"
]
}
]
}
This bundles files to the specified chrome-mv3-prod/assets
directory, accessible using runtime.getURL.
Service Workers lack access to Web Accessible Resources. Specify file locations based on Plasmo's bundled file structure, as files within the background
directory are merged into static/background/index.js
.
Plasmo can inline JSON data, ideal for initializing user data with @plasmohq/storage. Here's a simple example:
import { Storage } from '@plasmohq/storage'
import { ExtensionStorage } from '~contents/core'
import Browser from 'webextension-polyfill'
import defaultRules from '../../assets/rules.json'
Browser.runtime.onInstalled.addListener(async () => {
const storage = new Storage({ area: 'local' })
const oldRules = await storage.get(ExtensionStorage.UserRule)
if (!oldRules) {
await storage.set(ExtensionStorage.UserRule, defaultRules)
}
})
Addressing Common Issues in Plasmo
Inability to Use Top-Level Await
Common scenarios demand content scripts to check user settings in storage at runtime. Attempting top-level await like this:
import { Option } from '~contents/constants';
const storage = new Storage({ area: 'local' });
const forceConvertFont = await storage.get(Option.forceModifyFont);
if (!forceConvertFont) {
return;
}
Unfortunately, this won't work due to Plasmo's packaging within a nested IIFE (Immediately Invoked Function Expression). To address this, use an IIFE to counter the nested structure:
;(async function doSomething() {
const storage = new Storage({ area: 'local' })
const forceConvertFont = await storage.get(Option.forceModifyFont)
if (!forceConvertFont) {
return
}
})()
Additionally, Chrome extension Service Workers do not permit the use of top-level await.
Upstream Parcel Issues
Most of the issues encountered in Plasmo development seem linked to Parcel issues. To resolve these problems, it's recommended to refer to the Parcel Github Repository.
Examples of such issues include:
- Loss of SVG
viewBox
attribute in inline SVG files in the production environment, causing image scaling issues (plasmo#728). - Specifying
engines
inpackage.json
resulting in build failures (plasmo#750).
These issues persist due to challenges in Plasmo's underlying Parcel integration, making their resolution uncertain.