All skills
jwynia avatar

/electron-best-practices

@075cb8f
by J Wyniajwynia/agent-skills160 stars
20

Guide AI agents through Electron app development with React including security patterns, type-safe IPC, React integration, packaging with code signing, and testing. Keywords: electron, electron-vite, electron-forge, contextBridge, IPC, security, react, packaging, code signing, notarization, playwright, desktop app.

Use this Skill: https://skilld.dev/gh/jwynia/agent-skills/electron-best-practices

This session only. Nothing lands on disk.

referencesipcelectron-trpc.md

≈2.2k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Using electron-trpc for Type-Safe IPC

Overview

electron-trpc brings the tRPC framework to Electron, replacing manual IPC channel management with a fully typed RPC layer. You define a tRPC router in the main process and call procedures from the renderer with full type inference, runtime input validation via Zod, and optional React Query integration.

Best suited for apps with many IPC endpoints, complex input validation, or teams already familiar with tRPC from web projects.


When to Use electron-trpc

Good fit: 15+ IPC channels, runtime input validation needed, React Query caching desired, real-time subscriptions required, team knows tRPC.

Not ideal: Few IPC calls, bundle size constrained, team unfamiliar with tRPC, need low-level control over IPC message timing.


Installation

npm install @trpc/server @trpc/client electron-trpc zod
# For React integration (optional):
npm install @trpc/react-query @tanstack/react-query

Setting Up the tRPC Router (Main Process)

// main/trpc.ts
import { initTRPC } from '@trpc/server';

const t = initTRPC.create({ isServer: true });

export const router = t.router;
export const procedure = t.procedure;
// main/router.ts
import { z } from 'zod';
import { observable } from '@trpc/server/observable';
import { router, procedure } from './trpc';
import { TRPCError } from '@trpc/server';
import * as fs from 'node:fs';

export const appRouter = router({
  // --- Queries (read operations) ---
  greeting: procedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => `Hello, ${input.name}!`),

  getUser: procedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      const user = await db.users.findById(input.id);
      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `User ${input.id} not found`,
        });
      }
      return user;
    }),

  // --- Mutations (write operations) ---
  saveDocument: procedure
    .input(z.object({
      id: z.string(),
      title: z.string().min(1).max(255),
      content: z.string(),
      tags: z.array(z.string()).optional(),
    }))
    .mutation(async ({ input }) => {
      const saved = await documentService.save(input);
      return { success: true, savedAt: saved.updatedAt };
    }),

  deleteDocument: procedure
    .input(z.object({ id: z.string(), permanent: z.boolean().default(false) }))
    .mutation(async ({ input }) => {
      await documentService.delete(input.id, { permanent: input.permanent });
      return { deleted: true };
    }),

  // --- Subscriptions (real-time events) ---
  onFileChanged: procedure
    .input(z.object({ directory: z.string() }))
    .subscription(({ input }) => {
      return observable<{ event: string; filename: string }>((emit) => {
        const watcher = fs.watch(input.directory, (event, filename) => {
          if (filename) emit.next({ event, filename });
        });
        return () => watcher.close();
      });
    }),
});

export type AppRouter = typeof appRouter;

Attaching the Router to IPC (Main Process)

// main/index.ts
import { app, BrowserWindow } from 'electron';
import { createIPCHandler } from 'electron-trpc/main';
import { appRouter } from './router';

app.whenReady().then(() => {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, '../preload/index.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  createIPCHandler({ router: appRouter, windows: [mainWindow] });
  mainWindow.loadURL('http://localhost:5173');
});

For multiple windows, pass all of them in the windows array.


Preload Script

The preload for electron-trpc is minimal:

// preload/index.ts
import { exposeElectronTRPC } from 'electron-trpc/preload';

exposeElectronTRPC();

No manual channel definitions needed.


Renderer Client Setup (Vanilla)

// renderer/trpc.ts
import { createTRPCProxyClient } from '@trpc/client';
import { ipcLink } from 'electron-trpc/renderer';
import type { AppRouter } from '../main/router';

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [ipcLink()],
});

Usage with full type inference:

// renderer/app.ts
import { trpc } from './trpc';

