🗂

個人開発でTypeScriptフレームワークを使うときの技術選定案(frourio&Firebase&Railway)

2022/02/14に公開
3

TypeScriptフルスタックフレームワークのfrourioを使うときの技術選定について考えてみました。

https://frourio.com/

frourioは国産のTypeScriptフレームワークで、aspidaを使ってサーバーサイドからフロントエンドまでシームレスに型安全なREST APIを扱えることを特長としています。yarn create frourio-appコマンドで一瞬で試せるので興味ある方はぜひ動かしてみてください。

一方で、2022年現在では認証周りの実装方法やデプロイに関する知見が少ないのも事実なので、本記事では認証にFirebaseを、デプロイにRailwayを使った技術選定について説明します。

https://firebase.google.com/?hl=ja

https://railway.app?referralCode=BO0XJq

Firebase、Railwayともに非常に簡単に試せるため、個人開発に特におすすめです。

frourioについて

frourioは国産のTypeScript製フルスタックWebフレームワークです。

本記事はfrourio v0.26.0を利用しています。

frourioの特長

特長としては以下の点が挙げられます。

  • 型安全にREST API通信を記述できるライブラリaspidaを内包しており、フロントエンド〜バックエンド〜ORMまですべて型安全に書ける
  • 関数ベースでControllerを記述でき、フロントエンド寄りのエンジニアに対して馴染みの深い設計手法で実装できる
  • frourio自身が提供しているのはあくまでバックエンドのController層の周辺のため非常に軽量。フロントエンドをReact/Vue/etcから選択できる(ここけっこう勘違いされがちなので強調)。

便宜上フルスタックフレームワークと言っていますが、フルスタックフレームワークを実現するための肝となるフロントエンド↔バックエンドの型安全性に注力している軽量フレームワークと言っても良いかも知れません。

frourioを支えるライブラリ

同じ開発チームによるライブラリ

  • aspida
    • REST APIを型安全に書ける。API定義をTSで書くと、例えば/userといったAPIに対して$api.user.$get()でCallできるオブジェクトを自動生成する
  • useAspidaSWR
    • aspidaをSWRと統合することで、型安全にHooksを使った宣言的なAPIフェッチが実装できる

frourioが内包しているライブラリ

  • fastify
    • Node.jsサーバー内で最速のベンチマークを誇り、express代替として最有力。また、プラグイン機構が充実しており、多くのプラグインライブラリを利用することで簡単にいろいろな機能を活用したサーバーを構築できる。fasitfy-helmetやfastify-corsなどが定番。
  • Prisma
    • 独自形式のmigrationファイルやschemaファイルを書くことで型安全なORMが自動生成されるライブラリ。

frourioは高いDXを誇りながら、従来は開発が面倒だったSPA/BackendをREST APIベースで別々に開発することが可能になりますが、一方で軽量なフレームワークなので、2022年時点では認証周りやデプロイは自分である程度選定してセットアップする必要があります。

そこで本記事ではその一例としてFirebaseとRailwayの導入方法を示します。

いろいろ参考にさせていただきつつ独自に考えた組み合わせや実装なので、ご利用は自己責任でお願いします。 なにか指摘事項あればぜひコメントください。

Firebaseを使った認証機能の実装

課題

frourioには2022年現在、公式の認証機構の実装がなく、create-frourio-appした時点ではJWTベースで認証っぽいことをしている試験実装が載っているのみです。そのためメールアドレスログインをたとえば実装したいのであれば、自前でログインのロジックやそれらのデータベースへの保存、User IDの採番方法の選定などを行う必要があります。

個人的な考えですが、これらについて自分で実装するとメールアドレスの本人確認やパスワード再発行機能など、意外に実装することが多い点やセキュリティの観点で穴を開けてしまう可能性などリスクがあります。さらにほとんどのサービスにおいて認証機能を自前実装していることは差別化になんら寄与しないことから、可能な範囲で認証機能はフレームワーク側でサポートされているものを使うか、外部のXaaSに任せるのがいいでしょう。

Firebaseを使った設計の概要

FirebaseといえばFirestoreやCloud Functionsと一緒に使うことをイメージされることが多いかと思いますが、Authだけ使うことも可能です。

https://firebase.google.com/docs/auth/admin/manage-cookies?hl=ja

Firebase Auth上で認証すると、Cookieを発行することができるようになります。サーバーサイドと連携して、クライアントで発行した「IDトークン」をPOSTし、サーバーサイドで「Admin SDK」を使ってCookieを設定しレスポンスの「set-cookie」ヘッダを通してCookieを設定します。

つまり、Firebase Authは一般的にはその他のFirebaseのリソース、FirestoreやStorageにセキュアにアクセスするための手段としても利用可能ですが、任意のバックエンドサービスにAdmin SDKを組み込むことでログイン、ログアウト機能やUser IDの採番、いろいろなログイン方法のサポートなども実装ができる便利な仕組みです。

以下の記事ではNext.jsの利用を前提にFirebase AuthのCookieでの利用を解説しており、これのfrourio版について解説します。

https://zenn.dev/uttk/articles/f48fc75120f018

補足

  • Firebase AuthといえばgetIdToken()で取得したJWTをAuthorizationヘッダに載せる認証方法もありますが、個人的には以下の理由から(フロントエンドとバックエンドが同じ(サブ)ドメインであれば!)Cookieを使うことを推奨したいです
    • JWTをヘッダに載せる方法は、HTTPクライアントライブラリに追加実装が必要になる。axiosならInterceptで比較的容易に実装できるが、fetchで同様の実装は少々難しい
    • JWTは1時間おきに期限切れになるため、期限がしばしば切れることからリトライなどを使って401レスポンス時に再度トークン発行の実装などが必要となり複雑化する
    • そもそもJWTの置き場所としてローカルストレージが有力だが、XSS時に被害範囲が広がるといった定番の指摘事項の妥当性は(個人的には論点が微妙だと思っているので)ともかく、トークンを取得するたびにいちいちストレージに入れる実装を自前でしなければならない点がCookieと比較して面倒だと感じている。CookieならJavaScriptを介さずSet-Cookieヘッダという標準化された方法で設定できる
    • ローカルストレージにトークンを保存する実装方針だと、SSR時に認証させることが難しい(はず)
  • Firebase Admin SDKはNode.jsやGoなど主要な言語に対応しており、それぞれCookieのVerify機能は実装されています。

実装(サーバー)

firebase-adminfastify-cookieをインストールします。

    "firebase-admin": "^9.12.0",
    "fastify-cookie": "^5.5.0",

※fastify-cookieは不要かもしれませんが、安定を取って使うことにしました。

server/service/app.tsを編集します。デフォルトのCORS設定の場合、Credentialsを扱うときにOriginの制約に引っかかってしまうため、Originの検証を実装します(frourioを使う場合フロントエンドとバックエンドのオリジンは原則別になることに注意)。CLIENT_ORIGINといった名称で環境変数をserver/.envに設定し、クライアントの許可したいオリジンをバリデートする実装例を以下に示します[1]

  app.register(cors, {
    credentials: true,
    origin: (origin, cb) => {
      const hostname = new URL(origin).hostname;
      // 環境変数で指定したオリジンからのアクセスのみ許容(環境ごとに1つ前提)
      if (CLIENT_ORIGIN === hostname) {
        cb(null, true);
        return;
      }
      // 関係ないドメインならエラーを返す
      cb(new Error('Error by CORS Policy'), false);
    },
  });
  // Cookieのセットやリセットに使うが、ライブラリ自身の機能は使わずFirebaseで管理するためOptionはEmpty
  app.register(cookie, {});

server/api/session/index.tsで型定義を実装します。

export type Methods = {
  post: {
    reqBody: {
      id: string;
    };
    resBody: {
      status: 'success';
    };
  };
};

自動生成されるserver/api/session/controller.tsを編集します。ポイントとしては、現時点のfrourioでは各ControllerでRequestのオブジェクトをそのまま触ることは難しいっぽい(fastifyの理解が足りていないので、実はControllerでなんとかできるのかもしれない)ため、Hookで実装することでRequestオブジェクト内のCookieにアクセスして検証や発行ができます。

import { defineController, defineHooks } from './$relay';
import { firebaseAdmin } from '$/middleware/firebaseAdmin';

export type AdditionalRequest = {
  body: {
    id: string;
  };
};

export const hooks = defineHooks(() => ({
  preHandler: async (req, reply, done) => {
    const auth = firebaseAdmin.auth();

    // Tokenの有効期限
    const expiresIn = 60 * 60 * 24 * 5 * 1000; // Firebase公式ドキュメント通りいったん5日

    // セッションCookieを作成するためのIDを取得
    const id = (JSON.parse(req.body?.id ?? '') || '').toString();

    // Cookieに保存するセッションIDを作成する
    const sessionCookie = await auth.createSessionCookie(id, { expiresIn });

    // Cookieのオプション
    const options = {
      expires: Date.now() + expiresIn,
      httpOnly: true,
      secure: process.env.NODE_ENV !== 'development',
      path: '/',
      sameSite: 'lax',
    };

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    reply.setCookie('session', sessionCookie, options);
    done();
  },
}));

export default defineController(() => ({
  post: async () => {
    return {
      status: 200,
      body: {
        status: 'success',
      },
    };
  },
}));

ログアウト側の実装は省略しますが、以下の要領で実装できました。

ログアウト側の実装
    const auth = firebaseAdmin.auth();

    // Cookieに保存されているセッションIDを取得する
    const sessionId = req.cookies.session || '';

    // セッションIDから認証情報を取得する
    const decodedClaims = await auth.verifySessionCookie(sessionId).catch(() => null);

    // 全てのセッションを無効にする
    if (decodedClaims) {
      await auth.revokeRefreshTokens(decodedClaims.sub);
    }

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    reply.clearCookie('session', { path: '/' });
    done();

また、ログインできているかどうかを確認するためのエンドポイントも実装します。

server/api/me/index.ts

export type Methods = {
  get: {
    resBody:
      | {
          id: string;
          // name: string; TODO: その他のプロフィール情報を返すほうが便利なケースが多いと思う(これはDBから取得かな)
          status: 'login';
        }
      | {
          status: 'logout';
        };
  };
};

server/api/me/controller.ts

import { defineController } from './$relay';

export default defineController(() => ({
  get: ({ user }) => {
    return {
      status: 200,
      body: user
        ? { id: user.id, status: 'login' }
        : {
            status: 'logout',
          },
    };
  },
}));

また、こういった認証済みでなければアクセスできないエンドポイントの場合、HooksをControllerの前段に挟むことでガードすることができます。

https://frourio.com/docs/hooks/directory-level-hooks

server/api/me/hooks.ts

import { defineHooks } from './$relay';
import { OptionalAuthedRequest, withOptionalUser } from '$/middleware/auth';

export default defineHooks(() => ({
  preHandler: withOptionalUser,
}));

export type AdditionalRequest = OptionalAuthedRequest;

ディレクトリレベルのHooksを使うと、特定のパス以下に全部認証ガードを掛けることができるので、エンドポイントの設計としては認証済みでなければ使えないAPIは特定のパス以下に集約すると便利かと思います。

オリジナルの実装としてserver/middleware/auth.tsを実装して、認証周りの実装を外部に切り出しておきました。ここは好みです。

server/middleware/auth.ts
import { preHandlerHookHandler } from 'fastify';
import { firebaseAdmin } from '$/middleware/firebaseAdmin';

export type AuthedRequest = {
  user: {
    id: string;
    email: string;
  };
};
export type OptionalAuthedRequest = {
  user?: {
    id: string;
    email: string;
  };
};

type AddedHandler<T> = T extends (req: infer U, ...args: infer V) => infer W
  ? (req: U & Partial<AuthedRequest>, ...args: V) => W
  : never;
const createProcess: ({ throwError }: { throwError: boolean }) => AddedHandler<preHandlerHookHandler> = ({
  throwError,
}) => {
  const func: AddedHandler<preHandlerHookHandler> = async function (req, res) {
    try {
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      const sessionId = req.cookies.session;
      const auth = firebaseAdmin.auth();
      const user = await auth.verifySessionCookie(sessionId, true).catch(() => null);
      if (user) {
        // ユーザーオブジェクトをInjectする
        req.user = {
          id: user.uid,
          email: user.email ?? '',
        };
      }
    } catch (err) {
      // 単に握りつぶすと、userがNULLの状態で処理が続く
      if (throwError) {
        console.warn({ err, path: req.url });
        res.status(401).send();
      }
    }
  };
  return func;
};

export const requireAuth = createProcess({ throwError: true });
export const withOptionalUser = createProcess({ throwError: false });

ここでやっていることは要するにCookieをVerifyしてFirebaseのUserオブジェクトを取得してUidとメールアドレスを取ることができるため、それをRequestオブジェクトにInjectしてControllerサイドで使うことができるし、AdditionalRequestという型名でExportしたらControllerでも型安全に使えるので、それを使って認可処理したりDBにUidを保存したりできるよ、ということです。

