PROJECT: Chrome Extension Manifest V3
Skill gerado a partir do pack templates-claude-code. Arquivo de origem: backend/12-chrome-extension-mv3.md. Use como baseline e adapte ao projeto antes de mudancas grandes.
Conteudo do template
STACK
- Platform: Chrome Extension Manifest V3
- Language: TypeScript 5.x (strict)
- UI Framework: React 19 (popup + options pages)
- Build tool: Vite + CRXJS v2
- Styling: Tailwind CSS v3
- State: Zustand (popup) + chrome.storage.sync
- Testing: Vitest + happy-dom (unit), Playwright (E2E)
PROJECT STRUCTURE
src/
├── background/
│ └── service-worker.ts # Background service worker
├── content/
│ ├── index.ts # Content script entry
│ └── injected.ts # Injected script (isolated world)
├── popup/
│ ├── index.html
│ ├── App.tsx
│ └── components/
├── options/
│ ├── index.html
│ └── App.tsx
├── shared/
│ ├── messages.ts # Message type definitions
│ ├── storage.ts # chrome.storage wrappers
│ └── utils.ts
public/
└── icons/
manifest.json
vite.config.ts
tsconfig.json
ARCHITECTURE RULES
- Service worker is stateless — it can be terminated at any time; never rely on in-memory state; use
chrome.storage. - Content scripts are isolated — they run in the page context but cannot access page JS variables directly; use
window.postMessageor injected scripts. - Message passing is typed — define a discriminated union
AppMessage; usechrome.runtime.sendMessageonly with typed messages. - Permissions are minimal — request only what is needed; do not use
<all_urls>if a specific match pattern suffices. - No eval / dynamic code execution — MV3 CSP blocks it; build everything at compile time.
- storage.sync for user preferences —
storage.localfor large or sensitive data. - CRXJS handles HMR — never manually reload in development.
MANIFEST V3 CONFIGURATION
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "Production-ready Chrome Extension",
"permissions": [
"storage",
"activeTab",
"scripting"
],
"host_permissions": [
"https://api.example.com/*"
],
"background": {
"service_worker": "src/background/service-worker.ts",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["src/content/index.ts"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "src/popup/index.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": {
"page": "src/options/index.html",
"open_in_tab": true
}
}
TYPED MESSAGE PASSING
// src/shared/messages.ts
export type AppMessage =
| { type: 'GET_PAGE_DATA' }
| { type: 'PAGE_DATA'; payload: { title: string; url: string } }
| { type: 'SET_BADGE'; count: number }
| { type: 'OPEN_OPTIONS' }
// src/background/service-worker.ts
import type { AppMessage } from '../shared/messages'
// Service worker: listen for messages from popup or content
chrome.runtime.onMessage.addListener(
(message: AppMessage, sender, sendResponse) => {
if (message.type === 'GET_PAGE_DATA') {
// Query active tab
chrome.tabs.query({ active: true, currentWindow: true }, async ([tab]) => {
if (!tab.id) return
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => ({ title: document.title, url: location.href }),
})
sendResponse({ type: 'PAGE_DATA', payload: results[0].result })
})
return true // keeps channel open for async response
}
if (message.type === 'SET_BADGE') {
chrome.action.setBadgeText({ text: String(message.count) })
chrome.action.setBadgeBackgroundColor({ color: '#6366f1' })
}
}
)
// src/popup/App.tsx — sending messages
const getPageData = async () => {
const response = await chrome.runtime.sendMessage<AppMessage, AppMessage>({
type: 'GET_PAGE_DATA',
})
if (response.type === 'PAGE_DATA') setData(response.payload)
}
STORAGE API WRAPPER
// src/shared/storage.ts
export interface ExtConfig {
theme: 'light' | 'dark'
apiKey: string
autoCapture: boolean
}
const DEFAULTS: ExtConfig = {
theme: 'light', apiKey: '', autoCapture: false,
}
export const storage = {
async get(): Promise<ExtConfig> {
const raw = await chrome.storage.sync.get(DEFAULTS)
return raw as ExtConfig
},
async set(patch: Partial<ExtConfig>): Promise<void> {
await chrome.storage.sync.set(patch)
},
onChange(cb: (changes: Partial<ExtConfig>) => void): void {
chrome.storage.sync.onChanged.addListener((changes) => {
const patch: Partial<ExtConfig> = {}
for (const [key, { newValue }] of Object.entries(changes)) {
(patch as Record<string, unknown>)[key] = newValue
}
cb(patch)
})
},
}
CONTENT SCRIPT ISOLATION
// src/content/index.ts — runs in ISOLATED world; can read DOM
import type { AppMessage } from '../shared/messages'
// Observe DOM changes and notify background
const observer = new MutationObserver(() => {
const count = document.querySelectorAll('[data-item]').length
chrome.runtime.sendMessage<AppMessage>({ type: 'SET_BADGE', count })
})
observer.observe(document.body, { childList: true, subtree: true })
// src/content/injected.ts — access page JS from MAIN world
// Injected via chrome.scripting.executeScript with world: "MAIN"
// NOTE: Cannot use chrome.* APIs here
window.addEventListener('message', (event) => {
if (event.source !== window || event.data?.source !== 'my-ext') return
// Bridge page data → isolated context via postMessage
window.postMessage({ source: 'my-ext-page', data: window.__appState }, '*')
})
ROUTING TABLE (Message Type → Handler)
| Message Type | From | To | Action |
|---|---|---|---|
GET_PAGE_DATA |
Popup | Service Worker | Execute script in active tab |
PAGE_DATA |
Service Worker | Popup | Return title + URL |
SET_BADGE |
Content Script | Service Worker | Update badge count |
OPEN_OPTIONS |
Popup | Service Worker | chrome.runtime.openOptionsPage() |
storage.get |
Any | chrome.storage.sync | Read config |
storage.set |
Options page | chrome.storage.sync | Write config |
| content→bg | Content | Service Worker | DOM event notification |
| tab.onUpdated | — | Service Worker | React on navigation |
QUALITY GATES
-
tsc --noEmit— zero errors - No
eval,new Function(), orinnerHTML = userInput - Permissions declared in
manifest.json— nothing requested at runtime that isn't declared -
vitest runpasses all unit tests -
npm run buildproduces cleandist/(no TS source, no sourcemaps in prod) - Extension loads in Chrome without errors in
chrome://extensions/ - Service worker message handlers all
return trueif async - Content script does not access
chrome.storagedirectly inMAINworld scripts
FORBIDDEN
- ❌
eval()ordocument.write()— blocked by MV3 CSP - ❌ Remote scripts loaded at runtime — all code must be bundled
- ❌
chrome.tabs.executeScript(MV2 API) — usechrome.scripting.executeScript - ❌ Storing tokens in
localStorage— usechrome.storage.local - ❌
background.jsas classic script with DOM access — SW has no DOM - ❌
<all_urls>host permission without clear justification - ❌ Hardcoded API keys in extension source — fetch from storage
- ❌ Long-running tasks in service worker without keepalive strategy