🔥

Remix, Firebase Authのセッション管理をService Workerにする

2024/02/28に公開

FirebaseのService Workerによるセッション管理をRemix環境下で行う際のまとめです。

Service Workerによるセッション管理の概要

詳しくは公式を読んで貰えば良いと思います。
https://firebase.google.com/docs/auth/web/service-worker-sessions?hl=ja

フロー

こちらのスライドがとてもわかりやすいのでおすすめです。

参考: Firebase Authenticationのセッション管理術

Props

要点 説明
バックグラウンド同期 オフライン時でも認証状態を同期させることが可能になり、オンライン復帰時にセッションを復元しやすくなる。Cookieはブラウザがオフラインになると機能の制限があるため同じことはできない。
パフォーマンスの向上 Service Workerはネットワークリクエストをキャッシュできるため、認証情報のアクセス面でパフォーマンス向上できる。
セキュリティの向上 Service WorkerはHTTPS経由でのみ動作するためセキュリティ面ではCookieセッションより強化できる。

Cons

要点 説明
互換性の問題 レガシーブラウザでは動作が保証できません。
更新の遅延 Service Workerを介してキャッシュされたコンテンツは即時に更新されないため、ユーザーが最新の情報を受け取るまでに遅延が生じる可能性があります。
デバッグの難しさ Service Workerかそれ以外か実行時にエラーの検証が難しいことがあります。

Remixへの組み込み

Remixへの組み込み手順は次の通りとなります。

  1. Firebase Authenticationの設定・API Keyなどの必要情報取得
  2. remix-pwaのインストール・セットアップ
  3. 環境変数用jsonファイルの実装・ビルド
  4. Service Worker(entry.worker.ts)へFirebase Authenticationの実装

remix-pwaをインストール

Firebase Autheticationの設定は今更書くことでもないのでググってください。
というわけで、remix-pwaをインストール&セットアップします。

$ npx remix-pwa@latest init

TSかJSか選択。

? Is this a TypeScript or JavaScript project? Pick the opposite for chaos! … 
❯ TypeScript
  JavaScript

既にworkbox(Service Worker用の抽象APIモジュール)がありそれと統合したければyを、特にないならNを選択。

✔ Is this a TypeScript or JavaScript project? Pick the opposite for chaos! · ts
? Do you want to integrate workbox into your project? (y/N)false

プリキャッシング(各種リソースのキャッシュ)を行いたい場合はyを選択。とりあえず後でやるならN。

? Do you want to utilise precaching in this project? (y/N)false

この辺はPWA関連やバックグラウンド同期、Push通知などについてなのでService Workersだけでも良いと思います。Web Manifestをチェック入れると manifest[.]webmanifest.ts がroutesディレクトリ配下に生成されます。

? What features of remix-pwa do you need? Don't be afraid to pick all!(Use <space> to select, <return> to submit)
 o Background Sync
 o Web Manifest
 o Push Notifications
 o PWA Client Utilities
 o Development Icons
 ✔ Service Workers

はい、好きなのを選び、もう一度確認されるので問題なければY。

? What package manager do you use? … 
❯ npm
  yarn
  pnpm
? Do you want to run npm install? (Y/n)

これで必要なファイルやpackageを追加してくれます。
また、Remixがv2.7より以前の場合はdotenvも必要なのでインストールしてください。

$ npm i dotenv

package.jsonは次の様な感じになります(npmの場合)。

{
  "name": "app",
  //... 省略
  "type": "module",
  "scripts": {
    "build": "run-s build:*",
    "build:remix": "remix build",
    "build:worker": "remix-pwa build",
    "dev": "run-p dev:*",
    "dev:remix": "remix dev",
    "dev:worker": "remix-pwa dev",
    "format": "prettier --check './app/**/*.{ts,tsx}'",
    "format-run": "npx prettier --write './app/**/*.{ts,tsx}'",
    "lint": "TIMING=1 eslint --fix \"**/*.ts*\"",
    "start": "remix-serve ./build/index.js",
    "typecheck": "tsc"
  },
  "dependencies": {
    "@remix-pwa/cache": "^2.0.12",
    "@remix-pwa/strategy": "^2.1.9",
    "@remix-pwa/sw": "^2.1.12",
    "@remix-run/css-bundle": "^2.5.1",
    "@remix-run/node": "^2.5.1",
    "@remix-run/react": "^2.5.1",
    "@remix-run/serve": "^2.5.1",
    "dotenv": "^16.4.5",
    "firebase": "^10.8.0",
    "isbot": "^4.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    // 省略...
    "@remix-pwa/dev": "2.0.31",
    "@remix-pwa/worker-runtime": "^2.0.8",
    "@remix-run/dev": "^2.5.1",
    "@remix-run/eslint-config": "^2.0.0",
    "glob": "^10.3.10",
    "npm-run-all": "^4.1.5",
    "remix-pwa": "^3.0.19",
  }
}

