どこにでもいるTSコーダーの忘備録

プログラミングに関する忘備録を自分用に残しときます。マサカリ怖い。

`node-fetch`を使用したときに発生する`Error [ERR_REQUIRE_ESM]: require() of ES Module`の解決方法

Firebase Functionsでnode-fetchを使用してFirebase Emulatorで動かそうとしたら次のようなエラーが発生した。

Error: Failed to load function definition from source: Failed to generate manifest from function source: Error [ERR_REQUIRE_ESM]: require() of ES Module /.../node_modules/node-fetch/src/index.js from /.../firebase/functions/lib/routes/tab-one/activities/getActivities.js not supported.
Instead change the require of index.js in /...firebase/functions/lib/routes/tab-one/activities/getActivities.js to a dynamic import() which is available in all CommonJS modules.

これはnode-fetchのバージョン3からESM専用パッケージに変換されたため、コンパイルされたJavaScriptrequireを使ってこのパッケージをインポートできなくなったのが原因である。

一番簡単な解決策はCommonJSでビルドされた最後のバージョンであるnode-fetch@2.6.6をインストールすることである。 このバージョンでは型定義ファイルは自動的に含まれていないので、別途インストールする必要がある。

yarn add node-fetch@2.6.6
yarn add -D @types/node-fetch@2.x

Fix - node-fetch Error [ERR_REQUIRE_ESM]: not supported | bobbyhadz

Expoのマネージドアプリのアップデートに関するメモ

expo publishについて

expo publishを実行するとプロジェクトの永続的なURLが発行される。 これはExpo Goアプリで開くことができる。

そして、アプリの画像、フォント、動画などのアセットがすべてCDNにアップロードされる。

expo publishを実行した際に指定したリリースチャンネルとインストールされたアプリのリリースチャンネルが同じであり、そのプッシュされたリリースは最新版でかつ互換性のあるリリースである場合は、ユーザーはスタンドアロンなアプリを通してそのアップデートを取得することができる。

これは、いわゆるストアを経由しないでアップデートを配布できるOTAという仕組み。

互換性とは

互換性に与える影響には主に次の3つがある。

  • Expo SDKのバージョン
  • iOSAndroidなどのプラットフォーム
  • リリースチャンネル
更新が反映される流れ

「アプリのビルド時に指定したリリースチャンネルにプッシュされたすべてのリリースを確認したか」

この問いにYESの場合はすでに最新版が反映されているので何も行われない。

NOの場合はリリースチャンネルにプッシュされた最新版のリリースを取得する。

「互換性があるか?そしてSDKバージョンの互換性は大丈夫か」という問いが行われ、YESの場合にのみ最新版のリリースの反映が行われてアプリが更新される。

expo publishによる更新が反映できない場合に考えられること

app.jsonの特定のフィールドに対して変更を加えた場合は、expo publishを通して最新リリースを配信するのではなく、アプリのバイナリを再度ビルドしてストア経由でアップデートを配信する必要がある。

特定のフィールドとは次の通り。

  • iosキーに設定するオブジェクトのいかなる内容
  • androidキーに設定するオブジェクトのいかなる内容
  • notificationキーに設定するオブジェクトのいかなる内容
  • splashキーに設定するオブジェクトのいかなる内容
  • iconキーに設定する文字列の内容
  • nameキーに設定する文字列の内容
  • ownerキーに設定する文字列の内容
  • schemeキーに設定する文字列の内容
  • facebookSchemeキーに設定する文字列の内容
  • assetBundlePatternsキーに設定する配列の内容

そしてもちろん、Expo SDKのバージョンが変更された場合もexpo publishを通してアプリの更新を行うことはできないので、SDKのバージョンを上げる際には注意が必要になる。

加えて、Firebaseプロジェクトにアプリを追加した際に発行されるgoogle-services.jsonGoogleService-Info.plistの内容が変更になった場合にも、アプリの再ビルドが必要になる。

Publishing updates - Expo Documentation

ExpoのOTA updateの仕様について

