Das contextBridge-IPC-Pattern ist die idiomatische Art, in modernen Electron-Apps Renderer-zu-Main-Kommunikation zu strukturieren: keine offenen IPC-Channels im Web-Code, kein direktes ipcRenderer. Stattdessen eine getypte API, die im Preload definiert und im Renderer wie eine Library verwendet wird.

Die drei Schichten

SchichtWas passiert
RendererRuft window.api.foo() wie eine normale Library
PreloadBridged über contextBridge.exposeInMainWorld und ipcRenderer.invoke
MainEmpfängt mit ipcMain.handle, validiert, führt aus

Damit:

  • Der Renderer kennt keine IPC-Channels — die Implementierung ist gekapselt
  • Die gesamte Validierung und Native-Logik landet im Main
  • Beim Refactoring (Channel umbenennen) ändert sich nur Preload, der Renderer bleibt gleich

Vollständiges Pattern

JavaScript main.js
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import fs from 'node:fs/promises';
import path from 'node:path';

function registerIpcHandlers() {
    // app
    ipcMain.handle('app:get-version', () => app.getVersion());

    // dialog
    ipcMain.handle('dialog:open-file', async (event) => {
        const win = BrowserWindow.fromWebContents(event.sender);
        const result = await dialog.showOpenDialog(win, { properties: ['openFile'] });
        return result.canceled ? null : result.filePaths[0];
    });

    // files
    ipcMain.handle('files:read', async (_event, filePath) => {
        // VALIDIERUNG
        const userData = app.getPath('userData');
        const resolved = path.resolve(filePath);
        if (!resolved.startsWith(userData)) {
            throw new Error('Path outside allowed area');
        }
        return fs.readFile(resolved, 'utf-8');
    });
}

app.whenReady().then(() => {
    registerIpcHandlers();
    // Window erzeugen
});
JavaScript preload.js
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('api', {
    app: {
        getVersion: () => ipcRenderer.invoke('app:get-version')
    },
    dialog: {
        openFile: () => ipcRenderer.invoke('dialog:open-file')
    },
    files: {
        read: (path) => ipcRenderer.invoke('files:read', path)
    }
});
JavaScript renderer.js
async function loadConfig() {
    const path = await window.api.dialog.openFile();
    if (!path) return;

    try {
        const content = await window.api.files.read(path);
        document.getElementById('config').textContent = content;
    } catch (err) {
        alert('Fehler: ' + err.message);
    }
}

Der Renderer-Code liest sich wie gegen eine lokale Library. IPC ist komplett unsichtbar.

TypeScript für die API

TypeScript preload.ts
import { contextBridge, ipcRenderer } from 'electron';

const api = {
    app: {
        getVersion: (): Promise<string> => ipcRenderer.invoke('app:get-version')
    },
    files: {
        read: (path: string): Promise<string> => ipcRenderer.invoke('files:read', path),
        write: (path: string, content: string): Promise<void> =>
            ipcRenderer.invoke('files:write', path, content)
    }
} 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;
    }
}

export {};

Damit hat Renderer-TypeScript Auto-Complete für window.api.files.read('...') mit korrekten Typen.

Error-Handling

JavaScript Im Main definiert sauber Errors
ipcMain.handle('files:read', async (_event, path) => {
    try {
        return await fs.readFile(path, 'utf-8');
    } catch (err) {
        if (err.code === 'ENOENT') {
            throw new Error('Datei nicht gefunden');
        }
        if (err.code === 'EACCES') {
            throw new Error('Keine Leserechte');
        }
        throw err;
    }
});
JavaScript Im Renderer ganz normal mit try/catch
try {
    const content = await window.api.files.read(path);
} catch (err) {
    // err.message kommt direkt aus dem Main-Throw
    showToast('Fehler: ' + err.message);
}

Promise-Rejection im Main → Promise-Rejection im Renderer. Saubere Fehler-Propagation.

Wann Push-Events vom Main

Manche Daten initiiert der Main, nicht der Renderer — Theme-System-Wechsel, Download-Progress, Live-Updates. Dafür kombiniert man invoke/handle mit Subscriptions:

JavaScript preload.js — Subscription-Pattern
contextBridge.exposeInMainWorld('api', {
    theme: {
        onChange: (callback) => {
            const listener = (_event, isDark) => callback(isDark);
            ipcRenderer.on('theme:changed', listener);
            return () => ipcRenderer.removeListener('theme:changed', listener);
        }
    }
});
JavaScript main.js
nativeTheme.on('updated', () => {
    BrowserWindow.getAllWindows().forEach(win => {
        win.webContents.send('theme:changed', nativeTheme.shouldUseDarkColors);
    });
});
JavaScript renderer.js
const unsubscribe = window.api.theme.onChange((isDark) => {
    document.body.classList.toggle('dark', isDark);
});

// beim Cleanup
unsubscribe();

Besonderheiten

Renderer kennt KEINE IPC-Channels.

Der ganze Sinn: nur Preload kennt die Strings. Im Renderer rufst du window.api.files.read(...) — wenn du den Channel umbenennst, ändert sich nur eine Zeile im Preload, der Renderer-Code bleibt unberührt.

Validierung gehört in den Main, NICHT ins Preload.

Preload ist eine dünne Bridge ohne Logik. Wenn du dort validierst, denkt eine kompromittierte Web-Seite ggf. „Bridge umgehen" — und der Main hätte keinen Schutz. Pragmatisch: alle Sicherheitschecks im Main-Handler.

Namespacing macht die API verständlich.

window.api.files.read(...) liest sich wie eine echte Library. Ohne Namespacing wird's bei wachsendem API unübersichtlich (window.api.readFile, window.api.openDialog, window.api.getVersion...).

Subscription mit Cleanup-Funktion zurückgeben.

Bei ipcRenderer.on(...) muss auch ein Weg zum Abmelden da sein. Pattern: return () => ipcRenderer.removeListener(...). Sonst leaken Listener bei jedem Component-Re-Mount.

handle zentralisieren in einer Funktion.

registerIpcHandlers() als eine Funktion — bei wachsender App lohnt es sich, das in eigene Module zu splitten (ipc/files.ts, ipc/dialog.ts) und in main.js nur die Setup-Funktion aufzurufen.

Bei großen Apps: TypeScript-Channels strukturieren.

Ein Object-Type wie type IpcMap = { 'files:read': (path: string) => Promise<string> } plus generische invoke<K extends keyof IpcMap>-Wrapper sorgt für Typensicherheit auf beiden Seiten. Aufwand am Anfang, zahlt sich bei größeren Apps aus.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu IPC

Zur Übersicht