Remixの各種ファイルへ追加する

次のファイルの変更が必要です。

  • app/entry.client.tsx
  • app/entry.server.tsx
  • app/root.tsx
  • remix.config.js
  • tsconfig.json
app/entry.client.tsx
import { StrictMode, startTransition } from "react";
import { hydrateRoot } from "react-dom/client";

import { RemixBrowser } from "@remix-run/react";
+ import { loadServiceWorker } from "@remix-pwa/sw";
import { I18nextProvider } from "react-i18next";

import i18n from "./i18n";

+ loadServiceWorker();

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
        <RemixBrowser />
    </StrictMode>
  );
});
app/entry.server.tsx
import "dotenv/config";
import { renderToPipeableStream } from "react-dom/server";

import { PassThrough } from "node:stream";

import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";

- import type { AppLoadContext, EntryContext } from "@remix-run/node";
+ import type { EntryContext } from "@remix-run/node";

const ABORT_DELAY = 5_000;

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
  // This is ignored so we can keep it in the template for visibility.  Feel
  // free to delete this parameter in your app if you're not using it!
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    loadContext: AppLoadContext
+  //loadContext: AppLoadContext
) {
  const locale = request.headers.get("accept-language")?.split(",")[0] || "en";
  //... 以下略
}

function handleBotRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  //... 以下略
}

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  //... 以下略
}
app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
+ import { LiveReload } from "@remix-pwa/sw";
+ import { useSWEffect } from "@remix-pwa/sw";

import styles from "./styles/tailwind.css";
import type { LinksFunction } from "@remix-run/node";

export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];

export default function App() {
  useSWEffect();

  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}
remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
+ /** @type {import('@remix-pwa/dev').WorkerConfig} */
export default {
  ignoredRouteFiles: ["**/.*"],
  devServer: {
    port: 8003,
  },
  watchPaths: [
    "tailwind.config.js",
  ],
  serverDependenciesToBundle: [
+    /@remix-pwa\/.*/,
  ],
};
tsconfig.json
{
  //... 略
  "compilerOptions": {
    //... 略
+    "lib": ["es2022", "DOM", "DOM.Iterable", "WebWorker"],
    //... 略
  }
}

環境変数用jsonファイルの実装・ビルド

Remix v2.7以降は安定版viteが導入されているのでもしかしたら不要かもしれないですが、Service Workerはクライアントサイドであるため環境変数を参照することができません。
そこで、.envの内容で必要なものをjsonファイルでpublicディレクトリへ出力します。そのファイルをService Workerがfetchすることによって取得する様にします。

firebase.cjs
const fs = require("fs");
const path = require("path");
const dotenv = require("dotenv");

dotenv.config();

const firebaseConfig = {
  apiKey: process.env.FIREBASE_APIKEY,
  authDomain: process.env.FIREBASE_AUTHDOMAIN,
  projectId: process.env.FIREBASE_PROJECTID,
  storageBucket: process.env.FIREBASE_STORAGEBUCKET,
  messagingSenderId: process.env.FIREBASE_MESSAGINGSENDERID,
  appId: process.env.FIREBASE_APPID,
};

const outputPath = path.join(__dirname, "public", "firebase-config.json");
fs.writeFileSync(outputPath, JSON.stringify(firebaseConfig));

これを加えた上で、package.jsonを次の様にします。

