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

JavaScript preload.js
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:

JavaScript renderer.js
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

TypWird übertragen?
FunktionenJa, als Proxy
Primitive (string, number, boolean)Ja, als Wert
Plain Objects (POJOs)Ja, deep-cloned
ArraysJa
PromisesJa, async-fähig
Date, Map, SetJa (über Structured Clone)
Klassen-InstanzenEingeschränkt — Methoden gehen verloren
DOM-KnotenNein
Buffer, ArrayBufferJa

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:

JavaScript preload.js
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:

JavaScript renderer.js
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.

JavaScript preload.js
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);
    }
});
JavaScript renderer.js
// 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

JavaScript Direkter Zugriff funktioniert nicht
// FALSCH: contextBridge versteht keine Klassen-Instanzen
class MyService {
    ping() { return ipcRenderer.invoke('ping'); }
}
contextBridge.exposeInMainWorld('service', new MyService());
// → window.service.ping ist undefined

Funktioniert nicht — Methoden auf Klassen-Instanzen werden nicht mit-übertragen. Lösung: Plain Object mit Funktionen.

JavaScript Falsch: ipcRenderer komplett
// 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:

TypeScript preload.ts
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;
TypeScript src/types/global.d.ts
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.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Renderer & Preload

Zur Übersicht