Chrome拡張機能のAPI
Chrome拡張機能を開発する中で、既存のAPIサーバーと連携して何か処理を行いたい場面はよくあります。
たとえば:
- ログイン情報を使ってデータを取得したい
- ユーザーの操作に応じて商品情報を取得・保存したい
- セッション管理やCookieに依存するAPIを扱いたい
ところが、Chrome拡張機能には通常のWebアプリとは違う実行環境の壁があり、安易に fetch()
を使ったり、状態を共有しようとすると、さまざまな問題に直面します。
本記事では、そうした設計上の落とし穴と、その回避方法を具体例を交えて整理します。
前提:Chrome拡張機能の3つの構成要素
名称 | 役割 | 実行環境 |
---|---|---|
Background script | API通信、永続処理、イベント待機 | 拡張機能の独立プロセス |
Content script | ページへのDOMアクセス・操作 | 任意のWebページに埋め込まれる |
Popup | 拡張ボタン押下時のUI | 別ウィンドウとして実行 |
この3つのプロセスは完全に分離されており、直接関数呼び出しはできません。
例えば、ContentScript(フロント)からBackground(バック)の関数を直接実行したり、参照したりすることは出来ません。
相互通信には「メッセージイベント」を用いる必要があります。
落とし穴①:API呼び出しは background で!
普段の感覚でやってしまいがちなReactやVueからのFetchですが、推奨されていません。
結論から言えば
fetch(や axios)などのAPI通信は、必ず background script で行うべきです。
// content script 内で直接 fetch
fetch('https://my-api.example.com/data', {
credentials: 'include', // ← つけても意味なし
});
これは設計上の“推奨”ではなく、実際の動作やセキュリティに影響を与える「制約」による必須ルールです。
これが推奨されない理由は:
- content script はWebページと同じオリジン扱い
- そのため、拡張機能のセッションやCookieが引き継がれない
- CORS制限でブロックされることも多い
正しい設計
API通信は background script 側で行うべきです。
図のように、まずContentScriptからは「メッセージイベント」を送り、BackGrondのイベントリスナーに検知させます。
そしてBackGrondからApiサーバーにFetchするという仕組みが適切です。
// background.ts
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
if (message.type === 'FETCH_DOG_NAME') {
fetch('https://api.example.com/dog', {
method: 'POST',
body: JSON.stringify(message.payload),
credentials: 'include',
})
.then((res) => res.json())
.then((data) => {
sendResponse({ success: true, name: data.name });
});
return true; // 非同期応答を明示
}
});
なぜ content script や popup ではダメなのか?
① クロスオリジン制約(CORS)に引っかかる
Chrome拡張機能は、Webページとは別オリジンの仕組みですが、content script は対象のWebページのコンテキストで動作します。
例えば、Amazonならhttps://https://www.amazon.co.jp/
楽天ならhttps://www.rakuten.co.jp/
であります。
content script 内で fetch を行うと,
これらが「CORSポリシー違反」でブロックされる可能性が高いです。
(一つ一つ許可すれば可能なのですが、都度対応が必要なのと、後述するセッション問題も発生します)
② セッションやCookieが使えない
たとえばログイン済みセッションに依存するAPIでは、cookieやAuthorizationヘッダを使って認証を行うことが多いですよね。
しかし、content scriptやpopupから直接fetchを使うと、
- Chrome拡張のオリジンと異なるため Cookie が送られない
- credentials: 'include' を指定しても効かない場合がある
- サードパーティ扱いになるため SameSite=Lax/Strict のCookieがブロックされる
つまり、ログイン状態が維持されず、APIにアクセスできないという問題が起きます
なぜ background script ならうまくいくのか?
一言で言えば background script は
chrome-extension://<拡張ID>
という独立したオリジン(ドメイン)で動作するため、APIサーバー側でのアクセス制御がしやすく、セッションやCookieも正しく扱えるからです
background script の実行環境は特別
通常の Web ページや content script は、実行先のページと同じオリジンで動作しますが、background script は拡張機能専用のオリジン(chrome-extension://~)で実行されます。
このため、APIサーバー側では次のような対応が可能です:
- 拡張機能のオリジンだけをホワイトリスト登録できる
- Origin: chrome-extension://<固定ID> を見て許可/拒否できる
- セッションCookie(SameSite=Lax/Strict など)も正常に送受信できる
manifest.json で必要な権限を明示できる
拡張機能の manifest.json に permissions として対象APIのURLを指定することで、そのドメインに対する通信を許可することができます。
{
"permissions": [
"https://api.example.com/"
]
}
サーバーと拡張機能の信頼関係を築きやすい
拡張IDは manifest.json に "key" を指定することで固定化できます。
サーバー側でこの ID を使ってアクセスを制限することで、安全に「この拡張機能だけにアクセスを許可する」ことが可能になります。
Google側でも推奨されています。
落とし穴②: Content Script から Background への通信
前提:なぜ通信が必要なのか?
Content Script は「Webページに埋め込まれて実行される」スクリプトです。
DOM操作やページ上の値の取得には適しているものの、先述したように直接APIを呼び出すことはできません。
一方、Background Script は API通信やセッション維持に適している。
✅ つまり、Content Script は「やりたいこと」を Background に“依頼”し、結果を受け取る必要があります。
Content script から background にアクセスするには、以下のように chrome.runtime.sendMessage()
を使います。
// Content Script 側
chrome.runtime.sendMessage(
{
type: 'FETCH_DOG_NAME',
payload: { dogId: 123 },
},
(response) => {
console.log('犬の名前:', response.name);
},
);
// Background Script 側
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'FETCH_DOG_NAME') {
// ここで fetch を実行し、sendResponse() で返す
sendResponse({ name: 'ポチ' });
return true; // 非同期レスポンスを許可
}
});
問題点:コールバック地獄と非同期処理の複雑化
chrome.runtime.sendMessage() は非同期ですが、Promiseを返さず、コールバック形式です。
複数のAPIやメッセージを連続して扱おうとすると、以下のようにネストが深くなっていきます
例えば、Dogの名前とIDを取得して、それを元にさらなるリクエストをするAPIがあったとします。
そうすると、Dogの名前とIDを取得後の処理は、コールバック関数に記載する仕組みですので、どんどんネストが深くなっていきます。
chrome.runtime.sendMessage({ type: 'FETCH_A' }, (resA) => {
chrome.runtime.sendMessage({ type: 'FETCH_B', payload: resA }, (resB) => {
chrome.runtime.sendMessage({ type: 'FETCH_C', payload: resB }, (resC) => {
// 🙃 どんどん深くなる
});
});
});
これはいわゆる「コールバック地獄」です。
処理の流れが追いにくく、エラーハンドリングも煩雑になります。
解決策:Promiseでラップして await 可能にする!
そこで、以下のような共通関数を用意し、chrome.runtime.sendMessage() を Promise 化します。
// sendMessage.ts
export const sendMessage = <T, R>(payload: T): Promise<R> => {
return new Promise((resolve) => {
chrome.runtime.sendMessage<T, R>(payload, (response) => {
resolve(response);
});
});
};
何が嬉しいの?
- async/await で直列処理が書ける
- 処理の流れが 上から下へ素直に追える
- try/catch でのエラーハンドリングも可能に
各メッセージごとにラッパー関数を作る
共通化した sendMessage に対し、APIごとのメッセージを呼び出すラッパーを作ります。
🐶 例:犬の名前を取得するメッセージ
// sendMessageGetDogName.ts
import type { FetchGetDogNameMessage, FetchGetDogNameMessageResponse } from './types';
import { sendMessage } from './sendMessage';
export const sendMessageGetDogName = (
payload: FetchGetDogNameMessage['payload'],
): Promise<FetchGetDogNameMessageResponse> => {
return sendMessage<FetchGetDogNameMessage, FetchGetDogNameMessageResponse>({
type: 'FETCH_GET_DOG_NAME_MESSAGE',
payload,
});
};
呼び出し元ではこう書くことができます。
const handleClick = async () => {
const result = await sendMessageGetDogName({ dogId: 123 });
console.log('犬の名前:', result.name);
};
この設計のポイント
工夫 | 説明 |
---|---|
共通関数でラップ |
chrome.runtime.sendMessage() を Promise 化し、非同期処理を扱いやすくする |
ラッパー関数を分離 | 各メッセージごとに処理を分けることで責務が明確になり、import 管理もしやすくなる |
async/await 化 | ネストせず、処理の流れを直線的に書けるため、可読性と保守性が向上 |
型引数で安全性も確保 | ジェネリクス T , R を使うことで、送信データ・受信データ両方に型安全性と補完が効くようになる |
ディレクトリ構成
packages/
└── shared/
└── lib/
└── runtimeMessageClient/
├── sendMessage.ts
├── dog/
│ └── getName.ts
└── purchase/
└── getJanCode.ts
このように「共通処理は共通で」「APIごとに整理する」ことで、大規模になっても保守しやすい設計が実現できます。
まとめ
課題 | 解決策 |
---|---|
クロスオリジンで fetch できない |
background で API 通信を行う |
content script から background にアクセスできない |
chrome.runtime.sendMessage() を使う |
コールバック地獄で見通しが悪い | Promise化して共通関数で管理 |
おわりに
Chrome拡張機能は、実行環境が複数あり、通信の流れが直感的に見えにくいため、適切な設計が非常に重要です。
非同期通信はPromise
でラップし、責務を整理した構成にすることで、
保守性・見通しの良さ・再利用性が大きく向上します。
同じように苦戦している方の助けになれば幸いです!