最後に環境変数の設定をします。GoogleのサービスアカウントキーをFirebaseのコンソールから発行し、それを好みのディレクトリに配置し、そのパスをGOOGLE_APPLICATION_CREDENTIALS環境変数にセットします。

GOOGLE_APPLICATION_CREDENTIALS="./server/hogehoge/fugafuga.json"

実装(クライアント)

Firebaseでログインする方法はメールアドレス/パスワード認証やメールリンク認証、Google等のOAuthを使ったログインなどいろいろあるため、ここではどれを選択するかは割愛します。何かしらFirebaseのUserオブジェクトを保存したuser変数があるとして、以下のように実装します。

const id = await user.getIdToken();
await apiClient.session.$post({
    body: {
        id: JSON.stringify(id),
    },
});

ログアウトは以下のように実装します。

export const useSignOut = () => {
  const router = useRouter();
  const logout = useCallback(async () => {
    await apiClient.sessionLogout.$post();
    await getAuth().signOut();
    // pathpidaを使っている前提
    await router.push(pagesPath.$url());
  }, [router]);

  return {
    logout,
  };
};

あ、あとはutils/apiClient.tsでaxiosのwithCredentialsを忘れずに設定してください。

import aspida from '@aspida/axios'
import api from '~/server/api/$api'
import axios from 'axios'

// Cookieベースで認証するため必須
axios.defaults.withCredentials = true
export const apiClient = api(aspida(axios))

ここまで実装するとログイン、ログアウトが動作しました。

Railwayを使ったデプロイ

Railwayは現代版のHerokuのようなサービスです。CLIでいろいろな言語のバックエンドサービスをデータベース(PsostgreSQL、MySQL、MongoDB)やキャッシュ(Redis)とともに簡単にデプロイ、管理できます。

https://docs.railway.app

ちょっとおもしろいのがStarterという機能があって、Deploy on Vercelみたいな、ボタンを押すだけで自分の環境でデプロイできるようにセットアップすることができます。

https://railway.app/starters

Discord Botとか、Nextjs with tRPCとか、Blitzとか、Laravelとか、Umamiといったいろいろなサービスを簡単にデプロイして運用開始できます。このコンセプトはけっこう珍しいのではないかなと思います。

Railwayの使い方自体は、特に解説することもないかなと思っています。アカウントを作ってサービスを管理画面上で作成したら、PostgreSQLのプラグインの追加や、Dev環境の追加、それに伴ったホスト名などの取得にいたるまで非常にいい感じのUIで行えます。

カスタムドメインも紐付けることができるため、Cookie認証を実装するにあたってはドメインを取得してサブドメインなどをAPIサーバーのドメインとして紐付けておきましょう。

さて、Railwayを使ってfrourioをデプロイすることで、PostgreSQLとアプリケーションをまるっとデプロイすることができるためとても気軽なのですが、具体的にセットアップするには少々工夫が必要です。

理由としてはNode.jsのデプロイをするにあたっては、npm installnpm buildを実行するように決まっているからです。

https://docs.railway.app/deploy/nodejs

frourioはフロントエンドも同じリポジトリに突っ込んでいる背景から、サーバー側のビルドを行うにはnpm run build:serverという独自コマンドを実行する必要があり、現状はここをカスタマイズできないことから、標準のデプロイ方法は使えないということになります。

詰んだかなと思ったのもつかの間、Dockerfileを置くとそれをデプロイするよという方法があったので、これが使えました。

https://docs.railway.app/deploy/docker

将来的にDockerfileの位置を指定できるようになるとありがたいのですが、現状はルートに置かないといけなさそうです。モノレポのサポートも実装が進んでいるみたいなので進捗を監視したいところです。

Dockerfileでは以下のように実装すると、無事にRailway上にデプロイができました。./server/hogehoge/fugafuga.jsonはFirebaseのサービスアカウントキーのパスです。

FROM node:16.X.X

RUN mkdir /src
RUN mkdir -p /src/server/hogehoge

ARG PORT
ARG API_SERVER_PORT
ARG API_BASE_PATH
ARG FIREBASE_CREDENTIAL_BASE64
ARG GOOGLE_APPLICATION_CREDENTIALS

WORKDIR /src

COPY /server/package.json /server/yarn.lock ./server/
RUN yarn install --cwd server

COPY . .
RUN echo $FIREBASE_CREDENTIAL_BASE64 | base64 -d > ./server/hogehoge/fugafuga.json

EXPOSE 80
CMD yarn run build:server && yarn run start:server

