Electron
Desktop apps built by lunx itself: the renderer, the main process and the preload, with Electron started on the dev server. No plugin, nothing to configure.
lunx dev serves the renderer with HMR, builds main and preload with Rolldown, and starts Electron on the dev server.
Editing the main process restarts Electron; editing the preload reloads the window. Quitting the app ends lunx dev.
dist/ (renderer, relative URLs) and dist-electron/ (main, preload); package.json main points at dist-electron/main.js.
The preload exposes a typed API to the renderer; nodeIntegration stays off.
Setup
npx lunx-dev@latest create my-electron-app --template electron cd my-electron-app && npm install npm run dev # renderer with HMR, Electron started on it npm run build # dist/ and dist-electron/ npm run preview # the build, in Electron
Any project with electron installed and an electron/main.ts (or .js) gets the same, React or not. An existing project with vite-plugin-electron runs on lunx unchanged: lunx leaves Electron to the plugin.
Project structure
my-electron-app/ ├── electron/ │ ├── main.ts # Electron main process │ └── preload.ts # contextBridge preload ├── src/ # React renderer │ ├── main.tsx │ └── App.tsx ├── index.html └── package.json # "main": "dist-electron/main.js"
What lunx builds
- Renderer →
dist/, with relative URLs (base: './'), since Electron loads it from disk. - Main process →
dist-electron/main.js, ESM or CommonJS like your package. Packages independenciesstay innode_modules; everything else is bundled. - Preload →
dist-electron/preload.cjs, one CommonJS file, as sandboxed preloads (Electron's default) require.
The main process and the preload build in parallel with the renderer. In dev, Electron gets the dev server's URL in LUNX_DEV_SERVER_URL (and VITE_DEV_SERVER_URL, for code written for vite-plugin-electron).
Configuration (optional)
import { defineConfig } from 'lunx-dev' export default defineConfig({ electron: { main: 'src-electron/index.ts', // default: electron/main.* preload: 'src-electron/preload.ts', // default: electron/preload.*; false for none outDir: 'dist-electron', // default }, // electron: false — lunx leaves Electron alone })
LUNX_ELECTRON_ARGS="--inspect=5858" npm run dev passes switches to Electron (here, to debug the main process).
Main process
import { app, BrowserWindow, ipcMain, dialog } from 'electron' import path from 'node:path' import fs from 'node:fs/promises' import { fileURLToPath } from 'node:url' const dir = path.dirname(fileURLToPath(import.meta.url)) function createWindow() { const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(dir, 'preload.cjs') }, }) // `lunx dev` serves the renderer; a build loads it from disk. if (process.env.LUNX_DEV_SERVER_URL) win.loadURL(process.env.LUNX_DEV_SERVER_URL) else win.loadFile(path.join(dir, '../dist/index.html')) } // Runs in the privileged main process; the renderer asks through the preload. ipcMain.handle('dialog:openFile', async () => { const { filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] }) if (filePaths.length === 0) return null return { path: filePaths[0], content: await fs.readFile(filePaths[0], 'utf-8') } }) app.whenReady().then(createWindow)
Preload — contextBridge
import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('electronAPI', { openFile: () => ipcRenderer.invoke('dialog:openFile'), })
Using the API in the renderer
import { useState } from 'react' // Types for the API the preload exposes declare global { interface Window { electronAPI: { openFile: () => Promise<{ path: string; content: string } | null> } } } export function FileViewer() { const [content, setContent] = useState('') const [filePath, setFilePath] = useState<string | null>(null) async function open() { const result = await window.electronAPI.openFile() if (result) { setContent(result.content) setFilePath(result.path) } } return ( <div> <div> <button onClick={open}>Open File</button> {filePath && <span>{filePath}</span>} </div> <textarea value={content} onChange={e => setContent(e.target.value)} /> </div> ) }
Packaging
npm run build leaves the app ready for electron-builder or Electron Forge: dist/ holds the renderer (with relative URLs, as it loads from disk) and dist-electron/ the main process and the preload.
nodeIntegration: true in Electron gives every loaded web page direct access to Node.js APIs — a critical security vulnerability. Always use contextIsolation: true and the contextBridge preload pattern to expose only the specific APIs your renderer needs.