EAS Buildでは自動的にexpo publishが実行されなくなった

従来のビルドではexpo buildコマンドを実行していたが、この時の注意点としてビルドを実行する前にアプリバンドルが自動的にアップデートとして指定したリリースチャンネルに公開されてしまっていた。

EAS Buildではビルドプロセスのひとつとしてexpo publishが実行されなくなった。なので、ビルドするのと同時に誤って最新版が公開されてしまうかもという心配はいらないようだ。

Migrating from "expo build" - Expo Documentation

React Native + ExpoのアプリにHermes、EAS Build、EAS Submitを導入した

ExpoのRelease ChannelsとRuntime Versionsを調べてみた。

Release Channelsとは

リリースチャンネルを使用することで、スタンドアロンアプリのビルド時に指定したリリースチャンネルと同じリリースチャンネルが指定されたアップデートを配信することができる。

これにより、複数の環境やバージョンを分けてアップデートを配信したりすることができる。

例えば、特定のリリースチャンネルでビルドされたアプリ向けにアップデートを公開する場合は--release-channelフラグを使用する。

expo publish --release-channel リリースチャンネル名

EAS Buildを使用してスタンドアロンアプリをビルドする際はビルドプロファイルにreleaseChannelフィールドを指定する。指定しない場合はdefaultチャンネルが使用される。

// eas.json
{
  "build": {
    "preview": {
      "distribution": "internal",
      "releaseChannel": "staging",
      "env": {
        "APP_VARIANT": "staging"
      }
    },
    "production": {
      "releaseChannel": "prod-v1"
    }
  }
}

ビルドするときは、--profileのみを指定し、release-channelは指定しない。

上記のeas.jsonの設定でproductionプロファイルにprod-v1releaseChannelが指定されているので、--profile productionを指定した場合はprod-v1リリースチャンネルでビルドされる。

eas build --platform android --profile production

prod-v1でビルドしたアプリはあとでrelease-channelprod-v2に変更してexpo publish --release-channel prod-v2を実行しても、prod-v1でビルドされたアプリではそのprod-v2で公開されたリリースを受け取ることはできない。

アプリストアで公開されている最新バージョンのアプリがprod-v2リリースチャンネルでビルドされていて、まだprod-v1リリースチャンネルのアプリを使いづけているユーザー向けに最新版のprod-v1リリースを配信することもできる。

Release channels - Expo Documentation

Runtime Versionsとは

ランタイムバージョンで表されるのは、アプリのビルド時に存在するネイティブコードとそのコードの組み合わせと構成である。そしてこれはアプリのバイナリをビルドするたびに固有なものである。

デフォルトの設定では、Expo SDKのバージョンによってRuntime Versionが決定される。しかし、これでは正しい意味でのRuntime Versionを表すことはできない。

本来は互換性のあるビルドに合わせてRuntime Versionを設定したい。Expo SDKのバージョンが異なっても互換性があるなら同じRuntime Versionを使用したい。

Runtime Versionを更新するタイミングは、プロジェクトのネイティブモジュールを更新したり、JS-nativeインターフェースを変更したりするときである。

eas.jsonruntimeVersion{"policy":"nativeVersion"}を設定すると自動的にRuntime Versionが生成される。

Runtime Versionはapp.jsonで設定する。

{
  "expo": {
    "runtimeVersion": "2.718"
  }
}

runtimeVersionが異なるビルドにはexpo publishで配信されたリリースは配信されない。同じruntimeVersionが設定されているビルドにのみ配信される。

Runtime Versions - Expo Documentation

まとめ

Release Channelが異なればそもそも新しく公開されたリリースは配信されない。

Release Channelが同じでもRuntime Versionが異なれば、新しく公開されたリリースは配信されない。

Release Channelが同じでRuntime Versionが同じ場合は、新しく公開されたリリースが配信される。

....はず。

Firebase FunctionでJWTを作成して、バックエンドへのリクエストの際に使用する簡単なやり方

前提

