@aderra/tipc
v0.1.2
Published
Typesafe and runtime-validatable IPC for Electron
Readme
@aderra/tipc
Typesafe, runtime-validatable IPC for Electron applications.
What @aderra/tipc adds
@aderra/tipc turns nested Main-process functions into a typed asynchronous
Renderer client. It also provides opt-in Zod validation, mandatory sender
policies, a narrow context-bridge transport, typed Main-to-Renderer events and
requests, structured errors, deterministic cleanup, and an optional TanStack
React Query adapter.
Routes are ordinary zero- or one-argument business functions. There is no
procedure builder and no source compatibility with @egoist/tipc.
Installation
npm install @aderra/tipc zodThe package requires Node.js 22.14 or newer, Electron 35 or newer, and Zod 4.
Zod is a required peer because the root entrypoint re-exports z. For the
optional React Query entrypoint, also install its peers:
npm install @tanstack/react-query reactRenderer -> Main RPC
Define a nested router from direct business functions in Main. A route may
take zero or one argument; the Renderer always receives a Promise-returning
client.
// src/main/ipc/router.ts
import { defineRouter, validated, z } from '@aderra/tipc';
const StartCaptureSchema = z.object({
mode: z.enum(['region', 'window', 'screen']),
scaleFactor: z.coerce.number().min(0.5).max(4).default(1),
});
const screenshot = {
getState: () => ({ status: 'idle' as const }),
startCapture: async (input: z.output<typeof StartCaptureSchema>) => ({
status: 'capturing' as const,
...input,
}),
};
export const desktopRouter = defineRouter({
screenshot: {
getState: screenshot.getState,
startCapture: validated(StartCaptureSchema, screenshot.startCapture),
},
});
export type DesktopRouter = typeof desktopRouter;After registering the router and exposing the preload transport as shown below, create the Renderer client:
// src/renderer/ipc.ts
import { createClient } from '@aderra/tipc/renderer';
import type { DesktopRouter } from '../main/ipc/router';
export const desktop = createClient<DesktopRouter>({
transport: window.desktopTransport,
});
const state = await desktop.screenshot.getState();
const capture = await desktop.screenshot.startCapture({ mode: 'region' });Keep the router import type-only so Main code is erased from the Renderer bundle.
Opt-in Zod validation
Wrap only trust-sensitive routes with validated(schema, fn). It uses
safeParseAsync, so coercion, defaults, transforms, and asynchronous
refinements finish before the business function runs. The Renderer accepts
z.input<typeof schema> while the Main function receives
z.output<typeof schema>. Invalid input never reaches the function and is
reported as INPUT_VALIDATION_ERROR.
Unwrapped routes remain statically typed but do not receive runtime input validation.
Sender validation
Every Main registration requires an explicit sender policy. Check both the
target WebContents and its main frame:
// src/main/index.ts
import { ipcMain } from 'electron';
import { registerIpcMain } from '@aderra/tipc/main';
import { desktopRouter } from './ipc/router';
const disposeRpc = registerIpcMain(desktopRouter, {
ipcMain,
namespace: 'desktop',
validateSender(event) {
return (
event.sender === mainWindow.webContents &&
event.senderFrame === mainWindow.webContents.mainFrame
);
},
});The policy runs before payload validation and route execution. Dispose the
registration when its owning window or application lifecycle ends.
allowAllSenders exists for isolated tests; do not use it as a production
policy.
Safe preload transport
Create one preconstructed facade in the preload and expose only that facade:
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import { createPreloadTransport } from '@aderra/tipc/preload';
const desktopTransport = createPreloadTransport({
ipcRenderer,
namespace: 'desktop',
});
contextBridge.exposeInMainWorld('desktopTransport', desktopTransport);Add the matching Renderer type without importing runtime Main code:
// src/renderer/global.d.ts
import type { PreloadTransport } from '@aderra/tipc/preload';
declare global {
interface Window {
desktopTransport: PreloadTransport;
}
}
export {};PreloadTransport exposes only fixed, namespaced operations and removes
Electron event objects from callbacks. Never expose raw ipcRenderer,
arbitrary channel methods, or Electron event objects to web content.
Main -> Renderer send/listen
Define the bidirectional contract in a module that both processes can import:
// src/shared/desktop-events.ts
import { defineEvents } from '@aderra/tipc';
export interface ScreenshotState {
readonly status: 'idle' | 'capturing' | 'completed';
}
export const desktopEvents = defineEvents<{
screenshot: {
changed: (state: ScreenshotState) => void;
confirmReplace: (name: string) => boolean;
};
}>();A void-returning event maps to send in Main and listen in Renderer:
// Main
import { ipcMain } from 'electron';
import { createRendererClient } from '@aderra/tipc/main';
import { desktopEvents } from '../shared/desktop-events';
const renderer = createRendererClient<typeof desktopEvents>({
ipcMain,
webContents: mainWindow.webContents,
namespace: 'desktop',
timeoutMs: 10_000,
});
renderer.screenshot.changed.send({ status: 'completed' });// Renderer
import { createEventClient } from '@aderra/tipc/renderer';
import { desktopEvents } from '../shared/desktop-events';
const events = createEventClient<typeof desktopEvents>({
transport: window.desktopTransport,
});
const stopChanged = events.screenshot.changed.listen((state) => {
console.log(state.status);
});Main -> Renderer invoke/handle
A non-void event maps to invoke in Main and handle in Renderer:
// Renderer: register before Main invokes it.
const stopConfirm = events.screenshot.confirmReplace.handle(async (name) =>
window.confirm(`Replace ${name}?`),
);
// Main
const confirmed =
await renderer.screenshot.confirmReplace.invoke('capture.png');invoke defaults to a 10-second timeout. Responses are accepted only from the
target WebContents and the captured webContents.mainFrame; a child frame
cannot settle the request. listen and handle return idempotent cleanup
functions; both event clients also expose an idempotent dispose() method:
stopChanged();
stopConfirm();
events.dispose();
renderer.dispose();
disposeRpc();Create only one request-dispatching event client for a given preload transport and reuse it for all event routes.
React Query
The optional adapter wraps the regular Renderer client; it does not create a second transport.
import { createReactQueryClient } from '@aderra/tipc/react-query';
import type { DesktopRouter } from '../main/ipc/router';
import { desktop } from './ipc';
const api = createReactQueryClient<DesktopRouter>({ client: desktop });
export function CapturePanel() {
// Route input is always first; React Query options are always second.
// A no-input route therefore uses `undefined` when options are present.
const state = api.screenshot.getState.useQuery(undefined, {
staleTime: 1_000,
});
const capture = api.screenshot.startCapture.useMutation({ retry: false });
return (
<button
disabled={capture.isPending}
onClick={() => capture.mutate({ mode: 'region' })}
>
{state.data?.status ?? 'Loading'}
</button>
);
}For an input route the query shape is useQuery(input, options), never
useQuery(options). Mutation hook options go to useMutation(options) and
route input goes to mutate(input) or mutateAsync(input). Query keys are
derived from the exact route and input. The adapter owns queryKey, queryFn,
mutationKey, and mutationFn; use api.useUtils() for typed cache helpers.
Errors
Business functions may throw a genuine TipcError to expose a public code,
message, and structured-clone-safe details:
import { TipcError } from '@aderra/tipc';
throw new TipcError('CAPTURE_BUSY', 'Capture is already running', {
activeMode: 'region',
});Renderer clients reject with TipcClientError:
import { TipcClientError } from '@aderra/tipc';
try {
await desktop.screenshot.startCapture({ mode: 'region' });
} catch (error) {
if (error instanceof TipcClientError) {
console.error(error.code, error.message, error.details);
}
}Built-in codes are INPUT_VALIDATION_ERROR, UNTRUSTED_SENDER,
ROUTE_NOT_FOUND, RENDERER_UNAVAILABLE, RENDERER_TIMEOUT,
INVALID_REQUEST, and INTERNAL_ERROR. Unknown exceptions become a constant
INTERNAL_ERROR and do not expose stacks or private fields. Non-serializable
success values also become INTERNAL_ERROR; non-serializable public error
details are omitted.
Security checklist
- Use
contextIsolation: true,sandbox: true, andnodeIntegration: falsefor production windows. - Validate
event.senderandevent.senderFrameagainst the expected window andwebContents.mainFrameon every Renderer-to-Main registration. - Expose only a preconstructed
PreloadTransport; never expose rawipcRenderer, arbitrary channels, or Electron event objects. - Block untrusted navigation and window creation. Sender validation is one layer, not a replacement for Electron's window security controls.
- Apply
validated(schema, fn)to untrusted input and send only structured-clone-safe values. - Dispose registrations, listeners, handlers, and clients with their owner.
nodeIntegrationInSubFrames: trueappears only in the adversarial Electron smoke fixture so a child frame can attempt a spoofed response. It is smoke-test-only and must not be copied into production configuration.
API entrypoints
| Entrypoint | Purpose |
| -------------------------- | -------------------------------------------------------------------------------------- |
| @aderra/tipc | Routers, event definitions, Zod, structured errors, protocol helpers, and shared types |
| @aderra/tipc/main | registerIpcMain, createRendererClient, and the test-only allowAllSenders helper |
| @aderra/tipc/preload | createPreloadTransport and PreloadTransport |
| @aderra/tipc/renderer | createClient and createEventClient |
| @aderra/tipc/react-query | Optional createReactQueryClient hook and cache integration |
All entrypoints ship ESM, CommonJS, and matching declarations. Import the smallest process-specific entrypoint instead of pulling Electron adapters into the wrong bundle.
Development
npm ci
npm run verifyverify runs formatting, lint, type checks, runtime and declaration tests,
fresh ESM/CommonJS builds, packed-entrypoint audits, package linters, and the
real Electron 35 smoke test. npm run test:electron uses the already installed
Electron binary through Playwright's Electron launcher and does not download
Playwright Chromium.
The repository .npmrc points npm packages and Electron binary installation
at npmmirror. That mirror affects installation only: it does not change IPC
runtime behavior, and package publishing remains pinned to the public npm
registry by publishConfig.
Publishing
Publishing is manual. From a clean release checkout, the package owner should run:
npm whoami --registry https://registry.npmjs.org/
npm run verify
npm pack --dry-run
npm publish --dry-run --access public
npm publish --access publicBefore the final command, confirm that the dry-run targets
https://registry.npmjs.org/ and that the tarball contains only dist,
README.md, LICENSE, NOTICE, and package metadata. Automation and
contributors prepare and audit the package; the user performs the final real
npm publish.
License and upstream attribution
MIT licensed. @aderra/tipc is derived from @egoist/tipc; the upstream
EGOIST copyright and MIT license are preserved in LICENSE, with
fork attribution in NOTICE.
