Firebase Functions Gen 2 で Hono を動かす
はじめに
Cloud Functions for Firebase で Hono を使おうとして調べると、以下のような先駆者の記事が出てくる。
先駆者の記事は大変参考になるが、一方ここで紹介されている記述方法は 第1世代の記述であり、以下のように公式で推奨している第2世代の記述ではない。
可能な限り、新しい関数には 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 アプリを作る。
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.text("hello hono"));
export { app };
それを、Functions の onRequest
から以下のように呼ぶことでできるようにしたい。
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
型がないことに気づく。
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
という型を使っている。
どうやらExpress.jsの型を利用しているらしい。
今回使っているフレームワークは Hono であり、Express の型だけあればよい。
GitHub Copilot に相談したところ、以下を入れろと言われた。
npm install -D @types/express@^4.17.17
あとは import 文を修正することでエラーが解消された。
- 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
を使えばこれができる。
+ 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 アプリとして書く。
import { Hono } from "hono";
const app = new Hono();
// POST only
app.post("/", (c) => c.text("hello hono"));
export { app };
それをcallHandler
というものを後ほど用意するとして Functions にcall
を追加する。
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 全体
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 を加えれば期待通り動く。
App Check を有効化
onCall
に関する App Check の保護はとても簡単で、オプションのenforceAppCheck: true
にするだけでできる。
ローカル環境では App Check を無効化して使えるようにFUNCTIONS_EMULATOR
環境変数(予約語)を利用して切り替えるとすれば、以下のようにすれば良い。
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.json
のscripts
にあるように以下のコマンドである。
firebase deploy --only functions
このとき、実はリポジトリルートにfirebase init functions
で生成されていた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'
これで動作もチェックできた。
削除
用意した Function ごとに削除を実行する。以下のコマンドでできる。
firebase functions:delete api
firebase functions:delete call
Discussion