Building a Cross-Browser Extension with Svelte: Integrating Tailwind, TypeScript and WebAssembly
Welcome to a comprehensive guide on building a high-performance cross-browser extension using Svelte. This tutorial will walk you through setting up a streamlined and powerful development environment designed for modern web applications. Here’s what we’ll cover:
- Svelte: Harness the simplicity and reactivity of Svelte to build efficient and scalable browser extensions.
- Tailwind CSS: Integrate Tailwind for rapid, utility-first styling that can adapt to any design requirement with ease.
- WebAssembly (compiled from Rust): Boost performance in critical areas of your extension by incorporating WebAssembly modules.
- webextension-polyfill: for cross browser compatability
- Live reload: to make it easier for development
- ESLint and Prettier: Implement these tools to ensure your code is not only error-free but also adheres to industry standards for readability and consistency.
- Rollup: Utilize Rollup for its efficient module bundling capabilities, making our extension lightweight and fast.
- Optional full screen dashboard/tab
- Husky for pre-commit hooks
By the end of this guide, you’ll have a robust setup that leverages the best practices and tools available for extension development. Let’s get started on building something exceptional.
Lets start with creating an empty directory
mkdir cross-browser-extension && cd cross-browser-extension
Initialize
npm init -y
Install core dev dependencies
npm i -D typescript svelte tailwindcss webextension-polyfill rollup \
autoprefixer tslib
Lets add svelte specific dependencies
npm i -D svelte-check svelte-preprocess
Lets install rollup plugins
npm i -D @tsconfig/svelte @rollup/plugin-commonjs \
@rollup/plugin-node-resolve @rollup/plugin-typescript \
rollup-plugin-postcss rollup-plugin-svelte
Lets add the required types
npm i -D @types/node @types/webextension-polyfill
Lets add the tsconfig for building our popup
touch tsconfig.app.json
and paste this
{
"extends": "@tsconfig/svelte/tsconfig.json"
}
initialize tailwind
npx tailwindcss init -p
update the tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {},
},
plugins: [],
}
NOTE:change the postcss.config.js file to postcss.config.cjs
create src directory, all our code will reside here
mkdir src
inside the src folder
touch tailwind.css
add the following code
@tailwind base;
@tailwind components;
@tailwind utilities;
lets make a public folder in the root directory.
mkdir public
add a manifests folder, here we will add manifest file for chromium based browsers and firefox
mkdir manifests
lets first add for chrome , we will comeback to it later to add background and content scripts. For chrome it will be manifest v3
touch manifest_chrome.json
{
"manifest_version": 3,
"name": "cross-browser",
"version": "1.0",
"description": "cross browser extension",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["storage", "activeTab", "windows"],
"host_permissions": ["<all_urls>"],
"action": {
"default_popup": "popup.html"
},
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
Lets add another file for firefox
touch manifest_firefox.json
{
"manifest_version": 2,
"name": "example",
"version": "1.0",
"description": "example",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": [
"storage",
"activeTab",
"tabs",
"<all_urls>"
],
"browser_action": {
"default_icon": "icons/icon48.png",
"default_popup": "popup.html",
"default_title": "example"
},
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
}
now lets add a popup.html file inside public folder
touch popup.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte Extension</title>
<link rel='stylesheet' href='/build/popup.css'>
<script defer src="/build/popup.js"></script>
<style>
body {
width: 400px;
height: 400px;
margin: 10px;
padding: 10px;
}
</style>
</head>
<body>
<!-- Your extension's HTML content goes here -->
</body>
</html>
now lets add the basic popup
lets add the entry point
mkdir -p src/lib
cd src/lib
touch popup.svelte
in popup.svelte
<script lang="ts">
export let name: string;
</script>
<main>
<h1 class="bg-red-800">Hello {name} !!</h1>
</main>
in src/ directory
touch popup.ts
inside popup.ts
import App from "./lib/popup.svelte";
import "./tailwind.css";
const app = new App({
target: document.body,
props: {
name: "world",
}
});
export default app;
now lets configure rollup for this :
add rollup.config.js in the root directory
we are using brave for live reloading, if you want to change , please change the command in the serve function.
import { exec } from "child_process";
import svelte from "rollup-plugin-svelte";
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import preprocess from "svelte-preprocess";
import postcss from "rollup-plugin-postcss";
import autoprefixer from "autoprefixer";
import tailwindcss from "tailwindcss";
import os from "os";
import fs from "fs";
const production = !process.env.ROLLUP_WATCH;
const buildEnv = process.env.BUILD_ENV;
function serve() {
return {
writeBundle() {
// Open Brave browser with the specified URL
if(production) {
const manifestSource = buildEnv === 'firefox' ? 'public/manifests/manifest_firefox.json' : 'public/manifests/manifest_chrome.json';
const manifestDest = 'public/manifest.json';
fs.copyFileSync(manifestSource, manifestDest);
}else {
let command;
if (os.platform() === "linux") {
command = "brave --reload-extension=public/build";
} else {
command =
"'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' --reload-extension=public/build";
}
// Open Brave browser with the specified URL
exec(command, (err) => {
if (err) {
console.error("Failed to open Brave:", err);
}
});
}
},
};
}
function buildConfig(inputFileName, outputFileName) {
return {
input: `src/${inputFileName}.ts`,
output: {
file: `public/build/${outputFileName}.js`,
format: "iife",
name: "app",
sourcemap: !production,
},
plugins: [
svelte({
compilerOptions: {
dev: !production,
},
preprocess: preprocess({
typescript: {
tsconfigFile: "./tsconfig.app.json",
},
postcss: {
plugins: [tailwindcss, autoprefixer],
},
}),
}),
postcss({
extract: `${outputFileName}.css`,
minimize: production,
sourceMap: !production,
config: {
path: "./postcss.config.cjs",
},
}),
typescript({ sourceMap: !production, tsconfig: "./tsconfig.app.json" }),
resolve({ browser: true }),
commonjs(),
serve(),
],
watch: {
clearScreen: false,
},
};
}
export default [
buildConfig("popup", "popup"),
];
add svelte.d.ts file on root directory:
// svelte.d.ts
declare module '*.svelte' {
import { SvelteComponentDev } from 'svelte/internal';
export default SvelteComponentDev;
}
NOTE: Make sure that you are using the workspace version of typescript
update the package.json with the following scripts and type: “module”
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "BUILD_ENV=chrome rollup -c -w",
"chrome": "BUILD_ENV=chrome rollup -c",
"firefox": "BUILD_ENV=firefox rollup -c"
},
npm run dev will bring up the dev server it will use brave browser as default.
in the public directory create a new directory:
mkdir icons
now add your icons to it:
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
now run the command:
npm run chrome
it will output the build files to public folder.
NOTE: after building for firefox, if you want to continue running the dev build always run ‘npm run chrome’ because otherwise the manifest of firefox wont be replaced.
now lets run it as a development server
npm run dev
after loading the extension in brave, lets change the color of main to blue and confirm that everything is working as expected.
now lets add another entry point to run it in full screen(optional).
lets add dashboard.html in the public directory:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte Extension</title>
<link rel='stylesheet' href='/build/dashboard.css'>
<script defer src="/build/dashboard.js"></script>
</head>
<body>
</body>
</html>
add dashboard.svelte in
touch src/lib/dashboard.svelte
//src/lib/dahsboard.svelte
<main
class="w-screen h-screen flex justify-center items-center text-3xl font-semibold"
>
<h1>Hello world</h1>
</main>
add dashboard entry point
touch src/dashboard.ts
//src/dashboard.ts
import App from "./lib/dashboard.svelte";
import "./tailwind.css";
const app = new App({
target: document.body,
});
export default app;
update popup.svelte
<script lang="ts">
import browser from "webextension-polyfill";
export let name: string;
const openFullscreen = () => {
browser.tabs.create({ url: browser.runtime.getURL("dashboard.html") });
};
</script>
<main class="flex flex-col justify-center items-center">
<h1 class="bg-blue-600">Hello {name} !!</h1>
<button
class="bg-blue-600 px-[6px] py-[14px] mt-6 text-white font-semibold"
on:click={openFullscreen}>Click me</button
>
</main>
update at the end of rollup config:
export default [
buildConfig("popup", "popup"),
buildConfig("dashboard", "dashboard"),
];
then run
npm run dev
now this will bring up brave twice, that is because of the serve being called twice we will fix that after adding background script
popup should have a click me button and clicking on that should open a full page with our dashboard.svelte details.
lets add the background script
mkdir -p src/scripts && touch src/scripts/background.ts
//src/scripts/background.ts
import browser from 'webextension-polyfill';
browser.runtime.onInstalled.addListener(() => {
console.log("Extension installed successfully!");
});
lets add a tsconfig for building background.ts file in the root directory and lets name it tsconfig.background.json
touch tsconfig.background.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"moduleResolution": "node",
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"*": [
"src/*"
]
},
"allowJs": true,
"isolatedModules": true,
},
"include": [
"src/scripts/*.ts"
],
"exclude": [
"node_modules/*",
"src/**/*.svelte",
"public/*",
"src/scripts/*.wasm"
]
}
lets add the rollup config for compiling background script
export default [
buildConfig("popup", "popup"),
buildConfig("dashboard", "dashboard"),
{
input: "src/scripts/background.ts",
output: {
format: buildEnv === 'firefox' ? 'iife' : 'es',
name: "background",
file: "public/background.js",
sourcemap: !production,
},
plugins: [
typescript({
tsconfig: "./tsconfig.background.json",
sourceMap: !production,
}),
commonjs(),
resolve({ browser: true, preferBuiltins: false }),
],
watch: {
clearScreen: false,
},
},
];
now update both manifests
for manifest_chrome.json
"background": {
"type": "module",
"service_worker": "background.js"
},
and for manifest_firefox.json
"background": {
"scripts": ["background.js"]
},
now run so that the updated manifest is replaced by old manifest
npm run chrome
Now you can see the service worker in manage extensions tab, click on that and you will be able to see the message from background script.
now lets send and receive message to and from background script:
update popup.svelte
<script lang="ts">
import browser from "webextension-polyfill";
export let name: string;
const openFullscreen = () => {
browser.tabs.create({ url: browser.runtime.getURL("dashboard.html") });
};
const sendMessage = async () => {
const response = await browser.runtime.sendMessage({
message: "Hello from popup",
});
console.log(response);
};
</script>
<main class="flex flex-col justify-center items-center">
<h1 class="bg-blue-600">Hello {name} !!</h1>
<button
class="bg-blue-600 px-[6px] py-[14px] mt-6 text-white font-semibold"
on:click={openFullscreen}>Click me</button
>
<button
class="bg-green-600 px-[6px] py-[14px] mt-3 text-white font-semibold"
on:click={sendMessage}
>
Send Message</button
>
</main>
update background.ts script
import browser from 'webextension-polyfill';
browser.runtime.onInstalled.addListener(() => {
console.log("Extension installed successfully!");
});
browser.runtime.onMessage.addListener(async (request) => {
console.log(request);
// listners must be async.
return new Promise((resolve, reject) => {
resolve({ message: 'Hello from background script' });
});
})
you can see the messages by right clicking on popup -> click on inspect -> click on console
In the background side manage extension -> service worker-> console
if the message is not shown, manually reload the extension from manage extensions tab
now lets add content script
update manifest_chrome.
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
],
update manifest_firefox.json
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
],
add content.ts
touch src/scripts/content.ts
console.log('content script running')
here is config to update build for content script :
NOTE: full script given after this
{
input: "src/scripts/content.ts",
output: {
format: "iife",
name: "content",
file: "public/content.js",
sourcemap: !production,
},
plugins: [
typescript({
tsconfig: "./tsconfig.background.json",
}),
commonjs(),
resolve({ browser: true, preferBuiltins: false }),
serve(),
],
watch: {
clearScreen: false,
},
},
this is the final updated rollup script, here we removed the serve function to content script
import { exec } from "child_process";
import svelte from "rollup-plugin-svelte";
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import preprocess from "svelte-preprocess";
import postcss from "rollup-plugin-postcss";
import autoprefixer from "autoprefixer";
import tailwindcss from "tailwindcss";
import os from "os";
import fs from "fs";
const production = !process.env.ROLLUP_WATCH;
const buildEnv = process.env.BUILD_ENV;
function serve() {
return {
writeBundle() {
// Open Brave browser with the specified URL
if(production) {
const manifestSource = buildEnv === 'firefox' ? 'public/manifests/manifest_firefox.json' : 'public/manifests/manifest_chrome.json';
const manifestDest = 'public/manifest.json';
fs.copyFileSync(manifestSource, manifestDest);
}else {
let command;
if (os.platform() === "linux") {
command = "brave-browser --reload-extension=public/build";
} else {
command =
"'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' --reload-extension=public/build";
}
// Open Brave browser with the specified URL
exec(command, (err) => {
if (err) {
console.error("Failed to open Brave:", err);
}
});
}
},
};
}
function buildConfig(inputFileName, outputFileName) {
return {
input: `src/${inputFileName}.ts`,
output: {
file: `public/build/${outputFileName}.js`,
format: "iife",
name: "app",
sourcemap: !production,
},
plugins: [
svelte({
compilerOptions: {
dev: !production,
},
preprocess: preprocess({
typescript: {
tsconfigFile: "./tsconfig.app.json",
},
postcss: {
plugins: [tailwindcss, autoprefixer],
},
}),
}),
postcss({
extract: `${outputFileName}.css`,
minimize: production,
sourceMap: !production,
config: {
path: "./postcss.config.cjs",
},
}),
typescript({ sourceMap: !production, tsconfig: "./tsconfig.app.json" }),
resolve({ browser: true }),
commonjs(),
],
watch: {
clearScreen: false,
},
};
}
export default [
buildConfig("popup", "popup"),
buildConfig("dashboard", "dashboard"),
{
input: "src/scripts/background.ts",
output: {
format: buildEnv === 'firefox' ? 'iife' : 'es',
name: "background",
file: "public/background.js",
sourcemap: !production,
},
plugins: [
typescript({
tsconfig: "./tsconfig.background.json",
sourceMap: !production,
}),
commonjs(),
resolve({ browser: true, preferBuiltins: false }),
serve()
],
watch: {
clearScreen: false,
},
},
{
input: "src/scripts/content.ts",
output: {
format: "iife",
name: "content",
file: "public/content.js",
sourcemap: !production,
},
plugins: [
typescript({
tsconfig: "./tsconfig.background.json",
}),
commonjs(),
resolve({ browser: true, preferBuiltins: false }),
serve(),
],
watch: {
clearScreen: false,
},
},
];
now run build script to update the manifest
npm run chrome
now run dev script
npm run dev
now if you check on the console of any website content script message would be shown.
lets start implementing wasm
download and install rust from here
lets install wasm-pack
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
add a new directory in root
mkdir wasm && cd wasm
cargo init --lib
this will give initialize the folder for library
add wasm-bindgen in cargo.toml
[package]
name = "wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
now replace the src/lib.rs file with this code:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet() -> String {
"Hello, world! from wasm".into()
}
after that run the command inside the wasm folder
wasm-pack build --target web
now in the pkg directory
there would be a file called wasm_bg.wasm move that file to the public directory.
move wasm.js file to scripts folder (where background.ts is)
update the background script
import browser from 'webextension-polyfill';
import init, {greet} from './wasm';
browser.runtime.onInstalled.addListener(() => {
console.log("Extension installed successfully!");
});
browser.runtime.onMessage.addListener(async (request) => {
await init();
const response = await greet()
return response;
})
now go to root directory and run:
npm run dev
now when clicking on the send message button it should console log hello world.
if that doesn’t work manually reload
now lets add eslint and prettier
for eslint
npm install --save-dev eslint eslint-plugin-svelte @typescript-eslint/eslint-plugin
for prettier
add the .prettierrc file in the root directory
npm i -D prettier prettier-plugin-svelte
touch .prettierrc
{
"useTabs": true,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"svelteSortOrder": "options-scripts-styles-markup",
"svelteStrictMode": true,
"svelteBracketNewLine": false,
"svelteAllowShorthand": true,
"svelteIndentScriptAndStyle": true
}
touch .eslintrc.cjs
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ["eslint:recommended", "plugin:svelte/recommended"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
parserOptions: {
project: "./tsconfig.app.json",
ecmaVersion: "latest",
sourceType: "module",
extraFileExtensions: [".svelte"],
},
overrides: [
{
files: ["*.svelte"],
parser: "svelte-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
project: "./tsconfig.app.json",
},
rules: {
"indent": ["error", "tab", { "SwitchCase": 1 }],
"no-tabs": "off"
}
},
{
files: ["src/**/*.ts"],
parserOptions: {
project: "./tsconfig.app.json",
},
rules: {
"indent": ["error", "tab", { "SwitchCase": 1 }],
"no-tabs": "off"
}
},
],
rules: {
"indent": ["error", "tab", { "SwitchCase": 1 }],
"no-tabs": "off"
},
};
lets install husky
npm i -D husky
npx husky init
lets also add these scripts in the scripts section in package.json
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "BUILD_ENV=chrome rollup -c -w",
"chrome": "BUILD_ENV=chrome rollup -c",
"firefox": "BUILD_ENV=firefox rollup -c",
"lint": "eslint --fix 'src/**/*.{js,ts,svelte}' --ignore-pattern 'src/scripts/wasm.js'",
"format": "prettier --write 'src/**/*.{js,ts,svelte}'",
"prepare": "husky"
},
please add these to .gitignore
node_modules
wasm/target
wasm/pkg
public/build
public/*.js.map
in the pre-commit located in .husky/pre-commit script add
npm run format && npm run lint
npm run prepare
now when every you commit it will run linter and formatter and only commit if there are no errors.
the completed code is available here
Conclusion
When I started working on my extension for Osvauld, I found it difficult to get a good boilerplate to start with so, I created this boilerplate to streamline the development process. This boilerplate has been instrumental in ensuring a smooth and efficient workflow.
If you found this article helpful and are interested in secure credential management solutions, I’d like to introduce you to Osvauld. Osvauld is an open-source, self-hostable credentials manager for enterprises. It securely stores and shares sensitive information, enhancing collaboration and security. With features like secure encryption, role-based access control, and audit logs, Osvauld offers comprehensive credential management at a flat rate of $25/month regardless of the number of users.
Explore more about Osvauld and how it can help your organization streamline access management while maintaining control and reducing risks. Visit our official website and check out our project on GitHub for more details.
Thank you for reading!