Skip to content

lsadehaan/electron-to-web

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

42 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

electron-to-web

Deploy your Electron app to the web without rewriting code

CI npm version License: MIT Node.js Version

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.

The Problem

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

The Solution

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.

How It Works

electron-to-web uses JSON-RPC 2.0 over WebSocket to replicate Electron's IPC system:

  • ipcRenderer.invoke(channel, data) β†’ JSON-RPC request
  • ipcMain.handle(channel, handler) β†’ JSON-RPC method handler
  • webContents.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 };                       β”‚
β”‚  });                                                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Quick Start

Installation

npm install electron-to-web

Server Setup (Electron Main Process β†’ Web Server)

// 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');

Client Setup (Electron Renderer β†’ Browser)

// 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>
  );
}

Build Configuration (Vite Example)

// 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.

Features

βœ… Fully Supported

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

⚠️ Partial Support (Browser Limitations)

  • Synchronous IPC: ipcRenderer.sendSync() β†’ Not supported (async only in browsers)
  • Shared Workers: Can enable renderer-to-renderer communication (opt-in)

βœ… Native API Support (NEW!)

Client-side (Pure Web APIs - no server required):

  • βœ… clipboard - Full clipboard API via navigator.clipboard
  • βœ… dialog - File dialogs via File System Access API + fallback
  • βœ… Notification - System notifications via Web Notification API
  • βœ… screen - Display information via window.screen
  • βœ… shell.openExternal() - Open URLs via window.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.

❌ Not Supported (Desktop-only Features)

  • 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.

API Reference

Main Process (Server-side)

ipcMain.handle(channel, handler)

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: '...' };
});

ipcMain.handleOnce(channel, handler)

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 invocation

ipcMain.on(channel, listener)

Listen for one-way messages from renderer (sent via ipcRenderer.send()).

ipcMain.on('log:message', (event, message) => {
  console.log(`Client ${event.sender.id}:`, message);
});

ipcMain.once(channel, listener)

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 call

ipcMain.removeHandler(channel)

Remove a registered invoke handler.

ipcMain.removeHandler('channel:name');

ipcMain.removeListener(channel, listener) / ipcMain.off()

Remove specific event listener.

const handler = (event, data) => { /* ... */ };
ipcMain.on('channel', handler);
// Later:
ipcMain.removeListener('channel', handler);

ipcMain.removeAllListeners([channel])

Remove all listeners for a channel, or all channels if not specified.

ipcMain.removeAllListeners('channel'); // Remove all listeners for 'channel'
ipcMain.removeAllListeners(); // Remove all listeners

BrowserWindow.webContents.send(channel, ...args)

Broadcast notification to all connected clients.

import { BrowserWindow } from 'electron-to-web/main';

const mainWindow = new BrowserWindow();
mainWindow.webContents.send('notification', { message: 'Hello!' });

BrowserWindow.webContents.sendTo(clientId, channel, ...args)

Send notification to specific client by ID.

mainWindow.webContents.sendTo('client-123', 'private:message', { data: 'secret' });

Renderer Process (Client-side)

ipcRenderer.invoke(channel, ...args)

Send request and wait for response.

import { ipcRenderer } from 'electron-to-web/renderer';

const result = await ipcRenderer.invoke('channel:name', arg1, arg2);

ipcRenderer.send(channel, ...args)

Send one-way message (no response expected).

ipcRenderer.send('log:message', 'User clicked button');

ipcRenderer.on(channel, listener)

Listen for events from main process.

ipcRenderer.on('notification', (event, data) => {
  console.log('Received:', data);
});

ipcRenderer.once(channel, listener)

Listen for event once, then automatically remove listener.

ipcRenderer.once('ready', (event, data) => {
  console.log('App ready:', data);
}); // Auto-removed after first notification

ipcRenderer.removeListener(channel, listener) / ipcRenderer.off()

Remove specific event listener.

const handler = (event, data) => { /* ... */ };
ipcRenderer.on('event', handler);
// Later:
ipcRenderer.removeListener('event', handler);

ipcRenderer.removeAllListeners(channel?)

Remove all listeners for a channel (or all channels if omitted).

ipcRenderer.removeAllListeners('notification'); // Just this channel
ipcRenderer.removeAllListeners(); // All channels

Server Utilities

createWebServer(options)

Create 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)
});

Native APIs

electron-to-web provides shims for Electron's native APIs, allowing you to use familiar Electron APIs in the browser.

Clipboard API

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)
  • type parameter (selection/clipboard) is ignored in web

Dialog API

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

Notification API

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();
  }
}

Screen API

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)

Shell API

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');

Security Configuration

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.

Migration Guide

Step 1: Install Package

npm install electron-to-web

Step 2: Update Server Code

// 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' });

Step 3: Update Renderer Code

// 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!

Step 4: Build Frontend

npm run build  # Build your frontend (Vite, webpack, etc.)

Step 5: Start Server

node server.js  # Your web server

Step 6: Test

Open http://localhost:3001 in your browser. Your app should work exactly like the Electron version!

Examples

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)

Comparison with Alternatives

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

FAQ

Q: Does this work with existing Electron apps?

A: Yes! If your app uses standard ipcMain.handle() and ipcRenderer.invoke() patterns, it should work with minimal changes.

Q: What about security?

A: The library doesn't add authentication. You should add auth middleware to the Express server (JWT, sessions, etc.) based on your needs.

Q: Can I use both Electron and Web from the same codebase?

A: Yes! Use environment-based imports:

const ipc = process.env.IS_WEB
  ? require('electron-to-web/main')
  : require('electron');

Q: Performance impact?

A: JSON-RPC adds ~1-2ms latency compared to native Electron IPC. WebSocket keeps a persistent connection, so no HTTP overhead on each call.

Q: What about file uploads?

A: Binary data (Buffers, Files) needs special handling. We recommend:

  • Small files: Base64 encode in JSON
  • Large files: Separate HTTP POST endpoint

Q: Does it support bidirectional streaming?

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 });
}

Troubleshooting

WebSocket Connection Refused

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'

Handlers Not Called

Problem: ipcRenderer.invoke() hangs or times out

Solution: Check that:

  1. Handler is registered: ipcMain.handle('channel', handler)
  2. Channel name matches exactly (case-sensitive)
  3. Server logs show "Registered method: channel"

Events Not Received

Problem: ipcRenderer.on() listener never fires

Solution: Ensure:

  1. Listener registered before event sent
  2. WebSocket is connected (ipcRenderer.isConnected())
  3. Channel name matches exactly

TypeScript Errors

Problem: Cannot find module 'electron-to-web'

Solution: Install type definitions:

npm install --save-dev @types/node

And ensure tsconfig.json includes:

{
  "compilerOptions": {
    "moduleResolution": "node",
    "esModuleInterop": true
  }
}

Documentation

Essential Guides

πŸ“š 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

Key Concepts

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.

Examples

  • Code Snippet Manager - Full-featured app demonstrating:
    • Dialog operations (import/export)
    • Clipboard API
    • File System Access API
    • Notifications
    • Real-world migration patterns

Contributing

Contributions welcome! See CONTRIBUTING.md.

Roadmap

  • 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

License

MIT Β© 2026

Credits

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! ⭐

About

Add web support to electron apps

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors