Deploy your Electron app to the web without rewriting code
electron-to-web is a drop-in replacement for Electron's IPC system that enables your desktop app to run in the browser. It provides shims for ipcMain, ipcRenderer, and BrowserWindow that automatically convert IPC communication to JSON-RPC over WebSocket.
You have an Electron app and want to offer a web version, but:
- β Rewriting all IPC calls to HTTP endpoints is tedious and error-prone
- β Maintaining two codebases (Electron + Web) doubles your work
- β Custom IPC-to-HTTP adapters are complex and fragile
- β Real-time features (events, notifications) require separate WebSocket infrastructure
Change 2 import statements, and your Electron app works in the browser.
// Before (Electron desktop):
import { ipcMain, BrowserWindow } from 'electron';
// After (Web deployment):
import { ipcMain, BrowserWindow } from 'electron-to-web/main';That's it. Your IPC handlers, event listeners, and business logic remain 100% unchanged.
electron-to-web uses JSON-RPC 2.0 over WebSocket to replicate Electron's IPC system:
ipcRenderer.invoke(channel, data)β JSON-RPC requestipcMain.handle(channel, handler)β JSON-RPC method handlerwebContents.send(channel, data)β JSON-RPC notification (broadcast)ipcRenderer.on(channel, handler)β JSON-RPC notification listener
Architecture:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser (Your React/Vue/etc. app - UNCHANGED) β
β import { ipcRenderer } from 'electron-to-web/renderer' β
β β
β β’ await ipcRenderer.invoke('user:create', data) β
β β’ ipcRenderer.on('user:created', handler) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
JSON-RPC 2.0 over WebSocket (single connection)
β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Node.js Server (Your Electron main code - UNCHANGED) β
β import { ipcMain } from 'electron-to-web/main' β
β β
β ipcMain.handle('user:create', async (event, data) => { β
β const user = await db.createUser(data); β
β mainWindow.webContents.send('user:created', user); β
β return { success: true, user }; β
β }); β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
npm install electron-to-web// server.ts
import { ipcMain, BrowserWindow } from 'electron-to-web/main';
import { createWebServer } from 'electron-to-web/server';
// Create "window" instance (in web mode, this is just a shim)
const mainWindow = new BrowserWindow();
// Your IPC handlers work EXACTLY like Electron!
ipcMain.handle('user:create', async (event, userData) => {
const user = await createUser(userData);
// Send notification to all connected clients
mainWindow.webContents.send('user:created', user);
return { success: true, user };
});
ipcMain.handle('user:list', async (event) => {
const users = await listUsers();
return { success: true, users };
});
// Start web server
createWebServer({
port: 3001,
staticDir: './dist' // Your built frontend
});
console.log('Server running on http://localhost:3001');// App.tsx (or any frontend file)
import { ipcRenderer } from 'electron-to-web/renderer';
import { useEffect, useState } from 'react';
function App() {
const [users, setUsers] = useState([]);
useEffect(() => {
// Listen for real-time updates
ipcRenderer.on('user:created', (event, user) => {
console.log('New user created:', user);
loadUsers(); // Refresh list
});
loadUsers();
// Cleanup on unmount
return () => {
ipcRenderer.removeAllListeners('user:created');
};
}, []);
async function loadUsers() {
const result = await ipcRenderer.invoke('user:list');
if (result.success) {
setUsers(result.users);
}
}
async function createUser() {
const result = await ipcRenderer.invoke('user:create', {
name: 'John Doe',
email: 'john@example.com'
});
if (result.success) {
console.log('User created:', result.user);
}
}
return (
<div>
<button onClick={createUser}>Create User</button>
<ul>
{users.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
</div>
);
}// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: {
// Auto-resolve electron imports to electron-to-web
'electron': 'electron-to-web/renderer'
}
}
});That's it! Your app now runs in the browser with zero changes to your IPC logic.
ipcRenderer (Client-side):
- β
invoke()- Request/response pattern with async handlers - β
send()- One-way messages (fire-and-forget) - β
on()- Listen for events from main process - β
once()- Listen for event once, then auto-remove - β
removeListener()/off()- Remove specific listener - β
removeAllListeners()- Remove all listeners for channel
ipcMain (Server-side):
- β
handle()- Register async request handler - β
handleOnce()- Handle request once, then auto-remove - β
on()- Listen for one-way messages from renderer - β
once()- Listen for message once, then auto-remove - β
removeHandler()- Remove invoke handler - β
removeListener()/off()- Remove specific listener - β
removeAllListeners()- Remove all listeners for channel
webContents (Server-side):
- β
send()- Broadcast notifications to all connected clients - β
sendTo()- Send notification to specific client by ID
Additional Features:
- β Error Handling: Standard JSON-RPC error codes
- β Reconnection: Automatic WebSocket reconnection with exponential backoff
- β TypeScript: Full type safety with generics
- β Multiple Clients: Supports many browser tabs/windows
- β 100% Test Coverage: 25 comprehensive E2E tests
- Synchronous IPC:
ipcRenderer.sendSync()β Not supported (async only in browsers) - Shared Workers: Can enable renderer-to-renderer communication (opt-in)
Client-side (Pure Web APIs - no server required):
- β
clipboard- Full clipboard API vianavigator.clipboard - β
dialog- File dialogs via File System Access API + fallback - β
Notification- System notifications via Web Notification API - β
screen- Display information viawindow.screen - β
shell.openExternal()- Open URLs viawindow.open() - β
shell.beep()- Audio beep via Web Audio API
Server-side (Requires security configuration):
- β
shell.openPath()- Open files in default application - β
shell.showItemInFolder()- Show file in file manager - β
shell.trashItem()- Move files to trash - β
app.getPath()- Get system paths
Security Model: All server-side operations are disabled by default. Enable with explicit security configuration:
import { createWebServer, TRUSTED_SECURITY_CONFIG } from 'electron-to-web/server';
createWebServer({
port: 3001,
security: TRUSTED_SECURITY_CONFIG, // Enable all operations (for trusted environments)
});See Native APIs section for detailed usage and security configuration.
- Window Controls: BrowserWindow geometry, minimize, maximize β N/A for web
- Synchronous IPC:
ipcRenderer.sendSync()β Async only in browsers
See FEATURE_PARITY.md for detailed comparison.
Register a handler for IPC requests (for use with ipcRenderer.invoke()).
import { ipcMain } from 'electron-to-web/main';
ipcMain.handle('channel:name', async (event, ...args) => {
// event.sender.id - Client ID
// ...args - Arguments from renderer
return { success: true, data: '...' };
});Register a handler that automatically removes itself after being called once.
ipcMain.handleOnce('one-time:channel', async (event, data) => {
return { received: data };
}); // Auto-removed after first invocationListen for one-way messages from renderer (sent via ipcRenderer.send()).
ipcMain.on('log:message', (event, message) => {
console.log(`Client ${event.sender.id}:`, message);
});Listen for one-way message once, then automatically remove listener.
ipcMain.once('startup:complete', (event, data) => {
console.log('App started:', data);
}); // Auto-removed after first callRemove a registered invoke handler.
ipcMain.removeHandler('channel:name');Remove specific event listener.
const handler = (event, data) => { /* ... */ };
ipcMain.on('channel', handler);
// Later:
ipcMain.removeListener('channel', handler);Remove all listeners for a channel, or all channels if not specified.
ipcMain.removeAllListeners('channel'); // Remove all listeners for 'channel'
ipcMain.removeAllListeners(); // Remove all listenersBroadcast notification to all connected clients.
import { BrowserWindow } from 'electron-to-web/main';
const mainWindow = new BrowserWindow();
mainWindow.webContents.send('notification', { message: 'Hello!' });Send notification to specific client by ID.
mainWindow.webContents.sendTo('client-123', 'private:message', { data: 'secret' });Send request and wait for response.
import { ipcRenderer } from 'electron-to-web/renderer';
const result = await ipcRenderer.invoke('channel:name', arg1, arg2);Send one-way message (no response expected).
ipcRenderer.send('log:message', 'User clicked button');Listen for events from main process.
ipcRenderer.on('notification', (event, data) => {
console.log('Received:', data);
});Listen for event once, then automatically remove listener.
ipcRenderer.once('ready', (event, data) => {
console.log('App ready:', data);
}); // Auto-removed after first notificationRemove specific event listener.
const handler = (event, data) => { /* ... */ };
ipcRenderer.on('event', handler);
// Later:
ipcRenderer.removeListener('event', handler);Remove all listeners for a channel (or all channels if omitted).
ipcRenderer.removeAllListeners('notification'); // Just this channel
ipcRenderer.removeAllListeners(); // All channelsCreate Express server with WebSocket support.
import { createWebServer } from 'electron-to-web/server';
const { app, server, wss } = createWebServer({
port: 3001, // Server port
staticDir: './dist', // Serve static files
cors: true, // Enable CORS
wsPath: '/ipc' // WebSocket endpoint (default)
});electron-to-web provides shims for Electron's native APIs, allowing you to use familiar Electron APIs in the browser.
Maps Electron's clipboard API to Web Clipboard API (navigator.clipboard).
import { clipboard } from 'electron-to-web/renderer';
// Write text
await clipboard.writeText('Hello, World!');
// Read text
const text = await clipboard.readText();
// Write HTML
await clipboard.writeHTML('<h1>Title</h1>');
// Write image
const imageBlob = await fetch('/image.png').then(r => r.blob());
await clipboard.writeImage(imageBlob);
// Check if available (requires HTTPS)
if (clipboard.isAvailable()) {
console.log('Clipboard is available');
}Limitations:
- Requires HTTPS (browser security requirement)
typeparameter (selection/clipboard) is ignored in web
Maps Electron's dialog API to File System Access API with fallback to traditional file input.
import { dialog } from 'electron-to-web/renderer';
// Open file dialog
const result = await dialog.showOpenDialog({
title: 'Select Files',
filters: [
{ name: 'Images', extensions: ['png', 'jpg'] },
{ name: 'Documents', extensions: ['pdf', 'txt'] }
],
properties: ['openFile', 'multiSelections']
});
if (!result.canceled) {
console.log('Selected:', result.filePaths);
}
// Open directory dialog
const dirResult = await dialog.showOpenDialog({
properties: ['openDirectory']
});
// Save dialog
const saveResult = await dialog.showSaveDialog({
title: 'Save File',
defaultPath: 'document.pdf',
filters: [{ name: 'PDF', extensions: ['pdf'] }]
});
// Message box
const msgResult = await dialog.showMessageBox({
type: 'question',
title: 'Confirm',
message: 'Are you sure?',
buttons: ['Yes', 'No', 'Cancel'],
defaultId: 0
});
console.log('User chose:', msgResult.response);
// Error box
dialog.showErrorBox('Error', 'Something went wrong!');Implementation:
- Uses File System Access API in Chrome/Edge
- Falls back to
<input type="file">in older browsers - Custom modal for complex message boxes
- Simple alert/confirm for basic dialogs
Maps Electron's Notification API to Web Notification API.
import { Notification } from 'electron-to-web/renderer';
// Request permission
if (Notification.isSupported()) {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
// Create notification
const notif = new Notification({
title: 'Hello!',
body: 'This is a notification',
icon: '/icon.png',
tag: 'unique-tag'
});
// Listen for events
notif.on('click', () => {
console.log('Notification clicked');
});
notif.on('close', () => {
console.log('Notification closed');
});
// Show notification
await notif.show();
}
}Maps Electron's screen API to window.screen.
import { screen } from 'electron-to-web/renderer';
// Get primary display
const display = screen.getPrimaryDisplay();
console.log('Resolution:', display.bounds.width, 'x', display.bounds.height);
console.log('Work area:', display.workArea);
console.log('Scale factor:', display.scaleFactor);
console.log('Touch support:', display.touchSupport);
// Get all displays (web returns only primary)
const displays = screen.getAllDisplays();
// Listen for display changes
screen.on('display-metrics-changed', () => {
console.log('Display changed');
});Limitations:
- Web can only access primary display
getCursorScreenPoint()not available (browser security)
Provides shell operations - some client-side, some requiring server.
import { shell } from 'electron-to-web/renderer';
// Open URL (pure client-side)
await shell.openExternal('https://example.com');
// Play beep (pure client-side)
shell.beep();
// Server-side operations (require security config):
// Open file in default app
const error = await shell.openPath('/path/to/file.pdf');
if (error === '') {
console.log('Opened successfully');
}
// Show file in folder
await shell.showItemInFolder('/path/to/file.txt');
// Move to trash
await shell.trashItem('/path/to/old-file.txt');Server-side native operations require explicit security configuration:
import { createWebServer, TRUSTED_SECURITY_CONFIG } from 'electron-to-web/server';
// Option 1: Fully trusted (all operations allowed)
createWebServer({
port: 3001,
security: TRUSTED_SECURITY_CONFIG
});
// Option 2: Custom security config
createWebServer({
port: 3001,
security: {
allowShellExecution: true, // shell.openPath, showItemInFolder, trashItem
allowFileSystemAccess: true, // Future: fs operations
allowPathQueries: true, // app.getPath()
allowedPaths: [
'/home/user/projects', // Whitelist specific directories
'/tmp'
],
validateShellCommand: (command, args) => {
// Custom validation
const allowed = ['open', 'xdg-open', 'explorer'];
return allowed.some(cmd => command.startsWith(cmd));
},
validateFilePath: (path) => {
// Custom path validation
return !path.includes('..'); // Prevent directory traversal
}
}
});
// Option 3: Safe defaults (all disabled)
createWebServer({
port: 3001
// No security config = everything disabled
});Security Error Handling:
import { shell } from 'electron-to-web/renderer';
try {
await shell.openPath('/etc/passwd');
} catch (error) {
if (error.message.includes('[Security]')) {
console.error('Operation not allowed:', error.message);
}
}See examples/native-apis-example.ts for complete examples.
npm install electron-to-web// Before:
import { app, BrowserWindow, ipcMain } from 'electron';
// After:
import { BrowserWindow, ipcMain } from 'electron-to-web/main';
import { createWebServer } from 'electron-to-web/server';
// Replace app.on('ready') with createWebServer()
createWebServer({ port: 3001, staticDir: './dist' });// Before:
import { ipcRenderer } from 'electron';
// After:
import { ipcRenderer } from 'electron-to-web/renderer';Or use build-time aliasing (recommended):
// vite.config.ts
export default {
resolve: {
alias: { 'electron': 'electron-to-web/renderer' }
}
};Then no changes needed in renderer code!
npm run build # Build your frontend (Vite, webpack, etc.)node server.js # Your web serverOpen http://localhost:3001 in your browser. Your app should work exactly like the Electron version!
See the examples/ directory for complete working examples:
- basic/ - Minimal hello world
- todo-app/ - Full CRUD app with real-time sync
- auto-claude/ - Real-world example (Auto-Claude migration)
| Approach | Code Changes | Real-time | Type Safety | Complexity |
|---|---|---|---|---|
| Manual HTTP + WS | High (rewrite all IPC) | Custom implementation | Manual | High |
| Electron Forge | N/A (desktop only) | N/A | N/A | N/A |
| electron-to-web | Minimal (2 imports) | Built-in | Full | Low |
A: Yes! If your app uses standard ipcMain.handle() and ipcRenderer.invoke() patterns, it should work with minimal changes.
A: The library doesn't add authentication. You should add auth middleware to the Express server (JWT, sessions, etc.) based on your needs.
A: Yes! Use environment-based imports:
const ipc = process.env.IS_WEB
? require('electron-to-web/main')
: require('electron');A: JSON-RPC adds ~1-2ms latency compared to native Electron IPC. WebSocket keeps a persistent connection, so no HTTP overhead on each call.
A: Binary data (Buffers, Files) needs special handling. We recommend:
- Small files: Base64 encode in JSON
- Large files: Separate HTTP POST endpoint
A: Not yet. For streaming, use multiple notifications:
// Server sends progress updates
for await (const chunk of processData()) {
mainWindow.webContents.send('progress', { percent: chunk.progress });
}Problem: WebSocket connection to 'ws://localhost:3001/ipc' failed
Solution: Ensure the server is running and the wsPath matches:
createWebServer({ wsPath: '/ipc' }); // Server
// Client auto-connects to window.location.host + '/ipc'Problem: ipcRenderer.invoke() hangs or times out
Solution: Check that:
- Handler is registered:
ipcMain.handle('channel', handler) - Channel name matches exactly (case-sensitive)
- Server logs show "Registered method: channel"
Problem: ipcRenderer.on() listener never fires
Solution: Ensure:
- Listener registered before event sent
- WebSocket is connected (
ipcRenderer.isConnected()) - Channel name matches exactly
Problem: Cannot find module 'electron-to-web'
Solution: Install type definitions:
npm install --save-dev @types/nodeAnd ensure tsconfig.json includes:
{
"compilerOptions": {
"moduleResolution": "node",
"esModuleInterop": true
}
}π Before migrating, read these guides to avoid common issues:
-
Common Pitfalls
β οΈ - Must-read guide covering:- IPC calls not resolving
- Dialog and file system issues
- Build configuration problems
- Browser compatibility concerns
-
Build Configuration π§ - Detailed build setup:
- Vite configuration (recommended)
- Webpack alternatives
- Post-build scripts
- Performance optimization
-
Migration Guide π - Step-by-step migration:
- Architecture understanding
- Client vs server responsibilities
- Code examples
- Testing strategies
Client-Side vs Server-Side Operations:
CLIENT (Browser) SERVER (Node.js)
ββ Dialogs (File System ββ File operations (server)
β Access API) ββ Database queries
ββ Clipboard (browser) ββ Authentication
ββ Notifications (browser) ββ Business logic
ββ File handles ββ Shell operations (server)
Not all Electron APIs can go through IPC. Some must run on the client (dialogs, clipboard), others on the server (file operations, databases). See Common Pitfalls for details.
- Code Snippet Manager - Full-featured app demonstrating:
- Dialog operations (import/export)
- Clipboard API
- File System Access API
- Notifications
- Real-world migration patterns
Contributions welcome! See CONTRIBUTING.md.
- Core IPC (invoke, handle, on, send)
- WebSocket transport
- Auto-reconnection
- TypeScript support
- Authentication middleware helpers
- File upload utilities
- Streaming support
- Renderer-to-renderer IPC (SharedWorker)
- CLI migration analyzer
- Performance benchmarks
MIT Β© 2026
Inspired by:
- vscode-jsonrpc - Microsoft's JSON-RPC implementation
- json-rpc-2.0 - Lightweight JSON-RPC library
- The Electron team for building an amazing framework
Star this repo if it helps your project! β