NestJSプロジェクトの作成方法や、PassportとJWTを組み合わせて認証機構を実装する方法に関してはこちらを参照。

takagimeow.hatenablog.com

Firebase プロジェクトの作成方法についてはこちらの記事を参照。

takagimeow.hatenablog.com

Firebase FunctionsにVitestを導入する方法についてはこちらの記事を参照。

必要なパッケージをインストール

JWTを作成するためにjsonwebtokenを入れる。

yarn add jsonwebtoken

テスト時に作成したjwtをデコードしたいので、jw t-decodeパッケージを入れる。

yarn add -D jwt-decode

Firebase Functionsでfetchを使用したいので、node-fetchを入れる。

yarn add node-fetch

JWT文字列を生成する関数を作成する

createJwtという関数を作成する。

jsonwebtokensign関数は、渡したペイロードを同期的にJSON Web トークン文字列に署名する関数である。

オプションで指定したalgorithm: "HS256"は、HS256アルゴリズムを使用して署名するという意味である。

// src/utils/createJwt.ts

import {sign} from "jsonwebtoken";

export function createJwt(
    payload: Record<string, string | number>,
    secret: string
) {
  const token = sign(payload, secret, {
    algorithm: "HS256",
  });
  return token;
}
HS256とは

対象アルゴリズムと呼ばれていて、ひとつの鍵を署名側と検証側で共有するアルゴリズムである。なのでNestJSで使用しているシークレットとFirebase Functions側で同じ値を環境変数などを通して保有しておく。

RS256 と HS256 ってなにが違うの - Qiita

createJwt関数に対するテストを作成する

先ほど作成したcreateJwt関数に対して軽いテストを作成してみる。 作成したトークンが正しくデコードできて、その内容が元のペイロードと同じ内容を含んでいるかを試すだけの簡単なテスト。

// src/utils/createJwt.spec.ts

import {test, expect} from "vitest";
import jwtDecode from "jwt-decode";
import {createJwt} from "./createJwt";

// 環境変数と見立てる
const JWT_KEY = "secretKey";

test("smoke test", async () => {
  // payloadを準備する
  const payload = {
    userId: "569125ee-56f8-4955-937c-70a5fa8ababc",
    exp: 1657168882,
  };
  const result = createJwt(payload, JWT_KEY);
  const decodedPayload = jwtDecode<typeof payload>(result);
  expect(result).toBeTruthy();
  expect(decodedPayload.userId).toBe(payload.userId);
});

呼び出し可能関数を実装してみる

バックエンドで動いているNestJSサーバーに対してGETリクエストを送って受け取ったデータをそのままクライアントに返す感じ。

対象ユーザーのUIDはそのままcontext.auth.uidを使用する。これをもとにJWT文字列を作成して、ヘッダーに設定した上でリクエストを送信する。

実際に作成する場合は、送られてきたuidのユーザーがFirebase Authenticationに登録されているかを確認したり、バックエンドにも問い合わせたりなどの処理を追加した方が良いかもしれない。

// src/routes/tab-one/activities/getActivities.ts

import type {CallableMethod} from "~/@types/callableMethod";
import {createJwt} from "~/utils/createJwt";
import fetch from "node-fetch";

type Activity = {
  id: string;
  label: string;
  date: string;
  value: string;
  method: "COUNT" | "TIME";
  exp: number;
  createdAt: string;
  icon: string;
};

// eslint-disable-next-line @typescript-eslint/ban-types
export type RequestData = {};
export type ResponseData = {
  formError?: string;
  activities?: Activity[];
};

export const getActivities: CallableMethod = async (
    data,
    context
): Promise<ResponseData> => {
  // uidの有無を確認する
  if (!context.auth?.uid || context.auth.uid === "") {
    const result: ResponseData = {
      formError: "uidを取得することができませんでした",
    };
    return result;
  }

  const key = "secretKey";
  const payload = {
    sub: context.auth.uid,
  };
  const token = createJwt(payload, key);
  const response = await fetch(
      `http://localhost:3000/users/${
        payload.sub
      }/activities?method=year&base_date=${new Date().getTime()}`,
      {
        method: "GET",
        headers: {
          Accept: "application/json",
          Authorization: `Bearer ${token}`,
        },
      }
  );
  const latestActivities = await response.json() as Activity[];
  return {
    activities: latestActivities,
  };
};