FirebaseのサービスアカウントキーはJSONでダウンロードできるわけですが、そのままだとRailway環境に綺麗に渡せないため、Base64エンコードしたものをRailway上の環境変数に設定し、Dockerfile上でそれを取得してデコードして所定の場所に置いているわけです。

必要な環境変数は管理画面上、またはrailway variables setコマンドで設定できます。

手元でrailway upとコマンドを叩くとデプロイが完了します。Tokenを管理画面上から発行するとCIとの連携もできるようです(未検証)。

気になる料金ですが、まずは無料で始めることができます。利用金額が実質10ドルを超えたところからクレジットカードの入力が必須になるようなのですが、まだそこまで行っていないので、どの程度サービスがスケールしたら10ドルを超えるのかはわかっていません。少なくともDevとProduction環境を作ってデプロイして動かすだけだと、完全無料で実行できます。

PostgreSQLを動かせるサービスだと他にもrender.comとかfly.ioがあるため、もし料金面などで心配が出てきたらこれらのサービスと比較してみたいなと思っています。

補足(2022年2月20日)

Railwayはバックエンドのデプロイで使います。フロントエンドはフロントエンドで、別でVercelなどに自由にデプロイすることができます。

私はNext.jsをフロントエンドで使っているので、VercelをGitHub連携してデプロイしています。

frourioと統合しているNext.jsの場合、ビルドコマンドはnpm run generate && npm run build:clientにすると動作します。

まとめ

  • frourioにFirebaseのAuth機能だけ導入してCookieベースで認証ができるよ
  • データベース付きでアプリケーションをデプロイできるサービスはHeroku以外にも次々出てきていて、Railwayもけっこういい感じだよ

告知

frourioは実務で使っていないので情報収集や発信頻度が低めですが、実務で使っているaspidaをかuseAspidaSWRについては布教活動をよくやっています。

https://meety.net/matches/HANUvXfzWitg

Twitterもやっているし、普段自分がCTOを務めているマナリンクではエンジニア採用を随時やっておりますので、よろしければカジュアル面談などでお話しましょう!

https://twitter.com/Meijin_garden/status/1477212825177444352

よろしくお願いします。


最後まで読んでいただきありがとうございました!記事が参考になったらバッジお願いします!

脚注
  1. 当初はオリジンに対して正規表現でチェックする甘い実装でしたが、azuさんのコメントを受けて環境変数を参照するように修正しました!ありがとうございます! ↩︎

Discussion

azuazu

実装(サーバー)のCORSの実装についてなのですが、

localhostについては process.env.NODE_ENV をみているので現実的には問題ないのですが、
/example\.com/ の方は、 https://evil-example.comhttps://example.com.evil.test のようなURLからのリクエストに対してもCORSを通してしまうと思います。

そのため、originに対して厳密な一致でチェックした方が良いと思います。

  app.register(cors, {
    credentials: true,
    origin: (origin, cb) => {
      // ローカル開発中または所有しているドメインのときはTrue
      const isValidOrigin = (() => {
        if (process.env.NODE_ENV === 'development') {
-          return /localhost/.test(origin);
+          const hostname = new URL(origin).hostname;
+          return hostname === "localhost";
        }
-        return /example\.com/.test(origin);
+        return ["https://example.com"].includes(origin);

      })();
      if (isValidOrigin) {
        cb(null, true);
        return;
      }
      // 関係ないドメインならエラーを返す
      cb(new Error('Error by CORS Policy'), false);
    },
  });
  // Cookieのセットやリセットに使うが、ライブラリ自身の機能は使わずFirebaseで管理するためOptionはEmpty
  app.register(cookie, {});

fastify-corsのREADMEに同様の問題があったため修正しています。

meijinmeijin

ご指摘ありがとうございます!
仰るとおりですね・・・例が雑でした。後ほど記事のほうも修正させていただきます!

細かいところまで読んでいただいて大変嬉しいです。
NODE_ENVで縛っているとはいえ、localhostのほうも対応したほうがよさそうですね。動作確認してみます!

meijinmeijin

以下のように環境変数を参照するように修正しました!

    origin: (origin, cb) => {
      const hostname = new URL(origin).hostname;
      // 環境変数で指定したオリジンからのアクセスのみ許容(環境ごとに1つ前提)
      if (CLIENT_ORIGIN === hostname) {
        cb(null, true);
        return;
      }
      // 関係ないドメインならエラーを返す
      cb(new Error('Error by CORS Policy'), false);
    },