브라우저 확장/내부 통신 정의


개요

Chrome과 Firefox용 브라우저 확장 기능에서 제공하는 메시지 패싱 기능을 가지고 제한적인 RPC 기능을 구현할 수 있다.

목표

브라우저 확장 내부 통신을 typescript interface로 표현한다.

interface BackgroundService {
  func1(): Promise<void>;
  func2(param1: string, param2: number): Promise<string>;
  func3(params: string[]): void;
}

background script에서만 이 interface의 구현체를 작성하면, 다른 모든 스크립트에서는 이 interface를 통해 background script의 함수를 호출할 수 있어야 한다.

몇가지 제약이 있긴 한데:

  • parameters는 메시지 패싱 기능에서 허용하는 녀석들만 가능하다.
  • return type은 void, 혹은 Promise<T>만 가능하다. (단, T는 메시지 패싱 기능에서 허용하는 타입이어야 함)

단발성 메시지의 경우 굳이 Promise를 반환할 필요없이 바로 종료해도 좋겠지만, 구현의 편의를 위해 일단 모든 함수가 Promise를 반환하도록 했다. 설령 Promise를 기다리지 않는 경우라고 해도 그냥 불필요한 메시지 하나 내부 통신으로 전달될 뿐이니 크게 문제될 것은 없어보인다.

참고: Chrome 메시지 패싱 기능. 여기서도 구체적으로 어떤 형태의 자료형이 지원되는지 자세하게 설명되어 있지는 않지만, 대충 Serializable하면 대개 문제없다고 생각하면 될 듯 하다. Promise, function 같은 타입은 당연히 잘 안된다. (pure function이라면 기술적으로 되어야 하는것 아닌가 싶지만, 아 쫌…)

배경

그림: 브라우저 확장 내의 개별 스크립트들

하나의 브라우저 확장 안에서도 구현에 따라 여러 개의 개별 프로세스가 동작해야 하는 경우가 생긴다. 이를테면 확장 프로그램 팝업 페이지에서 기능 목록을 보여줘야 하는데, 기능 목록을 background worker에서 관리하고 있다고 가정하자. 그러면 팝업에선 worker에게 기능 목록을 요청해야 하는데, 이때 메시지 패싱을 사용하게 된다.

문제는 브라우저의 메시지 패싱 기능이 그다지 직관적이거나 편리하지 않다는 것이다. object를 주고받을 수 있긴 한데 그렇다고 object에 뭐든지 넣을 수 있는게 아니고 serializable한 녀석들만 넣을 수 있다. (간단히 말해서, function을 못넣는다. ㅠㅠ)

앗싸리 저수준 byte stream만 제공하는 수준이라면 아예 서버/클라이언트 프레임워크를 가져다 쓸 텐데, 브라우저 확장에 가져다 쓰기엔 너무 소잡는 칼 쓰는 느낌이 든다. 이를테면 HTTP 프로토콜을 메시지 패싱 위에서 구현하긴 조금 번거롭지 않냐는 거다.

하여튼 이 애매한 상황에 아주 작은 수요층 때문인지, 별로 괜찮은 브라우저 확장 내부 통신 프레임워크를 찾을 수 없었다. 혹시 괜찮은 통신 프레임워크를 알고계신 분께서는 트위터로 알려주시면 좋겠다.

구현

메시지 타입 정의

메시지 패싱에 실어보낼 메시지를 먼저 정의해보자. 아래는 Copilot이 작성한 코드다.

interface Message {
  type: string;
  payload: any;
}

…이따위로 하면 프로그래머 장사 접어야 할 것 같으니, 좀 더 구체적으로 정의해보자.

export type RequestMsg<K extends keyof BackgroundService> = {
  requestId: string;
  method: K;
  args: Parameters<BackgroundService[K]>;
};

export type RequestMsgs = {
  [K in keyof BackgroundService]: RequestMsg<K>;
}[keyof BackgroundService];

export type ResponseMsg<T extends keyof BackgroundService> = {
  requestId: string;
  result: Awaited<ReturnType<BackgroundService[T]>>;
};

