· 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 lesenInstagram 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 gesund machen wenn der nginx Healthcheck an localhost scheitert: Artikel lesen
Du planst eine Browser-Extension oder eine Power Pages Erweiterung mit Capture-Funktionalität? Lass uns das gemeinsam einschätzen.



