Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· 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.

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

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:

KontextLebensdauerDOM-Zugrifffetch()
Background Service WorkerKurzlebig, event-drivenNeinJa
Content ScriptSolange Tab offenJa (der Seite)Eingeschränkt (CORS)
PopupNur wenn geöffnetEigenes HTMLJa

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.

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.

Kommunikationsdiagramm der drei Chrome Extension MV3 Kontexte mit Message-Passing-Pfeilen 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:

  1. Einen neuen Extractor in src/content/
  2. Eine neue Builder-Funktion in payload-builder.ts
  3. 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

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen

  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen

  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen

  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern (dieser Artikel)

  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen

  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen

  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen

  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen

  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen

  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen

  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen

  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen

  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen

  14. Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen

  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen

  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen

  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen

  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen

  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen

  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen

  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen

  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen

  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen

  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen

  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen

  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen

  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen

  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen

  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen

  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen

  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen

  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen

  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen

  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

  35. Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen

  36. Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen

  37. 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.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen