contextBridge ist die kontrollierte Schleuse zwischen Preload und Web-Kontext. Mit exposeInMainWorld hängst du Funktionen ans window-Objekt — sicher, ohne den Preload-Kontext zu kompromittieren. Hier alle Patterns: einfache Funktionen, Async-APIs, Event-Listener mit Cleanup und das, was NICHT funktioniert.
Grundprinzip
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
getVersion: () => ipcRenderer.invoke('app:get-version'),
quit: () => ipcRenderer.send('app:quit')
});exposeInMainWorld(name, object) macht das Objekt unter window.<name> im Renderer verfügbar. Im Renderer:
const v = await window.api.getVersion();
document.getElementById('version').textContent = v;Was hinter den Kulissen passiert: das Objekt wird zwischen den isolierten JavaScript-Heaps kopiert. Funktionen werden als Proxy-Funktionen exponiert — ein Aufruf im Web-Kontext leitet zur Original-Funktion im Preload-Kontext.
Was übertragen wird, was nicht
| Typ | Wird übertragen? |
|---|---|
| Funktionen | Ja, als Proxy |
| Primitive (string, number, boolean) | Ja, als Wert |
| Plain Objects (POJOs) | Ja, deep-cloned |
| Arrays | Ja |
| Promises | Ja, async-fähig |
Date, Map, Set | Ja (über Structured Clone) |
| Klassen-Instanzen | Eingeschränkt — Methoden gehen verloren |
| DOM-Knoten | Nein |
Buffer, ArrayBuffer | Ja |
Faustregel: alles was über Structured Clone funktioniert, geht. Komplexe Klassen-Instanzen oder DOM-Knoten brauchen serialisierte Repräsentation.
Strukturierte APIs
Anstatt eine flache Liste von Funktionen kann man die API gruppieren:
contextBridge.exposeInMainWorld('api', {
app: {
getVersion: () => ipcRenderer.invoke('app:get-version'),
quit: () => ipcRenderer.send('app:quit')
},
files: {
read: (path) => ipcRenderer.invoke('files:read', path),
write: (path, content) => ipcRenderer.invoke('files:write', path, content),
openDialog: () => ipcRenderer.invoke('files:open-dialog')
},
theme: {
set: (mode) => ipcRenderer.invoke('theme:set', mode),
get: () => ipcRenderer.invoke('theme:get')
}
});Im Renderer dann sauber:
const v = await window.api.app.getVersion();
const text = await window.api.files.read('/path/to/file');
await window.api.theme.set('dark');Event-Listener mit Cleanup
Ein häufiges Pattern: der Main pusht Events an den Renderer, der Renderer registriert Listener — und braucht einen Weg, sie wieder abzumelden.
contextBridge.exposeInMainWorld('api', {
onProgress: (callback) => {
const listener = (_event, data) => callback(data);
ipcRenderer.on('download:progress', listener);
// Unsubscribe-Funktion zurückgeben
return () => ipcRenderer.removeListener('download:progress', listener);
}
});// Listener registrieren
const unsubscribe = window.api.onProgress(({ percent }) => {
updateProgressBar(percent);
});
// Beim Cleanup
unsubscribe();Ohne Cleanup-Pattern leakt jeder neu registrierte Listener — bei Component-Re-Mounts in React/Svelte/Vue füllen sie sich auf, der Main pusht jeden Event N-fach.
Was NICHT geht
// FALSCH: contextBridge versteht keine Klassen-Instanzen
class MyService {
ping() { return ipcRenderer.invoke('ping'); }
}
contextBridge.exposeInMainWorld('service', new MyService());
// → window.service.ping ist undefinedFunktioniert nicht — Methoden auf Klassen-Instanzen werden nicht mit-übertragen. Lösung: Plain Object mit Funktionen.
// SCHLECHT: das ganze ipcRenderer exposen
contextBridge.exposeInMainWorld('ipc', ipcRenderer);Damit kann der Web-Code auf JEDEN Channel zugreifen — auch interne. Sicherheits-Hole. Stattdessen: pro Funktion einen Wrapper.
TypeScript-Typen für die API
Bei TypeScript-Setups will man die API auch im Renderer typisiert haben:
import { contextBridge, ipcRenderer } from 'electron';
const api = {
app: {
getVersion: () => ipcRenderer.invoke('app:get-version') as Promise<string>
},
files: {
read: (path: string) => ipcRenderer.invoke('files:read', path) as Promise<string>
}
} as const;
contextBridge.exposeInMainWorld('api', api);
export type Api = typeof api;import type { Api } from '../preload/preload';
declare global {
interface Window {
api: Api;
}
}Damit hat der Renderer-TypeScript Auto-Complete für window.api.app.getVersion() und sieht den Return-Typ Promise<string>.
Besonderheiten
exposeInMainWorld nur einmal pro Name.
Zweimal mit dem gleichen Namen aufzurufen wirft einen Fehler. Wer mehrere Module hat: alle in einem Aufruf zusammenführen oder unterschiedliche Top-Level-Namen wählen (window.api, window.theme, window.files).
Funktionen sind Proxies, kein direkter Aufruf.
Hinter dem window.api.foo() steckt eine Cross-Heap-Brücke. Der Aufruf serialisiert Argumente, transferiert, ruft im Preload, transferiert das Resultat zurück. Pro Aufruf eine kleine Latenz — bei tausenden Aufrufen pro Sekunde messbar.
Klassen-Instanzen werden zu POJOs.
Methoden gehen verloren. Wer Klassen-Logik braucht: im Web-Code rekonstruieren. Daten roh übermitteln, im Renderer in eine Klasse wrappen.
Cleanup-Funktion zurückgeben für Subscriptions.
Bei ipcRenderer.on(...) immer eine return () => ipcRenderer.removeListener(...) zurückgeben. Sonst leaken Listener bei jedem Component-Re-Mount.
exposeInIsolatedWorld für getrennte API-Levels.
Erweiterte Funktion: API in eine separate Welt exponieren (Iframe-Style). Selten nötig — für Apps mit mehreren Sicherheitsstufen oder eingebetteten Drittinhalten.
API möglichst klein halten.
Jede exposed Funktion ist eine Angriffsfläche. Wer sechs Funktionen exposet, hat sechs Stellen, an denen Validierung sitzen muss. Lieber eine API-Funktion mehr in den Main verlagern als eine ins Preload.