· David Göschel · Webentwicklung · 7 minuten Lesezeit
Chrome Extension MV3 mit drei isolierten Kontexten und typsicherem Message-Passing
Eine Chrome Extension hat kein "main.js". Sie hat drei vollständig isolierte Laufzeitkontexte, die nur über ein striktes Message-Protokoll kommunizieren dürfen. Wer das nicht versteht, baut fragile Extensions. Wer es versteht, baut robuste Systeme.

Inhalt
- Das Missverständnis, das die meisten Extensions kaputt macht
- Wie das konkret in meiner Extension aussieht
- Das Message-Protokoll
- Das Strategy Pattern für plattformspezifische Payloads
- Was das in der Praxis bedeutet
- Alle Artikel der Serie
Das Missverständnis, das die meisten Extensions kaputt macht
Die meisten Entwickler, die zum ersten Mal eine Chrome Extension bauen, machen denselben Fehler: Sie denken in einer einzigen Codebasis mit geteiltem State.
Das funktioniert nicht. Chrome MV3 unterbindet das aktiv.
Eine Chrome Extension hat drei vollständig isolierte Laufzeitkontexte, die technisch nicht direkt miteinander kommunizieren können:
| Kontext | Lebensdauer | DOM-Zugriff | fetch() |
|---|---|---|---|
| Background Service Worker | Kurzlebig, event-driven | Nein | Ja |
| Content Script | Solange Tab offen | Ja (der Seite) | Eingeschränkt (CORS) |
| Popup | Nur wenn geöffnet | Eigenes HTML | Ja |
Diese Isolierung ist keine technische Einschränkung, sie ist eine Sicherheitsarchitektur. Wer sie versteht und damit arbeitet, baut stabilere Systeme.
Wie das konkret in meiner Extension aussieht
Background Service Worker
// src/background/main.ts
chrome.commands.onCommand.addListener(async (command) => {
if (command !== 'capture-screenshot') return;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return;
// 1. Get element bounds from the content script
const boundsResponse = await chrome.tabs.sendMessage<GetElementBoundsMessage, GetElementBoundsResponse>(tab.id, {
action: 'get-element-bounds',
});
// 2. Full-page screenshot via Chrome API
const screenshotDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
// 3. Crop via content script (canvas runs there)
const cropResponse = await chrome.tabs.sendMessage<CropScreenshotMessage, CropScreenshotResponse>(tab.id, {
action: 'crop-screenshot',
screenshotDataUrl,
bounds: boundsResponse.bounds,
});
// 4. Extract metadata
const metadataResponse = await chrome.tabs.sendMessage<GetInstagramMetadataMessage, GetInstagramMetadataResponse>(
tab.id,
{ action: 'get-instagram-metadata' }
);
// 5. Build payload and POST
const payload = buildPayload(cropResponse, metadataResponse, tab);
await fetch(API_ENDPOINT, { method: 'POST', body: JSON.stringify(payload) });
});Der Service Worker orchestriert alles. Er macht keine DOM-Operationen. Er speichert keinen State zwischen Aufrufen (er wird von Chrome jederzeit beendet und neu gestartet). Stattdessen delegiert er jeden Schritt an den richtigen Kontext. Das gilt auch für Authentifizierung: Der komplette PKCE OAuth-Flow mit chrome.identity.launchWebAuthFlow läuft im Background Service Worker, weil nur er stabil genug ist, um einen asynchronen Auth-Flow sauber abzuwarten.
Content Script
Das Content Script lebt im Tab. Es hat Zugang zum DOM der Seite, aber nur solange der Tab offen ist. Seine Aufgaben:
Hover-Tracking:
// src/content/main.ts
document.addEventListener('mouseover', (e) => {
if (!isActive) return;
if (hoveredElement) hoveredElement.style.outline = '';
hoveredElement = e.target as Element;
hoveredElement.style.outline = '2px solid red';
});Canvas-Cropping (DPR-korrekt):
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.action === 'crop-screenshot') {
const canvas = new OffscreenCanvas(
message.bounds.width * devicePixelRatio,
message.bounds.height * devicePixelRatio
);
const ctx = canvas.getContext('2d')!;
// ... load image and crop
canvas.convertToBlob().then((blob) => {
// send base64 back to background
});
return true; // required for async response
}
});Der entscheidende Punkt: devicePixelRatio. Auf einem Retina-Display gibt Chrome einen Screenshot mit doppelter Auflösung zurück. Wer das ignoriert, bekommt Crops, die versetzt oder falsch skaliert sind. Immer mit DPR multiplizieren.
Instagram-Metadaten-Extraktion:
// src/content/instagram-extractor.ts
export function extractInstagramMetadata(): InstagramPostMetadata {
// Walk up from hovered element to the <article> container
let el: Element | null = document.querySelector('[data-hovered]');
while (el && el.tagName !== 'ARTICLE') {
el = el.parentElement;
}
if (!el) return emptyMetadata();
const username = el.querySelector("a[href*='/']")?.textContent ?? null;
const caption =
el.querySelector("h1, [data-testid='post-comment-root-username']")?.closest('li')?.textContent ?? null;
const altTexts = [...el.querySelectorAll('img[alt]')].map((img) => (img as HTMLImageElement).alt).filter(Boolean);
return { username, caption, altTexts /* ... */ };
}Instagram rendert Posts als <article>-Elemente. Von einem geklickten Kindelement aus muss man im DOM nach oben navigieren, bis man den Container findet. Das ist typisch für moderne SPAs, bei denen das semantisch bedeutsame Element nicht direkt das gehoverte ist.
Popup
Das Popup ist technisch gesehen eine eigene HTML-Seite mit eigenem JS-Kontext. Es hat keinen direkten Zugriff auf das Content Script oder den Service Worker, nur über chrome.runtime.sendMessage oder chrome.storage.
// src/popup/main.ts
document.getElementById('toggle')?.addEventListener('change', async (e) => {
const isActive = (e.target as HTMLInputElement).checked;
await chrome.storage.sync.set({ isScreenshotActive: isActive });
// content script reads this value on next action
});chrome.storage.sync statt localStorage: Der wichtigste Unterschied. localStorage ist auf eine Herkunft begrenzt und nicht zwischen Extension-Kontexten geteilt. chrome.storage.sync ist für alle Kontexte zugänglich und synchronisiert sogar über Geräte hinweg.
Abbildung: Background Service Worker, Content Script und Popup sind vollständig isoliert. Jede Kommunikation läuft ausschließlich über chrome.tabs.sendMessage oder chrome.storage.
Das Message-Protokoll
Alle chrome.tabs.sendMessage-Aufrufe nutzen discriminated union types:
// src/types.ts
type GetElementBoundsMessage = { action: 'get-element-bounds' };
type CropScreenshotMessage = {
action: 'crop-screenshot';
screenshotDataUrl: string;
bounds: DOMRect;
};
type GetInstagramMetadataMessage = { action: 'get-instagram-metadata' };
type ContentScriptMessage = GetElementBoundsMessage | CropScreenshotMessage | GetInstagramMetadataMessage;Und der Aufruf ist vollständig typisiert:
const response = await chrome.tabs.sendMessage<CropScreenshotMessage, CropScreenshotResponse>(tabId, {
action: 'crop-screenshot',
screenshotDataUrl,
bounds,
});Warum ist das wichtig? Weil chrome.tabs.sendMessage standardmäßig any zurückgibt. In einer Extension mit drei Kontexten, asynchronem Message-Passing und verschiedenen Response-Typen ist any eine Fehlerquelle, die sich erst zur Laufzeit zeigt, genau dann wenn der User die Extension benutzt.
Discriminated unions sorgen dafür, dass TypeScript das Message-Routing zur Compile-Zeit prüft. Wenn ich eine neue Action einführe und sie im Content-Script-Listener nicht verarbeite, sagt mir TypeScript das, bevor der Code jemals im Browser landet.
Das Strategy Pattern für plattformspezifische Payloads
Die Extension unterstützt mehrere Plattformen (aktuell Instagram und eine generische Fallback-Variante). Das Payload-Building ist über ein funktionales Strategy Pattern gelöst:
// src/background/payload-builder.ts
type PayloadBuilder<T extends PlatformContext> = (context: T) => PlatformAnalysisPayload;
const instagramBuilder: PayloadBuilder<InstagramContext> = (ctx) => ({
platform: 'instagram',
metadata: ctx.instagramMetadata,
image: ctx.croppedImage,
capturedAt: new Date().toISOString(),
// ...
});
const genericBuilder: PayloadBuilder<GenericContext> = (ctx) => ({
platform: 'generic',
metadata: { pageTitle: ctx.pageTitle, pageUrl: ctx.pageUrl },
// ...
});// src/background/payload-context-builder.ts
export async function buildPayloadContext(tab: chrome.tabs.Tab): Promise<PlatformContext> {
if (tab.url?.includes('instagram.com')) {
const metadata = await chrome.tabs.sendMessage(tab.id!, { action: 'get-instagram-metadata' });
return { platform: 'instagram', instagramMetadata: metadata };
}
return { platform: 'generic', pageTitle: tab.title ?? '', pageUrl: tab.url ?? '' };
}Wenn eine neue Plattform (z.B. LinkedIn oder Pinterest) unterstützt werden soll, brauche ich nur:
- Einen neuen Extractor in
src/content/ - Eine neue Builder-Funktion in
payload-builder.ts - Eine neue Bedingung in
payload-context-builder.ts
Keine bestehende Logik wird angefasst. Open/Closed Principle in einer Chrome Extension.
Was das in der Praxis bedeutet
Diese Architektur-Entscheidungen sind keine theoretischen Übungen. Sie sind direkt relevant, wenn du Kunden berätst:
- Unternehmen, die Browser-Extensions als interne Tools nutzen wollen (z.B. CRM-Befüllung aus dem Browser)
- Power Pages Portale, die durch Extensions um Capture-Funktionalität erweitert werden sollen
- Jedes System, bei dem Daten aus dem Browser zuverlässig und typsicher extrahiert werden müssen
Das Verständnis dieser drei Kontexte und ihres Kommunikationsmodells ist der Unterschied zwischen einer Extension, die nach zwei Wochen kaputt ist, und einer, die produktiv im Einsatz läuft.
Alle Artikel der Serie
- Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
- RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
- AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
- Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: (dieser Artikel)
- Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
- Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
- Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
- tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesen - Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
- Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
- Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
- Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
- PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
- Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen
- Notiz und Tags beim Screenshot-Speichern: Artikel lesen
- Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
- Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
- Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
- PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
- React Frontend mit react-oidc-context und Zitadel: Artikel lesen
- Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
- Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
- MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
- access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
- Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
- Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
- Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
- Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
- Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
- Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
- DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
- Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
- Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
- Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen
- Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen
- Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen
- Frontend reparieren wenn der nginx Healthcheck an localhost scheitert: Artikel lesen
- Observability für meinen Docker Compose Stack mit Bull Board und Dozzle: Artikel lesen
- Qdrant Dashboard sicher öffnen mit lokalem Traefik und SSH Tunnel: Artikel lesen
- Diagnose: Warum mein Chunking trotz Tokenisierung noch scheiterte: Artikel lesen
- Entscheidung: Warum ich den Chunk auf 1500 Tokens gesetzt habe: Artikel lesen
- Implementierung: Wie ich den Embedding Workflow in mehrere saubere Schritte zerlegt habe: Artikel lesen
- Validierung: Wie ich Chunking, Speicherung und Suche wieder zusammenbringe: Artikel lesen
Du planst eine Browser-Extension oder eine Power Pages Erweiterung mit Capture-Funktionalität? Lass uns das gemeinsam einschätzen.



