| name | browser-extension |
| description | Build browser extensions with Manifest V3, content scripts, and service workers. Use when: creating Chrome extension, Firefox addon, browser plugin. Triggers: "extension", "browser extension", "chrome extension", "firefox addon", "manifest v3". |
Browser Extension Development
Project Protection Setup
MANDATORY before writing any code:
# 1. Create .gitignore
cat >> .gitignore << 'EOF'
# Build
dist/
node_modules/
*.zip
# Secrets
.env
api_keys.js
config.local.ts
# IDE
.idea/
.vscode/
.DS_Store
# Extension artifacts
*.crx
*.pem
EOF
# 2. Setup pre-commit hooks
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: detect-private-key
- id: check-added-large-files
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
EOF
pre-commit install
Overview
Browser extensions extend browser functionality using web technologies (HTML, CSS, JS/TS).
| Browser | Manifest | Store |
|---|---|---|
| Chrome | V3 (required) | Chrome Web Store |
| Firefox | V2/V3 | Firefox Add-ons |
| Edge | V3 | Edge Add-ons |
Quick Start
Project Structure
my-extension/
├── manifest.json # Extension config
├── src/
│ ├── background.ts # Service worker
│ ├── content.ts # Page injection
│ ├── popup/
│ │ ├── popup.html
│ │ ├── popup.ts
│ │ └── popup.css
│ └── options/
│ ├── options.html
│ └── options.ts
├── icons/
│ ├── icon-16.png
│ ├── icon-48.png
│ └── icon-128.png
├── package.json
├── tsconfig.json
└── vite.config.ts
manifest.json (V3)
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A sample browser extension",
"permissions": [
"storage",
"activeTab"
],
"host_permissions": [
"https://*.example.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content.js"],
"css": ["content.css"]
}
],
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"options_page": "options/options.html",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
Service Worker (Background)
The background script runs in a service worker (V3) - it's event-driven and doesn't persist.
// background.ts
// Listen for extension install
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
console.log('Extension installed');
// Set default settings
chrome.storage.sync.set({ enabled: true });
}
});
// Listen for messages from content scripts or popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_DATA') {
fetchData().then(sendResponse);
return true; // Keep channel open for async response
}
});
// Listen for tab updates
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url?.includes('example.com')) {
// Tab loaded, do something
}
});
// Context menu
chrome.contextMenus.create({
id: 'my-action',
title: 'Do Something',
contexts: ['selection'],
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'my-action') {
const selectedText = info.selectionText;
// Process selection
}
});
Content Scripts
Content scripts run in the context of web pages.
// content.ts
// DOM manipulation
const button = document.createElement('button');
button.textContent = 'My Extension';
button.onclick = () => {
chrome.runtime.sendMessage({ type: 'BUTTON_CLICKED' });
};
document.body.appendChild(button);
// Listen for messages from background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'HIGHLIGHT') {
document.body.style.backgroundColor = 'yellow';
sendResponse({ success: true });
}
});
// Send data to background
const pageData = document.title;
chrome.runtime.sendMessage({ type: 'PAGE_DATA', data: pageData });
Isolated World
Content scripts run in an isolated world - they can access the DOM but not page JS variables.
// To access page context, inject a script
const script = document.createElement('script');
script.src = chrome.runtime.getURL('injected.js');
document.head.appendChild(script);
// Communication via custom events
window.addEventListener('from-page', (e: CustomEvent) => {
console.log('Data from page:', e.detail);
});
// In injected.js (runs in page context)
window.dispatchEvent(new CustomEvent('from-page', {
detail: { data: window.somePageVariable }
}));
Popup
<!-- popup/popup.html -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<h1>My Extension</h1>
<label>
<input type="checkbox" id="enabled">
Enabled
</label>
<button id="action">Do Something</button>
</div>
<script src="popup.js" type="module"></script>
</body>
</html>
// popup/popup.ts
const enabledCheckbox = document.getElementById('enabled') as HTMLInputElement;
const actionButton = document.getElementById('action') as HTMLButtonElement;
// Load saved state
chrome.storage.sync.get(['enabled'], (result) => {
enabledCheckbox.checked = result.enabled ?? true;
});
// Save state on change
enabledCheckbox.addEventListener('change', () => {
chrome.storage.sync.set({ enabled: enabledCheckbox.checked });
});
// Send message to background
actionButton.addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type: 'HIGHLIGHT' });
}
});
Storage
Local vs Sync
// Local storage (per-device, larger quota)
chrome.storage.local.set({ key: 'value' });
chrome.storage.local.get(['key'], (result) => {
console.log(result.key);
});
// Sync storage (synced across devices, smaller quota)
chrome.storage.sync.set({ setting: true });
chrome.storage.sync.get(['setting'], (result) => {
console.log(result.setting);
});
// Listen for changes
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && changes.setting) {
console.log('Setting changed:', changes.setting.newValue);
}
});
Message Passing
Content <-> Background
// From content script
chrome.runtime.sendMessage({ type: 'GET_DATA' }, (response) => {
console.log('Response:', response);
});
// From background (to specific tab)
chrome.tabs.sendMessage(tabId, { type: 'UPDATE' }, (response) => {
console.log('Response:', response);
});
Long-lived Connections
// Content script
const port = chrome.runtime.connect({ name: 'my-channel' });
port.postMessage({ type: 'INIT' });
port.onMessage.addListener((msg) => {
console.log('Received:', msg);
});
// Background
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'my-channel') {
port.onMessage.addListener((msg) => {
port.postMessage({ response: 'ok' });
});
}
});
Permissions
Required vs Optional
{
"permissions": [
"storage", // Always needed
"activeTab" // Safe, no warning
],
"optional_permissions": [
"tabs", // Request when needed
"history"
],
"host_permissions": [
"https://*.example.com/*"
],
"optional_host_permissions": [
"https://*/*" // Request for all sites
]
}
Requesting Optional Permissions
async function requestPermission() {
const granted = await chrome.permissions.request({
permissions: ['tabs'],
origins: ['https://other-site.com/*']
});
if (granted) {
console.log('Permission granted');
}
}
Build Setup (Vite)
// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
rollupOptions: {
input: {
background: resolve(__dirname, 'src/background.ts'),
content: resolve(__dirname, 'src/content.ts'),
popup: resolve(__dirname, 'src/popup/popup.html'),
options: resolve(__dirname, 'src/options/options.html'),
},
output: {
entryFileNames: '[name].js',
},
},
outDir: 'dist',
emptyOutDir: true,
},
});
// package.json
{
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"zip": "cd dist && zip -r ../extension.zip ."
},
"devDependencies": {
"@types/chrome": "^0.0.260",
"typescript": "^5",
"vite": "^5"
}
}
Firefox Compatibility
Manifest Differences
{
"browser_specific_settings": {
"gecko": {
"id": "my-extension@example.com",
"strict_min_version": "109.0"
}
},
"background": {
"scripts": ["background.js"] // Firefox V2 style
}
}
API Differences
// Use browser namespace (Firefox) with chrome fallback
const api = typeof browser !== 'undefined' ? browser : chrome;
// Or use webextension-polyfill
import browser from 'webextension-polyfill';
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Service worker dies | Re-register listeners, use alarms |
| CORS in content scripts | Make requests from background |
| DOM not ready | Use DOMContentLoaded or MutationObserver |
| CSP blocks inline scripts | Use external script files |
| Storage quota exceeded | Use local storage for large data |
Testing
Manual Loading
- Chrome:
chrome://extensions/-> Developer mode -> Load unpacked - Firefox:
about:debugging-> This Firefox -> Load Temporary Add-on
Automated Testing
// Use puppeteer with extension
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
Publishing
Chrome Web Store
- Create developer account ($5 one-time)
- Create ZIP of extension
- Submit for review (1-3 days)
Firefox Add-ons
- Create developer account (free)
- Submit XPI or ZIP
- Review (1-7 days)
Testing
Unit Tests (Vitest)
import { describe, it, expect, vi } from 'vitest';
// Mock chrome API
const chrome = {
storage: {
sync: {
get: vi.fn(),
set: vi.fn(),
},
},
runtime: {
sendMessage: vi.fn(),
},
};
global.chrome = chrome;
describe('Storage helpers', () => {
it('saves settings', async () => {
await saveSettings({ enabled: true });
expect(chrome.storage.sync.set).toHaveBeenCalledWith({ enabled: true });
});
it('loads settings with defaults', async () => {
chrome.storage.sync.get.mockResolvedValue({});
const settings = await loadSettings();
expect(settings.enabled).toBe(true); // default
});
});
describe('Content script', () => {
it('highlights elements', () => {
document.body.innerHTML = '<div class="target">Test</div>';
highlightTargets();
expect(document.querySelector('.target')?.style.backgroundColor).toBe('yellow');
});
});
Integration Tests (Puppeteer)
import puppeteer from 'puppeteer';
describe('Extension E2E', () => {
let browser;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
});
it('popup opens', async () => {
const page = await browser.newPage();
await page.goto(`chrome-extension://${extensionId}/popup/popup.html`);
const title = await page.$eval('h1', el => el.textContent);
expect(title).toBe('My Extension');
});
afterAll(async () => {
await browser.close();
});
});
TDD Workflow
1. Task[tdd-test-writer]: "Create settings storage"
→ Writes test for save/load settings
→ npm test → FAILS (RED)
2. Task[rust-developer]: "Implement storage helpers"
→ Implements with chrome.storage API
→ npm test → PASSES (GREEN)
3. Repeat for each feature
4. Task[code-reviewer]: "Review extension"
→ Checks permissions, CSP, security
Security Checklist
- Minimal permissions (request only what's needed)
- No
eval()or inline scripts - Validate all external data
- Use HTTPS only
- Content Security Policy defined
- No sensitive data in storage.sync (visible across devices)
- No API keys in source (use environment or user input)
- Input sanitization in content scripts
- pre-commit hooks with gitleaks