Electron Adapter
Build cross-platform desktop apps with Node.js as the main process and any web framework as the renderer. Lunx produces a dual bundle: CJS for main, ESM for the browser renderer.
Electron 31 · Dual Bundle · IPC
Dual bundle output
Lunx produces separate bundles: CJS Node.js main process + ESM browser renderer process.
IPC communication
contextBridge + ipcRenderer/ipcMain for safe, typed communication between processes.
BrowserWindow API
Create native OS windows, menus, dialogs, and system tray icons from the main process.
Native file system
Full Node.js fs access in main process. Renderer requests file ops via IPC channels.
Fast rebuilds
Lunx only recompiles changed modules. Renderer changes reload without restarting the main process.
Auto-updater ready
Works with electron-updater for seamless auto-update delivery via GitHub Releases or S3.
Setup
bash
npm create lunx-dev@latest my-electron-app -- --framework electron --ts cd my-electron-app && npm install && npm run dev
Project structure
text
my-electron-app/ ├── src/ │ ├── main/ │ │ ├── main.ts # Electron main process entry │ │ ├── preload.ts # contextBridge preload script │ │ └── ipc-handlers.ts # IPC channel handlers │ └── renderer/ # React/Vue/Svelte frontend │ ├── main.tsx │ ├── App.tsx │ └── index.html ├── lunx.config.ts └── package.json
Main process
typescriptsrc/main/main.ts
import { app, BrowserWindow, ipcMain, dialog } from 'electron' import path from 'path' import fs from 'fs/promises' let mainWindow: BrowserWindow | null = null function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, // required for security nodeIntegration: false, // never enable in renderer }, }) // Dev: load from Lunx dev server if (process.env.NODE_ENV === 'development') { mainWindow.loadURL('http://localhost:5173') mainWindow.webContents.openDevTools() } else { mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) } } // IPC handler — runs in privileged main process ipcMain.handle('dialog:openFile', async () => { const { filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] }) if (filePaths.length === 0) return null const content = await fs.readFile(filePaths[0], 'utf-8') return { path: filePaths[0], content } }) app.whenReady().then(createWindow)
Preload — contextBridge
typescriptsrc/main/preload.ts
import { contextBridge, ipcRenderer } from 'electron' // Expose a typed API to the renderer — no nodeIntegration needed contextBridge.exposeInMainWorld('electronAPI', { openFile: () => ipcRenderer.invoke('dialog:openFile'), saveFile: (path: string, content: string) => ipcRenderer.invoke('fs:writeFile', path, content), onMenuOpen: (callback: () => void) => ipcRenderer.on('menu:open', callback), })
Using the API in the renderer
typescriptsrc/renderer/App.tsx
// TypeScript types for contextBridge API declare global { interface Window { electronAPI: { openFile: () => Promise<{ path: string; content: string } | null> saveFile: (path: string, content: string) => Promise<void> onMenuOpen: (callback: () => void) => void } } } export function FileEditor() { 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) } } async function save() { if (filePath) await window.electronAPI.saveFile(filePath, content) } return ( <div> <div> <button onClick={open}>Open File</button> <button onClick={save} disabled={!filePath}>Save</button> {filePath && <span>{filePath}</span>} </div> <textarea value={content} onChange={e => setContent(e.target.value)} /> </div> ) }
Security: never enable nodeIntegration
Setting
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.