# How draw.io Desktop Implements IPC Communication Between Renderer and Main Process

> Discover how draw.io Desktop secures IPC communication between renderer and main processes using contextBridge. Learn about request/response channels and whitelisted actions for enhanced security.

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

---

**draw.io Desktop uses Electron's `contextBridge` API to expose a constrained IPC interface where the renderer process dispatches requests via the `rendererReq` channel and receives asynchronous responses through `mainResp`, while the main process validates each sender and executes whitelisted actions.**

draw.io Desktop relies on Electron's inter-process communication (IPC) architecture to safely bridge the gap between the sandboxed web-based renderer and the privileged main process. The implementation creates a typed request/response layer that prevents direct Node.js API access while enabling critical desktop capabilities like file system operations and native file watching. This article examines the complete draw.io Desktop IPC communication flow from the preload script through the main process handlers.

## The Preload Bridge Architecture

The IPC layer originates in [`src/main/electron-preload.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron-preload.js), which executes in the renderer's isolated world before any web content loads. This script constructs a secure communication bridge that exposes only specific, vetted functionality to the web application.

### Request/Response State Management

The preload script manages asynchronous communication through a monotonically-increasing `reqId` counter and a `reqInfo` map that stores pending request callbacks. When the renderer invokes `window.electron.request()`, the script assigns a unique identifier to the message, stores the success and error callbacks, and transmits the payload via `ipcRenderer.send('rendererReq', msg)`.

```javascript
// src/main/electron-preload.js – excerpt
contextBridge.exposeInMainWorld('electron', {
    request: (msg, callback, error) => {
        msg.reqId = reqId++;
        reqInfo[msg.reqId] = {callback, error};

        // special case for watchFile – keep listener locally
        if (msg.action == 'watchFile') {
            fileChangedListeners[msg.path] = msg.listener;
            delete msg.listener;
        }

        ipcRenderer.send('rendererReq', msg);
    },
    /* …registerMsgListener, sendMessage, listenOnce… */
});

```

The preload listens for two distinct channels from the main process: **`mainResp`** carries the response for previous requests (lines 10‑24), while **`fileChanged`** notifies the renderer when a watched file changes on disk (lines 26‑34).

### File Watch Listener Handling

For file watching operations, the preload maintains a separate `fileChangedListeners` registry. When the renderer requests `watchFile`, the listener function is stripped from the message (since functions cannot cross the context bridge) and stored locally in the preload script. When the main process later emits a `fileChanged` event, the preload forwards it to the appropriate listener stored in `fileChangedListeners` (lines 28‑33).

## Main Process Request Handling

The counterpart to the preload bridge resides in [`src/main/electron.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron.js), which registers the `ipcMain` handler for the `rendererReq` channel and implements the actual desktop integration logic.

### Sender Validation and Action Routing

Every incoming IPC message first passes through `validateSender(event.senderFrame)` (line 68) to ensure the request originates from the trusted renderer frame. The handler then uses a switch statement on `args.action` to dispatch to specific async helpers such as `readFile`, `saveFile`, or `watchFile`.

```javascript
// src/main/electron.js – excerpt
ipcMain.on("rendererReq", async (event, args) => {
    if (!validateSender(event.senderFrame)) return null;

    try {
        let ret = null;
        switch(args.action) {
            case 'readFile':
                ret = await readFile(args.filename, args.encoding);
                break;
            /* …other actions… */
            case 'watchFile':
                ret = await watchFile(args.path);
                break;
        }
        event.reply('mainResp',
            {success: true, data: ret, reqId: args.reqId});
    } catch (e) {
        event.reply('mainResp',
            {error: true, msg: e.message, e, reqId: args.reqId});
    }
});

```

### Response Correlation

After the async helper resolves, the main process replies on the **`mainResp`** channel, echoing the original `reqId` to enable the preload script to match the response with the correct pending callbacks in `reqInfo`. Errors are caught and transmitted back with `error: true` (lines 58‑60).

## File System Event Propagation

