Skip to main content

ADR-005: IPC Communication Protocol

Statusโ€‹

Accepted - Standardized protocol for all Electron IPC communication

Contextโ€‹

The Electron application required a robust, type-safe communication protocol between the main process and renderer process. The solution needed to:

  • Provide type safety for all IPC communications
  • Handle validation and error responses consistently
  • Support cleanup and resource management
  • Enable easy testing through mocking
  • Maintain security through contextBridge isolation

Decisionโ€‹

We will implement a standardized IPC communication protocol using Electron's contextBridge with consistent patterns for all communication.

1. Centralized IPC Serviceโ€‹

All IPC handlers are registered through a central IpcService:

export class IpcService {
private registeredHandlers = new Set<string>();

public initialize(dependencies: IpcServiceDependencies): void {
this.registerSitesHandlers(dependencies);
this.registerMonitoringHandlers(dependencies);
this.registerSettingsHandlers(dependencies);
// ... other domain handlers
}

private registerStandardizedIpcHandler<T, R>(
channel: string,
handler: (params: T) => Promise<R>,
validator?: (params: unknown) => params is T
): void {
if (this.registeredHandlers.has(channel)) {
throw new Error(`IPC handler already registered: ${channel}`);
}

ipcMain.handle(channel, async (_event, params: unknown) => {
try {
if (validator && !validator(params)) {
throw new Error(`Invalid parameters for ${channel}`);
}
return await handler(params as T);
} catch (error) {
logger.error(`IPC handler failed: ${channel}`, error);
throw error;
}
});

this.registeredHandlers.add(channel);
}
}

2. Domain-Specific Handler Groupsโ€‹

IPC handlers are organized by domain with consistent validation:

private registerSitesHandlers(deps: IpcServiceDependencies): void {
this.registerStandardizedIpcHandler(
'sites:get-all',
async () => deps.siteManager.getAllSites(),
// No parameters to validate
);

this.registerStandardizedIpcHandler(
'sites:add',
async (params) => deps.siteManager.addSite(params),
isSiteCreationData
);

this.registerStandardizedIpcHandler(
'sites:remove',
async (params) => deps.siteManager.removeSite(params.identifier),
isIdentifierParams
);
}

3. Type-Safe Preload APIโ€‹

The preload script exposes a type-safe API to the renderer:

// preload.ts
const electronAPI = {
sites: {
addSite: (data: SiteCreationData): Promise<Site> =>
ipcRenderer.invoke("sites:add", data),
getSites: (): Promise<Site[]> => ipcRenderer.invoke("sites:get-all"),
removeSite: (identifier: string): Promise<void> =>
ipcRenderer.invoke("sites:remove", { identifier }),
},
events: {
onMonitorStatusChanged: (
callback: (data: MonitorStatusData) => void
) => {
const wrappedCallback = (_event: any, data: MonitorStatusData) =>
callback(data);
ipcRenderer.on("monitor:status-changed", wrappedCallback);
return () =>
ipcRenderer.off("monitor:status-changed", wrappedCallback);
},
},
// ... other domains
} as const;

contextBridge.exposeInMainWorld("electronAPI", electronAPI);

4. Event Forwarding Protocolโ€‹

Backend events are automatically forwarded to the frontend:

// In IpcService
private async forwardEventToRenderer(eventName: string, data: unknown): Promise<void> {
if (this.webContents && !this.webContents.isDestroyed()) {
this.webContents.send(eventName, data);
}
}

// Events are forwarded automatically when emitted
await this.eventBus.emitTyped('monitor:status-changed', eventData);
// โ†’ Automatically sent to renderer via IPC

5. Validation and Error Handlingโ€‹

All IPC operations include validation and consistent error handling:

// Validation functions for each domain
export function isSiteCreationData(data: unknown): data is SiteCreationData {
return (
typeof data === "object" &&
data !== null &&
"name" in data &&
typeof (data as any).name === "string"
);
}

