How escrcpy Implements Function-Based IPC Communication with @escrcpy/electron-ipcx

escrcpy uses the @escrcpy/electron-ipcx package to expose main-process functionality as standard async JavaScript functions, enabling type-safe, promise-based communication between Electron's main and renderer processes without manual message serialization.

escrcpy is an open-source Electron application that provides a GUI for scrcpy, the Android screen mirroring tool. To maintain clean separation between the main Node.js environment and the Chromium renderer while avoiding callback hell, the project implements function-based IPC communication through its dedicated @escrcpy/electron-ipcx package. This abstraction layer converts low-level IPC channels into callable async functions that support automatic error propagation and resource disposal.

Registering Handlers in the Main Process

The main process registers callable endpoints using ipcxMain.handle(), which accepts a channel name and an async handler function. This pattern is implemented in desktop/electron/modules/terminal/service.js, where terminal session management functions are exposed to renderers.

When a handler is registered, it receives the original ipcMain event object for context and a payload containing arguments from the renderer:

// desktop/electron/modules/terminal/service.js
import { ipcxMain } from '@escrcpy/electron-ipcx/main';

// Create a new terminal session
ipcxMain.handle('terminal:create-session', async (_event, config) => {
  const session = await createTerminalSession(config);
  return { sessionId: session.id };
});

// Write data to an existing session
ipcxMain.handle('terminal:write-session', async (_event, { sessionId, data }) => {
  const session = getSessionById(sessionId);
  await session.write(data);
});

// Resize a session's pseudo-tty
ipcxMain.handle('terminal:resize-session', async (_event, { sessionId, cols, rows }) => {
  const session = getSessionById(sessionId);
  await session.resize(cols, rows);
});

// Clean up a session
ipcxMain.handle('terminal:destroy-session', async (_event, { sessionId }) => {
  const session = getSessionById(sessionId);
  await session.close();
  // Optionally remove handlers if they are per-session
  ipcxMain.removeHandler('terminal:create-session');
  ipcxMain.removeHandler('terminal:write-session');
  ipcxMain.removeHandler('terminal:resize-session');
  ipcxMain.removeHandler('terminal:destroy-session');
});

Handlers can be removed dynamically using ipcxMain.removeHandler(channel), which is essential for cleaning up per-session resources when connections terminate.

Invoking Functions from the Renderer

On the renderer side, desktop/electron/middleware/terminal/index.js consumes these IPC functions through ipcxRenderer.invoke() for one-shot operations. This method returns a standard Promise that resolves with the handler's return value or rejects if the main process throws an error.

For long-lived resources such as terminal sessions, escrcpy uses ipcxRenderer.invokeRetained(), which returns both a promise and a disposal function:

// desktop/electron/middleware/terminal/index.js
import { ipcxRenderer } from '@escrcpy/electron-ipcx/renderer';

// Open a terminal session (retained = long-living)
export const createTerminal = async (config) => {
  const { promise, dispose } = ipcxRenderer.invokeRetained('terminal:create-session', config);
  const { sessionId } = await promise;
  return { sessionId, dispose };
};

// Write to the terminal
export const writeTerminal = (sessionId, data) => {
  return ipcxRenderer.invoke('terminal:write-session', { sessionId, data });
};

// Resize the terminal
export const resizeTerminal = (sessionId, cols, rows) => {
  return ipcxRenderer.invoke('terminal:resize-session', { sessionId, cols, rows });
};

// Close the terminal
export const destroyTerminal = (sessionId) => {
  return ipcxRenderer.invoke('terminal:destroy-session', { sessionId });
};

The invokeRetained pattern is critical for resources that persist beyond a single request, allowing the renderer to explicitly release remote references when components unmount or sessions end.

Error Handling and Type Safety

Because @escrcpy/electron-ipcx wraps native IPC in promise-based interfaces, error propagation works naturally with async/await semantics. Any exception thrown inside a main-process handler automatically rejects the renderer's promise, preserving stack traces and error types across the process boundary.

The package also ships with TypeScript definitions that enable IDE autocompletion for channel names and payload types. Developers can import shared type signatures to ensure compile-time safety for both handler registration and renderer invocation, eliminating the string-typing issues common in raw Electron IPC.

Summary

  • escrcpy implements function-based IPC communication by wrapping Electron's native IPC in the @escrcpy/electron-ipcx package, exposing main-process logic as standard async functions.
  • Main process handlers are registered via ipcxMain.handle() in files like desktop/electron/modules/terminal/service.js, with cleanup managed through ipcxMain.removeHandler().
  • Renderer processes invoke these functions using ipcxRenderer.invoke() for stateless calls and ipcxRenderer.invokeRetained() for long-lived resources that require explicit disposal.
  • Errors thrown in the main process automatically propagate as rejected promises to the renderer, maintaining standard JavaScript error-handling patterns.
  • The architecture provides full TypeScript support, enabling type-safe channel definitions across the main-renderer boundary.

Frequently Asked Questions

What is the difference between invoke and invokeRetained in @escrcpy/electron-ipcx?

ipcxRenderer.invoke() is designed for stateless, one-shot IPC calls that return a single value and immediately release resources. ipcxRenderer.invokeRetained() is used for long-lived resources like terminal sessions or file streams; it returns an object containing both a promise for the initial result and a dispose function that the renderer must call to clean up the remote resource when finished.

How does escrcpy clean up IPC handlers for terminated sessions?

When a terminal session ends, the renderer calls ipcxRenderer.invoke('terminal:destroy-session'), which triggers the main process to execute ipcxMain.removeHandler() for all related channel endpoints. This explicit cleanup prevents memory leaks from accumulating orphaned handlers in the main process when sessions close.

Is the IPC communication in escrcpy type-safe?

Yes, the @escrcpy/electron-ipcx package includes TypeScript definitions that allow developers to define channel signatures as typed interfaces. Both the main and renderer processes can import these definitions, providing compile-time validation of payload shapes and return types while enabling IDE features like autocomplete and refactoring support.

Why use function-based IPC instead of standard Electron ipcRenderer/ipcMain?

Function-based IPC eliminates boilerplate message serialization, manual event listener management, and callback coordination. By treating remote capabilities as standard JavaScript functions, escrcpy developers write cleaner async/await code with automatic error propagation and built-in support for resource disposal, significantly reducing the complexity of main-renderer communication compared to raw IPC message passing.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →