Appearance
Contract System
Contract 시스템은 IPC 채널과 타입을 한 곳에 정의하고, preload bridge / renderer service / main controller 세 면을 모두 contract 키로 통일한다. 새 IPC 메서드를 추가할 때 Contract + Controller 2곳만 수정하면 된다.
개요
shared/contract.ts (단일 파일, 모든 도메인 contract)
│
├─ runtime: createBridge(contracts) preload에서 ipcRenderer 바인딩 자동 생성
│ contextBridge.exposeInMainWorld('api', api)
│
├─ type: BridgeAPI<typeof contracts> renderer의 window.api 타입
│ createService(window.api, contracts)
│ → service.app.getTheme()
│
└─ main: @Controller(appContract) controller 메서드 이름 = contract 키로 자동 바인딩
class AppController { getTheme() {...} }세 면 모두 contract 키(getTheme)로 호출/구현된다. 채널 문자열('theme:get')은 contract 안에만 존재하는 와이어 ID다.
Contract 정의
contract(), handle(), forward(), send()로 도메인별 IPC 인터페이스를 선언한다. @repo/electron-ipc/contract에서 import한다.
ts
import { contract, handle, forward } from '@repo/electron-ipc/contract';
import type { ThemeInfo, ResolvedTheme, Theme } from './type';
export const appContract = contract({
handles: {
getTheme: handle<[], ThemeInfo>('theme:get'),
setTheme: handle<[Theme], { resolved: ResolvedTheme }>('theme:set'),
},
forwards: {
onThemeChanged: forward<ResolvedTheme>('theme:changed'),
},
});handle<Args, Return>(channel, options?)—ipcRenderer.invoke()호출을 정의한다. 제네릭으로 인자와 반환 타입을 지정한다.options.schema로 검증 스키마를 내장할 수 있다.forward<Data>(channel)—ipcRenderer.on()리스너를 정의한다. 제네릭으로 이벤트 데이터 타입을 지정한다.send<Data>(channel, options?)— 렌더러 → 메인 단방향 알림(ipcRenderer.send())을 정의한다. 응답이 없는 fire-and-forget으로, 게스트 뷰처럼 신뢰할 수 없는 발신자를 위한 primitive다 (아래 게스트 뷰와 send 참고).contract({ handles, forwards?, sends? })— 셋을 하나의 도메인 contract로 묶는다.
handle에 schema 내장
handle()의 두 번째 인자로 { schema } 옵션을 전달하면 contract가 검증 스키마를 함께 운반하고, 프레임워크가 별도 배선 없이 직접 강제한다 — 핸들러 실행 전에 schema.parse(args[0])가 모든 Pipe보다 먼저 실행되며, 검증 실패는 ExceptionHandler를 거쳐 에러 응답이 된다.
ts
import { z } from 'zod';
import { contract, handle } from '@repo/electron-ipc/contract';
const ThemeSchema = z.enum(['light', 'dark', 'system']);
export const appContract = contract({
handles: {
getTheme: handle<[], ThemeInfo>('theme:get'),
setTheme: handle<[Theme], void>('theme:set', { schema: ThemeSchema }),
},
});
// 'theme:set' invoke 시 ThemeSchema.parse가 자동 적용된다Parseable 인터페이스({ parse(data: unknown): unknown })만 충족하면 Zod 외 다른 라이브러리도 사용할 수 있다.
contracts 통합
여러 도메인의 contract를 하나의 객체로 모은다. 단일 파일(contract.ts)에서 정의하고 export한다:
ts
// shared/contract.ts
export const contracts = {
app: appContract,
agent: agentContract,
} as const;Controller 연결
@Controller(contract)에 contract를 전달한다. 두 가지가 동시에 강제된다:
- 클래스 형태: TS가
ControllerOf<C>타입으로 메서드 누락/시그니처 mismatch를 IDE에서 잡는다. - 자동 바인딩: 부트스트랩 시 contract의 키와 동일한 이름의 메서드를 IPC 채널에 바인딩한다. handle 메서드는
ipcMain.handle()로, forward 메서드는 EventSource 구독으로 등록된다.
ts
import { appContract } from '@shared/contract';
import { Controller, inject } from '@repo/electron-ipc';
@Controller(appContract)
class AppController {
private readonly theme = inject(ThemeService);
getTheme() { // contract.handles.getTheme → 'theme:get'
return this.theme.getThemeInfo();
}
onThemeChanged() { // contract.forwards.onThemeChanged → 'theme:changed'
return this.theme.themeChanged; // EventSource<ResolvedTheme>
}
}채널 문자열은 contract만 안다
controller에서 채널 문자열을 직접 적지 않는다. contract가 source of truth이며, 메서드 이름이 contract 키와 일치하기만 하면 자동으로 묶인다. 채널을 바꾸려면 contract만 수정하면 된다.
Preload Bridge
createBridge()가 contracts에서 preload API를 자동 생성한다. @repo/electron-ipc/bridge에서 import한다.
ts
import { contextBridge } from 'electron';
import { createBridge } from '@repo/electron-ipc/bridge';
import { contracts } from '@shared/contract';
const api = createBridge(contracts);
contextBridge.exposeInMainWorld('api', api);생성되는 API의 구조:
ts
window.api = {
app: {
getTheme: (...args) => ipcRenderer.invoke('theme:get', ...args),
setTheme: (...args) => ipcRenderer.invoke('theme:set', ...args),
onThemeChanged: (cb) => { ipcRenderer.on('theme:changed', listener); ... },
},
agent: { ... },
};Window 타입 선언
BridgeAPI 유틸리티 타입으로 window.api의 타입을 선언한다. @repo/electron-ipc/contract에서 import한다.
ts
import type { BridgeAPI } from '@repo/electron-ipc/contract';
import type { contracts } from '@shared/contract';
declare global {
interface Window {
api: BridgeAPI<typeof contracts>;
}
}Renderer Service
createService()가 bridge API를 감싸서 IpcResponse를 자동 언래핑한다. @repo/electron-ipc/service에서 import한다.
ts
import { createService } from '@repo/electron-ipc/service';
import { contracts } from '@shared/contract';
export const service = createService(window.api, contracts, {
// 모든 실패(IpcError + invoke rejection)가 수렴하는 중앙 에러 훅 (선택)
onError: (error, { domain, method }) => {
console.error(`[ipc] ${domain}.${method} 실패 (${error.code})`, error.message);
},
});service 메서드는 절대 throw하지 않는다 — 실패는 onError 호출 후 null 반환으로 수렴하므로 호출부는 null 체크만 하면 된다.
언래핑 규칙
| handle 반환 타입 | 성공 | 실패 |
|---|---|---|
R (데이터) | R | null |
void | true | null |
| forward | 동작 |
|---|---|
forward<T> | 패스스루 (callback 그대로 전달) |
사용 예
ts
// 데이터 반환 handle
const themeInfo = await service.app.getTheme();
// themeInfo: ThemeInfo | null
// void 반환 handle
const ok = await service.agent.removeProfile(id);
// ok: true | null
// forward
service.app.onThemeChanged((resolved) => {
// resolved: ResolvedTheme
});게스트 뷰와 send
WebContentsView에 얹은 외부 웹 컨텐츠(게스트)는 신뢰할 수 없어 SenderGuard가 handle을 거부한다. 게스트 → 메인이 필요하면 sends를 선언한다:
ts
export const browserContract = contract({
handles: { /* ... */ },
sends: {
swipe: send<'back' | 'forward'>('browser:swipe', { schema: DirectionSchema }),
},
});게스트 preload에서는 createGuestBridge로 바인딩한다 — sends만 노출되는 최소 표면이라 invoke·수신 경로가 없다:
ts
import { createGuestBridge } from '@repo/electron-ipc/bridge';
const { browser } = createGuestBridge({ browser: browserContract });
browser.swipe('back'); // ipcRenderer.send('browser:swipe', 'back')controller 메서드는 (data, ctx) 형태다 — 게스트는 payload를 위조할 수 있으므로 발신자 식별은 ctx.event.sender로만 한다:
ts
swipe(direction: 'back' | 'forward', ctx: IpcContext) {
this.browser.swipe(direction, ctx.event.sender);
}바인딩에는 AppConfig.sendGuards가 필수다(없으면 부트스트랩 에러 — 기본 거부). 응답 경로가 없으므로 Guard 거부·schema 실패·핸들러 예외는 모두 버려지고 onError('send:<채널>', error)로만 보고되며, 글로벌/메서드 Pipe는 적용되지 않는다. Guards의 GuestViewGuard를 참고한다.
엔트리포인트 정리
| 엔트리포인트 | 용도 | Electron 의존성 |
|---|---|---|
@repo/electron-ipc | 메인 프로세스 (DI, 데코레이터, createApp) | O |
@repo/electron-ipc/contract | contract 정의 (contract, handle, forward, send, 타입) | X |
@repo/electron-ipc/bridge | preload (createBridge, 게스트용 createGuestBridge) | O |
@repo/electron-ipc/service | 렌더러 (createService) | X |
WARNING
shared 코드(contract 정의, Window 타입 선언 등)에서는 반드시 @repo/electron-ipc/contract를 사용한다. 메인 엔트리(@repo/electron-ipc)를 import하면 create-app.ts의 import { ipcMain } from 'electron'이 렌더러 번들에 포함되어 __dirname is not defined 에러가 발생한다.
다음 단계
- Controllers — @Controller가 contract로 클래스 형태를 강제하는 방식
- API Reference — contract, createBridge, createService 시그니처