# Understanding the Preload Script and ContextBridge API Exposure in draw.io Desktop

> Explore the draw.io desktop preload script and contextBridge API. Learn how it securely exposes a minimal API for IPC communication, employing least privilege for enhanced security.

- Repository: [draw.io/drawio-desktop](https://github.com/jgraph/drawio-desktop)
- Tags: architecture
- Published: 2026-03-05

---

**The draw.io desktop application uses a single preload script at [`src/main/electron-preload.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron-preload.js) to expose a minimal, secure API through Electron's `contextBridge`, enabling controlled IPC communication between the renderer process and main process while enforcing the principle of least privilege.**

The draw.io desktop application (jgraph/drawio-desktop) relies on Electron's context isolation to separate untrusted web content from privileged Node.js APIs. At the heart of this security model sits the preload script, which carefully exposes only specific functionality through the ContextBridge API exposure pattern, allowing the web-based diagram editor to perform file operations and system interactions without compromising application security.

## Preload Script Architecture and State Management

Located at [`src/main/electron-preload.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron-preload.js), the preload script operates in the isolated context between the main and renderer processes. It imports only the essential Electron modules required for secure communication.

### Core Module Imports and State Tracking

The script begins by importing `contextBridge` for API exposure and `ipcRenderer` for inter-process communication. It maintains three critical pieces of state to manage asynchronous operations:

- `reqId`: A monotonically increasing integer that uniquely identifies each request
- `reqInfo`: A Map storing callback pairs (`onSuccess`, `onError`) for pending requests
- `fileChangedListeners`: A Map specifically for file-watching callbacks that persist across multiple events

### IPC Response Routing

The script sets up a persistent listener via `ipcRenderer.on('mainResp', …)` that matches incoming responses to pending requests using the `reqId` field. When a response arrives, it retrieves the corresponding callbacks from `reqInfo`, invokes either the success or error handler, and immediately cleans up the entry to prevent memory leaks.

For file system monitoring, a separate listener handles `ipcRenderer.on('fileChanged', …)` events, routing change notifications to the appropriate listener stored in `fileChangedListeners`.

## ContextBridge API Exposure Surface

Rather than exposing the full `ipcRenderer` object—which would create a security vulnerability—the script uses `contextBridge.exposeInMainWorld` to inject a carefully curated API into the renderer's `window` object.

### The electron Namespace

The primary API surface exposes an `electron` object with four methods:

- **`request(msg, onSuccess, onError)`**: The core RPC mechanism that sends messages to the main process and manages callback lifecycle
- **`registerMsgListener(channel, listener)`**: Allows the renderer to subscribe to arbitrary IPC channels from the main process
- **`sendMessage(channel, args)`**: Fire-and-forget message delivery without response handling
- **`listenOnce(channel, listener)`**: One-time event subscription that auto-removes after first trigger

The `request` method implements the critical logic that distinguishes between standard one-shot operations and persistent listeners. When `msg.action === 'watchFile'`, it stores the callback in `fileChangedListeners` rather than `reqInfo`, enabling continuous file monitoring.

### The process Namespace

For debugging and feature detection, the script exposes a read-only `process` namespace containing `process.type` and `process.versions`. This gives the renderer access to environment metadata without revealing sensitive system information or mutable process properties.

## RPC Communication Flow

The draw.io desktop application implements a strict request-response protocol that prevents direct renderer access to Node.js APIs. The flow proceeds through five distinct stages:

1. **Renderer Invocation**: The web application calls `window.electron.request()` with an action payload and callback functions.

2. **Request Enrichment**: The preload script increments `reqId`, stores the callbacks in `reqInfo`, and augments the message with the unique identifier.

3. **Main Process Transmission**: The script forwards the message via `ipcRenderer.send('rendererReq', msg)` to the main process defined in [`src/main/electron.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron.js).

4. **Main Process Execution**: The main process validates the sender in `ipcMain.on('rendererReq', …)`, executes the requested operation (such as `saveFile` or `readFile`), and replies with `event.reply('mainResp', { success: true, data: …, reqId: … })`.

5. **Callback Resolution**: The preload script's listener matches the `reqId`, invokes the appropriate success or error callback, and deletes the bookkeeping entry.

This architecture ensures that only vetted actions defined in the main process handler can execute, protecting against arbitrary code execution.

## Practical Implementation Examples

The following patterns demonstrate how the renderer-side code interacts with the exposed API to perform privileged operations.

### Reading Files

To read a diagram file from disk, the renderer sends a structured request with encoding specifications:

```javascript
window.electron.request(
  { action: 'readFile', filename: '/path/to/file.drawio', encoding: 'utf8' },
  data => {
    console.log('File content:', data);
  },
  (msg, err) => {
    console.error('Failed to read file:', msg, err);
  }
);

```

The preload script handles the callback registration and cleanup automatically once the main process returns the file contents via the `mainResp` channel.

### Watching Files for Changes

File monitoring requires special handling because the callback must persist across multiple change events:

```javascript
window.electron.request(
  {
    action: 'watchFile',
    path: '/path/to/file.drawio',
    listener: (curr, prev) => {
      console.log('File changed:', { curr, prev });
    }
  },
  () => {
    console.log('Watch started');
  },
  (msg, err) => {
    console.error('Watch error:', msg, err);
  }
);

```

When the `action` property equals `'watchFile'`, the preload script stores the `listener` function in `fileChangedListeners` instead of the temporary `reqInfo` map. The main process uses `fs.watchFile` and forwards each change event through the `fileChanged` IPC channel, triggering the stored callback repeatedly until explicitly removed.

### Fire-and-Forget Messages

For operations requiring no confirmation, use the `sendMessage` helper:

```javascript
window.electron.sendMessage('openExternal', { url: 'https://example.com' });

```

This method bypasses the request ID tracking system and simply proxies the payload to the main process via `ipcRenderer.send`.

### Custom Event Listeners

To subscribe to custom main-process events, use the registration method:

```javascript
window.electron.registerMsgListener('someCustomEvent', payload => {
  console.log('Received custom event:', payload);
});

```

This exposes `ipcRenderer.on` functionality without granting access to the underlying IPC mechanism directly.

## Security Architecture and Privilege Isolation

The preload script enforces the **principle of least privilege** by acting as the sole gateway between the untrusted renderer content and the privileged main process. Because `contextBridge` exposes only the specific methods defined in [`src/main/electron-preload.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron-preload.js), the renderer cannot:
- Access Node.js modules directly
- Send arbitrary IPC messages
- Intercept or forge request IDs
- Modify the exposed API surface after initialization

This design mitigates remote code execution risks while enabling the web-based draw.io application to perform essential desktop operations like native file dialogs, automatic saving, and external link handling.

## Summary

- The preload script at [`src/main/electron-preload.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron-preload.js) serves as the exclusive communication bridge between the renderer and main processes in draw.io desktop.
- State management uses `reqId` counters and Maps (`reqInfo`, `fileChangedListeners`) to track asynchronous operations and persistent file watchers.
- The `contextBridge` exposes a minimal `window.electron` API with `request`, `sendMessage`, `registerMsgListener`, and `listenOnce` methods.
- File-watching operations receive special treatment, storing callbacks in a dedicated map for multi-event lifecycle management.
- The RPC flow requires main process validation in [`src/main/electron.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron.js) before executing privileged actions, preventing unauthorized system access.

## Frequently Asked Questions

### What is the purpose of the preload script in draw.io desktop?

The preload script initializes the secure communication layer between the web-based renderer process and the Node.js main process. According to the jgraph/drawio-desktop source code, it runs in an isolated context with access to both Electron APIs and a limited DOM, allowing it to expose specific functionality through `contextBridge` while keeping dangerous APIs hidden from the diagram editor's web content.

### How does the ContextBridge API exposure prevent security vulnerabilities?

By using `contextBridge.exposeInMainWorld` instead of direct `window` assignment, the script creates a one-way, read-only API surface that cannot be modified by the renderer. The exposed `electron` object contains only four vetted methods, and all IPC traffic flows through the `rendererReq` and `mainResp` channels with strict request ID validation, preventing arbitrary code execution or unauthorized system access.

### Why does file watching use a different callback storage mechanism than regular requests?

Standard requests use the `reqInfo` Map which deletes entries immediately after receiving a `mainResp` event, suitable for one-shot operations. However, file watching requires the listener to persist across multiple file change events. When `msg.action === 'watchFile'`, the preload script stores the callback in `fileChangedListeners` instead, allowing the same listener to receive updates via the `fileChanged` IPC channel repeatedly until explicitly cleared.

### Where is the main process request handler implemented?

The main process counterpart to the preload script resides in [`src/main/electron.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron.js). This file contains the `ipcMain.on('rendererReq', …)` handler that validates incoming messages, executes the requested file system or window operations, and returns responses via `event.reply('mainResp', …)`. This separation ensures that business logic and security validation occur in the privileged main process, not the exposed renderer context.