Provides API compatibility matrices, polyfills, feature detection, and workarounds for browser extensions across Chrome, Firefox, Safari, and Edge.
npx claudepluginhub arustydev/agents --plugin browser-extension-devThis skill uses the workspace's default tool permissions.
Comprehensive guide to writing browser extensions that work across Chrome, Firefox, Safari, and Edge with proper feature detection and polyfills.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Comprehensive guide to writing browser extensions that work across Chrome, Firefox, Safari, and Edge with proper feature detection and polyfills.
Browser extensions share a common WebExtensions API standard, but implementations differ significantly. This skill covers how to handle those differences.
This skill covers:
This skill does NOT cover:
extension-anti-patterns skill)| Browser | Namespace | Promises | Polyfill Needed |
|---|---|---|---|
| Chrome | chrome.* | Callbacks | Yes |
| Firefox | browser.* | Native | No |
| Safari | browser.* | Native | No |
| Edge | chrome.* | Callbacks | Yes |
// Use webextension-polyfill for consistent API
import browser from 'webextension-polyfill';
// Now works in all browsers with Promises
const tabs = await browser.tabs.query({ active: true });
| API | Chrome | Firefox | Safari | Edge | Notes |
|---|---|---|---|---|---|
action.* | ✓ | ✓ | ✓ | ✓ | MV3 only |
alarms.* | ✓ | ✓ | ✓ | ✓ | Standard |
bookmarks.* | ✓ | ✓ | ✗ | ✓ | Safari: no support |
browserAction.* | MV2 | ✓ | MV2 | MV2 | Use action in MV3 |
commands.* | ✓ | ✓ | ◐ | ✓ | Safari: limited |
contextMenus.* | ✓ | ✓ | ✓ | ✓ | Standard |
cookies.* | ✓ | ✓ | ◐ | ✓ | Safari: restrictions |
downloads.* | ✓ | ✓ | ✗ | ✓ | Safari: no support |
history.* | ✓ | ✓ | ✗ | ✓ | Safari: no support |
i18n.* | ✓ | ✓ | ✓ | ✓ | Standard |
identity.* | ✓ | ◐ | ✗ | ✓ | Firefox: partial |
idle.* | ✓ | ✓ | ✗ | ✓ | Safari: no support |
management.* | ✓ | ✓ | ✗ | ✓ | Safari: no support |
notifications.* | ✓ | ✓ | ✗ | ✓ | Safari: no support |
permissions.* | ✓ | ✓ | ◐ | ✓ | Safari: limited |
runtime.* | ✓ | ✓ | ✓ | ✓ | Standard |
scripting.* | ✓ | ✓ | ◐ | ✓ | Safari: limited |
storage.* | ✓ | ✓ | ✓ | ✓ | Standard |
tabs.* | ✓ | ✓ | ◐ | ✓ | Safari: some limits |
webNavigation.* | ✓ | ✓ | ◐ | ✓ | Safari: limited |
webRequest.* | ✓ | ✓ | ◐ | ✓ | Safari: observe only |
windows.* | ✓ | ✓ | ◐ | ✓ | Safari: limited |
| API | Chrome | Firefox | Safari | Edge | Workaround |
|---|---|---|---|---|---|
declarativeNetRequest | ✓ | ◐ | ◐ | ✓ | Use webRequest |
offscreen | 109+ | ✗ | ✗ | 109+ | Content script |
sidePanel | 114+ | ✗ | ✗ | 114+ | Use popup |
storage.session | 102+ | 115+ | 16.4+ | 102+ | Use local + clear |
userScripts | 120+ | ✓ | ✗ | 120+ | Content scripts |
The Mozilla webextension-polyfill normalizes the Chrome callback-style API to Firefox's Promise-based API.
npm install webextension-polyfill
# TypeScript types
npm install -D @anthropic-ai/anthropic-sdk-types/webextension-polyfill
// background.ts
import browser from 'webextension-polyfill';
browser.runtime.onMessage.addListener(async (message, sender) => {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
return { tabId: tabs[0]?.id };
});
// content.ts
import browser from 'webextension-polyfill';
const response = await browser.runtime.sendMessage({ type: 'getData' });
console.log(response);
WXT provides built-in polyfill support:
// No import needed - browser is global
export default defineContentScript({
matches: ['*://*.example.com/*'],
main() {
// browser.* works everywhere
browser.runtime.sendMessage({ type: 'init' });
},
});
// Check if API exists
function hasAPI(api: string): boolean {
const parts = api.split('.');
let obj: any = typeof browser !== 'undefined' ? browser : chrome;
for (const part of parts) {
if (obj && typeof obj[part] !== 'undefined') {
obj = obj[part];
} else {
return false;
}
}
return true;
}
// Usage
if (hasAPI('sidePanel.open')) {
browser.sidePanel.open({ windowId });
} else {
// Fallback to popup
browser.action.openPopup();
}
// Detect browser at runtime
function getBrowser(): 'chrome' | 'firefox' | 'safari' | 'edge' | 'unknown' {
const ua = navigator.userAgent;
if (ua.includes('Firefox')) return 'firefox';
if (ua.includes('Safari') && !ua.includes('Chrome')) return 'safari';
if (ua.includes('Edg/')) return 'edge';
if (ua.includes('Chrome')) return 'chrome';
return 'unknown';
}
// Detect from extension APIs
function getBrowserFromAPIs(): 'chrome' | 'firefox' | 'safari' | 'edge' {
if (typeof browser !== 'undefined') {
// @anthropic-ai/anthropic-sdk-ts-expect-error - browser_specific_settings only in Firefox
if (browser.runtime.getBrowserInfo) return 'firefox';
return 'safari';
}
if (navigator.userAgent.includes('Edg/')) return 'edge';
return 'chrome';
}
// features.ts
export const FEATURES = {
sidePanel: hasAPI('sidePanel'),
offscreen: hasAPI('offscreen'),
sessionStorage: hasAPI('storage.session'),
userScripts: hasAPI('userScripts'),
declarativeNetRequest: hasAPI('declarativeNetRequest'),
} as const;
// Usage
import { FEATURES } from './features';
if (FEATURES.sidePanel) {
// Use side panel
} else {
// Use popup alternative
}
{
"browser_specific_settings": {
"gecko": {
"id": "my-extension@example.com",
"strict_min_version": "109.0"
}
}
}
{
"browser_specific_settings": {
"gecko": {
"id": "my-extension@example.com",
"data_collection_permissions": {
"required": [],
"optional": ["technicalAndInteraction"]
}
}
}
}
{
"browser_specific_settings": {
"gecko": {
"id": "my-extension@example.com"
},
"gecko_android": {
"strict_min_version": "120.0"
}
}
}
Safari extensions require a host app with PrivacyInfo.xcprivacy:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
</dict>
</plist>
// Safari doesn't support webRequest blocking
async function blockRequest(details: WebRequestDetails) {
const browser = getBrowser();
if (browser === 'safari') {
// Use declarativeNetRequest instead
await browser.declarativeNetRequest.updateDynamicRules({
addRules: [{
id: 1,
action: { type: 'block' },
condition: { urlFilter: details.url }
}]
});
} else {
// Use webRequestBlocking
return { cancel: true };
}
}
// Chrome service workers terminate after ~5 minutes
// Always persist state to storage
// BAD: State lost on worker termination
let count = 0;
// GOOD: Persist to storage
const countStorage = storage.defineItem<number>('local:count', {
defaultValue: 0
});
async function increment() {
const count = await countStorage.getValue();
await countStorage.setValue(count + 1);
}
// For DOM access in MV3 service worker
if (hasAPI('offscreen')) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSER'],
justification: 'Parse HTML content'
});
}
// wxt.config.ts
export default defineConfig({
manifest: ({ browser }) => ({
name: 'My Extension',
version: '1.0.0',
// Chrome/Edge
...(browser === 'chrome' && {
minimum_chrome_version: '116',
}),
// Firefox
...(browser === 'firefox' && {
browser_specific_settings: {
gecko: {
id: 'my-extension@example.com',
strict_min_version: '109.0',
},
},
}),
// Different permissions per browser
permissions: [
'storage',
'activeTab',
...(browser !== 'safari' ? ['notifications'] : []),
],
}),
});
| Feature | MV2 | MV3 |
|---|---|---|
| Background | backgroundcli | background.service_worker |
| Remote code | Allowed | Forbidden |
executeScript | Eval strings allowed | Functions only |
| Content security | Relaxed CSP | Strict CSP |
webRequestBlocking | Supported | Use DNR |
| Feature | Chrome | Firefox | Safari | Edge | Notes |
|---------|--------|---------|--------|------|-------|
| Install | [ ] | [ ] | [ ] | [ ] | |
| Popup opens | [ ] | [ ] | [ ] | [ ] | |
| Content script | [ ] | [ ] | [ ] | [ ] | |
| Background messages | [ ] | [ ] | [ ] | [ ] | |
| Storage sync | [ ] | [ ] | [ ] | [ ] | |
// tests/browser-compat.test.ts
import { describe, it, expect } from 'vitest';
import { fakeBrowser } from 'wxt/testing';
describe('cross-browser compatibility', () => {
it('handles missing sidePanel API', async () => {
// Simulate Safari (no sidePanel)
delete (fakeBrowser as any).sidePanel;
const result = await openUI();
expect(result.method).toBe('popup');
});
it('handles missing notifications API', async () => {
delete (fakeBrowser as any).notifications;
const result = await notify('Test');
expect(result.fallback).toBe('console');
});
});
Problem: Safari returns fewer tab properties.
Solution:
const tabs = await browser.tabs.query({ active: true });
const tab = tabs[0];
// Always check property existence
const url = tab?.url ?? 'unknown';
const favIconUrl = tab?.favIconUrl ?? '/default-icon.png';
| Browser | Local | Sync | Session |
|---|---|---|---|
| Chrome | 10MB | 100KB | 10MB |
| Firefox | Unlimited | 100KB | 10MB |
| Safari | 10MB | 100KB | 10MB |
Solution:
async function safeStore(key: string, data: unknown) {
const size = new Blob([JSON.stringify(data)]).size;
if (size > 100 * 1024 && storageArea === 'sync') {
console.warn('Data too large for sync, using local');
await browser.storage.local.set({ [key]: data });
} else {
await browser.storage[storageArea].set({ [key]: data });
}
}
Problem: Safari doesn't support blocking webRequests.
Solution: Use declarativeNetRequest for all browsers:
// Works in all browsers
await browser.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1],
addRules: [{
id: 1,
priority: 1,
action: { type: 'block' },
condition: {
urlFilter: '*://ads.example.com/*',
resourceTypes: ['script', 'image']
}
}]
});