Building a Cross-Browser Extension with Svelte: Integrating Tailwind, TypeScript and WebAssembly

Abraham George
11 min readJun 27, 2024

--

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!

--

--