// Queries
const greeting = await trpc.greeting.query({ name: 'World' });
const user = await trpc.getUser.query({ id: '550e8400-e29b-41d4-a716-446655440000' });

// Type error: invalid input
await trpc.getUser.query({ id: 123 });
// Error: Type 'number' is not assignable to type 'string'

// Mutations
const result = await trpc.saveDocument.mutate({
  id: 'doc-1',
  title: 'My Document',
  content: 'Hello world',
  tags: ['draft'],
});

// Subscriptions
const sub = trpc.onFileChanged.subscribe(
  { directory: '/home/user/documents' },
  {
    onData: ({ event, filename }) => console.log(`${filename}: ${event}`),
    onError: (err) => console.error('Subscription error:', err),
  }
);
sub.unsubscribe(); // cleanup

React Query Integration

// renderer/trpc-react.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../main/router';
export const trpc = createTRPCReact<AppRouter>();
// renderer/App.tsx
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ipcLink } from 'electron-trpc/renderer';
import { trpc } from './trpc-react';

export function App() {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({ links: [ipcLink()] })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <MainContent />
      </QueryClientProvider>
    </trpc.Provider>
  );
}
// renderer/components/UserProfile.tsx
import { trpc } from '../trpc-react';

export function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = trpc.getUser.useQuery({ id: userId });
  const saveMutation = trpc.saveDocument.useMutation();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <button
        onClick={() => saveMutation.mutate({ id: 'doc-1', title: 'New', content: '...' })}
        disabled={saveMutation.isPending}
      >
        {saveMutation.isPending ? 'Saving...' : 'Save'}
      </button>
    </div>
  );
}

Error Handling with tRPC Error Codes

tRPC provides structured error codes that map well to IPC failure modes:

// main/router.ts
import { TRPCError } from '@trpc/server';

export const appRouter = router({
  readFile: procedure
    .input(z.object({ path: z.string() }))
    .query(async ({ input }) => {
      try {
        return await fs.promises.readFile(input.path, 'utf-8');
      } catch (e) {
        const code = (e as NodeJS.ErrnoException).code;
        if (code === 'ENOENT') {
          throw new TRPCError({ code: 'NOT_FOUND', message: `File not found: ${input.path}`, cause: e });
        }
        if (code === 'EACCES') {
          throw new TRPCError({ code: 'FORBIDDEN', message: `Permission denied: ${input.path}`, cause: e });
        }
        throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: (e as Error).message, cause: e });
      }
    }),
});

Renderer-side errors arrive as typed TRPCClientError objects:

import { TRPCClientError } from '@trpc/client';

try {
  await trpc.readFile.query({ path: '/nonexistent' });
} catch (e) {
  if (e instanceof TRPCClientError) {
    console.error(e.data?.code);  // 'NOT_FOUND'
    console.error(e.message);     // 'File not found: /nonexistent'
  }
}

Comparison: electron-trpc vs Manual Typed IPC

Consideration Manual Typed IPC electron-trpc
Runtime validation You implement it Zod handles it
Channel count < 15 channels 15+ channels
React integration Manual state mgmt React Query hooks
Subscriptions Manual event wiring Built-in observables
Error handling Custom Result type TRPCError codes
Incremental adoption One channel at a time All-or-nothing router
Bundle overhead Zero deps tRPC + Zod (~30-50KB)

You can also use a hybrid: electron-trpc for most IPC and manual typed channels for performance-critical or low-level operations.


See Also

Source: SKILL.md on GitHub

2 warnings16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides comprehensive guidance, templates, reference scripts, and architectural configurations for developing secure Electron applications with React. All components adhere strictly to security standards, and all external tools, scripts, and configurations are handled using safe practices without any malicious or suspicious patterns.

  • Socket16d

    1 alert: gptSecurity

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    35/35 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 075cb8f. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Dormantupdated 8 months ago
Other metadata
compatibility
Requires Deno for analysis scripts. Applicable to any Electron project using TypeScript and React.
metadata
{
  "author": "agent-skills",
  "version": "1.0",
  "domain": "development",
  "type": "utility",
  "mode": "assistive"
}

README badge

README badge for jwynia/agent-skills/electron-best-practices