Service Workerで Firebase Authenticationの匿名認証する

はじめに
2021 年 8 月 25 日、ついに Firebase SDK @9.0.0 がメジャーリリースされました。 晴れて安定版がリリースされたことで、今後 V9 モジューラー SDK への移行が加速するでしょう。
さて、V9 モジューラー SDK は、主にバンドルサイズの削減を目的としたリリースでした。 コードベースは、クラス型の記述スタイルから、関数へ移行しました。 これにより、バンドラーのツリーシェイキングを最大限生かせるようになりました。
Firebase Authentication に関しては、最大でバンドルサイズを 70%程度削減することができるようです。 どの程度バンドルサイズが削減されたかについては、Firebase のモジュラー SDK でバンドルサイズ比較 を確認ください。
ただ、残念ながら依然としてサイズ問題は存在します。
例えば initializeAuth という関数は、 firebase/auth
モジュールを使用する際必ず行う初期化関数ですが、 この関数だけでおよそ 90kb
程度あります。
今回は、Firebase Authentication を Service Worker で使い、パフォーマンス改善の方法を紹介します。
Firebase Authentication の認証の種類
Firebase Authentication では、認証として主に2つの種類があります。 この分類は、認証タイミングと能動関係の違いを表しています。
- ユーザー認証
- 匿名認証
ユーザー認証
ユーザー認証は、ユーザーが能動的に行う認証です。メールアドレスや認証プロバイダーを使用して認証を行います。
Firebase Authentication のモジュールを読み込むタイミングは、ユーザーが submit
するか、 認証専用ページをリクエストしたタイミングまで遅延することができます。
Service Worker によるセッション管理 ではバックエンドが存在するアプリケーションを例に、Service Worker を利用する方法を紹介しています。
匿名認証
匿名認証を行うタイミングは、構成によって様々でしょうが、基本的に受動的に行われます。 多くの場合、ページの読み込み時でなくとも良く、ユーザーインタラクション後でも問題ないでしょう。
また、認証自体の処理は、UI を伴う必要性が薄いです。 この場合、メインスレッドで認証処理を行うのではなく、バックグラウンドスレッドに処理を移行できます。
バックグラウンドスレッドで処理を行うことで、UI を妨げることなく処理を実行できます。 また、基本的には CWVが向上します。
Service worker で Firebase Authentication を使う
今回の目的はパフォーマンス改善です。 initializeAuth
をメインスレッドで行わないことでパフォーマンスを改善させます。
Service Worker での Firebase SDK は モジュールバンドラーを使い、バンドルすることを前提に解説します。
CDN から importScripts を用いて読み込む場合は適宜読み替えてください。
加えて、認証情報の永続化には Indexed DB が利用されます。 Indexed DB
が実装されていないブラウザは利用できないため注意してください。
まず、既にある場合は不要ですが、Firebase SDK をインストールします。
npm i firebase@9今後、sw.ts
という一つのファイルで説明しますが、実際は適宜ファイル分割をしてください。
続いて、firebase/auth を初期化します。
sw.ts
import { initializeApp } from "firebase/app";
import { initializeAuth } from "firebase/auth";
const app = initializeApp(); /* firebaseOptions */
const auth = initializeAuth(app);Service Worker は大きな特徴として http
リクエストのプロキシとなることが挙げられます。 fetch
イベントにアタッチすることで、http リクエストをハイジャックできます。
これを利用し、特定のリソースに対するリクエストの際、 認証情報をヘッダーへ付与することができます。 匿名の認証情報を付与する場合は、次のようになります。
sw.ts
const whitelist = ["https://firestore.googleapis.com"];
self.addEventListener("fetch", async (ev) => {
const requestProcessor = async (): Promise<Response> => {
const url = new URL(ev.request.url);
if (whitelist.includes(url.origin)) {
const user = await getUser(auth);
if (user) {
const idToken = await getIdToken(user);
const request = makeAuthRequest(ev.request, idToken);
return fetch(request);
}
}
return fetch(ev.request);
};
ev.respondWith(requestProcessor());
});ここでは、特定のオリジンに対して、認証トークンを付与しています。
サインインと、トークンを付与したリクエストオブジェトを作成するヘルパー関数を作ると便利です。
sw.ts
import { onAuthStateChanged, signInAnonymously } from 'firebase/auth'
import type { User, Auth } from 'firebase/auth'
} from 'firebase/auth'
const getUser = (auth: Auth): Promise<User | undefined> =>
new Promise<User | undefined>((resolve) => {
const unsubscribe = onAuthStateChanged(auth, async (user) => {
unsubscribe()
if (user) {
resolve(user)
} else {
const { user } = await signInAnonymously(auth).catch(() => ({
user: undefined
}))
resolve(user)
}
})
})
const makeAuthRequest = (req: Request, idToken: string): Request => {
const headers = new Headers(req.headers)
headers.set('Authorization', `Bearer ${idToken}`)
const request = new Request(req, {
headers
})
return request
}Request オブジェクトの headers は読み取り専用なので、 Headers
オブジェクトは新たに作成しなければならない点に注意が必要です。
また、fetch イベントリスナーは iframe
などのサードパーティへのリクエストもハイジャックするため、全てのリクエストに認証情報を付与するべきではありません。
例では、Cloud Firestore のエンドポイントに対してのみ認証トークンを付与しています。
Service Worker をすぐにアクティベートする
Service Worker がすぐにアクティベートされるように設定します。 Service Worker は登録されたあと、ページがもう一度読み込まれるまで有効化されません。
認証は Service Worker 登録後すぐに使いたい機能なため、Clients.claim()
メソッドを使い、登録後すぐにアクティベートします。
sw.ts
self.addEventListener("activate", (ev) => {
ev.waitUntil(self.clients.claim());
});あとはフロントエンドから、Service Worker を登録するだけです。
Service Worker をビルドする
上の例は、 TypeScript と NPM モジュールを利用しています。 ブラウザで利用するためには、TypeScript はトランスパイルが必要ですし、 NPM モジュールはバンドルしなければなりません。
理想的には、メインバンドラーに Service Worker のビルドも任せたいところです。 しかし、残念ながら Service Worker のモジュールのバンドルまでサポートしているものは少ないです。
その実情を踏まえて、esbuild を使って別プロセスでビルドする方法を例示します。
Web workers
は別スレッドで動作するため、メインビルドとは切り離してビルドプロセスを構築できます。
まず、 esbuild をインストールします。
npm i -D esbuildesbuild は CLI で動作するコマンドを提供しているため、それを利用します。
npm run esbuild sw.ts --outdir=<outdir> --bundle --sourcemap --minify --format=esm --legal-comments=externaloutdir に メインビルドの出力先のルートディレクトリを指定します。
これでバンドル済みの Service Worker ファイルが出力されます。
あとはフロントエンドから Service Worker の登録をします。
if ("serviceWorker" in window.navigator) {
window.navigator.serviceWorker.register("/sw.js");
}これで Service Worker が利用できるようになりました。
開発環境でも Service Worker をテストするためには、ビルドプロセスの工夫が必要です。 詳しくは build.ts を参考にしてください。
匿名認証で Cloud Firestore を使う
匿名認証を導入する背景には、多くの場合 Cloud Firestore を利用していることでしょう。 現状は、認証部分だけワーカースレッドへ移行しました。
ここで問題なのは、 uid を取得するタイミングです。 Cloud Firestore で uid
をドキュメントの ID として管理するパターンは王道です。 その場合、事前に uid
を知る必要があります。
現状では、 Service Worker が fetch イベントを取得したタイミングでしか uid
を知る方法がありません。 Indexed DB から取得する方法も一応ありますが、一度
fetch イベントが発生しなければなりません。
さて、この問題を解決するために postMessage を使って、service worker
とメッセージングします。
Service Worker とメッセージングする
まず、現状を整理すると次のようになります。
graph LR
user["👤 User"]
subgraph sw_js ["📄 Sub thread: sw.js"]
node_auth["node: firebase/auth"]
self(("○ self"))
end
subgraph GCP ["☁️ GCP"]
auth["🔐 Authentication<br/>(User store)"]
db[("🔥 Firestore<br/>(/users/{uid})")]
end
user -.->|fetch hijack| self
self -.->|sign in or sign up| auth
auth -.->|user info| self
self -.->|fetch| dbユーザーがメインスレッドから fetch したタイミングでは、まだ uid
を知ることができません。 これは uid
を含むクエリが発行できないことを意味しています。
Service Worker とメインスレッドとは、 postMessage
を利用しメッセージのやり取りができます。 fetch 以外の別のタイミングで uid
をやり取りしてみます。
Service Worker では、message
イベントを登録することで、クライアントからのメッセージを待ち受けます。
sw.ts
self.addEventListener("message", async ({ source }) => {
const user = await getUser(auth);
source.postMessage(user.uid);
});postMessage を使い、クライアントにメッセージを送信します。ここでは uid
を送信しています。
残念ながら postMessage を使って送信できるメッセージには制限があります。
構造化複製アルゴリズム
を使って、JavaScript オブジェクトを複製できるもののみが送信できます。
例えば、メソッドを持つクラスオブジェクトは送信できません。そのため、ここでは
uid のみを送信しているわけです。
クライアント側では、次のようにメッセージの送受信をします。
const sw = window.navigator.serviceWorker;
sw.ready.then((registration) => {
// send
registration.active?.postMessage("");
});
// receive
sw.addEventListener(
"message",
({ data }) => {
// data is uid
},
{
once: true,
},
);active な Service Worker に対して、 postMessage でメッセージを送信します。
Service Worker からのメッセージは、 message イベントで取得できます。
これで uid を取得できました。このやり取りは非同期なため、Cloud Firestore
へリクエストする際は、 uid が取得できるまで待機するか、
リクエストの前に毎回上の処理を行うなどの対応が必要になります。
おわりに
パフォーマンスの観点からは、理想的には UI を伴わない処理はすべてワーカースレッドへ移行することが望ましいです。 しかし、往々にしてパフォーマンスの最適化と引き換えに複雑性をもたらします。
上の方法だけでなく、実は firebase/firestore モジュールを Web worker
で使う方法もあります。
またの機会に、 Web worker と Cloud Firestore について書きたいと思います。