呼び出し可能関数用のテストを作成してみる

先ほど作成したgetActivities関数を実際に呼び出してみて、結果が帰ってくるかの簡単なテストを行なってみる。

※先にNestJS側でサーバーを起動したり、必要なデータをシードしておくこと。

// src/routes/tab-one/activities/getActivities.spec.ts

import * as firebaseFunctions from "firebase-functions";
import firebaseFunctionsTest from "firebase-functions-test";

import {getActivities, RequestData} from "./getActivities";
import {WrappedFunction} from "firebase-functions-test/lib/v1";

let wrapped: WrappedFunction<RequestData>;

beforeAll(async () => {
  const {wrap} = firebaseFunctionsTest();
  // テスト対象の関数をラップする
  wrapped = wrap(firebaseFunctions.https.onCall(getActivities));
});

afterAll(() => {
  vi.clearAllMocks();
});

test("smoke test", async () => {
  expect(1).toBe(1);
  const userId = "09ed994e-7ca0-4d9f-a1a5-c7d24ada765a";
  // 実行する
  const result = await wrapped({}, {auth: {uid: userId}});
  expect(result.activities.length).toBe(4);
});

今回は内部でFirebase AuthenticationやFirestoreを使用していていないので、エミュレーターは使用しないので、そのままvitestを実行するだけで良い。

yarn vitest

もし使う場合は次のコマンドを使用する。yarn testを実行するために"test": "vitest"スクリプトをpackage.jsonに追加しておく。

firebase emulators:exec --project project-id --only auth 'yarn build && yarn test'

EASを使ってAndroidアプリをビルドして実機で動かしてみような話

EASにはおおまかにわけて2種類の主要なサービスがある。

  • EAS Build
  • EAS Submit

EAS Buildは、ExpoやReact Nativeで作成されたアプリのバイナリをビルドするためのホスティングサービスです。

EAS Submitは、AppleのAppStoreやGoogle Play StoreにEASビルドでビルドしたアプリのバイナリを自動でアップロードしてくれるホスティングサービスです。

今回使うのはEAS Buildの方なので、EAS Submitについては考えなくて良い。

普段使っているのがPixel 5aだし、Androidの方が好きなので、今回はAndroidのアプリ向けにEASの使い方を説明する。

iOS向けについては後日気が向いたら書くかも

ローカルで実行する

Expo CLIをインストールします。

npm install --global expo-cli eas-cli

yarnの代わりにnpmを使うことが推奨されている。

We recommend using npm instead of yarn for global package installations. You may alternatively use npx eas-cli, just remember to use that instead of eas whenever it's called for in the documentation.

Creating your first build - Expo Documentation

Expoアカウントにログインします。

eas login

既存のプロジェクトをEAS Build用に設定します。

eas build:configure
Android端末・Androidエミュレーター、またはiOSシミュレータ向けにビルドする

EAS BuildではデフォルトでAndroid App Bundle(.aab)形式でアプリをビルドする。 この形式だとエミュレーターAndroidバイスにそのままインストールできない。 インストールするためにはAPK(.apk)形式でビルドする必要がある。

eas.jsonを開いて、previewの次の項目を編集する。ちなみに、previewはドキュメントで推奨されているだけでこの値は好みの名前に変えていい。コマンドの実行時にどの設定を使うかを指定するために使う名前なだけである。

  • buildType
    • apk