package.json
{
  "name": "app",
  //... 省略
  "type": "module",
  "scripts": {
    "build": "run-s build:*",
    "build:remix": "remix build",
-    "build:worker": "remix-pwa build",
+    "build:worker": "npm run gen:firebase && remix-pwa build",
    "dev": "run-p dev:*",
    "dev:remix": "remix dev",
-    "dev:worker": "remix-pwa dev",
+    "dev:worker": "npm run gen:firebase && remix-pwa dev",
    "format": "prettier --check './app/**/*.{ts,tsx}'",
    "format-run": "npx prettier --write './app/**/*.{ts,tsx}'",
+    "gen:firebase": "node firebase.cjs",
    "lint": "TIMING=1 eslint --fix \"**/*.ts*\"",
    "start": "remix-serve ./build/index.js",
    "typecheck": "tsc"
  },
  "dependencies": {
    "@remix-pwa/cache": "^2.0.12",
    "@remix-pwa/strategy": "^2.1.9",
    "@remix-pwa/sw": "^2.1.12",
    "@remix-run/css-bundle": "^2.5.1",
    "@remix-run/node": "^2.5.1",
    "@remix-run/react": "^2.5.1",
    "@remix-run/serve": "^2.5.1",
    "dotenv": "^16.4.5",
    "firebase": "^10.8.0",
    "isbot": "^4.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    // 省略...
    "@remix-pwa/dev": "2.0.31",
    "@remix-pwa/worker-runtime": "^2.0.8",
    "@remix-run/dev": "^2.5.1",
    "@remix-run/eslint-config": "^2.0.0",
    "glob": "^10.3.10",
    "npm-run-all": "^4.1.5",
    "remix-pwa": "^3.0.19",
  }
}

あとは npm run dev  すると、その際にfirebase用のファイルがpublic配下に生成されます。

Service Worker(entry.worker.ts)へFirebase Authenticationの実装

ざっと説明します。尚、コードのほとんどはFiebase公式のドキュメントを参考にしています。

  1. Service Workerの登録しアクティベート(activate)のリスナー内でFirebase Autheticationの初期化などを行います。
  2. fetchリスナー内で特定のfetchに対してFirebaseのIDトークンを取得します。
  3. IDトークンをfetchのHeaderに仕込みます headers.append("x-idToken", data.idToken) 。x-idTokenは適当に名付けをしてください。バックエンドへのリクエストヘッダーにこれが渡されます。
  4. あとは適宜キャッシュを行なっています。
app/entry.worker.ts
/// <reference lib="WebWorker" />

import { Storage } from "@remix-pwa/cache";
import { cacheFirst, networkFirst } from "@remix-pwa/strategy";
import type { DefaultFetchHandler } from "@remix-pwa/sw";
import { RemixNavigationHandler, logger, matchRequest } from "@remix-pwa/sw";

+ import { initializeApp } from "firebase/app";
+ import {
+   getAuth,
+   getIdToken,
+   onAuthStateChanged,
+   type Auth,
+ } from "firebase/auth";

declare let self: ServiceWorkerGlobalScope;

const PAGES = "page-cache";
const DATA = "data-cache";
const ASSETS = "assets-cache";
+ const CACHE_NAME = "my-app-cache-v1";
+ let firebaseAuth: Auth;

// Open the caches and wrap them in `RemixCache` instances.
const dataCache = Storage.open(DATA, {
  ttl: 60 * 60 * 24 * 7 * 1_000, // 7 days
});
const documentCache = Storage.open(PAGES);
const assetCache = Storage.open(ASSETS);
const dataHandler = networkFirst({
  cache: dataCache,
});

const assetsHandler = cacheFirst({
  cache: assetCache,
  cacheQueryOptions: {
    ignoreSearch: true,
    ignoreVary: true,
  },
});

export const defaultFetchHandler: DefaultFetchHandler = ({
  context,
  request,
}) => {
  const type = matchRequest(request);

  if (type === "asset") {
    return assetsHandler(context.event.request);
  }

  if (type === "loader") {
    return dataHandler(context.event.request);
  }

  return context.fetchFromServer();
};

const handler = new RemixNavigationHandler({
  dataCache,
  documentCache,
});

+ // idTokenを取得
+ function getIdTokenPromise() {
+   return new Promise<{ uid: string; idToken: string } | null>((resolve) => {
+     const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
+       unsubscribe();
+ 
+       console.log("worker debug:", "getIdTokenPromise -> ", { user });
+ 
+       if (user) {
+         getIdToken(user)
+           .then((idToken) => {
+             resolve({
+               uid: user.uid,
+               idToken,
+             });
+           })
+           .catch((error) => {
+             console.log(error);
+             resolve(null);
+           });
+       } else {
+         console.log("worker debug:", "getIdTokenPromise -> ", "user is null.");
+         resolve(null);
+       }
+     });
+   });
+ }
+ 
+ function getOriginFromUrl(url: string): string {
+   const pathArray = url.split("/");
+   const protocol = pathArray[0];
+   const host = pathArray[2];
+   return protocol + "//" + host;
+ }