The IPC implementation supports bi-directional streaming for file system events. When the main process executes `watchFile`, it registers a Node.js `fs.watchFile` callback that forwards change notifications to the renderer via the `fileChanged` channel. This allows the web application to react to external file modifications without exposing raw `fs` APIs to the renderer context.

## Security Model

The draw.io Desktop IPC architecture employs multiple defense layers:

*   **Context Isolation** – The renderer cannot directly require Node modules; it only accesses the deliberately exposed `electron` global created by `contextBridge.exposeInMainWorld`.
*   **Sender Validation** – The main process checks the origin of each IPC call using `validateSender` before executing any action.
*   **Explicit Action Whitelisting** – Only actions defined in the main process switch statement are reachable, preventing arbitrary code execution or unauthorized API access.

## Practical Implementation Examples

### Reading Files from the Renderer

To read a file from the sandboxed renderer, the web application invokes the exposed `request` method with the `readFile` action:

```javascript
// In any renderer script (e.g., a draw.io plugin)
window.electron.request(
    {
        action: 'readFile',
        filename: '/tmp/example.txt',
        encoding: 'utf8'
    },
    data => console.log('File content:', data),          // success callback
    err  => console.error('Failed to read file:', err)   // error callback
);

```

### Watching Files for Changes

File watching requires registering a listener for the `fileChanged` channel before requesting the watch:

```javascript
// Register a listener for the custom ‘fileChanged’ IPC channel
window.electron.registerMsgListener('fileChanged', ({path, curr, prev}) => {
    console.log(`File ${path} changed`, {curr, prev});
});

// Ask the main process to start watching a file
window.electron.request(
    {
        action: 'watchFile',
        path: '/tmp/example.txt',
        // The listener is stored in the preload; we only need to provide it once
        listener: (curr, prev) => console.log('Change detected', curr, prev)
    },
    () => console.log('Watch started'),
    err => console.error('Watch error', err)
);

```

### Opening External URLs

One-time messages for system integration, such as opening external URLs, follow the same request pattern:

```javascript
window.electron.request(
    { action: 'openExternal', url: 'https://github.com/jgraph/drawio-desktop' },
    () => console.log('External URL opened'),
    err => console.error('Failed to open URL', err)
);

```

## Summary

-   **draw.io Desktop IPC communication** relies on a preload script in [`src/main/electron-preload.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron-preload.js) that uses `contextBridge` to expose a constrained API to the renderer.
-   The renderer sends requests on the **`rendererReq`** channel and receives correlated responses via **`mainResp`** using monotonically-increasing `reqId` values.
-   The main process in [`src/main/electron.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron.js) validates every sender with `validateSender` before dispatching actions through a whitelist-based switch statement.
-   File system events flow bi-directionally through the **`fileChanged`** channel, allowing the renderer to react to external changes without direct `fs` access.
-   Context isolation and explicit action whitelisting prevent unauthorized Node.js API access from the web content.

## Frequently Asked Questions

### What IPC channels does draw.io Desktop use for renderer-main communication?

draw.io Desktop uses three primary channels: **`rendererReq`** for requests from the renderer to main, **`mainResp`** for responses from main back to the renderer, and **`fileChanged`** for asynchronous file system notifications initiated by the main process.

### How does draw.io Desktop prevent unauthorized IPC calls?

The main process validates every incoming IPC message using `validateSender(event.senderFrame)` before processing, ensuring the request originates from the trusted application frame. Additionally, only whitelisted actions defined in the switch statement within [`src/main/electron.js`](https://github.com/jgraph/drawio-desktop/blob/main/src/main/electron.js) are executable.

### Why does draw.io Desktop use a preload script instead of enabling `nodeIntegration`?

The preload script maintains **context isolation**, preventing the renderer from directly accessing Node.js APIs while still enabling controlled desktop functionality. This approach follows Electron security best practices by exposing only necessary methods through `contextBridge.exposeInMainWorld` rather than granting full system access.

### Can the renderer process access Node.js file system APIs directly?

No, the renderer process runs in a sandboxed context without direct access to Node.js modules. All file system operations must route through the IPC layer, where the main process handles `readFile`, `writeFile`, and `watchFile` actions on behalf of the renderer.