定義
ブラウザ拡張機能では、Content Script、Background Service Worker、Side Panel は互いの関数を直接呼び出したり、メモリ上のオブジェクトを共有したりできない。メッセージベース RPC(Remote Procedure Call、別の実行空間へ構造化されたリクエストを送り、作業結果を受け取る方式) は、Chrome のメッセージ API の上にリクエスト schema、operation router、handler、成功・失敗の応答形式を重ね、関数呼び出しのように使うアプリケーション層である。
Chrome が完成済みの RPC フレームワークを提供しているわけではない。プラットフォームは runtime.sendMessage、runtime.onMessage、tabs.sendMessage のような転送手段を提供し、どのメッセージを許可し、エラーをどう表現するかは拡張機能側が決める。
なぜ必要か
ユーザーが Web ページで選択した文章を保存し、Side Panel で一覧を見る場面を考える。Content Script はページの DOM Range を読めるが、中央の保存責務まで持たせると各タブにデータ規則が散らばる。Background Service Worker は IndexedDB と権限を一か所で扱いやすいが、特定タブの DOM を直接読めない。Side Panel も別の実行空間なので、保存関数を import したとしても同じインスタンスを呼び出すわけではない。
文字列の type と任意のオブジェクトだけをやり取りすると、不正な payload は handler の奥深くで失敗し、転送エラーとビジネスエラーも区別しにくい。リクエスト境界で unknown を schema で検証し、対応する operation だけを router が実行し、結果を一定の応答エンベロープで返せば、実行空間同士の契約を一か所で確認できる。
動作原理
一般的な流れは次のとおりである。
UI または Content Script
→ sendRequest({ type, payload, requestId })
→ Chrome message transport
→ Background onMessage listener
→ runtime schema validation
→ operation router
→ handler / transaction
→ { ok: true, data } | { ok: false, error }
受信側はメッセージを信頼せず、unknown として受け取る。schema 検証を通過したリクエストだけを、type に登録された handler へ渡す。handler の例外はそのまま直列化せず、呼び出し側が処理できるエラーコードと安全なメッセージに変換する。同じリクエストが重複して届くとデータが二重に作られ得る作業には、requestId と冪等性記録、または保存先の一意制約を置く。
命令の応答と、別画面の状態更新は別の問題である。応答はリクエストを送った画面にだけ返るため、別画面には「データが変わった」というイベントをブロードキャストできる。受信した画面は関連 query を無効化し、IndexedDB のような単一の原本を再度読む。イベントに全データを載せて複製しなければ、後から開いた画面も原本から最新値を取得できる。
実務での適用
次のように transport と契約を薄く分離できる。
type Response<T> =
| { ok: true; data: T }
| { ok: false; error: { code: string; message: string } };
browser.runtime.onMessage.addListener(
async (raw): Promise<Response<unknown>> => {
const parsed = requestSchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false,
error: { code: "INVALID_REQUEST", message: "Invalid request" },
};
}
return routeRequest(parsed.data);
},
);
DOM 選択やハイライトのようにタブへ依存する作業は、tabs.sendMessage(tabId, request) で Content Script に送る。保存、権限確認、複数レコードの transaction のように中央調整が必要な作業は Background handler に置く。UI は transport の詳細ではなく、sendRequest() の型付き結果だけを処理する。
書き込み成功後に別画面を更新するときは、変更範囲を含む小さな信号だけを送る。たとえば { type: "items/changed", sourceId } を受け取った画面は、sourceId を含む query key だけを無効化する。キャッシュ全体を消したり、イベント payload を新しい原本のように保存したりしない。
トレードオフ
- 明示的な契約は実行空間の境界をはっきりさせ、テストを容易にするが、operation ごとに schema とエラーマッピングを保守する必要がある。
- 変更信号後の再取得は単一の原本を保ち、後から開いた画面にも安全だが、書き込み直後に追加の読み取りが発生する。読み取りコストが非常に大きく即時性が重要な場合だけ、応答データを使った限定的な optimistic update も併せて検討する。
- Background による中央調整はデータ規則を一か所に集めるが、Service Worker は再起動される可能性があるため、グローバルメモリに状態を長く保持できない。
使ってはいけない場合
- 同じ実行空間内の関数呼び出しまで RPC で包まない。直列化とエラー層だけが増える。
- 単純な一回限りのメッセージが一つだけなのに、汎用フレームワークやコード生成器を先に導入しない。小さな schema と router で十分か確認する。
- 大容量バイナリや高頻度ストリームを毎回通常メッセージでコピーしない。プラットフォーム制限とコストを測定したうえで、専用チャネルや保存先参照を使う。
よくある間違い
- TypeScript 型があるから runtime 検証も済んでいると誤解する。外部の実行空間から届いた値は、ビルド時型の保護を受けない。
- メッセージが届いた事実を、handler が成功した意味として扱う。
- 例外オブジェクトや直列化できない値を応答へそのまま入れる。
- 命令応答と全画面へのブロードキャストを一つに扱い、リクエストしていない画面まで結果を直接上書きする。
- Background Service Worker のグローバル変数を永続ストレージのように使い、再起動後に状態を失う。
- すべての変更イベントで全 query を無効化し、不要な再取得の急増を作る。
関連概念
- react-query-invalidate-vs-staletime — 外部の書き込み通知を受けた画面が関連する取得をすぐ更新する方法