Skip to content

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()로 도메인별 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() 리스너를 정의한다. 제네릭으로 이벤트 데이터 타입을 지정한다.
  • contract({ handles, forwards? }) — handle과 forward를 하나의 도메인 contract로 묶는다.

handle에 schema 내장

handle()의 두 번째 인자로 { schema } 옵션을 전달하면 contract가 검증 스키마를 함께 운반한다. extractSchemaMap()으로 channel → schema Map을 자동 추출할 수 있다.

ts
import { z } from 'zod';
import { contract, handle, extractSchemaMap } 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 }),
  },
});

// Pipe에서 자동 추출
const schemaMap = extractSchemaMap({ app: appContract });
// Map { 'theme:set' => ThemeSchema }

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를 전달한다. 두 가지가 동시에 강제된다:

  1. 클래스 형태: TS가 ControllerOf<C> 타입으로 메서드 누락/시그니처 mismatch를 IDE에서 잡는다.
  2. 자동 바인딩: 부트스트랩 시 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);

언래핑 규칙

handle 반환 타입성공실패
R (데이터)Rnull
voidtruenull
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
});

엔트리포인트 정리

엔트리포인트용도Electron 의존성
@repo/electron-ipc메인 프로세스 (DI, 데코레이터, createApp)O
@repo/electron-ipc/contractcontract 정의 (contract, handle, forward, 타입)X
@repo/electron-ipc/bridgepreload (createBridge)O
@repo/electron-ipc/service렌더러 (createService)X

WARNING

shared 코드(contract 정의, Window 타입 선언 등)에서는 반드시 @repo/electron-ipc/contract를 사용한다. 메인 엔트리(@repo/electron-ipc)를 import하면 create-app.tsimport { ipcMain } from 'electron'이 렌더러 번들에 포함되어 __dirname is not defined 에러가 발생한다.

다음 단계

  • Controllers — @Controller가 contract로 클래스 형태를 강제하는 방식
  • API Reference — contract, createBridge, createService 시그니처