Electron-Vite Configuration in Modly: How Preload and Renderer Processes Are Structured

The electron-vite configuration in lightningpixel/modly defines three separate Vite build targets—main, preload, and renderer—with distinct entry points, plugins, and aliases to isolate the privileged preload script from the sandboxed React UI.

This article examines the electron.vite.config.ts file in the modly repository to explain how electron-vite structures cross-process communication. Understanding this architecture is essential for developers building secure Electron applications with Vite and React.

Three-Process Architecture Overview

Modern Electron applications separate code into three contexts to maintain security boundaries. The electron-vite configuration in modly enforces this separation through independent build stanzas:

  • Main process: Node.js environment with full system access
  • Preload process: Privileged bridge running in an isolated context
  • Renderer process: Sandbox-hosted UI with restricted capabilities

Each process receives tailored Vite configuration in electron.vite.config.ts to ensure correct bundling behavior and appropriate security restrictions.

Main Process Configuration

The main process stanza bundles the Electron entry script with externalized dependencies:

// electron.vite.config.ts (excerpt)
main: {
  plugins: [externalizeDepsPlugin()],
  build: {
    lib: {
      entry: resolve('electron/main/index.ts')
    }
  }
}

The externalizeDepsPlugin() is critical here—it prevents native Node.js and Electron modules from being bundled, avoiding runtime errors when Electron loads the main script. The entry point electron/main/index.ts initializes the application window and registers IPC handlers.

Preload Process Configuration

The preload stanza builds the security bridge between main and renderer:

// electron.vite.config.ts (excerpt)
preload: {
  plugins: [externalizeDepsPlugin()],
  build: {
    lib: {
      entry: resolve('electron/preload/index.ts')
    }
  }
}

Like the main process, preload uses externalizeDepsPlugin() to preserve Electron imports. The entry electron/preload/index.ts executes in an isolated world with access to both Node.js APIs (via ipcRenderer) and limited DOM context (via webFrame).

Exposing the Typed API

The preload script uses contextBridge.exposeInMainWorld to inject a controlled interface onto the window object:

// electron/preload/index.ts
import { contextBridge, ipcRenderer, webFrame } from 'electron';
import { createElectronApi } from './electron-api';

contextBridge.exposeInMainWorld('electron', createElectronApi(ipcRenderer, webFrame));

This creates the window.electron namespace that renderer code can access. The actual API implementation lives in electron/preload/electron-api.ts, which organizes functionality into logical groups:

// electron/preload/electron-api.ts (excerpt)
export function createElectronApi(ipcRenderer, webFrame) {
  return {
    // Window controls
    window: {
      minimize: () => ipcRenderer.send('window:minimize'),
      maximize: () => ipcRenderer.send('window:maximize'),
      close: () => ipcRenderer.send('window:close'),
    },

    // UI helpers
    ui: {
      setZoomFactor: (factor: number) => webFrame.setZoomFactor(factor),
    },

    // Python bridge for backend integration
    python: {
      start: () => ipcRenderer.invoke('python:start'),
      stop: () => ipcRenderer.invoke('python:stop'),
      status: () => ipcRenderer.invoke('python:status'),
    },

    // File system dialogs
    fs: {
      selectImage: () => ipcRenderer.invoke('fs:selectImage'),
      saveFile: (data: string, filename: string) => 
        ipcRenderer.invoke('fs:saveFile', data, filename),
    },

    // Application metadata
    app: {
      info: () => ipcRenderer.invoke('app:info'),
    },

    // Settings persistence
    settings: {
      get: (key: string) => ipcRenderer.invoke('settings:get', key),
      set: (key: string, value: unknown) => 
        ipcRenderer.invoke('settings:set', key, value),
    },

    // Additional groups: model, log, workspace, extensions...
  };
}

Each method wraps ipcRenderer.send for fire-and-forget messages or ipcRenderer.invoke for request-response patterns with the main process.

Renderer Process Configuration

The renderer stanza configures the React-based UI layer with substantially different settings:

// electron.vite.config.ts (excerpt)
renderer: {
  root: 'src',
  build: {
    rollupOptions: {
      input: resolve('src/index.html')
    }
  },
  resolve: {
    alias: {
      '@': resolve('src'),
      '@areas': resolve('src/areas'),
      '@shared': resolve('src/shared'),
      '@styles': resolve('src/styles')
    }
  },
  plugins: [react()]
}

