🔥

Firebase Functions Gen 2 で Hono を動かす

に公開

はじめに

Cloud Functions for Firebase で Hono を使おうとして調べると、以下のような先駆者の記事が出てくる。

https://zenn.dev/singularity/articles/firebase-hono
https://zenn.dev/kazuph/articles/ee51d6cf08d620

先駆者の記事は大変参考になるが、一方ここで紹介されている記述方法は 第1世代の記述であり、以下のように公式で推奨している第2世代の記述ではない。

https://firebase.google.com/docs/functions/version-comparison?hl=ja

可能な限り、新しい関数には Cloud Functions(第 2 世代)を選択することをおすすめします。しかしながら、今後も Cloud Functions(第 1 世代)のサポートを継続する予定です。

したがって本記事では先駆者の記事を参考に、第2世代に合わせた記述に書き換えつつ、最終的にHello Honoを返す API を用意してみる。

前提

  • Firebase CLI はインストール済みとする。
  • Firebase のサービスについてある程度わかっているものとする。
  • Cloud Functions for Firebase が利用可能である。
  • TypeScript で書く。
  • npmを使う。pnpmもいいが、Functions でnpmが使われるので揃えておいた方が良い。
  • Cloud Functions for Firebase といちいち呼ぶのは面倒なので、これ以降は Functions と呼ぶことにする。

準備

以下のコマンドで functions のコーディング環境を作る。

firebase init functions

対話で Firebase のプロジェクトと紐付けたり TypeScript を指定したりすればfunctionsフォルダが作られるはずである。

cd functions

今後の作業はこのfunctionsフォルダの中で作業するものとする。

次に Hono をインストールする。

npm install hono

これでまずは登場人物が揃った。

onRequestで実装する

srcフォルダにapi.tsを以下のように作成し、"hello hono" と返す Hono アプリを作る。

functions/src/api.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => c.text("hello hono"));

export { app };

それを、Functions の onRequest から以下のように呼ぶことでできるようにしたい。

functions/src/index.ts
import { onRequest } from "firebase-functions/v2/https";
import { requestHandler } from "./handler";
import { app } from "./api";

export const api = onRequest(requestHandler(app));

あとは、先駆者たちの知見から Hono へ変換するハンドラ(requestHandler)を作れば良い。

ただ、先駆者の記述をそのままコピペして第2世代に対応しようとすると、Response型がないことに気づく。

functions/src/handler.ts
import { Request as FunctionRequest, Response /* 存在しない! */ } from "firebase-functions/v2/https"; // v2 にする
import { Hono } from "hono";

export const requestHandler = (app: Hono<any>) => {
  return async (req: FunctionRequest, resp: Response) => {
    const url = new URL(`${req.protocol}://${req.hostname}${req.url}`);

    const headers = new Headers();

    Object.keys(req.headers).forEach((k) => {
      headers.set(k, req.headers[k] as string);
    });
    const body = req.body;

    const newRequest = ["GET", "HEAD"].includes(req.method)
      ? new Request(url, {
          headers,
          method: req.method,
        })
      : new Request(url, {
          headers,
          method: req.method,
          body: Buffer.from(
            typeof body === "string" ? body : JSON.stringify(body || {})
          ),
        });
    const res = await app.fetch(newRequest);

    const contentType = res.headers.get("content-type");

    if (contentType?.includes("application/json")) {
      resp.json(await res.json());
    } else {
      resp.send(await res.text());
    }
  };
};

ということでfirebase-functionsの実装を見に行くと、express.Responseという型を使っている。
https://github.com/firebase/firebase-functions/blob/master/src/v2/providers/https.ts#L297

どうやらExpress.jsの型を利用しているらしい。

今回使っているフレームワークは Hono であり、Express の型だけあればよい。
GitHub Copilot に相談したところ、以下を入れろと言われた。

npm install -D @types/express@^4.17.17

あとは import 文を修正することでエラーが解消された。

functions/src/handler.ts
- import { Request as FunctionRequest, Response } from "firebase-functions/v2/https";
+ import type { Response } from "express";
+ import { Request as FunctionRequest } from "firebase-functions/v2/https";
  /* 以下同じ */

動作テスト

ローカルでエミュレータを立ち上げる。

npm run serve

別のターミナルを開いて以下の動作テストする。
(※ <your-firebase-project-id>は自身のプロジェクトIDを入れること)

curl http://127.0.0.1:5001/<your-firebase-project-id>/us-central1/api

hello honoと返ってくれば成功である。

リージョンを変える

動作テストをすると気になるのが、URL からみるにリージョンがus-central1になっていることである。これはリージョンが未指定だとデフォルトでこれになる。
なのでリージョンを東京(asia-northeast1)にしたい。

onRequestの第一引数に指定することも可能(※ handler は第二引数)であるが、これから作るであろう API は共通して東京リージョンに置きたい。
ということで、setGlobalOptionsを使えばこれができる。

functions/src/index.ts
+ import { setGlobalOptions } from "firebase-functions/v2";
  import { onRequest } from "firebase-functions/v2/https";
  import { requestHandler } from "./handler";
  import { app } from "./api";

+ setGlobalOptions({
+   region: "asia-northeast1",
+ });

  export const api = onRequest(requestHandler(app));

もう一度エミュレータを起動するとus-central1ではなくasia-northeast1に変わっていることがわかる。

onCallで実装する

これまで書いてきたことで API サーバーとして利用可能であることは分かったが、実際には App Check 等の認証で保護したくなる。
それを Hono の Middleware で書いていくのもありだが、手っ取り早いのが呼び出し可能関数onCallで記述することである。

ドキュメントの順序等からこっちの書き方を Firebase は推奨しているように見える。
また、onCallにするだけで以下のような恩恵を受ける。

Firebase Authentication については今回触れない(というのも handler の設計が難しいので避けたい)が、App Check の導入はこのあと触れる。

実装

そもそも呼び出し可能関数がなんなのかわからない人向けに、私の浅い理解を話すとつまり、すべてのリクエストを POST のみで行うような感じである。
(調べるのが面倒なので誰かコメントで補足してほしいが、Google だしおそらくgRPC、その元の RPC と思想的関連がありそう。)

なので、すべて POST をかえして実装するようにする。新しくcall.tsを用意し、別の Hono アプリとして書く。

functions/src/call.ts
import { Hono } from "hono";

const app = new Hono();

// POST only
app.post("/", (c) => c.text("hello hono"));

export { app };

それをcallHandlerというものを後ほど用意するとして Functions にcallを追加する。

functions/src/index.ts
  import { setGlobalOptions } from "firebase-functions/v2";
- import { onRequest } from "firebase-functions/v2/https";
- import { requestHandler } from "./handler";
- import { app } from "./api";
+ import { onRequest, onCall } from "firebase-functions/v2/https";
+ import { requestHandler, callHandler } from "./handler";
+ import { app as apiApp } from "./api";
+ import { app as callApp } from "./call";

  setGlobalOptions({
    region: "asia-northeast1",
  });

- export const api = onRequest(requestHandler(app));
+ export const api = onRequest(requestHandler(apiApp));

+ export const call = onCall(callHandler(callApp));

※ リネームしているので変更が多いが、重要なのは最後の行だけ。

次にcallHandlerの実装だが、以下の内容にした。

import { CallableRequest } from "firebase-functions/v2/https";

export function callHandler(app: Hono<any>) {
  return async (req: CallableRequest) => {
    const rawReq = req.rawRequest;

    const headers = new Headers();

    Object.keys(rawReq.headers).forEach((k) => {
      headers.set(k, rawReq.headers[k] as string);
    });
    const body = req.data;

    const res = await app.request(rawReq.url, {
      headers: headers,
      method: "POST",
      body: Buffer.from(
        typeof body === "string" ? body : JSON.stringify(body || {})
      ),
    });

    const contentType = res.headers.get("content-type");

    if (contentType?.includes("application/json")) {
      return await res.json();
    } else {
      return await res.text();
    }
  };
}
functions/src/handler.ts 全体
functions/src/handler.ts
import type { Response } from "express";
import {
  Request as FunctionRequest,
  CallableRequest,
} from "firebase-functions/v2/https";
import { Hono } from "hono";

export const requestHandler = (app: Hono<any>) => {
  return async (req: FunctionRequest, resp: Response) => {
    const url = new URL(`${req.protocol}://${req.hostname}${req.url}`);

    const headers = new Headers();

    Object.keys(req.headers).forEach((k) => {
      headers.set(k, req.headers[k] as string);
    });
    const body = req.body;

    const newRequest = ["GET", "HEAD"].includes(req.method)
      ? new Request(url, {
          headers,
          method: req.method,
        })
      : new Request(url, {
          headers,
          method: req.method,
          body: Buffer.from(
            typeof body === "string" ? body : JSON.stringify(body || {})
          ),
        });
    const res = await app.fetch(newRequest);

    const contentType = res.headers.get("content-type");

    if (contentType?.includes("application/json")) {
      resp.json(await res.json());
    } else {
      resp.send(await res.text());
    }
  };
};

export function callHandler(app: Hono<any>) {
  return async (req: CallableRequest) => {
    const rawReq = req.rawRequest;

    const headers = new Headers();

    Object.keys(rawReq.headers).forEach((k) => {
      headers.set(k, rawReq.headers[k] as string);
    });
    const body = req.data;

    const res = await app.request(rawReq.url, {
      headers: headers,
      method: "POST",
      body: Buffer.from(
        typeof body === "string" ? body : JSON.stringify(body || {})
      ),
    });

    const contentType = res.headers.get("content-type");

    if (contentType?.includes("application/json")) {
      return await res.json();
    } else {
      return await res.text();
    }
  };
}

一見複雑だが、requestHandlerとの違いはPOSTメソッドに限定したことくらいでほぼ変わらない。

これでonCallから呼び出せる状態になった。

動作テスト

ローカルでエミュレータを立ち上げる。

npm run serve

別のターミナルを開いて以下の動作テストする。

curl http://127.0.0.1:5001/<your-firebase-project-id>/asia-northeast1/call -d '{ "data": {} }' --header 'Content-Type: application/json'

{"result":"hello hono"}と返ってくれば成功である。

なお、onCallで作られた実装はクライアントでhttpsCallableから呼び出すことを前提としている。
ただ実態としてはPOSTメソッドであり、{ "data": { <Hono に渡したい情報> } }の JSON を加えれば期待通り動く。
https://firebase.google.com/docs/functions/callable-reference?hl=ja

App Check を有効化

onCallに関する App Check の保護はとても簡単で、オプションのenforceAppCheck: trueにするだけでできる。

ローカル環境では App Check を無効化して使えるようにFUNCTIONS_EMULATOR環境変数(予約語)を利用して切り替えるとすれば、以下のようにすれば良い。

functions/src/index.ts
  import { setGlobalOptions } from "firebase-functions/v2";
  import { onRequest, onCall } from "firebase-functions/v2/https";
  import { requestHandler, callHandler } from "./handler";
  import { app as apiApp } from "./api";
  import { app as callApp } from "./call";

+ const enforceAppCheck =
+   process.env.FUNCTIONS_EMULATOR === "true" ? false : true;

  setGlobalOptions({
    region: "asia-northeast1",
+   enforceAppCheck: enforceAppCheck,
  });

  export const api = onRequest(requestHandler(apiApp));

  export const call = onCall(callHandler(callApp));

デプロイ

ここまでできたが、まだエミュレータ上でしか動かせていないため、実際の環境にデプロイしてテストしたい。

以下のコマンドで実行できる。

npm run deploy

なお、このとき linter が走ってエラーでこけることがある。linter の言うとおりに直したり、.eslintrc.jsを調整することも可能だが、lint をそもそも発生させないようにすることもできる。

npm run deployの実態はpackage.jsonscriptsにあるように以下のコマンドである。

firebase deploy --only functions

このとき、実はリポジトリルートにfirebase init functionsで生成されていたfirebase.jsonの項目が影響する。

firebase.json
{
  "functions": [
    {
      "source": "functions",
      "codebase": "default",
      "ignore": [
        "node_modules",
        ".git",
        "firebase-debug.log",
        "firebase-debug.*.log",
        "*.local"
      ],
      "predeploy": [
        "npm --prefix \"$RESOURCE_DIR\" run lint",
        "npm --prefix \"$RESOURCE_DIR\" run build"
      ]
    }
  ]
}

このpredeployの項目に注目してほしい。ここはデプロイの前処理を指定していて、一個目がrun lintになっている。
なので、この行を削除してしまえば、lint を無視してデプロイが成功するはずである。

Firebase のコンソール画面から Functions の項目を見ると、ちゃんとデプロイされていることが確認できる。

あとは、実際に動くかテストする。
なお、App Check を有効化している場合は、有効化を切ってもう一度デプロイしてから以下のコマンドを実行すること。

curl https://asia-northeast1-<your-firebase-project-id>.cloudfunctions.net/api
curl https://asia-northeast1-<your-firebase-project-id>.cloudfunctions.net/call -d '{ "data": {} }' --header 'Content-Type: application/json'

https://firebase.google.com/docs/functions/http-events?hl=ja&gen=2nd#invoke_an_http_function

これで動作もチェックできた。

削除

用意した Function ごとに削除を実行する。以下のコマンドでできる。

firebase functions:delete api
firebase functions:delete call

https://firebase.google.com/docs/functions/manage-functions?hl=ja&gen=2nd#delete_functions

Discussion