export type ResponseMsgs = {
  [K in keyof BackgroundService]: ResponseMsg<K>;
}[keyof BackgroundService];

즉, RequestMsg<"func2"> 따위의 타입들이 존재하게 되고, 이는 아래와 같은 형태를 띈다.

{
  requestId: string;
  method: "func2";
  args: [string, number];
}

RequestMsgsRequestMsg<K>들의 union type이고, method 필드의 값을 이용해서 ResponseMsg<K>를 찾아낼 수 있다.

서버 구현

백그라운드 스크립트가 API를 제공하는 서버 역할을 한다.

const inst = new BackgroundServiceImpl();

browser.runtime.onConnect.addListener((port) => {
  function messageHandler(msg: RequestMsgs) {
    switch (msg.method) {
      case "func1":
        inst[msg.method](...msg.args).then(result => {
          port.postMessage({requestId: msg.requestId, result });
        });
        return;
      case "func2":
        inst[msg.method](...msg.args).then(result => {
          port.postMessage({requestId: msg.requestId, result });
        });
        return;
      case "func3":
        inst[msg.method](...msg.args);
        return;
      default:
        notExhaustive(msg);
    }
  }

  port.onMessage.addListener(messageHandler);
});

즉, inst라는 서비스 제공 singleton instance를 여러개의 커넥션들이 공유하면서 들어오는 메시지를 함수 호출로 변환해서 실행하고, 그 결과값이 있으면 다시 postMessage로 반환하는 것이다. 실행 순서가 꼬일 수 있으니 requestId를 이용해서 어떤 요청에 대한 응답인지 명시해준다.

잘 보면 func1과 func2 브랜치의 구현이 완전히 동일한데, 두 브랜치를 한 구현으로 합치는건 잘 안됐다. (타입스크립트가 narrowing을 잘 못한다) 그리고 만약 func3도 void 대신 Promise<void>를 반환하도록 하면 모든 브랜치를 한 구현으로 합칠 수도 있을 것 같다 (다만 one-way 구현이 있는편이 더 효율적일 것 같으니 이건 안하는 게 나을 듯 하다)

클라이언트 구현

class BackgroundServiceProxy implements BackgroundService {
  private seq: number = 0;
  private callbacks: Map<string, (result: any) => void> = new Map();

  constructor(private readonly conn: browser.Runtime.Port) {
    conn.onMessage.addListener((msg: ResponseMsgs) => {
      const cb = this.callbacks.get(msg.requestId);
      if (cb) {
        this.callbacks.delete(msg.requestId);
        cb(msg.result);
      }
    });
  }

  private factory<T extends keyof BackgroundService>(method: T) {
    return (...args: Parameters<BackgroundService[T]>): Promise<Awaited<ReturnType<BackgroundService[T]>>> => {
      const msg: RequestMsg<T> = {
        requestId: `${this.seq}`,
        method,
        args,
      };

      this.seq += 1;

      return new Promise<Awaited<ReturnType<BackgroundService[T]>>>((resolve) => {
        this.callbacks.set(msg.requestId, resolve);
        this.conn.postMessage(msg);
      });
    };
  }

  func1 = this.factory("func1");
  func2 = this.factory("func2");
  func3 = this.factory("func3");
}

export const instance = new BackgroundServiceProxy(browser.runtime.connect({ name: "frontend" }));

클라이언트는 흔한 Proxy 패턴이 된다.

결론

했다. 해냈다! 뭔가 좀 오버엔지니어링이 된 것 같은 느낌이 들기도 하지만 없는 것보단 훨씬 낫다는 느낌이다.

그런데…

타입만 가지고 코드 생성이 가능하면 현재 구현에 있는 반복적인 부분들을 제거할 수 있지 않을까? 이를테면 아래와 같은 형식으로 말이다.

export const instance = createProxyOf<BackgroundService>(browser.runtime.connect({ name: "frontend" }));

ts-patch가 그럴듯해 보이지만, 다수의 TS 컴파일 환경이 존재하는 현재 상황에서는 그렇게 손쉬운 선택지는 아닌 듯 하다.