Key differences from main/preload:

  • root: 'src': Treats the src directory as the project root, enabling clean imports
  • rollupOptions.input: Specifies src/index.html as the build entry point rather than a JavaScript file
  • resolve.alias: Defines path aliases for maintainable imports across feature areas
  • react() plugin: Adds JSX/TSX transformation and React Fast Refresh

Consuming the Preload API in Renderer Code

Renderer components access the exposed API through the global window.electron object:

// src/App.tsx (simplified example)
import React, { useEffect, useState } from 'react';

export default function App() {
  const [version, setVersion] = useState<string>('');
  const [pythonStatus, setPythonStatus] = useState<string>('stopped');

  useEffect(() => {
    // Fetch application metadata via preload bridge
    window.electron.app.info().then((info) => {
      setVersion(info.version);
    });

    // Check Python backend status
    window.electron.python.status().then((status) => {
      setPythonStatus(status);
    });
  }, []);

  const handleMinimize = () => {
    window.electron.window.minimize();
  };

  return (
    <div>
      <header>
        <span>Modly v{version}</span>
        <button onClick={handleMinimize}>Minimize</button>
      </header>
      <main>
        <p>Python backend: {pythonStatus}</p>
      </main>
    </div>
  );
}

The TypeScript types for window.electron are typically declared in a .d.ts file to provide IntelliSense and compile-time validation.

Security Boundaries and Build Isolation

The electron-vite configuration enforces critical security properties:

Aspect Implementation
Context isolation Preload runs in isolated context; renderer cannot directly require Node modules
API surface control Only explicitly exposed methods on window.electron are accessible
No eval in renderer Vite's default CSP-friendly build prevents unsafe execution
Separate bundles Each process has independent output preventing accidental import leakage

The externalizeDepsPlugin ensures that sensitive Electron APIs remain unavailable in the renderer bundle—even if malicious code executes in the UI layer, it cannot directly import ipcRenderer or other privileged modules.

Key Files and Responsibilities

File Purpose
electron.vite.config.ts Central Vite configuration defining all three build targets
electron/main/index.ts Main process entry; creates BrowserWindow with preload path
electron/preload/index.ts Preload entry; initializes context bridge
electron/preload/electron-api.ts API implementation with IPC wrappers
src/index.html Renderer HTML shell loaded by BrowserWindow
src/ (under root) React application source with path alias resolution

Summary

  • electron-vite in lightningpixel/modly configures three separate Vite builds via electron.vite.config.ts for main, preload, and renderer processes
  • Preload process uses externalizeDepsPlugin with lib.entry pointing to electron/preload/index.ts, which exposes a typed API via contextBridge.exposeInMainWorld
  • Renderer process configures root: 'src', HTML entry point, path aliases (@, @areas, @shared, @styles), and React plugin
  • Cross-process communication flows through window.electron methods that wrap ipcRenderer.invoke and ipcRenderer.send, maintaining security boundaries

Frequently Asked Questions

What is the purpose of externalizeDepsPlugin in electron-vite?

externalizeDepsPlugin prevents native Node.js and Electron modules from being bundled into the output, keeping them as external require() calls. This is necessary because Electron's main and preload processes run in a Node.js context where these modules must be loaded at runtime from the Electron binary, not from a bundled artifact.

How does the preload script communicate with the renderer process?

The preload script does not directly communicate with the renderer—it exposes an API to it. Using contextBridge.exposeInMainWorld, the preload injects a window.electron object that the renderer can call. These calls are internally translated to IPC messages sent to the main process, which then handles the actual operation and returns results.

Why does the renderer configuration use root: 'src' instead of the project root?

Setting root: 'src' simplifies import paths within the React application and isolates the UI build from Electron-specific files. This allows clean aliases like import { Component } from '@shared/ui' rather than relative paths like ../../../../shared/ui, and ensures Vite's dev server and build process focus only on the frontend code.

Can renderer code directly import Electron modules?

No—direct imports of Electron modules in renderer code would fail or create security vulnerabilities. The renderer runs in a Chromium sandbox with contextIsolation: true (the default when using preload). All Electron functionality must be accessed through the explicitly exposed window.electron API, which the preload script constructs using ipcRenderer on behalf of the renderer.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →