Agents wxt-framework-patterns
Comprehensive WXT browser extension framework patterns, security hardening rules, and cross-browser configuration
install
source · Clone the upstream repo
git clone https://github.com/aRustyDev/agents
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/aRustyDev/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/content/plugins/web/browser-extension-dev/skills/wxt-framework-patterns" ~/.claude/skills/arustydev-agents-wxt-framework-patterns && rm -rf "$T"
manifest:
content/plugins/web/browser-extension-dev/skills/wxt-framework-patterns/SKILL.mdsource content
WXT Framework Patterns
Comprehensive guide for building cross-browser extensions with WXT, including security hardening, Firefox/Safari specifics, and production patterns.
Overview
WXT is the leading framework for browser extension development, offering:
- Cross-browser support: Chrome, Firefox, Edge, Safari
- Manifest agnostic: MV2 and MV3 from single codebase
- File-based entrypoints: Auto-generated manifest
- Vite-powered: Fast HMR for all script types
- Framework agnostic: React, Vue, Svelte, Solid, vanilla
This skill covers:
- Project structure and entrypoint patterns
- Configuration and manifest generation
- Security hardening rules (49 rules)
- Firefox-specific patterns
- Safari-specific patterns
- Testing and debugging
This skill does NOT cover:
- General JavaScript/TypeScript patterns
- Specific UI framework implementations
- Store submission process (see
skill)store-submission
Quick Reference
CLI Commands
| Command | Purpose |
|---|---|
| Start dev mode with HMR |
| Production build |
| Firefox-specific build |
| Package for distribution |
| Generate TypeScript types |
| Clean output directories |
| Publish to stores |
Entrypoint Types
| Type | File | Manifest Key |
|---|---|---|
| Background | | |
| Content Script | | |
| Popup | | |
| Options | | |
| Side Panel | | |
| Unlisted | | Not in manifest |
Project Structure
my-extension/ ├── entrypoints/ │ ├── background.ts # Service worker │ ├── content.ts # Content script │ ├── content/ # Multi-file content script │ │ ├── index.ts │ │ └── styles.css │ ├── popup/ │ │ ├── index.html │ │ ├── main.ts │ │ └── App.vue │ ├── options/ │ │ └── index.html │ └── sidepanel/ │ └── index.html ├── public/ │ └── icon/ │ ├── 16.png │ ├── 32.png │ ├── 48.png │ └── 128.png ├── utils/ # Shared utilities ├── wxt.config.ts # WXT configuration ├── tsconfig.json └── package.json
Entrypoint Patterns
Background Script (Service Worker)
// entrypoints/background.ts export default defineBackground(() => { console.log('Extension loaded', { id: browser.runtime.id }); // Handle messages from content scripts browser.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'getData') { handleGetData(message.payload).then(sendResponse); return true; // Keep channel open for async response } }); // Use alarms for recurring tasks (MV3 service worker friendly) browser.alarms.create('sync', { periodInMinutes: 5 }); browser.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'sync') { performSync(); } }); });
Content Script
// entrypoints/content.ts export default defineContentScript({ matches: ['*://*.example.com/*'], runAt: 'document_idle', main(ctx) { console.log('Content script loaded'); // Use context for lifecycle management ctx.onInvalidated(() => { console.log('Extension updated/disabled'); cleanup(); }); // Create isolated UI const ui = createShadowRootUi(ctx, { name: 'my-extension-ui', position: 'inline', anchor: '#target-element', onMount(container) { // Mount your UI framework here return mount(App, { target: container }); }, onRemove(app) { app.$destroy(); }, }); ui.mount(); }, });
Content Script with Main World Access
// entrypoints/content.ts export default defineContentScript({ matches: ['*://*.example.com/*'], world: 'MAIN', // Access page's JavaScript context main() { // Can access page's window object window.myExtensionApi = { getData: () => { /* ... */ } }; }, });
Popup with Framework
<!-- entrypoints/popup/index.html --> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> <div id="app"></div> <script type="module" src="./main.ts"></script> </body> </html>
// entrypoints/popup/main.ts import { createApp } from 'vue'; import App from './App.vue'; import './style.css'; createApp(App).mount('#app');
Configuration
Basic Configuration
// wxt.config.ts import { defineConfig } from 'wxt'; export default defineConfig({ srcDir: 'src', entrypointsDir: 'src/entrypoints', outDir: 'dist', manifest: { name: 'My Extension', description: 'Extension description', version: '1.0.0', permissions: ['storage', 'activeTab'], host_permissions: ['*://*.example.com/*'], }, });
Cross-Browser Configuration
// wxt.config.ts import { defineConfig } from 'wxt'; export default defineConfig({ manifest: ({ browser }) => ({ name: 'My Extension', description: 'Cross-browser extension', // Browser-specific settings ...(browser === 'firefox' && { browser_specific_settings: { gecko: { id: 'my-extension@example.com', strict_min_version: '109.0', data_collection_permissions: { required: [], optional: ['technicalAndInteraction'], }, }, }, }), // Chrome-specific ...(browser === 'chrome' && { minimum_chrome_version: '116', }), }), });
Per-Browser Entrypoint Options
// entrypoints/background.ts export default defineBackground({ // Different behavior per browser persistent: { firefox: true, // Use persistent background in Firefox chrome: false, // Service worker in Chrome }, main() { // ... }, });
Security Hardening Rules
Manifest Security (Rules 1-10)
| # | Rule | Rationale |
|---|---|---|
| 1 | Minimize | Request only what's needed |
| 2 | Use | Request sensitive permissions at runtime |
| 3 | Scope | Narrow to specific domains, never |
| 4 | Set | Ensure security features are available |
| 5 | Avoid wildcards | Limit which sites can message extension |
| 6 | Set strict CSP | No , no external scripts |
| 7 | Use sparingly | Fingerprinting risk |
| 8 | Never expose source maps | Hide implementation details |
| 9 | Remove debug permissions in production | e.g., , |
| 10 | Validate manifest with | Catch permission bloat |
Content Script Security (Rules 11-20)
| # | Rule | Rationale |
|---|---|---|
| 11 | Use Shadow DOM for injected UI | Style isolation, DOM encapsulation |
| 12 | Never use with untrusted data | XSS prevention |
| 13 | Validate all messages from page | Don't trust window.postMessage |
| 14 | Use for cleanup | Prevent memory leaks |
| 15 | Avoid storing sensitive data in DOM | Page scripts can read it |
| 16 | Use over | Less intrusive, more stable |
| 17 | Scope CSS selectors narrowly | Avoid page conflicts |
| 18 | Never inject into banking/payment pages | High-risk surfaces |
| 19 | Use MutationObserver over polling | Performance |
| 20 | Validate URL before injecting | Prevent injection on wrong pages |
Background Script Security (Rules 21-30)
| # | Rule | Rationale |
|---|---|---|
| 21 | Persist state to | Service worker terminates |
| 22 | Use over | Survives worker restart |
| 23 | Validate all incoming messages | Don't trust content scripts |
| 24 | Never store secrets in code | Use secure storage |
| 25 | Use HTTPS for all fetch requests | Data in transit security |
| 26 | Implement rate limiting | Prevent abuse |
| 27 | Log security events | Audit trail |
| 28 | Handle extension update gracefully | Reconnect content scripts |
| 29 | Use carefully | Performance impact |
| 30 | Avoid long-running operations | Service worker termination |
Storage Security (Rules 31-40)
| # | Rule | Rationale |
|---|---|---|
| 31 | Use for sensitive data | Not synced to cloud |
| 32 | Encrypt sensitive values | Defense in depth |
| 33 | Implement storage quotas | Prevent unbounded growth |
| 34 | Validate data before storing | Type safety |
| 35 | Use versioned schema migrations | Data integrity |
| 36 | Clear storage on uninstall | User privacy |
| 37 | Don't store PII without consent | GDPR/CCPA compliance |
| 38 | Use for temporary data | Auto-cleared |
| 39 | Implement backup/restore | Data recovery |
| 40 | Audit storage access | Security logging |
Communication Security (Rules 41-49)
| # | Rule | Rationale |
|---|---|---|
| 41 | Use over | Type-safe, scoped |
| 42 | Validate sender in message handlers | Prevent spoofing |
| 43 | Never pass functions in messages | Serialization issues |
| 44 | Chunk large data transfers | Memory efficiency |
| 45 | Use typed message protocols | Maintainability |
| 46 | Implement request timeouts | Prevent hanging |
| 47 | Handle disconnection gracefully | Tab closed, extension disabled |
| 48 | Don't expose internal APIs externally | Use separate handlers |
| 49 | Log and monitor message patterns | Detect anomalies |
Firefox-Specific Patterns
Required Gecko Settings
// wxt.config.ts manifest: { browser_specific_settings: { gecko: { // Required for AMO submission id: 'my-extension@example.com', // Version constraints strict_min_version: '109.0', // Data collection (required since Nov 2025) data_collection_permissions: { required: [], optional: ['technicalAndInteraction'], }, }, // Firefox for Android gecko_android: { strict_min_version: '120.0', }, }, }
Firefox MV3 Differences
| Feature | Chrome MV3 | Firefox MV3 |
|---|---|---|
| Background | Service worker only | Event page supported |
| Persistent | No | Optional with |
API | Promisified polyfill needed | Native promises |
| DNR | Full support | Partial support |
| Side Panel | Supported | Not supported |
Firefox-Specific Build
# Build for Firefox only wxt build -b firefox # Build MV2 for Firefox (if needed) wxt build -b firefox --mv2
Handling Firefox Differences
// utils/browser-detect.ts export const isFirefox = navigator.userAgent.includes('Firefox'); // entrypoints/background.ts export default defineBackground({ persistent: isFirefox, // Keep background alive in Firefox main() { if (isFirefox) { // Firefox-specific initialization } }, });
Safari-Specific Patterns
Xcode Project Requirements
Safari extensions require an Xcode host app:
# Convert existing extension to Safari xcrun safari-web-extension-converter /path/to/extension \ --project-location /path/to/output \ --app-name "My Extension" \ --bundle-identifier com.example.myextension
Privacy Manifest (Required)
Every Safari extension host app needs
PrivacyInfo.xcprivacy:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSPrivacyTracking</key> <false/> <key>NSPrivacyTrackingDomains</key> <array/> <key>NSPrivacyCollectedDataTypes</key> <array/> <key>NSPrivacyAccessedAPITypes</key> <array/> </dict> </plist>
Safari Limitations
| Feature | Status | Workaround |
|---|---|---|
| Side Panel | Not supported | Use popup |
| Limited | Use |
API | Not supported | Use content script |
| Persistent background | Not supported | State persistence |
| Limited | Declare in manifest |
Safari Build Workflow
# 1. Build extension wxt build -b safari # 2. Convert to Xcode project xcrun safari-web-extension-converter dist/safari-mv3 \ --project-location safari-app # 3. Open in Xcode open safari-app/MyExtension.xcodeproj # 4. Add PrivacyInfo.xcprivacy to host app target # 5. Archive and submit to App Store
TestFlight Distribution
As of 2025, Safari extensions can be submitted as ZIP files to App Store Connect for TestFlight testing without needing Xcode locally.
Storage Patterns
Using WXT Storage Utility
// utils/storage.ts import { storage } from 'wxt/storage'; // Define typed storage items export const userSettings = storage.defineItem<{ theme: 'light' | 'dark'; notifications: boolean; }>('local:settings', { defaultValue: { theme: 'light', notifications: true, }, }); export const sessionData = storage.defineItem<string[]>( 'session:recentTabs', { defaultValue: [] } ); // Usage const settings = await userSettings.getValue(); await userSettings.setValue({ ...settings, theme: 'dark' }); // Watch for changes userSettings.watch((newValue, oldValue) => { console.log('Settings changed:', newValue); });
Storage Migrations
// utils/storage.ts import { storage } from 'wxt/storage'; export const userPrefs = storage.defineItem('local:prefs', { defaultValue: { version: 2, theme: 'system' }, migrations: [ // v1 -> v2: renamed 'darkMode' to 'theme' { version: 2, migrate(oldValue: { darkMode?: boolean }) { return { version: 2, theme: oldValue.darkMode ? 'dark' : 'light', }; }, }, ], });
Testing Patterns
Unit Testing with Vitest
// tests/background.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fakeBrowser } from 'wxt/testing'; describe('background script', () => { beforeEach(() => { fakeBrowser.reset(); }); it('handles getData message', async () => { // Setup fake response fakeBrowser.storage.local.get.mockResolvedValue({ data: 'test' }); // Import and run background script await import('../entrypoints/background'); // Simulate message const [listener] = fakeBrowser.runtime.onMessage.addListener.mock.calls[0]; const response = await new Promise((resolve) => { listener({ type: 'getData' }, {}, resolve); }); expect(response).toEqual({ data: 'test' }); }); });
E2E Testing
// tests/e2e/extension.test.ts import { test, expect, chromium } from '@playwright/test'; import path from 'path'; test('popup shows correct UI', async () => { const extensionPath = path.join(__dirname, '../../dist/chrome-mv3'); const context = await chromium.launchPersistentContext('', { headless: false, args: [ `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`, ], }); // Get extension ID const [background] = context.serviceWorkers(); const extensionId = background.url().split('/')[2]; // Open popup const popup = await context.newPage(); await popup.goto(`chrome-extension://${extensionId}/popup.html`); await expect(popup.locator('h1')).toHaveText('My Extension'); });
Production Checklist
Before Build
- Remove console.log statements
- Set production environment variables
- Verify all permissions are necessary
- Test on all target browsers
- Run security audit (
)npm audit - Check bundle size (
)wxt build --analyze
Manifest Validation
- Extension name and description are accurate
- Icons in all required sizes (16, 32, 48, 128)
- Version follows semver
- Gecko ID set for Firefox
- Privacy manifest for Safari
- CSP is strict (no unsafe-eval)
Cross-Browser Build
# Build all browsers wxt build -b chrome wxt build -b firefox wxt build -b safari wxt build -b edge # Package for submission wxt zip -b chrome wxt zip -b firefox