self.addEventListener("install", (event: ExtendableEvent) => {
  logger.log("Service worker installed");
  event.waitUntil(self.skipWaiting());
});

self.addEventListener("activate", (event: ExtendableEvent) => {
  logger.log("Service worker activated");
+ 
+   event.waitUntil(
+     (async () => {
+       const response = await fetch("/firebase-config.json");
+       const config = await response.json();
+       const app = initializeApp(config);
+       firebaseAuth = getAuth(app);
+     })()
+   );

  event.waitUntil(self.clients.claim());
});

self.addEventListener("fetch", async (event: FetchEvent) => {
+   async function getBodyContent(
+     req: Request
+   ): Promise<ArrayBuffer | string | void | undefined> {
+     if (req.method !== "GET" && req.method !== "DELETE") {
+       if (req.headers.get("Content-Type")?.indexOf("json") !== -1) {
+         return req.json().then((json) => JSON.stringify(json));
+       } else if (
+         req.headers.get("Content-Type")?.indexOf("multipart/form-data") !== -1
+       ) {
+         return req.clone().arrayBuffer();
+       } else {
+         return req.text();
+       }
+     }
+   }
+ 
+   async function requestProcessor(
+     data?: {
+       idToken: string;
+       uid: string;
+     } | null
+   ): Promise<Response> {
+     let req: Request = event.request;
+     let processRequestPromise = Promise.resolve();
+ 
+     const url = getOriginFromUrl(event.request.url);
+     const origin = self.location.origin;
+     const protocol = self.location.protocol;
+     const hostname = self.location.hostname;
+ 
+     if (
+       origin == getOriginFromUrl(url) &&
+       (protocol == "https:" || hostname == "localhost") &&
+       data
+     ) {
+       const headers = new Headers();
+       req.headers.forEach((val: string, key: string) => {
+         headers.append(key, val);
+       });
+       
+       headers.append("x-idToken", data.idToken);
+       const body = await getBodyContent(req);
+ 
+       req = new Request(req.url, {
+         method: req.method,
+         headers: headers,
+         mode: req.mode === "navigate" ? "same-origin" : req.mode,
+         credentials: req.credentials,
+         cache: req.cache,
+         redirect: req.redirect,
+         referrer: req.referrer,
+         referrerPolicy: req.referrerPolicy,
+         body: body as BodyInit | null,
+       });
+ 
+       try {
+         const response = await fetch(req);
+         if (!response || response.status !== 200 || response.type !== "basic") {
+           return response;
+         }
+
+         if (response.redirected) {
+           return response;
+         }
+ 
+         const responseToCache = response.clone();
+         if (req.method === "GET") {
+           const cache = await caches.open(CACHE_NAME);
+           cache.put(event.request, responseToCache);
+         }
+         return response;
+       } catch (error) {
+         console.log(error);
+         const responseFromCache = await caches.match(event.request);
+         if (responseFromCache) {
+           return responseFromCache;
+         }
+ 
+         return new Response("Network error happened", { status: 408 });
+       }
+     }
+ 
+     return processRequestPromise
+       .then(() => fetch(req))
+       .then((response: Response) => {
+         if (!response || response.status !== 200 || response.type !== "basic") {
+           return response;
+         }
+ 
+         const responseToCache = response.clone();
+ 
+         if (req.method === "GET") {
+           caches.open(CACHE_NAME).then((cache) => {
+             cache.put(event.request, responseToCache);
+           });
+         }
+ 
+         return response;
+       });
+   }
+ 
+   event.respondWith(
+     getIdTokenPromise()?.then(requestProcessor, requestProcessor)
+   );
+ });

self.addEventListener("message", (event) => {
  event.waitUntil(handler.handle(event));
});

まとめ

かなり手順も多く、Service Workerのコードが複雑です。

今回は後々のことも考えてremix-pwaにし、またTypeScriptで実装をしていますが、publicディレクトリにsw.jsの様にして渡すだけでも良いとは思います。

Discussion