# How Modly's Preload Script Exposes APIs to the Renderer Process in Electron

> Learn how Modly's preload script safely exposes Electron APIs to the renderer using contextBridge.exposeInMainWorld for secure application development.

- Repository: [lightningpixel/modly](https://github.com/lightningpixel/modly)
- Tags: internals
- Published: 2026-08-20

---

**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](https://github.com/lightningpixel/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`](https://github.com/lightningpixel/modly/blob/main/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.

```typescript
// 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`](https://github.com/lightningpixel/modly/blob/main/electron/preload/index.ts) file creates the actual bridge to the renderer world:

```typescript
// 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`](https://github.com/lightningpixel/modly/blob/main/src/shared/types/electron.d.ts):

```typescript
// 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:

```typescript
// 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`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts). Each channel is explicitly registered with `ipcMain.handle()`:

```typescript
// 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.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts)** defines `createElectronApi()`, which builds a typed, curated API surface wrapping `ipcRenderer` calls
- **[`electron/preload/index.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/index.ts)** executes the bridge creation when the preload script loads
- **[`src/shared/types/electron.d.ts`](https://github.com/lightningpixel/modly/blob/main/src/shared/types/electron.d.ts)** provides full TypeScript support for `window.electron` throughout the React application
- **All privileged operations** flow through `ipcMain.handle()` registrations in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/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`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) with proper IPC channel wiring; second, register the corresponding `ipcMain.handle()` listener in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts); third, update the `ElectronAPI` interface in [`src/shared/types/electron.d.ts`](https://github.com/lightningpixel/modly/blob/main/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.