// Error responses maintain consistent format
try {
const result = await handler(params);
return { success: true, data: result };
} catch (error) {
logger.error(`IPC operation failed: ${channel}`, error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}

Communication Patternsโ€‹

1. Request-Response Patternโ€‹

Standard async operations use the request-response pattern:

// Frontend request
const result = await window.electronAPI.sites.addSite(siteData);

// Backend handler
async (params: SiteCreationData) => {
const site = await this.siteManager.addSite(params);
return site;
};

2. Event Broadcasting Patternโ€‹

State changes are broadcast as events:

// Backend emits event
await this.eventBus.emitTyped("sites:added", { site: newSite });

// Frontend listens for event
const cleanup = window.electronAPI.events.onSiteAdded((data) => {
sitesStore.addSite(data.site);
});

3. Cleanup Patternโ€‹

Event listeners return cleanup functions:

useEffect(() => {
const cleanup = window.electronAPI.events.onMonitorStatusChanged((data) => {
handleStatusChange(data);
});

return cleanup; // Automatic cleanup on unmount
}, []);

Channel Naming Conventionโ€‹

Format: domain:actionโ€‹

  • Sites: sites:add, sites:remove, sites:get-all
  • Monitoring: monitoring:start, monitoring:stop
  • Settings: settings:get, settings:update
  • Events: Use past tense for completed actions: monitor:status-changed

Consistency Rulesโ€‹

  1. Domain prefix - Always use domain prefix for grouping
  2. Kebab-case - Use kebab-case for multi-word actions
  3. Verb-noun pattern - Action followed by resource (get-sites, not sites-get)
  4. Past tense for events - Events use past tense (status-changed)

Security Considerationsโ€‹

1. ContextBridge Isolationโ€‹

All IPC communication goes through contextBridge - no direct Node.js access in renderer.

2. Parameter Validationโ€‹

All parameters are validated before processing to prevent injection attacks.

3. Error Sanitizationโ€‹

Error messages are sanitized before sending to renderer to prevent information leakage.

Testing Strategyโ€‹

1. Mock ElectronAPIโ€‹

Global mock for testing:

const mockElectronAPI = {
sites: {
addSite: vi.fn().mockResolvedValue(mockSite),
getSites: vi.fn().mockResolvedValue([]),
removeSite: vi.fn().mockResolvedValue(undefined),
},
events: {
onMonitorStatusChanged: vi.fn((_callback) => vi.fn()),
},
};

Object.defineProperty(window, "electronAPI", {
value: mockElectronAPI,
writable: true,
});

2. Handler Testingโ€‹

IPC handlers are tested by mocking dependencies:

describe("Sites IPC Handlers", () => {
it("should handle add site request", async () => {
const mockSiteManager = {
addSite: vi.fn().mockResolvedValue(mockSite),
};

const ipcService = new IpcService();
ipcService.initialize({ siteManager: mockSiteManager });

const result = await ipcRenderer.invoke("sites:add", siteData);
expect(result).toEqual(mockSite);
});
});

Consequencesโ€‹

Positiveโ€‹

  • Type safety - Compile-time checking for all IPC communications
  • Consistent patterns - Standardized registration and handling
  • Error handling - Uniform error responses and logging
  • Testability - Easy mocking and testing of IPC operations
  • Security - Proper isolation through contextBridge
  • Maintainability - Domain-specific organization

Negativeโ€‹

  • Boilerplate - Requires validation functions and type definitions
  • Complexity - Additional abstraction layer over raw IPC
  • Learning curve - Developers need to understand IPC patterns

Implementation Requirementsโ€‹

1. Handler Registrationโ€‹

All IPC handlers must be registered through IpcService.registerStandardizedIpcHandler().

2. Type Definitionsโ€‹

All IPC operations must have corresponding TypeScript interfaces.

3. Validation Functionsโ€‹

All parameterized operations must include validation functions.

4. Error Handlingโ€‹

All handlers must use try-catch with proper error logging.

5. Cleanup Supportโ€‹

Event listeners must return cleanup functions.

Complianceโ€‹

All IPC communication follows this protocol:

  • Centralized registration through IpcService
  • Domain-specific handler grouping
  • Type-safe preload API exposure
  • Consistent validation and error handling
  • Automatic event forwarding