Firebase & JavaScript/TypeScript を使った開発で役立つTips15選

やまいも ([@yarnaimo](https://twitter.com/yarnaimo)) です。

Firebase と JavaScript/TypeScript を使って Web サービスをいくつか作っているうちに得られた知見をメモ程度にまとめてみました。

**W:** がついているものは Web、**A:** は Admin です。

### W: SSR 環境などでインスタンスが複数個作られないようにする

```ts
export const app = firebase.apps.length
    ? firebase.app()
    : firebase.initializeApp(firebaseConfig)
```

---

## 🔥 Firestore

**R:** は `firestore.rules`、**I:** は `firestore.indexes.json` です

### W: React Hooks でいい感じに使いたい

[**react-firebase-hooks**](https://github.com/CSFrequency/react-firebase-hooks) を使うとこのように書けます ↓

```tsx
type IPost = {
    title: string
}

const Post = () => {
    const [post, loading, error] = useDocumentData<IPost>(
        db.collection('posts').doc('id')
    )
    return <div>{post.title}</div>
}
```

### W&A: [TS] データに型を付けたい

**a.** データを取得・保存するときに変換したい場合 → **`withConverter`** を使う ([詳細はこちら](https://firebase.google.com/docs/reference/js/firebase.firestore.FirestoreDataConverter?hl=ja))

**b.** データを取得・保存するときに変換しなくていい場合

```ts
type IPost = {
    title: string
}

const postCollection = db.collection('posts') as firestore.CollectionReference<
    IPost
>

const snapshot = await postCollection.doc('id').get()
snapshot.data().title // => string
```

### A: ローカルエミュレータを使ったテストを書きたい

**`@firebase/testing`** を使います

```ts
import {
    clearFirestoreData,
    firestore,
    initializeAdminApp,
} from '@firebase/testing'
import * as admin from 'firebase-admin'

const projectId = 'project-id'
const app = initializeAdminApp({ projectId })

export const db = app.firestore()

afterEach(async () => {
    await clearFirestoreData({ projectId })
})

afterAll(async () => {
    await app.delete()
})
```

### R: ユーザーが管理者かどうか判定する

**`admins/{管理者のuid}`** に空のドキュメントを作っておくと `isAdmin()` でそのユーザーが管理者かどうか判定できます

```firestore.rules
function adminPath() {
  return /databases/$(database)/documents/admins/$(request.auth.uid)
}

function isAdmin() {
  return exists(adminPath());
}
```

### R: ユーザードキュメント以下でそのユーザーのみアクセスを許可する

```firestore.rules
match /users/{uid} {
  function isCurrentUser() {
    return request.auth.uid == uid;
  }

  match /posts/{postId} {
    allow read, write: if isCurrentUser();
  }
}
```

### I: collectionGroup の単一フィールドのインデックスを作りたい

**単一フィールド**のインデックスは **collection** だとデフォルトで作成されるようになっていますが、**collectionGroup** だと作成されません。

**`fieldOverrides`** を使うと collectionGroup で単一フィールドのインデックスを作ることができます。

`posts` という collectionGroup で `tags` フィールドの単一フィールドインデックスを作りたい場合:

```firestore.indexes.json
{
  "fieldOverrides": [
    {
      "collectionGroup": "posts",
      "fieldPath": "tags",
      "indexes": [
        {
          "order": "ASCENDING",
          "queryScope": "COLLECTION"
        },
        {
          "order": "DESCENDING",
          "queryScope": "COLLECTION"
        },
        {
          "arrayConfig": "CONTAINS",
          "queryScope": "COLLECTION"
        },
        {
          "order": "ASCENDING",
          "queryScope": "COLLECTION_GROUP"
        },
        {
          "order": "DESCENDING",
          "queryScope": "COLLECTION_GROUP"
        },
        {
          "arrayConfig": "CONTAINS",
          "queryScope": "COLLECTION_GROUP"
        }
      ]
    }
  ]
}
```

---

## 🔥 Cloud Functions

### W: 開発環境のブラウザからローカルエミュレータ上の関数を呼び出したい

```ts
// ↓ WebPack の EnvironmentPlugin などで環境変数を使えるようにしておく
const isDev = process.env.NODE_ENV !== 'production'

const functions = app.functions()

if (isDev) {
    functions.useFunctionsEmulator(`http://localhost:5000`)
}

// 開発環境だとローカルエミュレータ上の関数が呼び出される
functions.httpsCallable('function-name')(data)
```

### A: Pub/Sub 関数の実行日時のタイムゾーンを指定したい

日本時間の毎日 0:00 に実行したい場合

```ts
const timezone = 'Asia/Tokyo'

functions.pubsub
    .schedule('0 0 * * *')
    .timeZone(timezone)
    .onRun(async () => {
        // ...
    })
```

### A: 実行環境のタイムゾーンを指定したい

functions のエントリーポイントの最初にこれを書く

```ts
const timezone = 'Asia/Tokyo'
process.env.TZ = timezone
```

### A: ローカルでも環境変数を使いたい

プロジェクトルートの `.runtimeconfig.json` に環境変数を書いておくとローカルでも `functions.config()` で取得できます

```.runtimeconfig.json
{
  "a": {
    "key": "value"
  }
}
```

### A: 一部のファイル・フォルダをデプロイから除外したい

デフォルトではおそらく `node_modules` のみ除外されるようです

```firebase.json
{
  "functions": {
    "source": ".",
    "ignore": [
      ".git",
      "node_modules",
      "tmp"
    ]
  }
}
```

### W&A: [TS] Callable 関数の Request/Response に型を付けたい

#### Admin

**1.** `functions.https.onCall` のラッパーを作る (**[type-fest](https://github.com/sindresorhus/type-fest)** を使用)

(認証ユーザーでない場合は `unauthenticated` を返すようにしています)

```onCall.ts
import { https } from 'firebase-functions'
import { JsonObject, Merge, SetRequired } from 'type-fest'

type CallableData = {
    request: JsonObject
    response: JsonObject
}

export type Callable<T extends CallableData> = (
    request: T['request'],
    context: SetRequired<https.CallableContext, 'auth'>,
) => Promise<T['response']>

export type CallableDataType<
    C extends Callable<any>,
    T extends 'request' | 'response'
> = C extends Callable<infer Rs> ? Rs[T] : never

export const onCall = <T extends CallableData>(handler: Callable<T>) => {
    const wrapped = async (
        request: T['request'],
        context: https.CallableContext,
    ): Promise<T['response']> => {
        if (!context.auth) {
            throw new https.HttpsError('unauthenticated', '認証が必要です')
        }

        const response = await handler(request, {
            ...context,
            auth: context.auth,
        })
        return response
    }

    const runnable = https.onCall(wrapped)

    return runnable as Merge<typeof runnable, { run: typeof handler }>
}
```

**2.** ↑ のラッパーを使って Callable 関数を記述する

```callable/saveMessage.ts
import { onCall } from '../onCall'

export type CFSaveMessage = {
    request: {
        text: string
    }
    response: {
        result: boolean
    }
}

export const saveMessage = onCall<CFSaveMessage>(
    async (requestData, context) => {
        context.auth.uid // string
        requestData.text // string

        // ...
        return { result: true }
    },
)
```

**3.** Callable 関数を 1 つのオブジェクトにまとめる

```callable/index.ts
import { saveMessage } from './saveMessage'
// ...

export const callable = {
    saveMessage,
    // ...
}
```

**4 .** functions のエントリーポイントで ↑ の `callable` を import & export する

```ts
import { callable } from './path-to-callable'
export { callable }
```

こうすると **`callable-saveMessage`** のように Callble 関数の名前に prefix が付きます。

#### Web

**1.** 呼び出し用のラッパーを作る

```call.ts
import { callable } from './callable'
import { CallableDataType } from './onCall'

const functions = app.functions()

type Callables = typeof callable

export const call = async <
    N extends keyof Callables,
    C extends Callables[N] = Callables[N]
>(
    name: N,
    requestData: CallableDataType<C['run'], 'request'>,
) => {
    const result = await functions
        .httpsCallable(`callable-${name}`)({
            ...requestData,
        })
        .catch((error) => console.error(error))

    return result && (result.data as CallableDataType<C['run'], 'response'>)
}
```

**2.** 実際に呼び出してみる

```app.ts
const res = await call('saveMessage', { text: 'test' })
```

`call` の第 1 引数に指定した関数名に応じて Request/Response にも自動的に型が付きます。

---

## 🔥 Authentication

### W: パスワードの再設定メールの言語をブラウザの言語に合わせる

```ts
export const auth = app.auth()
auth.useDeviceLanguage()
```

---

## 🔥 Storage

### A: 保存したファイルを Public にして URL を取得したい

PNG 画像を保存する例:

```ts
export const savePNGImage = async (path: string, buffer: Buffer) => {
    const file = bucketAdmin.file(path)
    await file.save(buffer, { contentType: 'image/png' })
    await file.makePublic()
    const url = `https://storage.googleapis.com/${bucketAdmin.name}/${
        file.name
    }`
    return url
}
```