// eas.json
{
  "build": {
    "preview": {
      "android": {
        "buildType": "apk"
      }
  },
  "production": {
  }
}

previewの設定を使ってビルドする。

eas build -p android --profile preview

今回は実機にインストールする方法を紹介する。

ビルドの詳細ページにアクセスする。

表示されているURLをGmailかなんかで送って実機で開けるようにする。

準備ができたら、そのURLを実機で開いて画面に従ってAPKをインストールする。そしてアプリを起動する。

たったこれだけ。

一つの端末に複数環境用にビルドされたapkをインストールする話

ひとつの端末に本番環境、ステージング環境、開発環境用のそれぞれの環境でビルドされたアプリをインストールするためには、動的にapp.config.jsios.bundleIdentifierフィールドとandroid.packageフィールドを変更する必要がある。

そのために環境変数APP_VARIANTを今回は使用する。

APP_VARIANTdevelopmentの場合は開発環境を表、previewの場合はステージング環境、何も設定されていない場合は本番環境を表すようにする。

この値を変更するためにeas.jsonenvフィールドを利用する。

各ビルド用のプロファイルにenv.APP_VARIANTフィールドを用意してそれぞれの値を準備する。

// eas.json
{
  "build": {
    "development": {
      "developmentClient": true,
      "env": {
        "APP_VARIANT": "development",
      }
    },
    "preview": {
      "extends": "production",
      "env": {
        "APP_VARIANT": "preview"
      }
    },
    "production": {}
  }
}

次に、app.config.jsを作成する今回はすでにapp.jsonファイルが事前に作成されていることを前提とする。

app.jsonとapp.config.jsを併用することで、最初にapp.jsonが読み込まれた後、正規化された内容がapp.config.jsでexport defaultされた関数に渡されます。それを使用することで既存の設定内容を活かしながら、動的にせって内容を変更することが可能になります。

// app.config.js

const IS_DEV = process.env.APP_VARIANT === "development";
const IS_STAGING = process.env.APP_VARIANT === "preview";

function getAppIds() {
  let iOSBundleIdentifier = "com.company.myapp";
  let androidPackage = "com.company.myapp";
  if (process.env.APP_VARIANT === "development") {
    iOSBundleIdentifier = "com.company.myapp.dev";
    androidPackage = "com.company.myapp.dev";
  }
  if (process.env.APP_VARIANT === "preview") {
    iOSBundleIdentifier = "com.company.myapp.staging";
    androidPackage = "com.company.myapp.staging";
  }
  return {
    iOSBundleIdentifier,
    androidPackage,
  };
}

export default ({ config }) => {
  const { iosBundleIdentifier, androidPackage } = getAppIds();

  return {
    ...config,
    ios: {
      ...config.ios,
      bundleIdentifier: iosBundleIdentifier,
    },
    android: {
      ...config.android,
      package: androidPackage,
    },
  };
};

この設定によって、eas build --platform android --profile previewを実行した場合は、環境変数APP_VARIANTpreviewが設定されるのでandroid.packageの値がcom.company.myapp.stagingに更新された上でビルドされる。

VSCodeでVitestを導入したばかりのときに"Cannot find name 'beforeAll'"と表示されてしまう場合の対処法

まず、VitestはデフォルトではJestのようにグローバルなAPIを提供していないので、明治的にグローバルで使用したいということを設定しないといけない。

これを行うには2パターンある。

1つはvitest実行時に--globalsオプションを渡す。 もう一つがglobals: trueをvitest.config.tsで指定する。

// vitest.ts

/// <reference types="vitest" />
/// <reference types="vite/client" />

import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: {
    globals: true,
    environment: "node",
    include: ["./src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
  },
});

そして、これをTypeScriptでもグローバルAPIで動作するように指定するにはtsconfig.jsontypesフィールドにvitest/globalsを追加する。

// tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "outDir": "lib",
    "sourceMap": true,
    "strict": true,
    "target": "es2017",
    "paths": {
      "~/*": ["./src/*"],
    },
    "types": ["vitest/globals"],
    "esModuleInterop": true,
  },
  "compileOnSave": true,
  "include": [
    "src"
  ]
}

これでCannot find name 'beforeAll'という表示がエディタ上で消えるはず。

Configuring Vitest | Vitest