chrome extensionからFirebase AuthenticationとFunctionsを利用する

[別記事](https://qiita.com/festa78/items/e7a3888fd94095a484a1)にて簡易な記事閲覧アプリを作成しています。その一環でブラウザから今開いているタブのURLをアプリへ記事登録できるように簡単なchrome-extensionを作成しました。今回はその内容をまとめます。
アプリのソースは[こちら](https://github.com/festa78/snippet_box/tree/master/browser/chrome-extension)になります。

# 全体像

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/0b89dbe0-f357-4319-0909-2ba9870090ba.png)


ざっくりとした全体像を描くと上のようになります。

chrome-extension側ではバックグラウンド(`background.ts`)で実際のログインや登録したいURLのFirebaseへの送信等の機能を実装します。

`popup.ts`（あるいはトランスパイル後のjsファイル）chrome-extensionの`manifest.json`の`default_popup:`経由で呼ばれるよう設定します。これより拡張機能アイコンをクリックしたときにLogin及び（URLの）Saveイベントを受け取るButton UIを表示させます。
ユーザーがログイン前の場合はLogin buttonを、ログイン後の場合はSave buttionを表示させます。

この時`background.ts`と`popup.ts`間のリクエストやデータ通信はchrome-extensionの`message`や`storage`機能を使って実装します。

Firebase側ではAuthentication機能として`signInWithPopup()`機能を使います。`background.ts`からリクエストが来た際に認証を行い、ユーザー情報を返します。

またFunctionsでは`background.ts`の`SaveToDb(User)`から送られてきたURL情報をパースしFirestoreに保存します。これにより記事閲覧アプリから見れるようになります。

# Chrome-extension詳細

## 環境

[公式のチュートリアル](https://developer.chrome.com/docs/extensions/mv3/getstarted/)で扱われているものは、そのままだとFirebase SDKを使えません。そのためwebpack等を用いてnodejsアプリとしてbundleできるように整備しました。

今回は[chrome-extension-typescript-starter](https://github.com/chibat/chrome-extension-typescript-starter)をベースとして利用させてもらい、Firebaseとのbundleを構成しました。

Firebase SDK自体のnodejs向けインストール方法は[公式](https://firebase.google.com/docs/web/setup?hl=ja#node.js-apps)を参照して下さい。

一点嵌まったポイントとして、`webpack`経由で生成されたjavascriptファイルをchrome extension側から読ませた場合に`Encoding is not UTF-8`と表示されるエラーが発生する場合があります。これはFirebase SDKを`webpack`でbundleした際にUTF-8でない文字`�`が発生するためのようです（[詳細](https://github.com/firebase/firebase-js-sdk/issues/414#issuecomment-425810475)）。今回は回避手段として`sed`等を駆使して文字列検出と排除を行っています。

## background.ts

このファイルでは主にFirebase Authenticationを利用した`login`、及びFirebase Functionsを利用した`saveToDb`メソッドを実装します。

```typescript
var auth = firebase.auth();
var functions = firebase.functions();

chrome.runtime.onMessage.addListener(
  function (request, sender, sendResponse) {
    if (request.name === "login") {
      // Login request.
      var provider = new firebase.auth.GoogleAuthProvider();
      provider.addScope('https://www.googleapis.com/auth/contacts.readonly');

      // Chrome extension only supports popup sign in.
      auth.signInWithPopup(provider).then(function (result) {
        var user = result.user;
        var status = '200';
        sendResponse({ user, status });
      }).catch(function (error) {
        // Handle Errors here.
        var errorCode = error.code;
        if (errorCode === 'auth/account-exists-with-different-credential') {
          alert('You have already signed up with a different auth provider for that email.');
          // If you are using multiple auth providers on your app you should handle linking
          // the user's accounts here.
        } else {
          console.error(error);
        }
        var status = '400';
        sendResponse({ error, status });
      });
    } else {
      var error = "unknown request: " + request.name;
      console.error("unknown request", request.name);
      var status = '400';
      sendResponse({ error, status });
    }

    return true;
  });
```

`chrome.runtime.onMessage.addListener`は他（popup.ts）からのmessageを待ち受け、リクエストの種類（`login`）に応じて処理を行います。

`login`リクエストに対しては、今回Googleのpopupベースのsign inスキームを利用しています。セットアップ方法は[公式](https://firebase.google.com/docs/auth/web/google-signin)が参考になります。特にchrome-extensionは制約としてpopup operationのみのサポートとなっています。また利用時にはFirebase側でドメインの承認が必要です（[参考](https://firebase.google.com/docs/auth/web/google-signin#authenticate_with_firebase_in_a_chrome_extension)）

ログイン情報の管理は`background.ts`上で`onAuthStateChanged`を利用して以下のように設定できます。

```typescript
var auth = firebase.auth();

function initApp() {
  // Listening for auth state changes.
  auth.onAuthStateChanged(function (user: firebase.User | null) {
    if (user) {
      // User is signed in.
      let displayName = user.displayName;
      let email = user.email;
      let uid = user.uid;
      chrome.storage.sync.set({ displayName, email, uid }, () => {
        console.log('User is signed in: ', displayName, email, uid);
      });
    } else {
      // User is signed out.
      let displayName = '';
      let email = '';
      let uid = '';
      chrome.storage.sync.set({ displayName, email, uid }, () => {
        console.log('User is signed out');
      });
    }
  });
}

initApp();
```

ここで現在のログイン情報は`chrome.storage`機能を使って保持し、後ほど`popup.ts`からも見えるようにしています。

続いて`saveToDb`リクエストに対する処理も同様に`chrome.runtime.onMessage.addListener`へ加えていきます。

```typescript
    } else if (request.name === "saveToDb") {
      // Save requested URL to DB.
      let url = request.url;

      // Request to Firebase functions.
      chrome.storage.sync.get("uid", ({ uid }) => {
        let saveToDb = functions.httpsCallable('saveToDb');
        saveToDb({ uid, url })
          .catch((error) => console.log('error saving url:', error));
      });
```

こちらではFirebase functions側の`httpsCallable`を使った呼び出しにてfunctions側に対応する登録済`saveToDb`メソッドを呼び出していきます。functions側のメソッドは今回省略しますが、現時点では単純にFirestoreへデータ追加を行うのみとなります。

## popup.ts

先述した通り、`manifest.json`の設定より、拡張機能のアイコンをクリックした際にこのスクリプトが呼び出されることになります。

`popup.ts`ではReactを使って`login`や`saveToDb`に関わるViewを実装します。

```tsx
const Popup = () => {
  const [uid, setUid] = useState<string>("");
  const [currentURL, setCurrentURL] = useState<string>();

  useEffect(() => {
    chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
      setCurrentURL(tabs[0].url);
    });
  }, []);

  useEffect(() => {
    chrome.storage.sync.get("uid", ({ uid }) => {
      setUid(uid);
    });
  }, []);

  const Login = () => {
    chrome.runtime.sendMessage({ name: "login" }, response => {
      if (response.status !== "200") {
        console.error("Error on login request", response.error);
        alert(response.error);
        return;
      }
      console.log("Login succeeded:", response.user);
      setUid(response.user.uid);
    });
  }

  if (!uid) {
    // User is signed out.
    return (
      <>
        <ul>
          <li>Current URL: {currentURL}</li>
          <li>Current Time: {new Date().toLocaleTimeString()}</li>
          <li>User ID: Not logged in</li>
        </ul>
        <button
          onClick={() => Login()}
        >
          Log in
        </button>
      </>
    );
  }

  // User is signed in.
  let view: React.ReactElement;
  if (!isUrlSaved) {
    view = (
      <button
        onClick={() => {
          SendUrlToDb(currentURL);
          setIsUrlSaved(true);
        }}
      >
        Save button
      </button>
    );
  } else {
    view = (<p>"URL saved!"</p>);
  }

  return (
    <>
      <ul>
        <li>Current URL: {currentURL}</li>
        <li>Current Time: {new Date().toLocaleTimeString()}</li>
        <li>User ID: {uid}</li>
      </ul>
      {view}
    </>
  );
};

ReactDOM.render(
  <React.StrictMode>
    <Popup />
  </React.StrictMode>,
  document.getElementById("root")
);
```

`Popup`メソッドはユーザー情報に基づいてlogin button、あるいはsave buttonを表示させます。ユーザー情報は先程`background.ts`側で`chrome.storage`を用いて保持してあります。

続いてログイン後に表示されるsave buttonをクリックすると、以下のような`sendToDb`メソッドが呼ばれます。

```tsx
const SendToDb = (url: string | undefined) => {
  if (url === undefined) {
    console.error("Given URL is undefined.");
    alert("Given URL is undefined.");
    return;
  }
  console.log("Send URL to firebase DB", url);

  chrome.runtime.sendMessage({ name: "sendToDb", url }, response => {
    if (response.status !== "200") {
      console.error("Error on sending URL", response.error);
      alert(response.error);
      return;
    }
    console.log("Send URL successfully:", url);
  });
}
```

`background.js`側でmessage requestを受ける機能を実装済ですので、このメソッドではそれを`url`と共に単純に呼び出しています。

## デモ

Firebase側ではemulatorを使って実際に動かしてみました。

Webpackから出力したファイル群をchrome-extensionの`パッケージ化されていない拡張機能を読み込む`から読み込みます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/b8c23f99-a184-d048-3dad-2c922c50e6f6.png)

テスト用に[example.com](http://example.com/)へアクセスしアイコンをクリックしてみます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/79211c53-7d0b-df47-6109-e1009e6662b5.png)

Log in buttonをクリックしてGoogleからログインします。するとポップアップ表示が以下に変わりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/fedd71e0-e844-5a3b-becf-4d24ccaa13a7.png)

Save buttonを押してみた後、Firebase Firestoreを確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/991cb498-baae-f5a6-61cf-0ef6878a329f.png)

以上のようにFireStore側でデータが追加されている事が確認できました。

## 補足

Firebase emulatorでauthenticationをemulateしようとしましたが、恐らく？[既知のバグ](https://github.com/firebase/firebase-js-sdk/issues/4335)があるようで今回は断念し、実際の環境で試しています。