yieldless/ipc
Typed Electron IPC helpers that preserve tuple results across the process boundary.
Electron IPC is a good place for Yieldless because the boundary is inherently failure-heavy and the transport only accepts structured-clone-safe data.
Exports
createIpcMain(ipcMain)createIpcRenderer(ipcRenderer)createIpcBridge(client, channels)createAbortableIpcMain(ipcMain)createAbortableIpcRenderer(ipcRenderer)createAbortableIpcBridge(client, channels)serializeIpcError(error)deserializeIpcResult(payload)
Core types
IpcProcedureIpcContractIpcClientIpcBridgeAbortableIpcClientAbortableIpcBridgeSerializedIpcError
Contract example
import type { IpcProcedure } from "yieldless/ipc";
type Contract = {
getStatus: IpcProcedure<[directory: string], { output: string }, Error>;
};Main process
const server = createIpcMain<Contract>(ipcMain);
server.handle("getStatus", async (_event, directory) => {
return await runGitStatus(directory);
});Renderer process
const client = createIpcRenderer<Contract>(ipcRenderer);
const [error, result] = await client.invoke("getStatus", "/tmp/repo");Abortable renderer requests
When a renderer can abandon stale work, use the abortable helpers instead of pushing request IDs and cancel channels through your own app code.
const client = createAbortableIpcRenderer<Contract>(ipcRenderer);
const bridge = createAbortableIpcBridge(client, ["getStatus"] as const);
const controller = new AbortController();
const result = await bridge.withSignal.getStatus(
controller.signal,
"/tmp/repo",
);On the main-process side, the handler receives a shared AbortSignal:
const server = createAbortableIpcMain<Contract>(ipcMain);
server.handle("getStatus", async (_event, signal, directory) => {
return await runCommandSafe("git", ["status", "--short"], {
cwd: directory,
signal,
});
});Why the serialization layer matters
Electron can flatten thrown errors when they cross IPC. Yieldless avoids that by serializing tuple errors into plain objects before they cross the boundary, then decoding them back into tuple form on the receiving side.
Good fits
- React or Vue renderers calling main-process Git operations
- Preload bridges that expose only an allowlisted set of channels
- Desktop apps where you want one consistent error model from UI to subprocess
- Screens that need to cancel stale in-flight requests when the user navigates away
Good
Define the contract in one shared type module.
type GitContract = {
status: IpcProcedure<[repoPath: string], { stdout: string }, SerializedIpcError>;
fetch: IpcProcedure<[repoPath: string], { stdout: string }, SerializedIpcError>;
};Expose only allowlisted bridge methods from preload code.
const bridge = createAbortableIpcBridge(client, [
"status",
"fetch",
] as const);Use abortable IPC for views that can be replaced before the main-process work finishes.
Avoid
Do not throw rich Error objects across Electron IPC and expect every property to survive.
ipcMain.handle("status", async () => {
throw Object.assign(new Error("git failed"), { code: "E_GIT" });
});Return tuple errors through Yieldless so they are serialized into clone-safe objects.