Una aplicación de Electron son, como mínimo, dos programas distintos que hablan entre sí: un proceso principal con acceso total al sistema y uno o varios renderers que pintan HTML y no deberían poder tocar casi nada. Toda la funcionalidad que cruza esa frontera (abrir un archivo, leer una configuración, cambiar el título de la ventana, reaccionar a un menú) pasa por IPC. Si esa capa está mal diseñada, el resto de la seguridad de la app se queda en papel.
Este artículo recorre el patrón actual con Electron 45, que es la rama estable en el momento de escribirlo (la 45.2.2 en npm), y construye un ejemplo mínimo pero completo con los cuatro flujos habituales. Si vienes de nuestra primera app de escritorio con Electron, de 2016, verás que casi todo ha cambiado: lo que entonces se hacía con require('electron') directamente en la página hoy es justo lo que no se debe hacer.
Tres actores: main, preload y renderer
Para entender el IPC moderno hay que distinguir tres piezas:
- Proceso main. Corre en Node.js, crea las ventanas y es el único que debería tocar el sistema de archivos, los diálogos nativos o la red con privilegios.
- Renderer. Es la página web. Cuanto menos sepa de Electron, mejor.
- Script preload. Se ejecuta en el renderer antes de que cargue la página, pero en un contexto de JavaScript aislado. Es el único sitio donde conviven
ipcRenderery la página, y su trabajo es exponer una API pequeña y con nombre propio mediantecontextBridge.
Los valores por defecto ya empujan en esa dirección, según la guía de seguridad oficial:
Opción | Valor por defecto | Desde |
| desactivada | Electron 5 |
| activada | Electron 12 |
| activado | Electron 20 |
Aun así conviene declararlas de forma explícita en el código, como haremos más abajo: el valor por defecto protege a quien no toca nada, pero deja de hacerlo en cuanto alguien activa nodeIntegration «solo para probar». Además, desactivar el aislamiento de contexto desactiva también el sandbox, así que un solo cambio quita dos capas de protección.
Los cuatro patrones de IPC
La documentación de Electron describe cuatro formas de comunicar procesos. Cada una tiene su sitio:
Patrón | API principal | Cuándo usarlo |
Renderer ? main, un sentido |
| Avisos sin respuesta: cambiar el título, registrar un evento |
Renderer ? main, con respuesta |
| Peticiones: abrir un diálogo, leer datos, ejecutar una operación |
Main ? renderer |
| Notificaciones desde menús, actualizaciones, eventos del sistema |
Renderer ? renderer |
| Comunicación directa entre ventanas sin pasar por el main |
Para casi todo lo que necesite una respuesta, el patrón correcto es invoke/handle. Las alternativas antiguas siguen existiendo pero tienen problemas: responder con event.reply obliga a montar un segundo listener y no hay forma limpia de emparejar petición y respuesta, y ipcRenderer.sendSync bloquea el renderer entero hasta que llega el resultado. Se pueden olvidar sin perder nada.
Estructura del ejemplo
El proyecto tiene cinco ficheros y una sola dependencia:
electron-ipc-demo/ ??? package.json ??? main.js ??? preload.js ??? index.html ??? renderer.js
{
"name": "electron-ipc-demo",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^45.2.2"
}
}
Tras un npm install, se arranca con npm start. El ejemplo implementa una petición con respuesta (abrir un archivo de texto), un aviso de un solo sentido (cambiar el título), un mensaje del main al renderer (un contador que se maneja desde el menú) y la validación del emisor en todos los canales.
El proceso main
const { app, BrowserWindow, Menu, dialog, ipcMain } = require('electron')
const { pathToFileURL } = require('node:url')
const fs = require('node:fs/promises')
const path = require('node:path')
const INDEX = path.join(__dirname, 'index.html')
const INDEX_URL = pathToFileURL(INDEX).href
const MAX_BYTES = 1024 * 1024
let ventana = null
// frame es null si el marco ya no existe
function emisorValido(frame) {
return Boolean(frame) && frame.url === INDEX_URL
}
// Registra un canal invoke/handle que rechaza emisores desconocidos
function manejar(canal, fn) {
ipcMain.handle(canal, (event, ...args) => {
if (!emisorValido(event.senderFrame)) {
throw new Error(`Emisor no autorizado en ${canal}`)
}
return fn(event, ...args)
})
}
manejar('app:version', () => app.getVersion())
manejar('dialog:openFile', async (event) => {
const origen = BrowserWindow.fromWebContents(event.sender)
const { canceled, filePaths } = await dialog.showOpenDialog(origen, {
properties: ['openFile'],
filters: [{ name: 'Texto', extensions: ['txt', 'md'] }]
})
if (canceled) return null
const ruta = filePaths[0]
const { size } = await fs.stat(ruta)
if (size > MAX_BYTES) throw new Error('El archivo supera 1 MB')
return { nombre: path.basename(ruta), contenido: await fs.readFile(ruta, 'utf8') }
})
// Canal de un solo sentido: no devuelve nada, así que validamos y descartamos
ipcMain.on('window:set-title', (event, titulo) => {
if (!emisorValido(event.senderFrame)) return
if (typeof titulo !== 'string' || titulo.length > 100) return
BrowserWindow.fromWebContents(event.sender)?.setTitle(titulo)
})
ipcMain.on('contador:valor', (event, valor) => {
if (!emisorValido(event.senderFrame)) return
if (!Number.isInteger(valor)) return
console.log(`Contador en el renderer: ${valor}`)
})
function crearMenu() {
const plantilla = [
{
label: 'Contador',
submenu: [
{
label: 'Sumar 1',
accelerator: 'CmdOrCtrl+Up',
click: () => ventana?.webContents.send('contador:cambio', 1)
},
{
label: 'Restar 1',
accelerator: 'CmdOrCtrl+Down',
click: () => ventana?.webContents.send('contador:cambio', -1)
},
{ type: 'separator' },
{ role: 'quit' }
]
}
]
Menu.setApplicationMenu(Menu.buildFromTemplate(plantilla))
}
function crearVentana() {
ventana = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
ventana.loadFile(INDEX)
ventana.on('closed', () => { ventana = null })
}
app.whenReady().then(() => {
crearMenu()
crearVentana()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) crearVentana()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
Hay varias decisiones que merece la pena explicar:
manejar()centraliza la validación. Cada canalinvokepasa por la misma comprobación del emisor, de modo que no depende de que alguien se acuerde de escribirla en el handler número doce.- El diálogo lo abre el main. El renderer no pasa ninguna ruta: pide «abrir archivo» y es el usuario quien elige, con un filtro de extensiones y un límite de tamaño. Se evita así que una página comprometida lea cualquier fichero del disco.
- Los canales de un solo sentido validan y descartan. Con
ipcMain.onno hay respuesta que devolver, así que ante un argumento extraño lo más sensato es ignorarlo. - El menú envía con
webContents.send. El?.evita un error si la ventana ya se ha cerrado.
La validación del emisor compara la URL del marco con la del index.html que carga la propia app. La guía de seguridad de Electron recomienda comprobar el origin del marco en lugar de la URL cuando la app carga contenido remoto, porque about:blank, blob: y los documentos en sandbox no identifican de forma fiable a quien los controla. Para una app que solo carga un fichero local, la comparación exacta con la URL es suficiente y más estricta. Si algún día añades contenido remoto, cambia a una lista de orígenes permitidos. Y recuerda que senderFrame puede ser null si el marco ya no existe.
El preload y el puente
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
// Petición con respuesta (invoke/handle)
version: () => ipcRenderer.invoke('app:version'),
abrirArchivo: () => ipcRenderer.invoke('dialog:openFile'),
// Un solo sentido, renderer -> main
ponerTitulo: (titulo) => ipcRenderer.send('window:set-title', titulo),
informarContador: (valor) => ipcRenderer.send('contador:valor', valor),
// main -> renderer. Se reenvía solo el dato, nunca el event,
// y se devuelve una función para darse de baja
alCambiarContador: (callback) => {
const listener = (_event, delta) => callback(delta)
ipcRenderer.on('contador:cambio', listener)
return () => ipcRenderer.removeListener('contador:cambio', listener)
}
})
La regla de oro es que la página ve funciones con nombre, no el canal de comunicación. window.api.abrirArchivo() es una función concreta que hace una cosa; ipcRenderer entero sería un teléfono directo con el main para cualquier canal que se le ocurra a un script.
El caso del listener tiene su truco. El callback que registra ipcRenderer.on recibe como primer argumento el event, y ese objeto contiene una referencia al propio ipcRenderer. Si se pasara el callback de la página tal cual, la página lo recibiría y se saltaría todo el aislamiento. Por eso el preload envuelve el callback en un listener propio y reenvía solo el dato (delta). Devolver una función de baja permite además quitar el listener cuando la página ya no lo necesita, algo que se olvida con frecuencia y que acaba en fugas de memoria o en handlers duplicados.
Una nota sobre formato: con sandbox: true el preload corre con un require reducido que solo da acceso a un subconjunto de módulos (los de Electron como contextBridge e ipcRenderer, además de algunos módulos básicos de Node), y debe ser un script CommonJS. Para este patrón es más que suficiente.
La página: HTML y renderer
El HTML incluye una política de seguridad de contenidos (CSP) restrictiva. Para páginas servidas por file://, que no pueden usar cabeceras HTTP, la vía es la etiqueta <meta http-equiv="Content-Security-Policy">:
<!doctype html> <html lang="es"> <head> <meta charset="utf-8"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'"> <title>IPC en Electron</title> </head> <body> <h1>IPC en Electron</h1> <p>Versión de la app: <span id="version"></span></p> <button id="abrir">Abrir archivo de texto</button> <pre id="contenido"></pre> <input id="titulo" placeholder="Nuevo título de ventana"> <button id="cambiar">Cambiar título</button> <p>Contador: <strong id="contador">0</strong> (menú Contador o Ctrl/Cmd + flechas)</p> <script src="renderer.js"></script> </body> </html>
Fíjate en que el JavaScript va en un fichero aparte. Con script-src 'self' no se ejecuta ningún script incrustado, que es justo lo que buscamos: si alguien consigue inyectar HTML, tampoco consigue ejecutar código.
const $ = (id) => document.getElementById(id)
let contador = 0
async function iniciar() {
$('version').textContent = await window.api.version()
}
$('abrir').addEventListener('click', async () => {
try {
const archivo = await window.api.abrirArchivo()
if (archivo) {
$('contenido').textContent = `${archivo.nombre}nn${archivo.contenido}`
}
} catch (error) {
$('contenido').textContent = `Error: ${error.message}`
}
})
$('cambiar').addEventListener('click', () => {
window.api.ponerTitulo($('titulo').value)
})
const darseDeBaja = window.api.alCambiarContador((delta) => {
contador += delta
$('contador').textContent = contador
window.api.informarContador(contador)
})
window.addEventListener('pagehide', darseDeBaja)
iniciar()
El renderer no sabe nada de IPC. Llama a window.api.version() y espera una promesa, igual que haría con cualquier otra API asíncrona. Cuando handle lanza una excepción, el renderer recibe un rechazo cuyo mensaje tiene la forma Error invoking remote method 'dialog:openFile': Error: El archivo supera 1 MB. Según la documentación, solo el atributo message del error original llega al otro lado, así que nada de apoyarse en clases de error personalizadas ni en propiedades adicionales: si necesitas distinguir casos, devuelve un objeto con un código en lugar de lanzar.
Main hacia renderer: el flujo contrario
Probar el contador es lo más sencillo: ejecuta la app, pulsa Ctrl+Flecha arriba (Cmd en macOS) o usa el menú y verás cómo sube el número. El recorrido es: el menú llama a ventana.webContents.send('contador:cambio', 1), el listener del preload recibe el valor, lo reenvía al callback de la página y esta contesta con informarContador, que llega al main por un canal de un solo sentido y se imprime en la consola del terminal.
Con webContents.send hay una limitación importante: solo llega a la página de esa ventana. Si el mensaje debe llegar a varias, hay que recorrer BrowserWindow.getAllWindows() o gestionar una lista propia.
Entre ventanas: MessagePorts
Dos renderers no tienen un canal directo. Una opción es reenviar los mensajes a través del main, que funciona pero lo convierte en cuello de botella y en carga de código. La alternativa es crear un MessageChannelMain en el main y entregar un puerto a cada ventana. A partir de ahí se hablan directamente, sin pasar por él:
const { MessageChannelMain } = require('electron')
const { port1, port2 } = new MessageChannelMain()
ventanaA.once('ready-to-show', () => {
ventanaA.webContents.postMessage('port', null, [port1])
})
ventanaB.once('ready-to-show', () => {
ventanaB.webContents.postMessage('port', null, [port2])
})
Los puertos no se pueden mandar con send ni con invoke: hay que usar webContents.postMessage y, en el lado del renderer, ipcRenderer.postMessage. Con el aislamiento de contexto activo, el preload recibe el puerto pero no puede pasárselo a la página directamente, así que lo reenvía con window.postMessage una vez cargada la ventana:
const { ipcRenderer } = require('electron')
const ventanaCargada = new Promise((resolve) => {
window.onload = resolve
})
ipcRenderer.on('port', async (event) => {
await ventanaCargada
// Con contextIsolation, el puerto cruza al mundo principal con window.postMessage
window.postMessage('port', '*', event.ports)
})
window.onmessage = (event) => {
if (event.source === window && event.data === 'port') {
const [puerto] = event.ports
puerto.onmessage = (mensaje) => {
console.log('Recibido:', mensaje.data)
}
puerto.postMessage('ping')
}
}
El '*' como origen de destino es el que usa la documentación oficial y aquí es aceptable porque se trata de una página local propia. Si la ventana mostrara contenido de terceros, habría que concretarlo. En el listener de la página conviene comprobar también event.source === window, como se hace arriba, para que otro marco no pueda hacerse pasar por el preload.
Los errores de seguridad más habituales
Casi todos los problemas de IPC en Electron entran por tres sitios.
1. Exponer el módulo en bruto. Es el atajo más tentador cuando se migra código antiguo, y también el más peligroso:
// MAL: cualquier script de la página puede mandar mensajes a cualquier canal
contextBridge.exposeInMainWorld('electron', { ipcRenderer })
// MAL: el callback recibe el event, y con él acceso al ipcRenderer
contextBridge.exposeInMainWorld('api', {
on: (canal, callback) => ipcRenderer.on(canal, callback)
})
En ambos casos la página gana acceso a canales que el autor nunca pensó exponer. La documentación lo dice sin rodeos: no hay que exponer ipcRenderer ni sus métodos genéricos, solo funciones concretas.
2. Confiar en lo que llega al handler. Cualquier marco web que pueda enviar IPC (incluidos algunos iframes y ventanas hijas) puede llamar a tus canales, y los argumentos son datos no confiables aunque tu propio renderer los envíe con buena intención:
// MAL: el renderer decide qué ruta se lee, sin límites ni comprobación del emisor
ipcMain.handle('fs:leer', (event, ruta) => fs.readFile(ruta, 'utf8'))
Ese handler entrega cualquier fichero del equipo del usuario a quien sepa llamarlo. La versión correcta no deja que el renderer decida la ruta:
// BIEN: el renderer no elige la ruta; el main la obtiene de un diálogo nativo
// y solo acepta un identificador de una lista cerrada
const CONFIGS = { tema: 'tema.json', atajos: 'atajos.json' }
manejar('config:leer', async (_event, nombre) => {
if (!Object.hasOwn(CONFIGS, nombre)) throw new Error('Configuración desconocida')
const ruta = path.join(app.getPath('userData'), CONFIGS[nombre])
return fs.readFile(ruta, 'utf8')
})
Dos ideas se repiten: lo que el renderer manda es una intención («dame la configuración del tema»), no un detalle de implementación («lee esta ruta»), y la lista de opciones válidas vive en el main.
3. Ablandar la ventana. Activar nodeIntegration o desactivar contextIsolation o el sandbox convierte cualquier fallo de XSS en ejecución de código con los permisos del usuario. La guía oficial es tajante: bajo ninguna circunstancia se debe cargar y ejecutar código remoto con la integración de Node activada. Y no ayuda mucho la CSP si luego se afloja con unsafe-inline «para que funcione».
Qué se puede enviar y qué no
El IPC serializa los mensajes con el algoritmo de clonado estructurado. Funcionan los tipos habituales (cadenas, números, booleanos, arrays, objetos planos, fechas, Map, Set, buffers), pero no se pueden pasar objetos del DOM, objetos de Node respaldados por clases de C++ (como process.env) ni objetos de Electron como WebContents, BrowserWindow o WebFrame. Si necesitas referirte a una ventana, envía su identificador y resuélvelo en el main con BrowserWindow.fromId().
Además, contextBridge también tiene sus reglas: las funciones se pueden exponer y devolver (el proxy cruza el aislamiento), pero los objetos que cruzan se copian y las clases pierden su prototipo. Es otra razón para que la API del puente se parezca más a un contrato de datos que a un objeto con comportamiento.
Lista de comprobación antes de publicar
contextIsolation: true,nodeIntegration: falseysandbox: trueen cadaBrowserWindow, escritos a mano en el código.- Ningún
ipcRendererni callback sin envolver expuesto en el puente; una función con nombre por cada operación. - Canales
invoke/handlepara todo lo que necesite respuesta, sinsendSync. - Validación del emisor (
senderFrame) y de los argumentos en cada handler del main. - El renderer no elige rutas, comandos ni URLs: pide acciones de una lista cerrada.
- CSP restrictiva y sin scripts incrustados.
- Listeners con baja explícita cuando la vista se destruye.
Si usas TypeScript, el siguiente paso natural es tipar la API del puente en un fichero .d.ts compartido para que la superficie expuesta en window.api quede documentada y comprobada en compilación, algo muy parecido a lo que ya se hace con el tipado de servicios en TypeScript con Angular. Y si tu renderer es un framework, la regla no cambia: la capa de IPC se envuelve en un servicio y el resto de la aplicación nunca toca window.api directamente.
Imagen: Pexels / Markus Winkler
