How Modly's Preload Script Exposes APIs to the Renderer Process in Electron
Modly uses contextBridge.exposeInMainWorld() in a typed preload script to safely expose curated Electron APIs to the renderer without granting direct access to Node.js or IPC internals.
Electron applications like Modly face a fundamental security challenge: the renderer process runs untrusted web content, yet needs to access privileged system capabilities. The preload script solves this by acting as a controlled bridge. This article explains exactly how Modly implements this pattern—based on the actual source code—to expose a fully typed, secure API surface to its React-based renderer.
The Three-Step Architecture
Modly's preload system follows Electron's recommended security model through three coordinated files. Each step intentionally restricts what the renderer can access while preserving full functionality.
Step 1: Build the Typed API Object
In electron/preload/electron-api.ts, the createElectronApi function constructs a plain JavaScript object containing only the methods and properties the renderer legitimately needs. Each method wraps an ipcRenderer.invoke() call or webFrame operation, returning properly typed Promise objects.
// electron/preload/electron-api.ts (conceptual structure)
export const createElectronApi = () => ({
window: {
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
onMaximizeChange: (callback) => {
const channel = 'window:maximize-changed';
ipcRenderer.on(channel, (_, isMaximized) => callback(isMaximized));
return () => ipcRenderer.removeListener(channel, callback);
}
},
app: {
info: () => ipcRenderer.invoke('app:info')
},
shell: {
openExternal: (url) => ipcRenderer.invoke('shell:open-external', url)
},
python: {
start: () => ipcRenderer.invoke('python:start'),
stop: () => ipcRenderer.invoke('python:stop')
},
model: {
download: (repoId, modelId) => ipcRenderer.invoke('model:download', repoId, modelId),
list: () => ipcRenderer.invoke('model:list')
},
settings: {
get: (key) => ipcRenderer.invoke('settings:get', key),
set: (key, value) => ipcRenderer.invoke('settings:set', key, value)
},
extensions: {
list: () => ipcRenderer.invoke('extensions:list'),
install: (path) => ipcRenderer.invoke('extensions:install', path)
}
});
This design intentionally omits direct ipcRenderer access—the renderer cannot arbitrarily send or listen to any channel. It can only invoke the specific methods exposed through this curated object.
Step 2: Expose via Context Bridge
The electron/preload/index.ts file creates the actual bridge to the renderer world:
// electron/preload/index.ts
import { contextBridge } from 'electron';
import { createElectronApi } from './electron-api';
// Expose the API as window.electron
contextBridge.exposeInMainWorld('electron', createElectronApi());
The contextBridge.exposeInMainWorld() API is Electron's secure alternative to legacy approaches like window.electron = api. It guarantees:
- Prototype pollution protection — The exposed object cannot be modified by the renderer
- Context isolation compatibility — Works even when
contextIsolation: true(Electron's default and recommended setting) - No leakage of Node.js/Electron internals — Only the explicitly passed object crosses the boundary
Step 3: Type and Consume in the Renderer
The renderer accesses the API through window.electron, with full TypeScript support provided by src/shared/types/electron.d.ts:
// src/shared/types/electron.d.ts
export interface ElectronAPI {
window: {
minimize(): Promise<void>;
maximize(): Promise<void>;
unmaximize(): Promise<void>;
close(): Promise<void>;
isMaximized(): Promise<boolean>;
onMaximizeChange(callback: (isMaximized: boolean) => void): () => void;
};
app: {
info(): Promise<{ version: string; platform: string; arch: string }>;
};
shell: {
openExternal(url: string): Promise<void>;
};
python: {
start(): Promise<{ success: boolean; error?: string }>;
stop(): Promise<void>;
status(): Promise<{ running: boolean; port?: number }>;
};
model: {
download(repoId: string, modelId: string): Promise<{ success: boolean }>;
list(): Promise<Array<{ id: string; name: string; installed: boolean }>>;
};
settings: {
get<T>(key: string, defaultValue?: T): Promise<T>;
set<T>(key: string, value: T): Promise<void>;
};
extensions: {
list(): Promise<Array<{ id: string; name: string; version: string }>>;
install(path: string): Promise<{ success: boolean }>;
uninstall(id: string): Promise<{ success: boolean }>;
};
}
declare global {
interface Window {
electron: ElectronAPI;
}
}
This augmentation allows IDE autocompletion and compile-time type checking throughout the React application.
Practical Renderer Usage Examples
With the preload script complete, the renderer consumes these APIs naturally:
// React component using the exposed API
import { useEffect, useState } from 'react';
function AppHeader() {
const [isMaximized, setIsMaximized] = useState(false);
const [version, setVersion] = useState('');
useEffect(() => {
// Subscribe to window state changes
const unsubscribe = window.electron.window.onMaximizeChange(setIsMaximized);
// Load app info on mount
window.electron.app.info().then(info => setVersion(info.version));
return unsubscribe;
}, []);
return (
<header>
<span>Modly v{version}</span>
<button onClick={() => window.electron.window.minimize()}>—</button>
<button onClick={() => window.electron.window.maximize()}>
{isMaximized ? '❐' : '□'}
</button>
<button onClick={() => window.electron.window.close()}>×</button>
</header>
);
}
// Async Python bridge operation
async function handleModelDownload(repoId: string, modelId: string) {
// Ensure Python backend is running
const status = await window.electron.python.start();
if (!status.success) {
throw new Error(`Failed to start Python: ${status.error}`);
}
// Initiate download through main process orchestration
const result = await window.electron.model.download(repoId, modelId);
return result.success;
}
// External link handler (security-safe)
function openDocumentation() {
// Validated URL, opened via main process shell API
window.electron.shell.openExternal('https://github.com/lightningpixel/modly');
}
How the Main Process Handles These Calls
The preload script forwards to IPC channels defined in electron/main/ipc-handlers.ts. Each channel is explicitly registered with ipcMain.handle():
// electron/main/ipc-handlers.ts (representative handlers)
import { ipcMain, BrowserWindow, shell } from 'electron';
export function registerIpcHandlers(mainWindow) {
// Window controls
ipcMain.handle('window:minimize', () => {
mainWindow.minimize();
});
ipcMain.handle('window:maximize', () => {
mainWindow.maximize();
});
// App info
ipcMain.handle('app:info', () => ({
version: app.getVersion(),
platform: process.platform,
arch: process.arch
}));
// External links (security validated)
ipcMain.handle('shell:open-external', async (_, url) => {
// Optional: validate URL scheme before opening
await shell.openExternal(url);
});
// Python bridge coordination
ipcMain.handle('python:start', async () => {
// Launch Python subprocess, manage lifecycle
return pythonService.start();
});
// Model operations (orchestrated via Python bridge)
ipcMain.handle('model:download', async (_, repoId, modelId) => {
return modelManager.download(repoId, modelId);
});
}
This creates a clear separation of concerns: the preload defines what the renderer can ask for, while the main process handlers define how those requests are fulfilled.
Key Security Design Decisions
Modly's preload implementation follows critical Electron security best practices:
- No Node.js API exposure —
require('fs'),child_process, and similar are completely inaccessible from the renderer - Channel namespacing — All IPC channels use prefixed names (
window:,model:,python:) preventing collision and enabling audit - Structured responses — All async operations return result objects with
{ success, ... }shape for consistent error handling - Cleanup subscriptions — Event listeners return unsubscribe functions to prevent memory leaks in long-running renderer sessions
Summary
- Modly's preload script uses
contextBridge.exposeInMainWorld('electron', api)to safely expose capabilities to the renderer electron/preload/electron-api.tsdefinescreateElectronApi(), which builds a typed, curated API surface wrappingipcRenderercallselectron/preload/index.tsexecutes the bridge creation when the preload script loadssrc/shared/types/electron.d.tsprovides full TypeScript support forwindow.electronthroughout the React application- All privileged operations flow through
ipcMain.handle()registrations inelectron/main/ipc-handlers.ts - No Node.js or unrestricted IPC access is granted—only explicitly defined methods cross the context bridge boundary
Frequently Asked Questions
Why use contextBridge instead of directly attaching to window?
contextBridge.exposeInMainWorld() provides security guarantees that direct property assignment cannot. According to Electron's documentation, the context bridge ensures the exposed object is read-only and prototype-poisoning-resistant, even when contextIsolation is enabled. Direct assignment like window.electron = api would allow the renderer to modify or replace the exposed object, and would break under modern Electron's default security settings.
Can the renderer access all ipcRenderer methods through this preload?
No—Modly's preload explicitly does not expose ipcRenderer itself. Instead, it wraps specific ipcRenderer.invoke() calls inside typed methods. The renderer cannot send arbitrary IPC messages, listen to unapproved channels, or use ipcRenderer.sendSync(). This prevents the renderer from bypassing the intended API surface and potentially exploiting handler vulnerabilities.
What happens if an IPC handler throws an error?
Errors thrown in ipcMain.handle() handlers are automatically caught by Electron and converted to rejected promises on the renderer side. Modly's createElectronApi implementation ensures these propagate through the typed methods, allowing renderer code to use standard try/catch or .catch() patterns. The error stack traces are sanitized to avoid leaking main process internals.
How does Modly add new APIs to the preload system?
Adding functionality requires coordinated changes across three files: first, add the method to createElectronApi() in electron/preload/electron-api.ts with proper IPC channel wiring; second, register the corresponding ipcMain.handle() listener in electron/main/ipc-handlers.ts; third, update the ElectronAPI interface in src/shared/types/electron.d.ts to expose the new type signatures to the renderer. This intentional friction ensures all new capabilities are explicitly designed rather than accidentally exposed.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →