DenoからFirebaseを使う

新しいJavaScriptランタイムである**Deno**と、2021年8月にリリースされた**Firebase JavaScript SDK v9**の組み合わせを試してみたいと思います。

なお、以下のコードはdeno deployでも動作します。

:::note warn
**この記事は2022年12月に大幅に更新されました。**
:::

:::note warn
**2022年9月、Denoがnpmからのインポートに対応することが[発表されました](https://deno.com/blog/changes)。**

そのため以下で紹介するFirebase SDKを使う方法に代わって、**今後新たな方法が出てくる可能性があります**。

以下の内容は**2022年12月時点の情報です**。

※Denoのnpmインポートについては [Denoの新機能「npmインポート」について予習する](https://qiita.com/access3151fq/items/e38f78db2d96a1ddd6cc) という記事で詳しく解説しています。
:::

## 「Firebase JavaScript SDK」と「Firebase Admin Node.js SDK」

Firebaseの機能を利用するためのSDKには、**クライアントサイド向け**である**Firebase JavaScript SDK**と**サーバーサイド向け**である**Firebase Admin Node.js SDK**が存在します。両者の特徴は以下の通りです。

||JavaScript SDK|Admin Node.js SDK|
|-|-|-|
|実行環境|**ブラウザ**|Node.js|
|Admin権限|なし|あり|
|提供方法|npm／CDN|npm|

Denoはブラウザ互換のランタイムなので、ブラウザ向けに作られている**Firebase JavaScript SDK**を利用します。

:::note warn
ちなみに、（v9ではなく）Firebase v8を利用する方法を解説した[公式ドキュメント](https://deno.com/deploy/docs/tutorial-firebase#concepts)でも、ブラウザ向けSDKの利用が推奨されています。
:::

## Firebase JavaScript SDKを使う

Firebase JavaScript SDKは、2022年12月現在、https://esm.sh から使用できることが知られています。

```ts
import { initializeApp } from "https://esm.sh/firebase@9.14.0/app?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";
import { getDatabase } from "https://esm.sh/firebase@9.14.0/database?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";
```

[GoogleのCDN](https://www.gstatic.com/firebasejs/9.6.0/firebase-app.js)からimportする方法などもあるにはあるのですが、esm.shを使用すればTypeScriptの型定義も自動で付いてくるため、使いやすいです。

:::note warn
#### import文のクエリパラメータについて

Firebase JavaScript SDKについては、

- https://esm.sh/firebase@9.14.0/app がインポートする[`@firebase/component`](https://www.npmjs.com/package/@firebase/component)のバージョン
- https://esm.sh/firebase@9.14.0/auth がインポートする[`@firebase/component`](https://www.npmjs.com/package/@firebase/component)のバージョン
- https://esm.sh/firebase@9.14.0/detabase がインポートする[`@firebase/component`](https://www.npmjs.com/package/@firebase/component)のバージョン

が**それぞれ一致しないと正しく動作しない**という特徴があります。（同様の問題は[`@firebase/component`](https://www.npmjs.com/package/@firebase/component)だけでなく、[`@firebase/app`](https://www.npmjs.com/package/@firebase/app)、[`@firebase/util`](https://www.npmjs.com/package/@firebase/util)、[`@firebase/logger`](https://www.npmjs.com/package/@firebase/logger)にもある）

https://esm.sh にはこのような状況に対処するため、`?deps=`というクエリパラメータが用意されています。**このクエリパラメータを用いて依存関係のバージョンを固定**することができます。

結果として、import文に書くURLは`https://esm.sh/firebase@9.14.0/app?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0`のようなものになります。
（このクエリパラメータはそれぞれのライブラリの最新バージョンを使用するにように設定してあります。）
:::

#### Firebase Realtime Database

[ブラウザ向けのドキュメントに書いてある内容](https://firebase.google.com/docs/database/web/read-and-write)が、import元を変えるだけで基本的にそのまま動きます。

```ts
import { initializeApp } from "https://esm.sh/firebase@9.14.0/app?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";
import {
  getDatabase,
  push,
  ref,
  set,
} from "https://esm.sh/firebase@9.14.0/database?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";

// firebaseコンソール画面から取得できるfirebaseConfig
const firebaseConfig = {...};

// 実環境では、writeDataが呼ばれるまでinitializeAppの呼び出しを延期するなど工夫してもいいかも
const app = initializeApp(firebaseConfig);
const database = getDatabase(app);

/** データベースへ書き込む */
export async function writeData(name: string) {
  const db = await getRealtimeDatabase();
  await push(ref(db, `hello/world`), name);
  // await set(ref(db, `hello/woooooeld`), data);
}
```

TypeScriptの型もついてイイ感じです。

`enableLogging`をimportして呼び出すことで、デバッグログを表示させることができます。

```diff_tsx
  import {
+   enableLogging,
    getDatabase,
    push,
    ref,
    set,
  } from "https://esm.sh/firebase@9.14.0/database?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";

  // 詳細なログが表示される
+ enableLogging(console.log);
```

なお、このコードは処理が止まらず、永遠にデータを待ち受け続けます。
ローカル実行等、最後まで行ったら処理を止めたい場合は、`firebase/app`から`deleteApp`関数をimportした上で、処理の最後に呼び出す必要があります。

#### firestore

firestoreからデータを読み取る際にはこのようになります。
こちらは実はまだ試せていないのですが、以下のようなコードが動くはずです。

```ts
import { initializeApp } from "https://esm.sh/firebase@9.14.0/app?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";
import { 
  doc,
  getDoc,
  getFirestore,
} from "https://esm.sh/firebase@9.14.0/firestore?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";

const firebaseConfig = {...};

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

const docSnap = await getDoc(doc(db, "hello", id));
```



## セキュリティルール

firebaseではデータベースの操作に対してセキュリティルールを設定することができます。

https://firebase.google.com/docs/rules/basics?hl=ja

本来であればサーバー側ではセキュリティルールに関係なくアクセスできるAdmin SDKを使うのですが、今はブラウザ向けSDKを使っているのでセキュリティルールを考慮する必要があります。

もし書き込み権限を許可したい場合、「全て許可」してしまうと、外部からデータを操作し放題になってしまいます。
これを防ぐためには、Firebase Authenticationを使用し、

1. セキュリティルールで、管理者アカウント以外の操作を禁止する
2. サーバー側では、管理者アカウントとしてプログラム上でログインしてからDBに接続する

といった制御を行う必要があります。
以下はRealtime Databaseで設定を行った時の流れですが、Firestoreでも基本的には同じかと思われます。

##### 1. 管理者アカウントの作成

まず、Firebase Authenticationのメール認証で、管理者アカウントを作成します。このアカウントはプログラムから操作するために使うダミーユーザーとなります。

この際、「**メールアドレス**」と「**パスワード**」には任意の文字列を設定します。これらの値は後ほどログイン処理で使います。
値は適当で構いませんが、推測されないような文字列を使いましょう。（筆者はブラウザコンソールで`crypto.randomUUID()`した値を入れました。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/527afb2e-e846-335d-98ae-d60a268696a9.png)

ユーザーを追加すると、「**ユーザーID**」という値がこのアカウントに対して設定されます。この値も次で使います。

##### 2. セキュリティルールの設定

次に、Realtime Databaseのセキュリティルールを設定します。
以下のセキュリティルールの例のように設定して下さい。

```json:realtime databaseのセキュリティルール
{
  "rules": {
    ".read": "auth.uid == '<作成した管理者のユーザーid>'",
    ".write": "auth.uid == '<作成した管理者のユーザーid>'"
  }
}
```

`<作成した管理者のユーザーid>`という部分には、先ほど作成したダミーの管理者ユーザーの「**ユーザーID**」をコピー＆ペーストします。
こうすることで、管理者アカウントのメールアドレスとパスワードでログインしていない場合は、データベースを読み書きできなくなります。

この設定をしておくと、もし管理者以外のユーザーで読み書きしようとした場合はセキュリティルールでアクセスが弾かれ、Permission deniedエラーが発生します。

※フロントエンドからもデータベースにアクセスがある場合は、これに加えて適宜権限を追加してください（未検証）。

##### 3. プログラムから管理者アカウントで認証する

データベースにアクセスする前に、先ほど作成した管理者アカウントで認証する必要があります。コードは以下のようになります。
ここで、Firebase Authenticationで登録した「**メールアドレス**」と「**パスワード**」を使用します。
先ほどemail認証で追加したので、`signInWithEmailAndPassword`関数を使います。

```ts
import { initializeApp } from "https://esm.sh/firebase@9.14.0/app?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";
import {
  getAuth,
  signInWithEmailAndPassword,
} from "https://esm.sh/firebase@9.14.0/auth?deps=@firebase/app@0.9.0,@firebase/component@0.6.0,@firebase/util@1.8.0,@firebase/logger@0.4.0";

const firebaseConfig = {...};
const app = initializeApp(firebaseConfig);

// 実環境では、DBにアクセスするまで認証処理を延期すると⭕
// （top-level-awaitがコールドスタート時間に影響するため）

// (開発者用のダミーアカウントで)認証する
const FIREBASE_AUTH_EMAIL = Deno.env.get("FIREBASE_AUTH_EMAIL")!; // メールアドレス
const FIREBASE_AUTH_PASSWORD = Deno.env.get("FIREBASE_AUTH_PASSWORD")!; // パスワード
const auth = getAuth(app);
await signInWithEmailAndPassword(auth, FIREBASE_AUTH_EMAIL, FIREBASE_AUTH_PASSWORD);

// 以下、DBに対する処理
```

認証に使用する管理者アカウントのemailとpasswordは、機密情報なので環境変数やdotenvで管理し、`Deno.env.get()`で読み込みましょう。

[バージョン8を使用した公式のexample](https://deno.com/deploy/docs/tutorial-firebase#write-the-application)も参考になると思います。
（Deno.env.get()を使用してemailとpasswordを取得し、メール認証しているのが分かると思います。）

## まとめ

不完全ではありますが、Denoでfirebaseを動作させることができました。

注意するポイントとしては、

- Admin SDKではなくブラウザ向けのSDKを使う
- セキュリティルールに注意する

という所に気を付けて使う必要があります。

昔はesm.shからimportするとエラーが出ていたのですが、最近改善されたことでhackが必要なくなりました。

## 追記：Supabaseという選択肢もある

[Supabase](https://supabase.com/)は公式サイトで「Firebase Alternative」と書かれている通り、Firebaseの代替となるものです。

FirebaseをDenoプロジェクトで使う場合、CLIツールや[Cloud Functions for Firebase](https://firebase.google.com/docs/functions?hl=ja)などがNode.js前提のしくみになっており、使うのが辛いところがあります。
それに対して、2022/4/1に発表された[Supabase Functions](https://supabase.com/edge-functions)はdeno deploy上に構築されており、Deno側との親和性が高くなっているようです。

DenoプロジェクトでFirebaseライクな機能が使いたい時は、Supabaseを検討してみる価値があるかもしれません。